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
9 changes: 4 additions & 5 deletions docs/guides/getting-started/repo-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,16 +82,15 @@ repos:
forge: gitlab
```

All repos under the same owner must use the same forge. A GitHub org
and a GitLab group with the same name are different entities, and
mixing forges under one owner would route API calls incorrectly.

For GitLab repos, set the `GITLAB_TOKEN` environment variable or pass
`--gitlab-token` to `fullsend repos` subcommands. For self-hosted GitLab
instances, set `GITLAB_API_URL` to the API base URL (e.g.
`https://gitlab.example.com/api/v4`).

> **Note:** Mixed-forge manifests currently require that all repos share the
> same forge as `defaults.forge`. Per-entry client switching (connecting to
> both GitHub and GitLab APIs in a single run) is planned but not yet
> implemented.

See `fullsend repos init --help` or the [CLI reference](../../cli/repos.md)
for all flags.

Expand Down
2 changes: 1 addition & 1 deletion docs/plans/repos-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ type InitResult struct {
}

func Init(ctx context.Context, cfg InitConfig,
client forge.Client,
clients ForgeClientFactory,
selectRepos RepoSelectFunc,
progress ProgressFunc) (*InitResult, error)
```
Expand Down
14 changes: 7 additions & 7 deletions docs/plans/repos-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,7 +561,7 @@ func LoadManifest(pathOrURL string) (*Manifest, error)
func (m *Manifest) Validate() error

func (m *Manifest) ExpandGlobs(ctx context.Context,
client forge.Client) ([]ResolvedRepo, error)
clients ForgeClientFactory) ([]ResolvedRepo, error)

func (m *Manifest) ResolveConfig(owner, repo string) ResolvedConfig
```
Expand Down Expand Up @@ -819,7 +819,7 @@ type Drift struct {
}

func Status(ctx context.Context, manifest *Manifest,
client forge.Client, maxConcurrency int) ([]RepoStatus, error)
clients ForgeClientFactory, maxConcurrency int) ([]RepoStatus, error)
```

Per-repo discovery (parallelizable, read-only):
Expand Down Expand Up @@ -907,7 +907,7 @@ type BatchInstallResult struct {
}

func BatchInstall(ctx context.Context, cfg BatchInstallConfig,
client forge.Client, provisionerFactory ProvisionerFactory,
clients ForgeClientFactory, provisionerFactory ProvisionerFactory,
progress ProgressFunc) (*BatchInstallResult, error)
```

Expand Down Expand Up @@ -1025,10 +1025,10 @@ type Change struct {
}

func Diff(ctx context.Context, manifest *Manifest,
client forge.Client, maxConcurrency int) ([]Change, error)
clients ForgeClientFactory, maxConcurrency int) ([]Change, error)

func Sync(ctx context.Context, manifest *Manifest,
client forge.Client, maxConcurrency int,
clients ForgeClientFactory, maxConcurrency int,
progress ProgressFunc) ([]Change, error)
```

Expand Down Expand Up @@ -1124,7 +1124,7 @@ type UpgradeResult struct {
}

func Upgrade(ctx context.Context, cfg UpgradeConfig,
client forge.Client,
clients ForgeClientFactory,
progress ProgressFunc) ([]UpgradeResult, error)

func UpgradeMint(ctx context.Context, manifest *Manifest,
Expand Down Expand Up @@ -1264,7 +1264,7 @@ type RemoveResult struct {
}

func Remove(ctx context.Context, cfg RemoveConfig,
client forge.Client, provisionerFactory ProvisionerFactory,
clients ForgeClientFactory, provisionerFactory ProvisionerFactory,
progress ProgressFunc) ([]RemoveResult, error)
```

Expand Down
73 changes: 64 additions & 9 deletions internal/cli/forge_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"strings"
"sync"

"github.com/fullsend-ai/fullsend/internal/forge"
gl "github.com/fullsend-ai/fullsend/internal/forge/gitlab"
Expand Down Expand Up @@ -52,18 +53,72 @@ func newForgeClient(forgeName, gitlabToken string) (forge.Client, error) {
}
}

// forgeClientFromManifest resolves the dominant forge from the manifest
// defaults and returns the appropriate client. For manifests with a
// single forge type, this returns the correct client. Mixed-forge
// manifests (e.g., both GitHub and GitLab entries) are not yet fully
// supported — the default forge client is used and GitLab-specific
// entries will fail with a clear error.
func forgeClientFromManifest(m *repos.Manifest, gitlabToken string) (forge.Client, error) {
forgeName := m.Defaults.Forge
// forgeClientFactory lazily creates and caches per-forge API clients.
// Each client is created on first use and reused for subsequent calls
// with the same forge name. The sync.Mutex protects the client cache
// for concurrent goroutines in per-repo batch loops.
type forgeClientFactory struct {
gitlabToken string
mu sync.Mutex
clients map[string]forge.Client
}

// newForgeClientFactory returns a ForgeClientFactory that lazily creates
// and caches forge clients. A GitLab token is only resolved if the
// factory is asked for a GitLab client, so single-forge GitHub manifests
// never require GITLAB_TOKEN.
func newForgeClientFactory(gitlabToken string) repos.ForgeClientFactory {
return &forgeClientFactory{
gitlabToken: gitlabToken,
clients: make(map[string]forge.Client),
}
}

// ConfigFor returns a ForgeConfig with a live Client for the named forge.
// Clients are created lazily and cached — at most 2 clients per command
// invocation (one GitHub, one GitLab).
func (f *forgeClientFactory) ConfigFor(forgeName string) (repos.ForgeConfig, error) {
f.mu.Lock()
defer f.mu.Unlock()

// Normalize empty forge name to github (backward compat).
if forgeName == "" {
forgeName = repos.ForgeGitHub
}

client, ok := f.clients[forgeName]
if !ok {
var err error
client, err = newForgeClient(forgeName, f.gitlabToken)
if err != nil {
return repos.ForgeConfig{}, err
}
f.clients[forgeName] = client
}

cfg := repos.ForgeConfigFor(forgeName)
cfg.Client = client
return cfg, nil
}

// singleClientFactory wraps a single forge.Client as a ForgeClientFactory,
// returning the same client for any forge name. Used in tests and CLI
// test-override paths where a single FakeClient backs all operations.
type singleClientFactory struct {
client forge.Client
}

func newSingleClientFactory(client forge.Client) repos.ForgeClientFactory {
return &singleClientFactory{client: client}
}

func (f *singleClientFactory) ConfigFor(forgeName string) (repos.ForgeConfig, error) {
if forgeName == "" {
forgeName = repos.ForgeGitHub
}
return newForgeClient(forgeName, gitlabToken)
cfg := repos.ForgeConfigFor(forgeName)
cfg.Client = f.client
return cfg, nil
}

// getGitLabToken extracts the --gitlab-token flag from the command chain.
Expand Down
58 changes: 40 additions & 18 deletions internal/cli/forge_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,33 +70,27 @@ func TestNewForgeClient_Unsupported(t *testing.T) {
assert.Contains(t, err.Error(), "unsupported forge")
}

func TestForgeClientFromManifest_GitHub(t *testing.T) {
func TestNewForgeClientFactory_GitHub(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
m := &repos.Manifest{
Defaults: repos.DefaultsConfig{Forge: repos.ForgeGitHub},
}
client, err := forgeClientFromManifest(m, "")
factory := newForgeClientFactory("")
cfg, err := factory.ConfigFor(repos.ForgeGitHub)
require.NoError(t, err)
assert.NotNil(t, client)
assert.NotNil(t, cfg.Client)
}

func TestForgeClientFromManifest_EmptyForgeDefaultsToGitHub(t *testing.T) {
func TestNewForgeClientFactory_EmptyForgeDefaultsToGitHub(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
m := &repos.Manifest{
Defaults: repos.DefaultsConfig{Forge: ""},
}
client, err := forgeClientFromManifest(m, "")
factory := newForgeClientFactory("")
cfg, err := factory.ConfigFor("")
require.NoError(t, err)
assert.NotNil(t, client)
assert.NotNil(t, cfg.Client)
}

func TestForgeClientFromManifest_GitLab(t *testing.T) {
m := &repos.Manifest{
Defaults: repos.DefaultsConfig{Forge: repos.ForgeGitLab},
}
client, err := forgeClientFromManifest(m, "glpat-direct")
func TestNewForgeClientFactory_GitLab(t *testing.T) {
factory := newForgeClientFactory("glpat-direct")
cfg, err := factory.ConfigFor(repos.ForgeGitLab)
require.NoError(t, err)
assert.NotNil(t, client)
assert.NotNil(t, cfg.Client)
}

func TestGetGitLabToken_FromFlag(t *testing.T) {
Expand All @@ -121,3 +115,31 @@ func TestGetGitLabToken_Empty(t *testing.T) {
cmd := &cobra.Command{}
assert.Equal(t, "", getGitLabToken(cmd))
}

func TestNewForgeClientFactory_Caching(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
factory := newForgeClientFactory("")

cfg1, err := factory.ConfigFor(repos.ForgeGitHub)
require.NoError(t, err)

cfg2, err := factory.ConfigFor(repos.ForgeGitHub)
require.NoError(t, err)

assert.Same(t, cfg1.Client, cfg2.Client, "same forge should return the same cached client instance")
}

func TestNewForgeClientFactory_MixedForge(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
factory := newForgeClientFactory("glpat-test-token")

ghCfg, err := factory.ConfigFor(repos.ForgeGitHub)
require.NoError(t, err)

glCfg, err := factory.ConfigFor(repos.ForgeGitLab)
require.NoError(t, err)

assert.NotSame(t, ghCfg.Client, glCfg.Client, "different forges should return different clients")
assert.NotNil(t, ghCfg.Client)
assert.NotNil(t, glCfg.Client)
}
Loading
Loading