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
2 changes: 1 addition & 1 deletion docs/plans/repos-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
38 changes: 15 additions & 23 deletions docs/plans/repos-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 7 additions & 7 deletions internal/cli/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 5 additions & 2 deletions internal/forge/fake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down
32 changes: 29 additions & 3 deletions internal/forge/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand Down Expand Up @@ -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) }},
Expand Down Expand Up @@ -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"))
Expand Down
20 changes: 12 additions & 8 deletions internal/forge/forge.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,22 +266,26 @@ 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
// governance. Forks may live outside the org's permission boundary
// 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
Expand Down
19 changes: 13 additions & 6 deletions internal/forge/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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++ {
Expand All @@ -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{
Expand Down
32 changes: 30 additions & 2 deletions internal/forge/github/github_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,14 +49,42 @@ 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)
assert.Equal(t, "org/repo1", repos[0].FullName)
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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/forge/gitlab/gitlab_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/forge/gitlab/methods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
Loading
Loading