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
1 change: 1 addition & 0 deletions docs/cli/repos.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ fullsend repos init <owner/repo> --forge github --mint-project <PROJECT>
| `--inference-project` | | Default GCP project for inference |
| `--concurrency` | `8` | Max parallel API calls (capped at 64) |
| `--forge` | **(required)** | Forge type for discovered repos (`github` or `gitlab`) |
| `--forge-url` | | Forge instance URL (required for `gitlab`; defaults to `https://github.com` for `github`) |
| `--force` | `false` | Overwrite output file if it already exists |

### Discovery
Expand Down
9 changes: 6 additions & 3 deletions docs/guides/getting-started/repo-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ forge:
mint_url: https://mint.example.com
mint_project: my-project
mint_region: us-central1
gitlab:
url: https://gitlab.example.com
defaults:
forge: github
fullsend_ref: v2.5.0
Expand All @@ -88,9 +90,10 @@ 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`).
`--gitlab-token` to `fullsend repos` subcommands. Self-hosted GitLab
instances require `forge.gitlab.url` in the manifest (e.g.
`https://gitlab.example.com`). The `GITLAB_API_URL` environment variable
is kept as a fallback for callers without a manifest.

See `fullsend repos init --help` or the [CLI reference](../../cli/repos.md)
for all flags.
Expand Down
3 changes: 3 additions & 0 deletions docs/plans/repos-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ Flags:
include. Skips interactive selection.
- `--all` (bool): include all eligible repos without prompting.
- `--forge` (string, **required**): forge type (`github` or `gitlab`).
- `--forge-url` (string): forge instance URL. Required for `gitlab`;
defaults to `https://github.com` for `github`.
- `--mint-project` (string): GCP project for the `forge.github.mint_project` field.
- `--mint-region` (string, default `us-central1`): GCP region for
the `forge.github.mint_region` field.
Expand All @@ -79,6 +81,7 @@ type InitConfig struct {
Repos []string // explicit repo names (nil = interactive/all)
All bool // include all repos without prompting
Forge string // forge type ("github" or "gitlab")
ForgeURL string // forge instance URL
MintProject string
MintRegion string
InferenceProject string
Expand Down
11 changes: 11 additions & 0 deletions docs/plans/repos-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,16 @@ version: 1
# Per-forge infrastructure configuration.
forge:
github:
# Instance URL — defaults to https://github.com; set for GitHub Enterprise Server.
# url: https://github.example.com
# Shared mint infrastructure — one mint serves all repos.
# mint_url: Cloud Run endpoint (contains a random hash, not derivable from project/region).
# mint_project + mint_region: needed for WIF provisioning (IAM bindings).
mint_url: https://fullsend-mint-abc123-uc.a.run.app
mint_project: acme-fullsend-prod
mint_region: us-central1
# gitlab:
# url: https://gitlab.example.com # required, no default

# Default configuration applied to all repos unless overridden.
defaults:
Expand Down Expand Up @@ -500,11 +504,16 @@ type ForgeSection struct {
}

type GitHubForgeInfra struct {
URL string `yaml:"url,omitempty"`
MintURL string `yaml:"mint_url,omitempty"`
MintProject string `yaml:"mint_project,omitempty"`
MintRegion string `yaml:"mint_region,omitempty"`
}

type GitLabForgeInfra struct {
URL string `yaml:"url"`
}

type DefaultsConfig struct {
Forge string `yaml:"forge"`
InferenceProject string `yaml:"inference_project"`
Expand Down Expand Up @@ -596,8 +605,10 @@ the URL fetching logic from the harness resource loader.
`Validate()` checks:

- `version` is 1 (only supported version).
- `forge.github.url` defaults to `https://github.com` when unset; must be a valid HTTPS URL with no path.
- `forge.github.mint_url` is a valid HTTPS URL (when GitHub repos are present).
- `forge.github.mint_project` and `forge.github.mint_region` are non-empty (when GitHub repos are present).
- `forge.gitlab.url` is required and must be a valid HTTPS URL with no path (when GitLab repos are present).
- Each repo entry has a valid `owner/repo` format.
- No duplicate repos (after glob expansion).
- Glob patterns are valid `filepath.Match` patterns with an `org/`
Expand Down
6 changes: 3 additions & 3 deletions internal/cli/foreign.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ func newForeignAllowCmd() *cobra.Command {
if err != nil {
return err
}
client := newGitHubLiveClient(token)
client := newGitHubLiveClient(token, "")
printer := ui.New(os.Stdout)
ctx := cmd.Context()

Expand Down Expand Up @@ -121,7 +121,7 @@ func newForeignListCmd() *cobra.Command {
if err != nil {
return err
}
client := newGitHubLiveClient(token)
client := newGitHubLiveClient(token, "")
printer := ui.New(os.Stdout)
ctx := cmd.Context()

Expand Down Expand Up @@ -214,7 +214,7 @@ func newForeignRevokeCmd() *cobra.Command {
if err != nil {
return err
}
client := newGitHubLiveClient(token)
client := newGitHubLiveClient(token, "")
printer := ui.New(os.Stdout)
ctx := cmd.Context()

Expand Down
56 changes: 42 additions & 14 deletions internal/cli/forge_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ func resolveGitLabToken() (string, error) {
// For GitHub, it uses the standard token resolution chain (GH_TOKEN,
// GITHUB_TOKEN, gh auth token). For GitLab, it uses GITLAB_TOKEN or
// the provided gitlabToken override.
func newForgeClient(forgeName, gitlabToken string) (forge.Client, error) {
//
// The baseURL parameter, when non-empty, sets the forge instance URL
// (from the manifest's forge section). It takes precedence over the
// GITLAB_API_URL / GITHUB_API_URL environment variables, which are
// kept as a fallback for callers that don't have a manifest yet
// (e.g., repos init).
func newForgeClient(forgeName, gitlabToken, baseURL string) (forge.Client, error) {
switch forgeName {
case repos.ForgeGitLab:
token := gitlabToken
Expand All @@ -38,16 +44,18 @@ func newForgeClient(forgeName, gitlabToken string) (forge.Client, error) {
}
}
var opts []gl.Option
if base := strings.TrimSpace(os.Getenv("GITLAB_API_URL")); base != "" {
opts = append(opts, gl.WithBaseURL(base))
if baseURL != "" {
opts = append(opts, gl.WithBaseURL(baseURL))
} else if envURL := strings.TrimSpace(os.Getenv("GITLAB_API_URL")); envURL != "" {
opts = append(opts, gl.WithBaseURL(envURL))
}
return gl.New(token, opts...)
case repos.ForgeGitHub, "":
token, err := resolveToken()
if err != nil {
return nil, err
}
return newGitHubLiveClient(token), nil
return newGitHubLiveClient(token, baseURL), nil
default:
return nil, fmt.Errorf("unsupported forge %q", forgeName)
}
Expand All @@ -58,19 +66,24 @@ func newForgeClient(forgeName, gitlabToken string) (forge.Client, error) {
// 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
gitlabToken string
forgeSection repos.ForgeSection
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 {
// and caches forge clients. The forgeSection carries per-forge URLs
// from the manifest; when a URL is set it takes precedence over the
// GITLAB_API_URL / GITHUB_API_URL environment variables.
//
// 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, forgeSection repos.ForgeSection) repos.ForgeClientFactory {
return &forgeClientFactory{
gitlabToken: gitlabToken,
clients: make(map[string]forge.Client),
gitlabToken: gitlabToken,
forgeSection: forgeSection,
clients: make(map[string]forge.Client),
}
}

Expand All @@ -88,8 +101,9 @@ func (f *forgeClientFactory) ConfigFor(forgeName string) (repos.ForgeConfig, err

client, ok := f.clients[forgeName]
if !ok {
baseURL := f.forgeURL(forgeName)
var err error
client, err = newForgeClient(forgeName, f.gitlabToken)
client, err = newForgeClient(forgeName, f.gitlabToken, baseURL)
if err != nil {
return repos.ForgeConfig{}, err
}
Expand All @@ -101,6 +115,20 @@ func (f *forgeClientFactory) ConfigFor(forgeName string) (repos.ForgeConfig, err
return cfg, nil
}

// forgeURL returns the manifest-configured URL for the given forge.
// Returns "" when no URL is set, letting newForgeClient fall back to
// env vars or built-in defaults.
func (f *forgeClientFactory) forgeURL(forgeName string) string {
switch forgeName {
case repos.ForgeGitLab:
return f.forgeSection.GitLab.URL
case repos.ForgeGitHub:
return f.forgeSection.GitHub.URL
default:
return ""
}
}

// 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.
Expand Down
68 changes: 56 additions & 12 deletions internal/cli/forge_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,75 +24,99 @@ func TestResolveGitLabToken_Missing(t *testing.T) {
}

func TestNewForgeClient_GitLab_WithToken(t *testing.T) {
client, err := newForgeClient(repos.ForgeGitLab, "glpat-direct-token")
client, err := newForgeClient(repos.ForgeGitLab, "glpat-direct-token", "")
require.NoError(t, err)
assert.NotNil(t, client)
}

func TestNewForgeClient_GitLab_FromEnv(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "glpat-env-token")
client, err := newForgeClient(repos.ForgeGitLab, "")
client, err := newForgeClient(repos.ForgeGitLab, "", "")
require.NoError(t, err)
assert.NotNil(t, client)
}

func TestNewForgeClient_GitLab_NoToken(t *testing.T) {
t.Setenv("GITLAB_TOKEN", "")
_, err := newForgeClient(repos.ForgeGitLab, "")
_, err := newForgeClient(repos.ForgeGitLab, "", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "no GitLab token found")
}

func TestNewForgeClient_GitLab_WithBaseURL(t *testing.T) {
t.Setenv("GITLAB_API_URL", "https://gitlab.example.com/api/v4")
client, err := newForgeClient(repos.ForgeGitLab, "glpat-test")
client, err := newForgeClient(repos.ForgeGitLab, "glpat-test", "")
require.NoError(t, err)
assert.NotNil(t, client)
}

func TestNewForgeClient_GitLab_ManifestURLTakesPrecedence(t *testing.T) {
t.Setenv("GITLAB_API_URL", "https://should-not-use.example.com")
client, err := newForgeClient(repos.ForgeGitLab, "glpat-test", "https://gitlab.self-hosted.example.com")
require.NoError(t, err)
assert.NotNil(t, client)
}

func TestNewForgeClient_GitHub(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
client, err := newForgeClient(repos.ForgeGitHub, "")
client, err := newForgeClient(repos.ForgeGitHub, "", "")
require.NoError(t, err)
assert.NotNil(t, client)
}

func TestNewForgeClient_EmptyDefaultsToGitHub(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
client, err := newForgeClient("", "")
client, err := newForgeClient("", "", "")
require.NoError(t, err)
assert.NotNil(t, client)
}

func TestNewForgeClient_Unsupported(t *testing.T) {
_, err := newForgeClient("bitbucket", "")
_, err := newForgeClient("bitbucket", "", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported forge")
}

func TestNewForgeClientFactory_GitHub(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
factory := newForgeClientFactory("")
factory := newForgeClientFactory("", repos.ForgeSection{})
cfg, err := factory.ConfigFor(repos.ForgeGitHub)
require.NoError(t, err)
assert.NotNil(t, cfg.Client)
}

func TestNewForgeClientFactory_EmptyForgeDefaultsToGitHub(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
factory := newForgeClientFactory("")
factory := newForgeClientFactory("", repos.ForgeSection{})
cfg, err := factory.ConfigFor("")
require.NoError(t, err)
assert.NotNil(t, cfg.Client)
}

func TestNewForgeClientFactory_GitLab(t *testing.T) {
factory := newForgeClientFactory("glpat-direct")
factory := newForgeClientFactory("glpat-direct", repos.ForgeSection{})
cfg, err := factory.ConfigFor(repos.ForgeGitLab)
require.NoError(t, err)
assert.NotNil(t, cfg.Client)
}

func TestNewForgeClientFactory_WithManifestURLs(t *testing.T) {
t.Setenv("GH_TOKEN", "ghp-test-token")
forgeSection := repos.ForgeSection{
GitHub: repos.GitHubForgeInfra{URL: "https://github.com"},
GitLab: repos.GitLabForgeInfra{URL: "https://gitlab.self-hosted.example.com"},
}
factory := newForgeClientFactory("glpat-test", forgeSection)

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

glCfg, err := factory.ConfigFor(repos.ForgeGitLab)
require.NoError(t, err)
assert.NotNil(t, glCfg.Client)
}

func TestGetGitLabToken_FromFlag(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().String("gitlab-token", "", "")
Expand All @@ -118,7 +142,7 @@ func TestGetGitLabToken_Empty(t *testing.T) {

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

cfg1, err := factory.ConfigFor(repos.ForgeGitHub)
require.NoError(t, err)
Expand All @@ -131,7 +155,7 @@ func TestNewForgeClientFactory_Caching(t *testing.T) {

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

ghCfg, err := factory.ConfigFor(repos.ForgeGitHub)
require.NoError(t, err)
Expand All @@ -143,3 +167,23 @@ func TestNewForgeClientFactory_MixedForge(t *testing.T) {
assert.NotNil(t, ghCfg.Client)
assert.NotNil(t, glCfg.Client)
}

func TestGitHubAPIURL(t *testing.T) {
tests := []struct {
name string
instanceURL string
want string
}{
{"empty returns empty", "", ""},
{"github.com returns empty (use default)", "https://github.com", ""},
{"github.com trailing slash returns empty", "https://github.com/", ""},
{"GHES derives API URL", "https://ghes.example.com", "https://ghes.example.com/api/v3"},
{"trailing slash stripped", "https://ghes.example.com/", "https://ghes.example.com/api/v3"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := githubAPIURL(tt.instanceURL)
assert.Equal(t, tt.want, got)
})
}
}
Loading
Loading