From 2e19492f542f483c9d88b0c71c4ca457e226845b Mon Sep 17 00:00:00 2001 From: Greg Allen Date: Tue, 21 Jul 2026 14:15:19 -0400 Subject: [PATCH] feat(#5271): include private repos in repos.yaml glob expansion ListOrgRepos now accepts an includePrivate parameter. ExpandGlobs passes includePrivate=true because repos.yaml manifests operate in per-repo mode, where agents run on the target repo and public log exposure does not apply. All existing per-org callers pass false to preserve the original exclusion. Archived and forked repos remain excluded regardless of the flag. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Greg Allen --- docs/plans/repos-init.md | 2 +- docs/plans/repos-management.md | 38 +++++++----------- internal/cli/admin.go | 14 +++---- internal/cli/github.go | 2 +- internal/forge/fake.go | 7 +++- internal/forge/fake_test.go | 32 +++++++++++++-- internal/forge/forge.go | 20 ++++++---- internal/forge/github/github.go | 19 ++++++--- internal/forge/github/github_test.go | 32 ++++++++++++++- internal/forge/gitlab/gitlab_test.go | 2 +- internal/forge/gitlab/methods_test.go | 4 +- internal/forge/gitlab/repo.go | 10 +++-- internal/layers/dispatch.go | 2 +- internal/repos/init.go | 2 +- internal/repos/manifest.go | 8 ++-- internal/repos/manifest_test.go | 56 ++++++++++++++++++++++++--- 16 files changed, 180 insertions(+), 70 deletions(-) diff --git a/docs/plans/repos-init.md b/docs/plans/repos-init.md index ebfa7fdd26..fe84a0dda8 100644 --- a/docs/plans/repos-init.md +++ b/docs/plans/repos-init.md @@ -145,7 +145,7 @@ manifest contains one entry. **Org-level** (target has no `/`): -1. Call `ListOrgRepos(ctx, org)` to enumerate eligible repos. +1. Call `ListOrgRepos(ctx, org, false)` to enumerate eligible repos (the `includePrivate` parameter is `false` for per-org mode). 2. Check for per-org config repo (`{org}/.fullsend`): - Read `config.yaml` via `GetFileContent` (`internal/forge/forge.go:199`). diff --git a/docs/plans/repos-management.md b/docs/plans/repos-management.md index 6fc666fbec..0dd28b794a 100644 --- a/docs/plans/repos-management.md +++ b/docs/plans/repos-management.md @@ -111,13 +111,11 @@ org portion and filtering by the glob pattern. Expansion happens at command execution time. Glob-expanded repos inherit defaults (no per-repo overrides). Explicit entries take precedence over globs. -> **Limitation: glob patterns exclude private, archived, and forked -> repos.** The current `ListOrgRepos` excludes all three categories -> (designed for per-org mode). In per-repo mode, private repos are -> valid targets. The implementation must extend `ListOrgRepos` with a -> new method signature to include private repos without regressing -> per-org callers. Until then, private repos must be listed explicitly. -> Archived and forked repos remain excluded by default. +> **Note:** `ListOrgRepos` accepts an `includePrivate` parameter. +> `ExpandGlobs` passes `includePrivate=true` because repos.yaml +> manifests operate in per-repo mode where private repos are valid +> targets. Per-org callers pass `false` to preserve the original +> exclusion. Archived and forked repos remain excluded by default. #### Multi-org support @@ -587,15 +585,12 @@ the URL fetching logic from the harness resource loader. `ExpandGlobs()`: - For entries containing `*`, extract the org prefix. -- Call `ListOrgRepos(ctx, org)` to list eligible repos. Note: the - current `ListOrgRepos` implementation excludes private, archived, - and forked repos (`internal/forge/github/github.go:343`) — it was - designed for per-org mode where agents run on a public `.fullsend` - config repo. For per-repo mode, private repos are valid targets - since agents run on the target repo itself. The implementation must - extend `ListOrgRepos` (or add a variant) to include private repos - when called from glob expansion. Archived and forked repos remain - excluded by default. +- Call `ListOrgRepos(ctx, org, true)` to list eligible repos. + `ExpandGlobs` passes `includePrivate=true` because repos.yaml + manifests operate in per-repo mode where private repos are valid + targets. Per-org callers pass `false` to preserve the original + exclusion. Archived and forked repos remain excluded regardless of + the flag. - Filter by glob pattern using `filepath.Match`. - Merge with explicit entries (explicit wins over glob). - Return `[]ResolvedRepo` with resolved configuration per repo. @@ -694,13 +689,10 @@ DeleteRepoSecret(ctx context.Context, owner, repo, name string) error `DeleteRepoVariable` and `DeleteRepoSecret` are needed by `repos remove` (PR 8) and are cheaper to add here alongside `ListRepoVariables`. -Also add a `ListOrgReposIncludePrivate(ctx, org)` method (or an -`includePrivate bool` parameter on `ListOrgRepos`) so that glob -expansion in per-repo mode includes private repos. The current -`ListOrgRepos` excludes them because per-org mode runs agents on a -public `.fullsend` config repo, but per-repo mode runs agents on the -target repo itself, making private repos valid targets. The new -signature avoids regressing existing per-org callers. +`ListOrgRepos` now accepts an `includePrivate bool` parameter so that +glob expansion in per-repo mode includes private repos. Per-org callers +pass `false` to preserve the original exclusion. Archived and forked +repos remain excluded regardless of the flag. #### `internal/forge/github/github.go` (modify) diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 3abf13b2e0..7fe3baac72 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -454,7 +454,7 @@ Inference authentication: } // Discover all org repos upfront to avoid redundant API calls in runDryRun/runInstall. - allRepos, err := client.ListOrgRepos(ctx, org) + allRepos, err := client.ListOrgRepos(ctx, org, false) if err != nil { return fmt.Errorf("listing org repos: %w", err) } @@ -1343,7 +1343,7 @@ func runDryRun(ctx context.Context, client forge.Client, printer *ui.Printer, or allRepos = discoveredRepos printer.StepDone(fmt.Sprintf("Using %d discovered repositories", len(allRepos))) } else { - allRepos, err = client.ListOrgRepos(ctx, org) + allRepos, err = client.ListOrgRepos(ctx, org, false) if err != nil { return fmt.Errorf("listing org repos: %w", err) } @@ -1652,7 +1652,7 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o printer.StepDone(fmt.Sprintf("Found %d repositories", len(allRepos))) } else { printer.Header("Discovering repositories") - allRepos, err = client.ListOrgRepos(ctx, org) + allRepos, err = client.ListOrgRepos(ctx, org, false) if err != nil { return fmt.Errorf("listing org repos: %w", err) } @@ -1953,7 +1953,7 @@ func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, // runAnalyze assesses the current installation state. func runAnalyze(ctx context.Context, client forge.Client, printer *ui.Printer, org, analyzeFullsendSource string) error { - allRepos, err := client.ListOrgRepos(ctx, org) + allRepos, err := client.ListOrgRepos(ctx, org, false) if err != nil { return fmt.Errorf("listing org repos: %w", err) } @@ -2465,7 +2465,7 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe // 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") - allOrgRepos, err = client.ListOrgRepos(ctx, org) + allOrgRepos, err = client.ListOrgRepos(ctx, org, false) 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") @@ -2484,7 +2484,7 @@ func runEnableRepos(ctx context.Context, client forge.Client, printer *ui.Printe // one API call per repo (O(n) → O(1) API calls). printer.StepStart("Validating repository names") - allOrgRepos, err = client.ListOrgRepos(ctx, org) + allOrgRepos, err = client.ListOrgRepos(ctx, org, false) 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") @@ -2718,7 +2718,7 @@ func runDisableRepos(ctx context.Context, client forge.Client, printer *ui.Print // Sync org variable visibility to revoke access for disabled repos. // Skipped in PR mode — repo-maintenance reconciles on merge. if cfg.Dispatch.Mode == "oidc-mint" && !pr { - allOrgRepos, listErr := client.ListOrgRepos(ctx, org) + allOrgRepos, listErr := client.ListOrgRepos(ctx, org, false) if listErr != nil { printer.StepWarn(fmt.Sprintf("could not list org repos for variable sync: %v", listErr)) } else { diff --git a/internal/cli/github.go b/internal/cli/github.go index 7e440b2431..ac6255e2be 100644 --- a/internal/cli/github.go +++ b/internal/cli/github.go @@ -359,7 +359,7 @@ func runGitHubSetupPerOrg(ctx context.Context, client forge.Client, printer *ui. } } - allRepos, err := client.ListOrgRepos(ctx, org) + allRepos, err := client.ListOrgRepos(ctx, org, false) if err != nil { return fmt.Errorf("listing org repos: %w", err) } diff --git a/internal/forge/fake.go b/internal/forge/fake.go index 061b7f31d8..4e81135c6a 100644 --- a/internal/forge/fake.go +++ b/internal/forge/fake.go @@ -277,7 +277,7 @@ func (f *FakeClient) err(method string) error { return f.Errors[method] } -func (f *FakeClient) ListOrgRepos(_ context.Context, org string) ([]Repository, error) { +func (f *FakeClient) ListOrgRepos(_ context.Context, org string, includePrivate bool) ([]Repository, error) { f.mu.Lock() defer f.mu.Unlock() @@ -292,7 +292,10 @@ func (f *FakeClient) ListOrgRepos(_ context.Context, org string) ([]Repository, var result []Repository for _, r := range source { - if r.Archived || r.Fork || r.Private { + if r.Archived || r.Fork { + continue + } + if r.Private && !includePrivate { continue } result = append(result, r) diff --git a/internal/forge/fake_test.go b/internal/forge/fake_test.go index dbca2f6e58..7825af8c23 100644 --- a/internal/forge/fake_test.go +++ b/internal/forge/fake_test.go @@ -22,13 +22,39 @@ func TestFakeClient_ListOrgRepos(t *testing.T) { }, } - repos, err := fc.ListOrgRepos(ctx, "org") + repos, err := fc.ListOrgRepos(ctx, "org", false) require.NoError(t, err) assert.Len(t, repos, 2) assert.Equal(t, "active", repos[0].Name) assert.Equal(t, "also-active", repos[1].Name) } +func TestFakeClient_ListOrgRepos_IncludePrivate(t *testing.T) { + ctx := context.Background() + fc := &FakeClient{ + Repos: []Repository{ + {Name: "public", FullName: "org/public"}, + {Name: "private", FullName: "org/private", Private: true}, + {Name: "archived", FullName: "org/archived", Archived: true}, + {Name: "forked", FullName: "org/forked", Fork: true}, + }, + } + + // includePrivate=false excludes private repos. + repos, err := fc.ListOrgRepos(ctx, "org", false) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "public", repos[0].Name) + + // includePrivate=true includes private repos but still excludes archived/fork. + repos, err = fc.ListOrgRepos(ctx, "org", true) + require.NoError(t, err) + require.Len(t, repos, 2) + assert.Equal(t, "public", repos[0].Name) + assert.Equal(t, "private", repos[1].Name) + assert.True(t, repos[1].Private) +} + func TestFakeClient_CreateRepo(t *testing.T) { ctx := context.Background() fc := &FakeClient{} @@ -648,7 +674,7 @@ func TestFakeClient_ErrorInjection(t *testing.T) { name string call func(fc *FakeClient) error }{ - {"ListOrgRepos", func(fc *FakeClient) error { _, err := fc.ListOrgRepos(ctx, "org"); return err }}, + {"ListOrgRepos", func(fc *FakeClient) error { _, err := fc.ListOrgRepos(ctx, "org", false); return err }}, {"CreateRepo", func(fc *FakeClient) error { _, err := fc.CreateRepo(ctx, "o", "r", "d", false); return err }}, {"DeleteRepo", func(fc *FakeClient) error { return fc.DeleteRepo(ctx, "o", "r") }}, {"CreateFile", func(fc *FakeClient) error { return fc.CreateFile(ctx, "o", "r", "p", "m", nil) }}, @@ -786,7 +812,7 @@ func TestFakeClient_ThreadSafety(t *testing.T) { wg.Add(1) go func(n int) { defer wg.Done() - _, _ = fc.ListOrgRepos(ctx, "org") + _, _ = fc.ListOrgRepos(ctx, "org", false) _, _ = fc.CreateRepo(ctx, "org", "r", "d", false) _ = fc.DeleteRepo(ctx, "o", "r") _ = fc.CreateFile(ctx, "o", "r", "p", "m", []byte("data")) diff --git a/internal/forge/forge.go b/internal/forge/forge.go index b318aada8c..1e73d8fad7 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -266,14 +266,18 @@ type DirectoryEntry struct { type Client interface { // Repository operations // ListOrgRepos returns repositories eligible for fullsend enrollment. - // It excludes archived repos (no active development), forks, and - // private repos. + // It excludes archived repos (no active development) and forks. // - // Private repos are excluded because the default .fullsend config repo - // is public, and agent workflows dispatched to it run with public logs. - // Enrolling a private repo would expose its code in those logs when - // agents check out and process the repo content. Private repo support - // requires per-repo .fullsend mode where agents run on the target repo. + // When includePrivate is false, private repos are also excluded. + // This is the appropriate setting for per-org mode because the + // default .fullsend config repo is public and agent workflows + // dispatched to it run with public logs. Enrolling a private repo + // would expose its code in those logs when agents check out and + // process the repo content. + // + // When includePrivate is true, private repos are included in the + // result. This is appropriate for per-repo mode where agents run + // on the target repo itself, so public log exposure does not apply. // // Forks are excluded because fullsend's trust model is org-centric: // trust derives from org repository permissions and CODEOWNERS @@ -281,7 +285,7 @@ type Client interface { // or lack the same CODEOWNERS configuration, which could bypass // human-approval gates. Installing on both a fork and its upstream // also risks duplicate agent PRs and conflicting changes. - ListOrgRepos(ctx context.Context, org string) ([]Repository, error) + ListOrgRepos(ctx context.Context, org string, includePrivate bool) ([]Repository, error) GetRepo(ctx context.Context, owner, repo string) (*Repository, error) CreateRepo(ctx context.Context, org, name, description string, private bool) (*Repository, error) DeleteRepo(ctx context.Context, owner, repo string) error diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 81a43377da..7d671735a6 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -370,18 +370,22 @@ func decodeJSON(resp *http.Response, v any) error { return json.NewDecoder(resp.Body).Decode(v) } -// ListOrgRepos returns public, non-archived, non-fork repositories for an org. +// ListOrgRepos returns non-archived, non-fork repositories for an org. // -// Private repos are excluded because the default .fullsend config repo is -// public and agent workflow logs are visible to anyone. Enrolling a private -// repo would expose its code in those public logs. +// When includePrivate is false, private repos are also excluded. This +// is the appropriate setting for per-org mode because the .fullsend +// config repo is public and agent workflow logs are visible to anyone. +// +// When includePrivate is true, private repos are included. This is +// appropriate for per-repo mode where agents run on the target repo +// itself and logs are not publicly exposed. // // Forks are excluded because fullsend's trust model assumes org-owned repos // where CODEOWNERS governance and org-level permissions control agent // autonomy. Fork repos may have different ownership and CODEOWNERS configs, // which could bypass human-approval gates. Archived repos are excluded // because they represent inactive targets where agent work would be wasted. -func (c *LiveClient) ListOrgRepos(ctx context.Context, org string) ([]forge.Repository, error) { +func (c *LiveClient) ListOrgRepos(ctx context.Context, org string, includePrivate bool) ([]forge.Repository, error) { var result []forge.Repository for page := 1; page <= 100; page++ { @@ -405,7 +409,10 @@ func (c *LiveClient) ListOrgRepos(ctx context.Context, org string) ([]forge.Repo } for _, r := range repos { - if r.Archived || r.Fork || r.Private { + if r.Archived || r.Fork { + continue + } + if r.Private && !includePrivate { continue } result = append(result, forge.Repository{ diff --git a/internal/forge/github/github_test.go b/internal/forge/github/github_test.go index 6699ddc9ca..8c977c50e8 100644 --- a/internal/forge/github/github_test.go +++ b/internal/forge/github/github_test.go @@ -49,7 +49,7 @@ func TestListOrgRepos(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - repos, err := client.ListOrgRepos(context.Background(), "org") + repos, err := client.ListOrgRepos(context.Background(), "org", false) require.NoError(t, err) require.Len(t, repos, 1) assert.Equal(t, "repo1", repos[0].Name) @@ -57,6 +57,34 @@ func TestListOrgRepos(t *testing.T) { assert.Equal(t, "main", repos[0].DefaultBranch) } +func TestListOrgRepos_IncludePrivate(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode([]map[string]any{ + {"name": "public-repo", "full_name": "org/public-repo", "default_branch": "main", "private": false, "archived": false, "fork": false}, + {"name": "private-repo", "full_name": "org/private-repo", "default_branch": "main", "private": true, "archived": false, "fork": false}, + {"name": "archived-repo", "full_name": "org/archived-repo", "default_branch": "main", "private": false, "archived": true, "fork": false}, + {"name": "forked-repo", "full_name": "org/forked-repo", "default_branch": "main", "private": false, "archived": false, "fork": true}, + }) + })) + defer srv.Close() + + client := newTestClient(t, srv) + + // includePrivate=false excludes private repos. + repos, err := client.ListOrgRepos(context.Background(), "org", false) + require.NoError(t, err) + require.Len(t, repos, 1) + assert.Equal(t, "public-repo", repos[0].Name) + + // includePrivate=true includes private repos but still excludes archived/fork. + repos, err = client.ListOrgRepos(context.Background(), "org", true) + require.NoError(t, err) + require.Len(t, repos, 2) + assert.Equal(t, "public-repo", repos[0].Name) + assert.Equal(t, "private-repo", repos[1].Name) + assert.True(t, repos[1].Private) +} + func TestCreateRepo(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "POST", r.Method) @@ -2266,7 +2294,7 @@ func TestListOrgRepos_Pagination(t *testing.T) { defer srv.Close() client := newTestClient(t, srv) - repos, err := client.ListOrgRepos(context.Background(), "org") + repos, err := client.ListOrgRepos(context.Background(), "org", false) require.NoError(t, err) assert.Len(t, repos, 101) assert.Equal(t, 2, page) // Should have made exactly 2 requests diff --git a/internal/forge/gitlab/gitlab_test.go b/internal/forge/gitlab/gitlab_test.go index 04c6d495e8..76af749afc 100644 --- a/internal/forge/gitlab/gitlab_test.go +++ b/internal/forge/gitlab/gitlab_test.go @@ -363,7 +363,7 @@ func TestListOrgRepos(t *testing.T) { } }) - repos, err := client.ListOrgRepos(context.Background(), "myorg") + repos, err := client.ListOrgRepos(context.Background(), "myorg", false) require.NoError(t, err) // Only public-repo passes the filter (not archived, not forked, not private) require.Len(t, repos, 1) diff --git a/internal/forge/gitlab/methods_test.go b/internal/forge/gitlab/methods_test.go index c48fa2bfe3..344964df5b 100644 --- a/internal/forge/gitlab/methods_test.go +++ b/internal/forge/gitlab/methods_test.go @@ -1800,7 +1800,7 @@ func TestListOrgRepos_ExcludesInternal(t *testing.T) { }) }) - repos, err := client.ListOrgRepos(ctx, "myorg") + repos, err := client.ListOrgRepos(ctx, "myorg", false) require.NoError(t, err) require.Len(t, repos, 1) assert.Equal(t, "myorg/public-proj", repos[0].FullName) @@ -1820,7 +1820,7 @@ func TestListOrgRepos_IncludesSubgroups(t *testing.T) { }) }) - repos, err := client.ListOrgRepos(ctx, "myorg") + repos, err := client.ListOrgRepos(ctx, "myorg", false) require.NoError(t, err) require.Len(t, repos, 1) assert.Equal(t, "myorg/sub/sub-project", repos[0].FullName) diff --git a/internal/forge/gitlab/repo.go b/internal/forge/gitlab/repo.go index 14b121a2e0..69f8080f07 100644 --- a/internal/forge/gitlab/repo.go +++ b/internal/forge/gitlab/repo.go @@ -80,7 +80,7 @@ func (c *LiveClient) getTreeMap(ctx context.Context, owner, repo, ref string) (m return result, nil } -func (c *LiveClient) ListOrgRepos(ctx context.Context, org string) ([]forge.Repository, error) { +func (c *LiveClient) ListOrgRepos(ctx context.Context, org string, includePrivate bool) ([]forge.Repository, error) { var result []forge.Repository for page := 1; page <= 100; page++ { @@ -105,7 +105,11 @@ func (c *LiveClient) ListOrgRepos(ctx context.Context, org string) ([]forge.Repo } for _, p := range projects { - if p.Archived || p.ForkedFromProject != nil || p.Visibility != "public" { + if p.Archived || p.ForkedFromProject != nil { + continue + } + private := p.Visibility != "public" + if private && !includePrivate { continue } result = append(result, forge.Repository{ @@ -113,7 +117,7 @@ func (c *LiveClient) ListOrgRepos(ctx context.Context, org string) ([]forge.Repo Name: p.Name, FullName: p.PathWithNamespace, DefaultBranch: p.DefaultBranch, - Private: false, + Private: private, Archived: false, Fork: false, }) diff --git a/internal/layers/dispatch.go b/internal/layers/dispatch.go index 72351a048a..ec5dbd223a 100644 --- a/internal/layers/dispatch.go +++ b/internal/layers/dispatch.go @@ -148,7 +148,7 @@ func (l *DispatchTokenLayer) installOIDC(ctx context.Context) error { // dot-prefixed names cannot read GitHub org variables due to a platform // bug, so callers use this to set repo-level fallback variables. func (l *DispatchTokenLayer) dotPrefixedRepos(ctx context.Context, repoIDs []int64) []forge.Repository { - allRepos, err := l.client.ListOrgRepos(ctx, l.org) + allRepos, err := l.client.ListOrgRepos(ctx, l.org, false) if err != nil { l.ui.StepWarn("could not list org repos to detect dot-prefixed names: " + err.Error()) return nil diff --git a/internal/repos/init.go b/internal/repos/init.go index 6d6570ddfd..efa9be6ffc 100644 --- a/internal/repos/init.go +++ b/internal/repos/init.go @@ -148,7 +148,7 @@ 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) + allOrgRepos, err := client.ListOrgRepos(ctx, org, false) if err != nil { return nil, fmt.Errorf("listing repos for org %s: %w", org, err) } diff --git a/internal/repos/manifest.go b/internal/repos/manifest.go index d592477dc6..4d65ae2116 100644 --- a/internal/repos/manifest.go +++ b/internal/repos/manifest.go @@ -397,9 +397,9 @@ func (m *Manifest) Validate() error { // win over glob-matched entries. The returned list is deduplicated and // sorted. // -// ListOrgRepos excludes private, archived, and forked repositories. -// Private repos must be listed as explicit entries in the manifest -// until the forge interface is extended (see implementation plan). +// ListOrgRepos is called with includePrivate=true because repos.yaml +// manifests are used in per-repo mode, where agents run on the target +// repo itself. Archived and forked repos remain excluded. func (m *Manifest) ExpandGlobs(ctx context.Context, client forge.Client) ([]ResolvedRepo, error) { // First pass: separate explicit entries from glob patterns. explicit := make(map[string]RepoEntry) @@ -441,7 +441,7 @@ func (m *Manifest) ExpandGlobs(ctx context.Context, client forge.Client) ([]Reso repos, ok := orgRepoCache[g.org] if !ok { var err error - repos, err = client.ListOrgRepos(ctx, g.org) + repos, err = client.ListOrgRepos(ctx, g.org, true) if err != nil { return nil, fmt.Errorf("expanding glob %q: listing repos for org %q: %w", g.org+"/"+g.pattern, g.org, err) } diff --git a/internal/repos/manifest_test.go b/internal/repos/manifest_test.go index 2253ba6332..acaebbc3fe 100644 --- a/internal/repos/manifest_test.go +++ b/internal/repos/manifest_test.go @@ -535,18 +535,21 @@ repos: {Name: "service-api", FullName: "acme/service-api"}, {Name: "service-web", FullName: "acme/service-web"}, {Name: "lib-utils", FullName: "acme/lib-utils"}, - // Archived/private/fork repos should be filtered by FakeClient. + // Archived and fork repos are always excluded. {Name: "service-old", FullName: "acme/service-old", Archived: true}, - {Name: "service-priv", FullName: "acme/service-priv", Private: true}, {Name: "service-fork", FullName: "acme/service-fork", Fork: true}, + // Private repos are included because ExpandGlobs passes + // includePrivate=true (repos.yaml is per-repo mode). + {Name: "service-priv", FullName: "acme/service-priv", Private: true}, } ctx := context.Background() resolved, err := m.ExpandGlobs(ctx, fc) require.NoError(t, err) - // Should have: explicit-repo, service-api, service-web (not lib-utils, not archived/private/fork) - require.Len(t, resolved, 3) + // Should have: explicit-repo, service-api, service-priv, service-web + // (not lib-utils which doesn't match the glob, not archived/fork). + require.Len(t, resolved, 4) // Sorted alphabetically. assert.Equal(t, "acme", resolved[0].Owner) @@ -558,7 +561,50 @@ repos: assert.Equal(t, "glob-proj", resolved[1].Entry.InferenceProject.Value) assert.Equal(t, "acme", resolved[2].Owner) - assert.Equal(t, "service-web", resolved[2].Repo) + assert.Equal(t, "service-priv", resolved[2].Repo) + assert.True(t, resolved[2].Entry.InferenceProject.Set) + + assert.Equal(t, "acme", resolved[3].Owner) + assert.Equal(t, "service-web", resolved[3].Repo) +} + +func TestExpandGlobs_IncludesPrivateRepos(t *testing.T) { + input := ` +version: 1 +mint: + url: https://mint.example.com + project: p + region: r +repos: + - acme/* +` + var m Manifest + require.NoError(t, yaml.Unmarshal([]byte(input), &m)) + + fc := forge.NewFakeClient() + fc.Repos = []forge.Repository{ + {Name: "public-repo", FullName: "acme/public-repo"}, + {Name: "private-repo", FullName: "acme/private-repo", Private: true}, + {Name: "archived-repo", FullName: "acme/archived-repo", Archived: true}, + {Name: "forked-repo", FullName: "acme/forked-repo", Fork: true}, + } + + ctx := context.Background() + resolved, err := m.ExpandGlobs(ctx, fc) + require.NoError(t, err) + + // Private repos should be included (per-repo mode), but archived + // and forked repos remain excluded. + require.Len(t, resolved, 2) + + repoNames := make(map[string]bool) + for _, rr := range resolved { + repoNames[rr.Repo] = true + } + assert.True(t, repoNames["public-repo"], "public repo should be included") + assert.True(t, repoNames["private-repo"], "private repo should be included in per-repo mode") + assert.False(t, repoNames["archived-repo"], "archived repo should be excluded") + assert.False(t, repoNames["forked-repo"], "forked repo should be excluded") } func TestExpandGlobs_ExplicitWinsOverGlob(t *testing.T) {