From f1fdaa808e8d1351bd4108341e27b08291dd1dbf Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Mon, 20 Jul 2026 17:56:14 +0200 Subject: [PATCH 1/3] api: extract API base URL resolution Add unit tests covering known domains, explicit forge types, configured forge types, and unknown-domain fallback behavior. --- internal/cli/api.go | 29 +++++---- internal/cli/api_test.go | 129 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 13 deletions(-) create mode 100644 internal/cli/api_test.go diff --git a/internal/cli/api.go b/internal/cli/api.go index bdf0c8e..496c38f 100644 --- a/internal/cli/api.go +++ b/internal/cli/api.go @@ -32,19 +32,7 @@ var apiCmd = &cobra.Command{ endpoint = strings.ReplaceAll(endpoint, "{owner}", owner) endpoint = strings.ReplaceAll(endpoint, "{repo}", repoName) - // Build full URL - var baseURL string - switch { - case strings.Contains(domain, "github"): - baseURL = "https://api." + domain - case strings.Contains(domain, "gitlab"): - baseURL = "https://" + domain + "/api/v4" - case strings.Contains(domain, "bitbucket"): - baseURL = "https://api.bitbucket.org/2.0" - default: - baseURL = "https://" + domain + "/api/v1" - } - + baseURL := apiBaseURL(domain, flagForgeType) url := baseURL + "/" + strings.TrimLeft(endpoint, "/") var body io.Reader @@ -129,6 +117,21 @@ var apiCmd = &cobra.Command{ }, } +func apiBaseURL(domain, forgeType string) string { + _ = forgeType + + switch { + case strings.Contains(domain, "github"): + return "https://api." + domain + case strings.Contains(domain, "gitlab"): + return "https://" + domain + "/api/v4" + case strings.Contains(domain, "bitbucket"): + return "https://api.bitbucket.org/2.0" + default: + return "https://" + domain + "/api/v1" + } +} + var ( flagAPIMethod string flagAPIFields []string diff --git a/internal/cli/api_test.go b/internal/cli/api_test.go new file mode 100644 index 0000000..07dd1f7 --- /dev/null +++ b/internal/cli/api_test.go @@ -0,0 +1,129 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/git-pkgs/forge/internal/config" +) + +func TestAPIBaseURLKnownDomains(t *testing.T) { + tests := []struct { + name string + domain string + forgeType string + want string + }{ + { + name: "github", + domain: "github.com", + want: "https://api.github.com", + }, + { + name: "gitlab", + domain: "gitlab.com", + want: "https://gitlab.com/api/v4", + }, + { + name: "codeberg", + domain: "codeberg.org", + want: "https://codeberg.org/api/v1", + }, + { + name: "bitbucket", + domain: "bitbucket.org", + want: "https://api.bitbucket.org/2.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := apiBaseURL(tt.domain, tt.forgeType) + if got != tt.want { + t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", tt.domain, tt.forgeType, got, tt.want) + } + }) + } +} + +func TestAPIBaseURLUnknownDomainWithForgeType(t *testing.T) { + tests := []struct { + name string + forgeType string + want string + }{ + { + name: "github", + forgeType: "github", + want: "https://api.mylab.example.org", + }, + { + name: "gitlab", + forgeType: "gitlab", + want: "https://mylab.example.org/api/v4", + }, + { + name: "gitea", + forgeType: "gitea", + want: "https://mylab.example.org/api/v1", + }, + { + name: "forgejo", + forgeType: "forgejo", + want: "https://mylab.example.org/api/v1", + }, + { + name: "bitbucket", + forgeType: "bitbucket", + want: "https://api.bitbucket.org/2.0", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := apiBaseURL("mylab.example.org", tt.forgeType) + if got != tt.want { + t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", "mylab.example.org", tt.forgeType, got, tt.want) + } + }) + } +} + +func TestAPIBaseURLUnknownDomainWithConfigType(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + + xdgConfigHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgConfigHome) + + configDir := filepath.Join(xdgConfigHome, "forge") + if err := os.MkdirAll(configDir, 0700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(configDir, "config") + if err := os.WriteFile(configPath, []byte(`[mylab.example.org] +type = gitlab +`), 0600); err != nil { + t.Fatal(err) + } + + got := apiBaseURL("mylab.example.org", "") + want := "https://mylab.example.org/api/v4" + if got != want { + t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", "mylab.example.org", "", got, want) + } +} + +func TestAPIBaseURLUnknownDomainWithoutForgeTypeOrConfig(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + got := apiBaseURL("mylab.example.org", "") + want := "https://mylab.example.org/api/v1" + if got != want { + t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", "mylab.example.org", "", got, want) + } +} From 38b7fffd0aff6bffdf77905230eb13c13ee73697 Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Mon, 20 Jul 2026 18:00:46 +0200 Subject: [PATCH 2/3] api: resolve base URL from forge type --- internal/cli/api.go | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/cli/api.go b/internal/cli/api.go index 496c38f..115e94c 100644 --- a/internal/cli/api.go +++ b/internal/cli/api.go @@ -9,6 +9,7 @@ import ( "os" "strings" + "github.com/git-pkgs/forge/internal/config" "github.com/git-pkgs/forge/internal/resolve" "github.com/spf13/cobra" ) @@ -118,7 +119,26 @@ var apiCmd = &cobra.Command{ } func apiBaseURL(domain, forgeType string) string { - _ = forgeType + if forgeType == "" { + if cfg, err := config.Load(); err == nil && cfg != nil { + forgeType = cfg.Domains[domain].Type + } + } + + if forgeType == "" { + forgeType = knownDomainForgeType(domain) + } + + switch forgeType { + case "github": + return "https://api." + domain + case "gitlab": + return "https://" + domain + "/api/v4" + case "bitbucket": + return "https://api.bitbucket.org/2.0" + case "gitea", "forgejo": + return "https://" + domain + "/api/v1" + } switch { case strings.Contains(domain, "github"): @@ -132,6 +152,21 @@ func apiBaseURL(domain, forgeType string) string { } } +func knownDomainForgeType(domain string) string { + switch domain { + case "github.com": + return "github" + case "gitlab.com": + return "gitlab" + case "codeberg.org": + return "forgejo" + case "bitbucket.org": + return "bitbucket" + default: + return "" + } +} + var ( flagAPIMethod string flagAPIFields []string From 657c55915bca19798f8bbea07494ba858739b35f Mon Sep 17 00:00:00 2001 From: Pierre Gambarotto Date: Thu, 23 Jul 2026 08:30:57 +0200 Subject: [PATCH 3/3] api: resolve base URL from forge backend Add an optional APIBaseURLProvider interface and implement it for the built-in forge backends. Use the resolved forge in `forge api` to derive the API root, while preserving the legacy domain heuristic as a fallback for forge implementations that do not expose an API base URL. Add tests for backend-provided API URLs, legacy fallback behavior, and self-hosted GitLab domains configured with `type = gitlab`. --- bitbucket/bitbucket.go | 4 ++ forge.go | 6 ++ gitea/gitea.go | 5 ++ github/github.go | 12 +++- gitlab/gitlab.go | 13 +++- internal/cli/api.go | 49 ++++------------ internal/cli/api_test.go | 124 +++++++++++++++++++++------------------ 7 files changed, 112 insertions(+), 101 deletions(-) diff --git a/bitbucket/bitbucket.go b/bitbucket/bitbucket.go index 0f06097..549b9c1 100644 --- a/bitbucket/bitbucket.go +++ b/bitbucket/bitbucket.go @@ -47,6 +47,10 @@ func New(token string, hc *http.Client) forge.Forge { return &bitbucketForge{token: token, httpClient: hc} } +func (f *bitbucketForge) APIBaseURL() string { + return bitbucketAPI +} + type bitbucketRepoService struct { token string httpClient *http.Client diff --git a/forge.go b/forge.go index dbef2d4..6d46242 100644 --- a/forge.go +++ b/forge.go @@ -50,6 +50,12 @@ func (e *HTTPError) Error() string { return fmt.Sprintf("forge: HTTP %d from %s", e.StatusCode, e.URL) } +// APIBaseURLProvider is implemented by forge backends that can expose their +// raw API root URL for arbitrary endpoint requests. +type APIBaseURLProvider interface { + APIBaseURL() string +} + // Forge is the interface each forge backend implements. type Forge interface { Repos() RepoService diff --git a/gitea/gitea.go b/gitea/gitea.go index 62d62d9..5f450a0 100644 --- a/gitea/gitea.go +++ b/gitea/gitea.go @@ -4,6 +4,7 @@ import ( "context" forge "github.com/git-pkgs/forge" "net/http" + "strings" "code.gitea.io/sdk/gitea" ) @@ -34,6 +35,10 @@ func New(baseURL, token string, hc *http.Client) forge.Forge { return &giteaForge{client: c, baseURL: baseURL, token: token, httpClient: hc} } +func (f *giteaForge) APIBaseURL() string { + return strings.TrimRight(f.baseURL, "/") + "/api/v1" +} + type giteaRepoService struct { client *gitea.Client } diff --git a/github/github.go b/github/github.go index bb7299a..1cdabd0 100644 --- a/github/github.go +++ b/github/github.go @@ -4,6 +4,7 @@ import ( "context" forge "github.com/git-pkgs/forge" "net/http" + "strings" "github.com/google/go-github/v82/github" ) @@ -11,7 +12,8 @@ import ( const defaultPageSize = 100 type gitHubForge struct { - client *github.Client + client *github.Client + apiBaseURL string } // New creates a GitHub forge backend for github.com. @@ -20,14 +22,18 @@ func New(token string, hc *http.Client) forge.Forge { if token != "" { c = c.WithAuthToken(token) } - return &gitHubForge{client: c} + return &gitHubForge{client: c, apiBaseURL: "https://api.github.com"} } // NewWithBase creates a GitHub forge backend for a GitHub Enterprise instance. func NewWithBase(baseURL, token string, hc *http.Client) forge.Forge { c := github.NewClient(hc).WithAuthToken(token) c, _ = c.WithEnterpriseURLs(baseURL, baseURL) - return &gitHubForge{client: c} + return &gitHubForge{client: c, apiBaseURL: strings.TrimRight(baseURL, "/") + "/api/v3"} +} + +func (f *gitHubForge) APIBaseURL() string { + return f.apiBaseURL } type gitHubRepoService struct { diff --git a/gitlab/gitlab.go b/gitlab/gitlab.go index 7847d1d..37a6d9f 100644 --- a/gitlab/gitlab.go +++ b/gitlab/gitlab.go @@ -4,24 +4,31 @@ import ( "context" forge "github.com/git-pkgs/forge" "net/http" + "strings" gitlab "gitlab.com/gitlab-org/api/client-go" ) type gitLabForge struct { - client *gitlab.Client + client *gitlab.Client + apiBaseURL string } // New creates a GitLab forge backend. func New(baseURL, token string, hc *http.Client) forge.Forge { + apiBaseURL := strings.TrimRight(baseURL, "/") + "/api/v4" opts := []gitlab.ClientOptionFunc{ - gitlab.WithBaseURL(baseURL + "/api/v4"), + gitlab.WithBaseURL(apiBaseURL), } if hc != nil { opts = append(opts, gitlab.WithHTTPClient(hc)) } c, _ := gitlab.NewClient(token, opts...) - return &gitLabForge{client: c} + return &gitLabForge{client: c, apiBaseURL: apiBaseURL} +} + +func (f *gitLabForge) APIBaseURL() string { + return f.apiBaseURL } type gitLabRepoService struct { diff --git a/internal/cli/api.go b/internal/cli/api.go index 115e94c..136eaee 100644 --- a/internal/cli/api.go +++ b/internal/cli/api.go @@ -9,7 +9,7 @@ import ( "os" "strings" - "github.com/git-pkgs/forge/internal/config" + forges "github.com/git-pkgs/forge" "github.com/git-pkgs/forge/internal/resolve" "github.com/spf13/cobra" ) @@ -24,7 +24,7 @@ var apiCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { endpoint := args[0] - _, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) + forge, owner, repoName, domain, err := resolve.Repo(flagRepo, flagForgeType) if err != nil { return err } @@ -33,8 +33,8 @@ var apiCmd = &cobra.Command{ endpoint = strings.ReplaceAll(endpoint, "{owner}", owner) endpoint = strings.ReplaceAll(endpoint, "{repo}", repoName) - baseURL := apiBaseURL(domain, flagForgeType) - url := baseURL + "/" + strings.TrimLeft(endpoint, "/") + baseURL := apiBaseURL(forge, domain) + url := strings.TrimRight(baseURL, "/") + "/" + strings.TrimLeft(endpoint, "/") var body io.Reader if len(flagAPIFields) > 0 { @@ -118,28 +118,16 @@ var apiCmd = &cobra.Command{ }, } -func apiBaseURL(domain, forgeType string) string { - if forgeType == "" { - if cfg, err := config.Load(); err == nil && cfg != nil { - forgeType = cfg.Domains[domain].Type - } - } - - if forgeType == "" { - forgeType = knownDomainForgeType(domain) +func apiBaseURL(forge forges.Forge, domain string) string { + provider, ok := forge.(forges.APIBaseURLProvider) + if ok { + return provider.APIBaseURL() } - switch forgeType { - case "github": - return "https://api." + domain - case "gitlab": - return "https://" + domain + "/api/v4" - case "bitbucket": - return "https://api.bitbucket.org/2.0" - case "gitea", "forgejo": - return "https://" + domain + "/api/v1" - } + return legacyAPIBaseURL(domain) +} +func legacyAPIBaseURL(domain string) string { switch { case strings.Contains(domain, "github"): return "https://api." + domain @@ -152,21 +140,6 @@ func apiBaseURL(domain, forgeType string) string { } } -func knownDomainForgeType(domain string) string { - switch domain { - case "github.com": - return "github" - case "gitlab.com": - return "gitlab" - case "codeberg.org": - return "forgejo" - case "bitbucket.org": - return "bitbucket" - default: - return "" - } -} - var ( flagAPIMethod string flagAPIFields []string diff --git a/internal/cli/api_test.go b/internal/cli/api_test.go index 07dd1f7..a82f9f8 100644 --- a/internal/cli/api_test.go +++ b/internal/cli/api_test.go @@ -5,92 +5,110 @@ import ( "path/filepath" "testing" + forges "github.com/git-pkgs/forge" + "github.com/git-pkgs/forge/bitbucket" + "github.com/git-pkgs/forge/gitea" + ghforge "github.com/git-pkgs/forge/github" + glforge "github.com/git-pkgs/forge/gitlab" "github.com/git-pkgs/forge/internal/config" + "github.com/git-pkgs/forge/internal/resolve" ) -func TestAPIBaseURLKnownDomains(t *testing.T) { +func TestAPIBaseURLFromForge(t *testing.T) { tests := []struct { - name string - domain string - forgeType string - want string + name string + f forges.Forge + want string }{ { - name: "github", - domain: "github.com", - want: "https://api.github.com", + name: "github", + f: ghforge.New("", nil), + want: "https://api.github.com", }, { - name: "gitlab", - domain: "gitlab.com", - want: "https://gitlab.com/api/v4", + name: "github enterprise", + f: ghforge.NewWithBase("https://github.enterprise.example.org", "", nil), + want: "https://github.enterprise.example.org/api/v3", }, { - name: "codeberg", - domain: "codeberg.org", - want: "https://codeberg.org/api/v1", + name: "gitlab", + f: glforge.New("https://mylab.example.org", "", nil), + want: "https://mylab.example.org/api/v4", }, { - name: "bitbucket", - domain: "bitbucket.org", - want: "https://api.bitbucket.org/2.0", + name: "gitea", + f: gitea.New("https://gitea.example.org", "", nil), + want: "https://gitea.example.org/api/v1", + }, + { + name: "forgejo", + f: gitea.New("https://forgejo.example.org", "", nil), + want: "https://forgejo.example.org/api/v1", + }, + { + name: "bitbucket", + f: bitbucket.New("", nil), + want: "https://api.bitbucket.org/2.0", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := apiBaseURL(tt.domain, tt.forgeType) + got := apiBaseURL(tt.f, "fallback.example.org") if got != tt.want { - t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", tt.domain, tt.forgeType, got, tt.want) + t.Fatalf("APIBaseURL() = %q, want %q", got, tt.want) } }) } } -func TestAPIBaseURLUnknownDomainWithForgeType(t *testing.T) { +func TestAPIBaseURLFallsBackToLegacyDomainHeuristics(t *testing.T) { tests := []struct { - name string - forgeType string - want string + name string + domain string + want string }{ { - name: "github", - forgeType: "github", - want: "https://api.mylab.example.org", - }, - { - name: "gitlab", - forgeType: "gitlab", - want: "https://mylab.example.org/api/v4", + name: "github", + domain: "github.example.org", + want: "https://api.github.example.org", }, { - name: "gitea", - forgeType: "gitea", - want: "https://mylab.example.org/api/v1", + name: "gitlab", + domain: "gitlab.example.org", + want: "https://gitlab.example.org/api/v4", }, { - name: "forgejo", - forgeType: "forgejo", - want: "https://mylab.example.org/api/v1", + name: "bitbucket", + domain: "bitbucket.example.org", + want: "https://api.bitbucket.org/2.0", }, { - name: "bitbucket", - forgeType: "bitbucket", - want: "https://api.bitbucket.org/2.0", + name: "default", + domain: "forge.example.org", + want: "https://forge.example.org/api/v1", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := apiBaseURL("mylab.example.org", tt.forgeType) + got := apiBaseURL(&mockForge{}, tt.domain) if got != tt.want { - t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", "mylab.example.org", tt.forgeType, got, tt.want) + t.Fatalf("apiBaseURL(..., %q) = %q, want %q", tt.domain, got, tt.want) } }) } } -func TestAPIBaseURLUnknownDomainWithConfigType(t *testing.T) { +func TestAPIBaseURLUsesLegacyFallbackForUnknownDomainWithoutConfiguredForgeType(t *testing.T) { + got := apiBaseURL(&mockForge{}, "mylab.example.org") + want := "https://mylab.example.org/api/v1" + if got != want { + t.Fatalf("apiBaseURL(..., %q) = %q, want %q", "mylab.example.org", got, want) + } +} + +func TestAPIBaseURLUsesConfiguredForgeTypeForUnknownDomain(t *testing.T) { config.ResetCache() defer config.ResetCache() @@ -108,22 +126,14 @@ type = gitlab t.Fatal(err) } - got := apiBaseURL("mylab.example.org", "") - want := "https://mylab.example.org/api/v4" - if got != want { - t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", "mylab.example.org", "", got, want) + forge, _, _, domain, err := resolve.Repo("mylab.example.org/imt/deployments", "") + if err != nil { + t.Fatal(err) } -} - -func TestAPIBaseURLUnknownDomainWithoutForgeTypeOrConfig(t *testing.T) { - config.ResetCache() - defer config.ResetCache() - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - got := apiBaseURL("mylab.example.org", "") - want := "https://mylab.example.org/api/v1" + got := apiBaseURL(forge, domain) + want := "https://mylab.example.org/api/v4" if got != want { - t.Fatalf("apiBaseURL(%q, %q) = %q, want %q", "mylab.example.org", "", got, want) + t.Fatalf("apiBaseURL(..., %q) = %q, want %q", domain, got, want) } }