diff --git a/README.md b/README.md index 45603db..071c9c4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # forge -Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, Gerrit, Tangled and Radicle (read-only) through a single interface. +Go library and CLI for working with git forges. Supports GitHub, GitLab, Gitea/Forgejo, Bitbucket Cloud, Gerrit, Tangled and Radicle (read-only) through a single interface. GitHub Enterprise and other hosts that serve GitHub's REST API work through the GitHub backend. ## CLI @@ -14,7 +14,7 @@ Or with Go: go install github.com/git-pkgs/forge/cmd/forge@latest ``` -The CLI detects which forge to use from your git remote, or you can set it with `--forge-type`. +The CLI detects which forge to use from your git remote, or you can set it with `--forge-type`. Upstream is built in: remotes on `git.coline.app` and `ssh.git.coline.app`, where Upstream serves git, resolve to its API on `upstream.codes`. ``` forge repo view @@ -61,7 +61,7 @@ Command for token (e.g. rbw get github.com): rbw get github-token Check what's configured with `forge auth status`. -Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`, `TANGLED_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` to override it (for example `forge --host gitea.com repo list someone`). `FORGE_HOST` sets the host for `-R OWNER/REPO` and for commands that don't act on the current repo, but doesn't override the current repo's remote. +Tokens are resolved in this order: CLI flags, environment variables (`FORGE_TOKEN`, `GITHUB_TOKEN`/`GH_TOKEN`, `GITLAB_TOKEN`, `FORGEJO_TOKEN`/`GITEA_TOKEN`, `BITBUCKET_TOKEN`, `TANGLED_TOKEN`, `UPSTREAM_TOKEN`), then the config file at `~/.config/forge/config`. The target host is inferred from the current directory's git remote; use `--host` to override it (for example `forge --host gitea.com repo list someone`). `FORGE_HOST` sets the host for `-R OWNER/REPO` and for commands that don't act on the current repo, but doesn't override the current repo's remote. Radicle repositories have no owner, so they're selected by repository ID: `-R rad:` or `-R HOST/rad:`. The node to query comes from the host in `-R`, `--host`, `FORGE_HOST` or a section with `type = radicle` in your user config (a project `.forge` file can't pick the node). There's no default node, so a repository ID is never sent to a seed you didn't choose. For example `forge --host seed.radicle.dev -R rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5 file ls`. The Radicle backend is read-only over `radicle-httpd` and currently covers repositories, branches, tags and files. @@ -189,7 +189,7 @@ case errors.Is(err, forges.ErrServerError): // 5xx } ``` -The `factory` package imports every provider and returns a `Client` that already knows github.com, gitlab.com, codeberg.org, bitbucket.org and tangled.org. It detects and registers any other domain over https the first time a repository URL on it is used, which sends requests to that domain: +The `factory` package imports every provider and returns a `Client` that already knows github.com, gitlab.com, codeberg.org, bitbucket.org, tangled.org and upstream.codes, and sends repository URLs on Upstream's git hosts, `git.coline.app` and `ssh.git.coline.app`, to upstream.codes. `forges.WithHostAlias` adds such a mapping for another forge. It detects and registers any other domain over https the first time a repository URL on it is used, which sends requests to that domain: ```go import "github.com/git-pkgs/forge/factory" diff --git a/factory/factory.go b/factory/factory.go index f71dfa0..f578054 100644 --- a/factory/factory.go +++ b/factory/factory.go @@ -38,15 +38,30 @@ func DefaultForges(token func(domain string) string, hc *http.Client) map[string "codeberg.org": gitea.New("https://codeberg.org", token("codeberg.org"), hc), "bitbucket.org": bitbucket.New(token("bitbucket.org"), hc), "tangled.org": tangled.New("https://tangled.org", token("tangled.org"), hc), + // Upstream serves GitHub's REST API at /api/v3. + "upstream.codes": github.NewWithBase("https://upstream.codes", token("upstream.codes"), hc), } } -// NewClient returns a Client with the default forges registered and detection -// enabled for every other domain. Options apply first, so WithToken and -// WithHTTPClient reach the default forges, and a domain registered with -// WithForge keeps that registration. +// DefaultHostAliases returns the git hosts of well-known forges that serve git +// from a different host than their API, mapped to the API's domain. +func DefaultHostAliases() map[string]string { + return map[string]string{ + "git.coline.app": "upstream.codes", + "ssh.git.coline.app": "upstream.codes", + } +} + +// NewClient returns a Client with the default forges and host aliases +// registered and detection enabled for every other domain. Options apply +// first, so WithToken and WithHTTPClient reach the default forges, and a +// domain registered with WithForge or WithHostAlias keeps that registration. func NewClient(opts ...forges.Option) *forges.Client { - c := forges.NewClient(append([]forges.Option{forges.WithDetection(Builders())}, opts...)...) + base := []forges.Option{forges.WithDetection(Builders())} + for host, domain := range DefaultHostAliases() { + base = append(base, forges.WithHostAlias(host, domain)) + } + c := forges.NewClient(append(base, opts...)...) tokens := c.Tokens() defaults := DefaultForges(func(domain string) string { return tokens[domain] }, c.HTTPClient()) for domain, f := range defaults { diff --git a/factory/factory_test.go b/factory/factory_test.go index 9d97427..69e359c 100644 --- a/factory/factory_test.go +++ b/factory/factory_test.go @@ -71,11 +71,12 @@ func (rt *giteaTransport) RoundTrip(req *http.Request) (*http.Response, error) { func TestNewClientRegistersDefaultForges(t *testing.T) { c := NewClient() for domain, wantAPI := range map[string]string{ - "github.com": "https://api.github.com", - "gitlab.com": "https://gitlab.com/api/v4", - "codeberg.org": "https://codeberg.org/api/v1", - "bitbucket.org": "https://api.bitbucket.org/2.0", - "tangled.org": "https://tangled.org/xrpc", + "github.com": "https://api.github.com", + "gitlab.com": "https://gitlab.com/api/v4", + "codeberg.org": "https://codeberg.org/api/v1", + "bitbucket.org": "https://api.bitbucket.org/2.0", + "tangled.org": "https://tangled.org/xrpc", + "upstream.codes": "https://upstream.codes/api/v3", } { f, err := c.ForgeFor(domain) if err != nil { @@ -93,6 +94,62 @@ func TestNewClientRegistersDefaultForges(t *testing.T) { } } +func TestNewClientResolvesUpstreamGitHosts(t *testing.T) { + rt := &recordingTransport{} + c := NewClient(forges.WithHTTPClient(&http.Client{Transport: rt})) + want, err := c.ForgeFor("upstream.codes") + if err != nil { + t.Fatalf("ForgeFor: %v", err) + } + + for _, repoURL := range []string{ + "https://git.coline.app/owner/project.git", + "git@ssh.git.coline.app:owner/project.git", + } { + f, owner, repo, err := c.ForgeForURL(context.Background(), repoURL) + if err != nil { + t.Errorf("ForgeForURL(%q): %v", repoURL, err) + continue + } + if f != want { + t.Errorf("ForgeForURL(%q) returned %T, want the upstream.codes forge", repoURL, f) + } + if owner != "owner" || repo != "project" { + t.Errorf("ForgeForURL(%q) owner/repo = %q/%q", repoURL, owner, repo) + } + } + if len(rt.auth) != 0 { + t.Errorf("requests sent to %v, want no detection probe", rt.auth) + } +} + +func TestNewClientKeepsRegisteredHostAlias(t *testing.T) { + c := NewClient(forges.WithHostAlias("git.coline.app", "github.com")) + want, err := c.ForgeFor("github.com") + if err != nil { + t.Fatalf("ForgeFor: %v", err) + } + f, _, _, err := c.ForgeForURL(context.Background(), "https://git.coline.app/owner/project") + if err != nil { + t.Fatalf("ForgeForURL: %v", err) + } + if f != want { + t.Errorf("ForgeForURL returned %T, want the github.com forge", f) + } +} + +func TestNewClientKeepsForgeRegisteredForAliasHost(t *testing.T) { + want := &stubForge{} + c := NewClient(forges.WithForge("git.coline.app", want)) + f, _, _, err := c.ForgeForURL(context.Background(), "https://git.coline.app/owner/project") + if err != nil { + t.Fatalf("ForgeForURL: %v", err) + } + if f != forges.Forge(want) { + t.Errorf("ForgeForURL returned %T, want the forge registered for git.coline.app", f) + } +} + func TestNewClientAppliesOptionsToDefaultForges(t *testing.T) { rt := &recordingTransport{} c := NewClient( diff --git a/forge.go b/forge.go index 32127ef..ad65c1a 100644 --- a/forge.go +++ b/forge.go @@ -152,6 +152,7 @@ type Client struct { tokens map[string]string httpClient *http.Client detection *ForgeBuilders + aliases map[string]string } // Option configures a Client. @@ -180,6 +181,16 @@ func WithForge(domain string, f Forge) Option { } } +// WithHostAlias makes repository URLs on host use the forge for domain, for +// forges that serve git from a different host than their API. A Forge +// registered for host itself takes precedence. Hosts are matched +// case-insensitively. +func WithHostAlias(host, domain string) Option { + return func(c *Client) { + c.aliases[strings.ToLower(host)] = strings.ToLower(domain) + } +} + // WithDetection makes the Client detect and register domains it hasn't seen, // using the given builders, the first time a repository URL on that domain is // used. Detection always uses https, whatever the scheme of the URL, so a @@ -195,8 +206,9 @@ func WithDetection(builders ForgeBuilders) Option { // NewClient creates a Client and applies the given options. func NewClient(opts ...Option) *Client { c := &Client{ - forges: make(map[string]Forge), - tokens: make(map[string]string), + forges: make(map[string]Forge), + tokens: make(map[string]string), + aliases: make(map[string]string), } for _, opt := range opts { opt(c) @@ -332,6 +344,11 @@ func (c *Client) forgeOrDetect(ctx context.Context, domain string) (Forge, error if _, host, err := normalizeBaseURL(domain); err == nil { domain = host } + if alias, ok := c.aliases[domain]; ok { + if _, err := c.forgeFor(domain); err != nil { + domain = alias + } + } } f, err := c.forgeFor(domain) if err == nil || c.detection == nil || strings.Contains(domain, "://") { diff --git a/forges_test.go b/forges_test.go index 898fa78..46b1533 100644 --- a/forges_test.go +++ b/forges_test.go @@ -896,6 +896,35 @@ func TestClientMatchesDomainsCaseInsensitively(t *testing.T) { } } +func TestClientFollowsHostAlias(t *testing.T) { + mock := &mockForge{repoService: &mockRepoService{repos: []Repository{{FullName: "org/repo"}}}} + rt := &detectionTransport{} + c := NewClient( + WithHTTPClient(&http.Client{Transport: rt}), + WithForge("forge.example", mock), + WithHostAlias("Git.Forge.Example", "forge.example"), + WithDetection(ForgeBuilders{Gitea: func(string, string, *http.Client) Forge { return &mockForge{} }}), + ) + + for _, repoURL := range []string{ + "https://git.forge.example/org/repo.git", + "git@GIT.forge.example:org/repo.git", + } { + f, owner, repo, err := c.ForgeForURL(context.Background(), repoURL) + if err != nil || f != Forge(mock) { + t.Errorf("ForgeForURL(%q) = %T, %v, want the aliased forge", repoURL, f, err) + } + assertEqual(t, "owner", "org", owner) + assertEqual(t, "repo", "repo", repo) + } + if repos, err := c.ListRepositories(context.Background(), "git.forge.example", "org", ListRepoOpts{}); err != nil || len(repos) != 1 { + t.Errorf("ListRepositories = %+v, %v", repos, err) + } + if len(rt.urls) != 0 { + t.Errorf("probed %v, want no requests", rt.urls) + } +} + func TestListRepositoriesTrailingSlashKeepsRegistration(t *testing.T) { mock := &mockForge{repoService: &mockRepoService{repos: []Repository{{FullName: "org/repo"}}}} rt := &detectionTransport{} diff --git a/internal/cli/auth.go b/internal/cli/auth.go index b12f170..2b97ab5 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -214,7 +214,7 @@ func authStatusCmd() *cobra.Command { } // Known domains to check for env var tokens - knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org", "tangled.org"} + knownDomains := []string{"github.com", "gitlab.com", "codeberg.org", "bitbucket.org", "tangled.org", "upstream.codes"} // Collect all unique domains domains := make(map[string]bool) diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index 8961a71..492e25d 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -137,7 +137,7 @@ func repoFromFlag(flagRepo, flagForgeType string) (forges.Forge, string, string, } case 3: // host/owner/repo - domain, owner, repo = parts[0], parts[1], parts[2] + domain, owner, repo = mapSSHHost(parts[0]), parts[1], parts[2] default: return nil, "", "", "", fmt.Errorf("invalid repo format %q, expected OWNER/REPO or HOST/OWNER/REPO", flagRepo) } @@ -322,13 +322,16 @@ func resolveRemote() (domain, owner, repo string, err error) { // hostname when the config declares them as different. Self-hosted GitLab // can serve ssh on ssh.gitlab.test while the API lives at gitlab.test; // without this mapping the parsed remote domain would point at the wrong host. -// Returns the input unchanged when no mapping is configured. +// A configured ssh_host takes precedence over factory.DefaultHostAliases. +// Returns the input unchanged when neither maps it. func mapSSHHost(domain string) string { cfg, err := config.Load() - if err != nil { - return domain + if err == nil { + if api := cfg.DomainForSSHHost(domain); api != "" { + return api + } } - if api := cfg.DomainForSSHHost(domain); api != "" { + if api, ok := factory.DefaultHostAliases()[strings.ToLower(domain)]; ok { return api } return domain @@ -547,6 +550,10 @@ func TokenForDomainEnv(domain string) string { if t := os.Getenv("TANGLED_TOKEN"); t != "" { return t } + case "upstream.codes": + if t := os.Getenv("UPSTREAM_TOKEN"); t != "" { + return t + } } // FORGE_TOKEN is a fallback for any domain without a specific token. diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 2302e6e..a630455 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -56,6 +56,10 @@ func TestMapSSHHost(t *testing.T) { _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[gitlab.test] type = gitlab ssh_host = ssh.gitlab.test + +[mirror.example] +type = github +ssh_host = git.coline.app `), 0600) tests := []struct { @@ -67,6 +71,11 @@ ssh_host = ssh.gitlab.test // no mapping: pass through unchanged {"github.com", "github.com"}, {"gitlab.test", "gitlab.test"}, + // a configured ssh_host takes precedence over a default host alias + {"git.coline.app", "mirror.example"}, + // default host aliases from the factory package + {"ssh.git.coline.app", "upstream.codes"}, + {"SSH.Git.Coline.App", "upstream.codes"}, } for _, tt := range tests { @@ -88,6 +97,58 @@ func TestMapSSHHostNoConfig(t *testing.T) { if got != "ssh.gitlab.test" { t.Errorf("with no config, expected passthrough, got %q", got) } + // Default host aliases still apply. + if got := mapSSHHost("git.coline.app"); got != "upstream.codes" { + t.Errorf("mapSSHHost(%q) with no config = %q, want %q", "git.coline.app", got, "upstream.codes") + } +} + +func TestRepoFromUpstreamGitHost(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not installed") + } + config.ResetCache() + defer config.ResetCache() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + assertUpstream := func(t *testing.T, f forges.Forge, owner, repo, domain string) { + t.Helper() + if domain != "upstream.codes" || owner != "owner" || repo != "project" { + t.Errorf("got %q/%q on %q, want owner/project on upstream.codes", owner, repo, domain) + } + provider, ok := f.(forges.APIBaseURLProvider) + if !ok { + t.Fatalf("forge %T does not expose its API URL", f) + } + if got := provider.APIBaseURL(); got != "https://upstream.codes/api/v3" { + t.Errorf("API URL = %q, want %q", got, "https://upstream.codes/api/v3") + } + } + + for _, remote := range []string{ + "https://git.coline.app/owner/project.git", + "git@ssh.git.coline.app:owner/project.git", + } { + t.Run(remote, func(t *testing.T) { + t.Chdir(t.TempDir()) + mustGit(t, "init", "-q") + mustGit(t, "remote", "add", "origin", remote) + + f, owner, repo, domain, err := Repo("", "") + if err != nil { + t.Fatalf("Repo: %v", err) + } + assertUpstream(t, f, owner, repo, domain) + }) + } + + t.Run("-R git.coline.app/owner/project", func(t *testing.T) { + f, owner, repo, domain, err := Repo("git.coline.app/owner/project", "") + if err != nil { + t.Fatalf("Repo: %v", err) + } + assertUpstream(t, f, owner, repo, domain) + }) } func clearTokenEnv(t *testing.T) { @@ -95,7 +156,7 @@ func clearTokenEnv(t *testing.T) { for _, v := range []string{ "GITHUB_TOKEN", "GH_TOKEN", "GITLAB_TOKEN", "GLAB_TOKEN", - "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", "TANGLED_TOKEN", + "FORGEJO_TOKEN", "GITEA_TOKEN", "BITBUCKET_TOKEN", "TANGLED_TOKEN", "UPSTREAM_TOKEN", "FORGE_TOKEN", } { t.Setenv(v, "") @@ -172,6 +233,14 @@ func TestTokenForDomain(t *testing.T) { t.Errorf("expected tangled-tok, got %q", got) } t.Setenv("TANGLED_TOKEN", "") + + // Upstream + t.Setenv("UPSTREAM_TOKEN", "upstream-tok") + got = TokenForDomain("upstream.codes") + if got != "upstream-tok" { + t.Errorf("expected upstream-tok, got %q", got) + } + t.Setenv("UPSTREAM_TOKEN", "") } func TestTokenForDomainLogsFailingCommand(t *testing.T) { @@ -661,6 +730,8 @@ func TestRepoResolvesGitRemoteURLs(t *testing.T) { {name: "HTTPS userinfo", remoteURL: "https://user:token@github.com/owner/repo.git", domain: "github.com", owner: "owner"}, {name: "bracketed IPv6", remoteURL: "ssh://git@[2001:db8::1]:2222/owner/repo.git", domain: "2001:db8::1", owner: "owner"}, {name: "bracketed IPv6 SCP", remoteURL: "git@[2001:db8::1]:owner/repo.git", domain: "2001:db8::1", owner: "owner"}, + {name: "Upstream HTTPS", remoteURL: "https://git.coline.app/owner/repo.git", domain: "upstream.codes", owner: "owner"}, + {name: "Upstream SSH", remoteURL: "git@ssh.git.coline.app:owner/repo.git", domain: "upstream.codes", owner: "owner"}, {name: "empty host", remoteURL: ":owner/repo", wantErr: true}, }