From bdfb3f84a5a5c46cfd6a4b7b4e86db2ed978998c Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Wed, 12 Aug 2026 11:08:00 +0100 Subject: [PATCH] Support plain-HTTP self-hosted forges --host and FORGE_HOST now accept a full http://host:port URL, and a scheme = http key is accepted under [domain] in config (settable via forge auth login --scheme). The API base URL is built from that scheme instead of hardcoding https, so a local Forgejo/Gitea on a private IP without TLS is reachable. DetectForgeType and Client.RegisterDomain accept an optional scheme prefix on the domain argument for the same reason; the bare host is still used as the registry key. --- README.md | 10 ++ detect.go | 8 +- forge.go | 44 ++++++++- forges_test.go | 68 +++++++++++++ internal/cli/auth.go | 9 +- internal/cli/auth_test.go | 27 +++++ internal/cli/root.go | 2 +- internal/config/config.go | 40 +++++++- internal/config/config_test.go | 80 +++++++++++++-- internal/resolve/resolve.go | 80 +++++++++++++-- internal/resolve/resolve_test.go | 164 +++++++++++++++++++++++++++++++ 11 files changed, 506 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 7ef21cc..56e075c 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,16 @@ type = gitlab This tells forge that the project uses GitLab and that `gitlab.internal.dev` is a GitLab instance, so contributors don't each need `--forge-type` or `FORGE_HOST`. +For a self-hosted instance served over plain HTTP (a local Forgejo in Docker, say), add `scheme = http` to its section in `~/.config/forge/config`, use `forge auth login --scheme http`, or pass a full URL to `--host`/`FORGE_HOST`: + +```ini +[172.30.0.10:3000] +type = forgejo +scheme = http +``` + +Committed `.forge` files cannot set the API scheme. + Precedence from highest to lowest: CLI flags, environment variables, `.forge`, `~/.config/forge/config`, built-in defaults. ## Library diff --git a/detect.go b/detect.go index c8c8a7d..3cdeb0a 100644 --- a/detect.go +++ b/detect.go @@ -12,13 +12,17 @@ import ( // DetectForgeType probes a domain to identify which forge software it runs. // It checks HTTP response headers first, then falls back to API endpoints. -// If hc is nil, http.DefaultClient is used. +// The domain may include an http:// or https:// prefix; without one, https is +// assumed. If hc is nil, http.DefaultClient is used. func DetectForgeType(ctx context.Context, domain string, hc ...*http.Client) (ForgeType, error) { client := http.DefaultClient if len(hc) > 0 && hc[0] != nil { client = hc[0] } - baseURL := "https://" + domain + baseURL, _, err := normalizeBaseURL(domain) + if err != nil { + return Unknown, err + } ft, err := detectFromHeaders(ctx, client, baseURL) if err != nil { diff --git a/forge.go b/forge.go index 5ff4ce3..877efa6 100644 --- a/forge.go +++ b/forge.go @@ -131,15 +131,50 @@ func (c *Client) HTTPClient() *http.Client { return c.httpClient } +// normalizeBaseURL accepts either a bare host[:port] or a full http(s) URL and +// returns (baseURL, host). A bare host is assumed https. +func normalizeBaseURL(s string) (baseURL, host string, err error) { + if !strings.Contains(s, "://") { + s = "https://" + s + } + + u, err := url.Parse(s) + if err != nil { + return "", "", fmt.Errorf("invalid forge URL %q: %w", s, err) + } + u.Scheme = strings.ToLower(u.Scheme) + if u.Scheme != "http" && u.Scheme != "https" { + return "", "", fmt.Errorf("invalid forge URL scheme %q: must be http or https", u.Scheme) + } + if u.Host == "" { + return "", "", fmt.Errorf("invalid forge URL %q: host is required", s) + } + if u.User != nil { + return "", "", fmt.Errorf("invalid forge URL %q: user information is not supported", s) + } + if u.ForceQuery || u.RawQuery != "" || u.Fragment != "" { + return "", "", fmt.Errorf("invalid forge URL %q: query and fragment are not supported", s) + } + + u.Path = strings.TrimRight(u.Path, "/") + u.RawPath = strings.TrimRight(u.RawPath, "/") + return u.String(), u.Host, nil +} + // RegisterDomain detects the forge type for a domain and registers the -// appropriate Forge using the provided builder functions. +// appropriate Forge using the provided builder functions. The domain may +// include an http:// or https:// prefix; without one, https is assumed. +// The bare host[:port] is used as the registry key. func (c *Client) RegisterDomain(ctx context.Context, domain, token string, builders ForgeBuilders) error { - ft, err := DetectForgeType(ctx, domain, c.httpClient) + baseURL, domain, err := normalizeBaseURL(domain) + if err != nil { + return err + } + ft, err := DetectForgeType(ctx, baseURL, c.httpClient) if err != nil { return fmt.Errorf("detecting forge type for %s: %w", domain, err) } c.tokens[domain] = token - baseURL := "https://" + domain switch ft { case GitHub: c.forges[domain] = builders.GitHub(baseURL, token, c.httpClient) @@ -320,6 +355,9 @@ func ParseRepoURL(rawURL string) (domain, owner, repo string, err error) { return "", "", "", fmt.Errorf("invalid URL: %w", err) } domain = u.Hostname() + if scheme := strings.ToLower(u.Scheme); scheme == "http" || scheme == "https" { + domain = u.Host + } return splitOwnerRepo(domain, u.Path) } diff --git a/forges_test.go b/forges_test.go index 1615060..1334608 100644 --- a/forges_test.go +++ b/forges_test.go @@ -80,6 +80,14 @@ func TestParseRepoURL(t *testing.T) { input: "https://bitbucket.org/atlassian/stash-example-plugin", domain: "bitbucket.org", owner: "atlassian", repo: "stash-example-plugin", }, + { + input: "http://172.30.0.10:3000/owner/repo.git", + domain: "172.30.0.10:3000", owner: "owner", repo: "repo", + }, + { + input: "ssh://git@forge.example.com:2222/owner/repo.git", + domain: "forge.example.com", owner: "owner", repo: "repo", + }, { input: "", wantErr: true, @@ -200,6 +208,66 @@ func TestDetectForgeTypeUsesProvidedClient(t *testing.T) { } } +func TestDetectForgeTypeAcceptsHTTPURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Forgejo-Version", "7.0.0") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + // srv.URL is http://127.0.0.1:PORT — passing it directly must not be + // rewritten to https. + ft, err := DetectForgeType(context.Background(), srv.URL) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if ft != Forgejo { + t.Errorf("want Forgejo, got %s", ft) + } +} + +func TestRegisterDomainAcceptsHTTPURL(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Forgejo-Version", "7.0.0") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + var gotBase string + c := NewClient() + inputURL := srv.URL + "/forge/" + err := c.RegisterDomain(context.Background(), inputURL, "tok", ForgeBuilders{ + Gitea: func(baseURL, token string, hc *http.Client) Forge { + gotBase = baseURL + return nil + }, + }) + if err != nil { + t.Fatalf("RegisterDomain: %v", err) + } + if want := srv.URL + "/forge"; gotBase != want { + t.Errorf("builder got base %q, want %q", gotBase, want) + } + // Registry key must be the bare host, not the full URL. + host := strings.TrimPrefix(srv.URL, "http://") + if _, err := c.ForgeFor(host); err != nil { + t.Errorf("ForgeFor(%q) after RegisterDomain(%q): %v", host, inputURL, err) + } +} + +func TestNormalizeBaseURLRejectsUnsupportedURLParts(t *testing.T) { + for _, input := range []string{ + "ftp://forge.example.com", + "https://user@forge.example.com", + "https://forge.example.com?query=value", + "https://forge.example.com#fragment", + } { + if _, _, err := normalizeBaseURL(input); err == nil { + t.Errorf("normalizeBaseURL(%q) should return an error", input) + } + } +} + func TestDetectForgeTypeHeaders(t *testing.T) { tests := []struct { header string diff --git a/internal/cli/auth.go b/internal/cli/auth.go index bb5401a..6121e2c 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -31,6 +31,7 @@ func authLoginCmd() *cobra.Command { token string tokenCmd string forgeType string + scheme string ) cmd := &cobra.Command{ @@ -69,7 +70,12 @@ func authLoginCmd() *cobra.Command { } } - if err := config.SetDomain(domain, token, tokenCmd, forgeType); err != nil { + scheme = strings.ToLower(scheme) + if scheme != "" && scheme != "http" && scheme != "https" { + return fmt.Errorf("invalid --scheme %q: must be http or https", scheme) + } + + if err := config.SetDomain(domain, token, tokenCmd, forgeType, scheme); err != nil { return fmt.Errorf("saving config: %w", err) } @@ -86,6 +92,7 @@ func authLoginCmd() *cobra.Command { cmd.Flags().StringVar(&token, "token", "", "API token") cmd.Flags().StringVar(&tokenCmd, "token-cmd", "", "Shell command whose stdout is used as the token") cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled") + cmd.Flags().StringVar(&scheme, "scheme", "", "API scheme: http or https (default https). Use http for plain-HTTP self-hosted instances.") cmd.MarkFlagsMutuallyExclusive("token", "token-cmd") return cmd } diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go index 3790129..c85b6ca 100644 --- a/internal/cli/auth_test.go +++ b/internal/cli/auth_test.go @@ -113,6 +113,33 @@ func TestAuthLoginNonInteractive(t *testing.T) { } } +func TestAuthLoginNormalizesScheme(t *testing.T) { + resetCmd(rootCmd) + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + config.ResetCache() + defer config.ResetCache() + + rootCmd.SetArgs([]string{ + "auth", "login", + "--domain", "forgejo.example.com", + "--token", "test_token_123", + "--scheme", "HTTP", + }) + + if err := rootCmd.Execute(); err != nil { + t.Fatalf("auth login: %v", err) + } + + data, err := os.ReadFile(filepath.Join(dir, "forge", "config")) + if err != nil { + t.Fatalf("reading config: %v", err) + } + if !strings.Contains(string(data), "scheme = http") { + t.Errorf("expected normalized scheme, got:\n%s", data) + } +} + func TestAuthLoginTokenCmd(t *testing.T) { resetCmd(rootCmd) dir := t.TempDir() diff --git a/internal/cli/root.go b/internal/cli/root.go index 7faabdf..a60bf6b 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -46,7 +46,7 @@ func Execute() error { func init() { rootCmd.PersistentFlags().StringVarP(&flagRepo, "repo", "R", "", "Select a repository (OWNER/REPO or HOST/OWNER/REPO)") rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled") - rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com); overrides FORGE_HOST and remote detection") + rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com, http://forgejo.local:3000); overrides FORGE_HOST and remote detection") rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format: table, json, plain") rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when not specifying -R (default origin)") } diff --git a/internal/config/config.go b/internal/config/config.go index 8ef5a3f..c7aaea9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -31,6 +31,7 @@ type DefaultSection struct { type DomainSection struct { Type string // github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled + Scheme string // http or https; only from user config (empty = https) Token string // resolved token value; only from user config, never .forge TokenExec string // non-empty when token is retrieved via a shell command (from "token-cmd" config key) SSHHost string // alternate host for git-over-ssh; the section name remains the API host @@ -94,6 +95,17 @@ func GitProtocolFor(domain string) string { return "https" } +func parseScheme(v string) (string, error) { + switch strings.ToLower(v) { + case "http": + return "http", nil + case "https": + return "https", nil + default: + return "", fmt.Errorf("invalid scheme %q: must be \"http\" or \"https\"", v) + } +} + func parseGitProtocol(v string) (string, error) { switch strings.ToLower(v) { case "ssh": @@ -166,7 +178,7 @@ func load() (*Config, error) { return cfg, nil } -func loadFile(cfg *Config, path string, allowTokens bool) error { +func loadFile(cfg *Config, path string, userConfig bool) error { f, err := os.Open(path) if os.IsNotExist(err) { return nil @@ -205,6 +217,15 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { if v, ok := kv["type"]; ok { ds.Type = v } + if userConfig { + if v, ok := kv["scheme"]; ok { + s, err := parseScheme(v) + if err != nil { + return fmt.Errorf("%s: [%s] %w", path, name, err) + } + ds.Scheme = s + } + } if v, ok := kv["git_protocol"]; ok { p, err := parseGitProtocol(v) if err != nil { @@ -212,12 +233,12 @@ func loadFile(cfg *Config, path string, allowTokens bool) error { } ds.GitProtocol = p } - if allowTokens { + if userConfig { if v, ok := kv["ssh_host"]; ok { ds.SSHHost = v } } - if allowTokens { + if userConfig { _, hasToken := kv["token"] _, hasTokenCmd := kv["token-cmd"] if hasToken && hasTokenCmd { @@ -317,7 +338,15 @@ func findProjectConfig(dir string) string { // SetDomain updates or adds a domain section in the user config file. // Creates the config directory if needed. Sets file permissions to 0600 // since the file may contain tokens. -func SetDomain(domain, token, tokenCmd, forgeType string) error { +func SetDomain(domain, token, tokenCmd, forgeType, scheme string) error { + if scheme != "" { + normalizedScheme, err := parseScheme(scheme) + if err != nil { + return err + } + scheme = normalizedScheme + } + path := UserConfigPath() if path == "" { return fmt.Errorf("cannot determine config path") @@ -351,6 +380,9 @@ func SetDomain(domain, token, tokenCmd, forgeType string) error { if forgeType != "" { sections[domain]["type"] = forgeType } + if scheme != "" { + sections[domain]["scheme"] = scheme + } return writeINI(path, sections) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index d49d23b..0966db7 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -168,15 +168,18 @@ ssh_host = ssh.gitlab.test } } -func TestLoadFileIgnoresSSHHostWithoutAllowTokens(t *testing.T) { +func TestLoadFileIgnoresUserOnlySettingsForProjectConfig(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") _ = os.WriteFile(path, []byte(`[attacker.com] type = gitea +scheme = http ssh_host = ssh.legit.test `), 0600) - cfg := &Config{Domains: make(map[string]DomainSection)} + cfg := &Config{Domains: map[string]DomainSection{ + "attacker.com": {Scheme: "https"}, + }} if err := loadFile(cfg, path, false); err != nil { t.Fatal(err) } @@ -185,6 +188,9 @@ ssh_host = ssh.legit.test if got != "" { t.Errorf("project config should not set SSHHost, got %q", got) } + if got := cfg.Domains["attacker.com"].Scheme; got != "https" { + t.Errorf("project config should not override Scheme, got %q", got) + } if cfg.Domains["attacker.com"].Type != "gitea" { t.Error("type should still be set from project config") @@ -209,6 +215,7 @@ token = ghp_user [gitea.example.com] type = gitea +scheme = https token = gitea_tok `), 0600) @@ -221,6 +228,7 @@ forge-type = gitlab [gitea.example.com] type = forgejo +scheme = http token = should_be_ignored `), 0644) @@ -256,6 +264,9 @@ token = should_be_ignored if ds.Token != "gitea_tok" { t.Errorf("expected token from user config only (not overwritten by project), got %q", ds.Token) } + if ds.Scheme != "https" { + t.Errorf("expected scheme from user config only, got %q", ds.Scheme) + } } func TestProjectConfigTokensIgnored(t *testing.T) { @@ -350,7 +361,7 @@ func TestSetDomain(t *testing.T) { dir := t.TempDir() t.Setenv("XDG_CONFIG_HOME", dir) - err := SetDomain("gitea.example.com", "tok123", "", "gitea") + err := SetDomain("gitea.example.com", "tok123", "", "gitea", "") if err != nil { t.Fatalf("SetDomain: %v", err) } @@ -406,7 +417,7 @@ func TestSetDomainTightensExistingPermissions(t *testing.T) { t.Fatal(err) } - if err := SetDomain("github.com", "ghp_secret", "", ""); err != nil { + if err := SetDomain("github.com", "ghp_secret", "", "", ""); err != nil { t.Fatalf("SetDomain: %v", err) } @@ -434,7 +445,7 @@ type = gitlab `), 0600) // Add a new domain; existing entries should survive. - err := SetDomain("codeberg.org", "tok_new", "", "gitea") + err := SetDomain("codeberg.org", "tok_new", "", "gitea", "") if err != nil { t.Fatalf("SetDomain: %v", err) } @@ -468,7 +479,7 @@ token = old_token `), 0600) // Update - err := SetDomain("github.com", "new_token", "", "") + err := SetDomain("github.com", "new_token", "", "", "") if err != nil { t.Fatalf("SetDomain: %v", err) } @@ -483,6 +494,63 @@ token = old_token } } +func TestSetDomainScheme(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + if err := SetDomain("forgejo.local:3000", "tok", "", "forgejo", "HTTP"); err != nil { + t.Fatalf("SetDomain: %v", err) + } + + data, _ := os.ReadFile(filepath.Join(dir, "forge", "config")) + content := string(data) + if !strings.Contains(content, "scheme = http") { + t.Errorf("expected scheme = http in config, got:\n%s", content) + } + + ResetCache() + cfg, err := Load() + if err != nil { + t.Fatalf("Load: %v", err) + } + if got := cfg.Domains["forgejo.local:3000"].Scheme; got != "http" { + t.Errorf("Scheme = %q, want http", got) + } +} + +func TestSetDomainRejectsInvalidScheme(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + + err := SetDomain("forgejo.local", "tok", "", "forgejo", "ftp") + if err == nil { + t.Fatal("expected invalid scheme error") + } + if !strings.Contains(err.Error(), "invalid scheme") { + t.Errorf("expected scheme error, got %v", err) + } + if _, statErr := os.Stat(filepath.Join(dir, "forge", "config")); !os.IsNotExist(statErr) { + t.Errorf("config should not be written for an invalid scheme, stat error: %v", statErr) + } +} + +func TestLoadFileInvalidScheme(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config") + _ = os.WriteFile(path, []byte(`[forgejo.local] +scheme = ftp +`), 0600) + + cfg := &Config{Domains: make(map[string]DomainSection)} + err := loadFile(cfg, path, true) + if err == nil { + t.Fatal("expected error for invalid scheme") + } + if !strings.Contains(err.Error(), "invalid scheme") { + t.Errorf("expected error about invalid scheme, got: %v", err) + } +} + func TestLoadFileTokenCommand(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config") diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go index d83e3ce..3c4fea1 100644 --- a/internal/resolve/resolve.go +++ b/internal/resolve/resolve.go @@ -22,6 +22,7 @@ import ( var ( remoteName = "origin" hostOverride string + schemeOverride string forgeTypeOverride string // testForge allows tests to inject a mock forge. When set, Repo() returns @@ -50,11 +51,30 @@ func RemoteName() string { // SetHost forces a specific forge domain, taking precedence over FORGE_HOST, // --forge-type, and git remote detection. The CLI calls this from the --host -// persistent flag. An empty string is ignored. +// persistent flag. An empty string is ignored. A leading http:// or https:// +// is stripped from the stored host and remembered as the API scheme for that +// host so plain-HTTP self-hosted instances can be addressed. func SetHost(host string) { - if host != "" { - hostOverride = host + if host == "" { + return } + schemeOverride, hostOverride = splitScheme(host) +} + +// splitScheme splits a leading http:// or https:// off s and returns +// (scheme, host). If s has no scheme, scheme is "". +func splitScheme(s string) (scheme, host string) { + s = strings.TrimRight(s, "/") + u, err := url.Parse(s) + if err == nil && u.Host != "" { + switch strings.ToLower(u.Scheme) { + case "http": + return "http", u.Host + case "https": + return "https", u.Host + } + } + return "", s } // SetForgeType forces the API client implementation for the resolved domain, @@ -157,16 +177,24 @@ func ResourceFromURL(rawURL string) (forge forges.Forge, domain string, ref *for return nil, "", nil, fmt.Errorf("invalid URL: %w", err) } } + scheme := strings.ToLower(u.Scheme) + if scheme != "http" && scheme != "https" { + return nil, "", nil, fmt.Errorf("invalid URL scheme %q: must be http or https", u.Scheme) + } + if u.Host == "" { + return nil, "", nil, fmt.Errorf("invalid URL: host is required") + } - domain = u.Hostname() + domain = u.Host path := strings.Trim(u.Path, "/") + baseURL := scheme + "://" + domain var f forges.Forge if testForge != nil { f = testForge } else { - client := newClient(domain) - f, err = forgeForDomainMaybeConfig(context.Background(), client, domain) + client := newClientForBaseURL(domain, baseURL) + f, err = forgeForDomainAtBaseURL(context.Background(), client, domain, baseURL) if err != nil { return nil, "", nil, err } @@ -245,6 +273,10 @@ func OwnerForBranch(ctx context.Context, branch string) (string, error) { } func newClient(domain string) *forges.Client { + return newClientForBaseURL(domain, baseURLFor(domain)) +} + +func newClientForBaseURL(domain, baseURL string) *forges.Client { token := TokenForDomain(domain) var opts []forges.Option if token != "" { @@ -270,13 +302,29 @@ func newClient(domain string) *forges.Client { if ft == "" { ft = configForgeType(domain) } - if f := forgeForType(ft, "https://"+domain, token, hc); f != nil { + if f := forgeForType(ft, baseURL, token, hc); f != nil { opts = append(opts, forges.WithForge(domain, f)) } return forges.NewClient(opts...) } +// baseURLFor returns the API base URL for a domain. The scheme is chosen from, +// in order: a scheme given via --host, a scheme in FORGE_HOST (when it names +// this domain), the config [domain] scheme key, then https. +func baseURLFor(domain string) string { + if schemeOverride != "" && hostOverride == domain { + return schemeOverride + "://" + domain + } + if s, h := splitScheme(os.Getenv("FORGE_HOST")); s != "" && h == domain { + return s + "://" + domain + } + if s := configScheme(domain); s != "" { + return s + "://" + domain + } + return "https://" + domain +} + func forgeForType(forgeType, baseURL, token string, hc *http.Client) forges.Forge { switch forgeType { case "gitea", "forgejo": @@ -297,12 +345,16 @@ func forgeForType(forgeType, baseURL, token string, hc *http.Client) forges.Forg // fails and the config declares a type for the domain, it registers the domain // using that type (skipping network detection). Otherwise falls back to probing. func forgeForDomainMaybeConfig(ctx context.Context, client *forges.Client, domain string) (forges.Forge, error) { + return forgeForDomainAtBaseURL(ctx, client, domain, baseURLFor(domain)) +} + +func forgeForDomainAtBaseURL(ctx context.Context, client *forges.Client, domain, baseURL string) (forges.Forge, error) { f, err := client.ForgeFor(domain) if err == nil { return f, nil } token := TokenForDomain(domain) - if regErr := client.RegisterDomain(ctx, domain, token, builders); regErr != nil { + if regErr := client.RegisterDomain(ctx, baseURL, token, builders); regErr != nil { return nil, fmt.Errorf("unknown forge at %s: %w (use --forge-type, or set type under [%s] in config, to skip detection)", domain, regErr, domain) } return client.ForgeFor(domain) @@ -318,6 +370,16 @@ func configForgeType(domain string) string { return cfg.Domains[domain].Type } +// configScheme returns the API scheme for a domain from config files, +// or empty string if not configured. +func configScheme(domain string) string { + cfg, err := config.Load() + if err != nil || cfg == nil { + return "" + } + return cfg.Domains[domain].Scheme +} + // TokenForDomain looks up an auth token. Checks environment variables first // (highest precedence), then falls back to the user config file. func TokenForDomain(domain string) string { @@ -396,7 +458,7 @@ func Domain(forgeType string) string { if hostOverride != "" { return hostOverride } - if d := os.Getenv("FORGE_HOST"); d != "" { + if _, d := splitScheme(os.Getenv("FORGE_HOST")); d != "" { return d } if forgeType != "" { diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go index 2b1b6a3..a9f2bd0 100644 --- a/internal/resolve/resolve_test.go +++ b/internal/resolve/resolve_test.go @@ -3,15 +3,48 @@ package resolve import ( "context" "io" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" + "github.com/git-pkgs/forge" "github.com/git-pkgs/forge/internal/config" ) +func TestResourceFromURLUsesSchemeAndPort(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Forgejo-Version", "7.0.0") + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + f, domain, ref, err := ResourceFromURL(srv.URL + "/owner/repo/pulls/42") + if err != nil { + t.Fatalf("ResourceFromURL: %v", err) + } + if want := strings.TrimPrefix(srv.URL, "http://"); domain != want { + t.Errorf("domain = %q, want %q", domain, want) + } + if ref.Owner != "owner" || ref.Repo != "repo" || ref.Type != forges.ResourceTypePR || ref.Number != 42 { + t.Errorf("unexpected resource ref: %+v", ref) + } + provider, ok := f.(forges.APIBaseURLProvider) + if !ok { + t.Fatal("forge does not implement APIBaseURLProvider") + } + if got, want := provider.APIBaseURL(), srv.URL+"/api/v1"; got != want { + t.Errorf("APIBaseURL = %q, want %q", got, want) + } +} + func TestMapSSHHost(t *testing.T) { config.ResetCache() defer config.ResetCache() @@ -366,6 +399,135 @@ func TestSetHost(t *testing.T) { } } +func TestSetHostWithScheme(t *testing.T) { + oldH, oldS := hostOverride, schemeOverride + defer func() { hostOverride, schemeOverride = oldH, oldS }() + + SetHost("HTTP://172.30.0.10:3000/forge/") + if hostOverride != "172.30.0.10:3000" { + t.Errorf("SetHost with URL should store bare host, got %q", hostOverride) + } + if schemeOverride != "http" { + t.Errorf("SetHost with URL should record scheme, got %q", schemeOverride) + } + + // Bare host clears any prior scheme override so a later --host without a + // scheme does not inherit the previous one. + SetHost("gitea.example.com") + if schemeOverride != "" { + t.Errorf("SetHost with bare host should clear scheme, got %q", schemeOverride) + } +} + +func TestBaseURLForDefaultsToHTTPS(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("FORGE_HOST", "") + + oldH, oldS := hostOverride, schemeOverride + defer func() { hostOverride, schemeOverride = oldH, oldS }() + hostOverride, schemeOverride = "", "" + + if got := baseURLFor("gitea.example.com"); got != "https://gitea.example.com" { + t.Errorf("baseURLFor default = %q, want https://gitea.example.com", got) + } +} + +func TestBaseURLForFromHostFlag(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("FORGE_HOST", "") + + oldH, oldS := hostOverride, schemeOverride + defer func() { hostOverride, schemeOverride = oldH, oldS }() + SetHost("http://172.30.0.10:3000") + + if got := baseURLFor("172.30.0.10:3000"); got != "http://172.30.0.10:3000" { + t.Errorf("baseURLFor from --host = %q, want http://172.30.0.10:3000", got) + } + // Scheme override only applies to the host it was given for. + if got := baseURLFor("codeberg.org"); got != "https://codeberg.org" { + t.Errorf("baseURLFor other domain = %q, want https://codeberg.org", got) + } +} + +func TestBaseURLForFromEnv(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("FORGE_HOST", "http://forgejo.local:3000") + + oldH, oldS := hostOverride, schemeOverride + defer func() { hostOverride, schemeOverride = oldH, oldS }() + hostOverride, schemeOverride = "", "" + + if got := Domain(""); got != "forgejo.local:3000" { + t.Errorf("Domain with URL FORGE_HOST = %q, want forgejo.local:3000", got) + } + if got := baseURLFor("forgejo.local:3000"); got != "http://forgejo.local:3000" { + t.Errorf("baseURLFor from FORGE_HOST = %q, want http://forgejo.local:3000", got) + } + if got := baseURLFor("codeberg.org"); got != "https://codeberg.org" { + t.Errorf("baseURLFor other domain = %q, want https://codeberg.org", got) + } +} + +func TestBaseURLForFromConfig(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("FORGE_HOST", "") + cfgDir := filepath.Join(dir, "forge") + _ = os.MkdirAll(cfgDir, 0700) + _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[172.30.0.10:3000] +type = forgejo +scheme = http +`), 0600) + + oldH, oldS := hostOverride, schemeOverride + defer func() { hostOverride, schemeOverride = oldH, oldS }() + hostOverride, schemeOverride = "", "" + + if got := baseURLFor("172.30.0.10:3000"); got != "http://172.30.0.10:3000" { + t.Errorf("baseURLFor from config = %q, want http://172.30.0.10:3000", got) + } +} + +func TestForgeForDomainHTTPScheme(t *testing.T) { + config.ResetCache() + defer config.ResetCache() + + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + t.Setenv("FORGE_HOST", "") + cfgDir := filepath.Join(dir, "forge") + _ = os.MkdirAll(cfgDir, 0700) + _ = os.WriteFile(filepath.Join(cfgDir, "config"), []byte(`[forgejo.local:3000] +type = forgejo +scheme = http +`), 0600) + + oldH, oldS := hostOverride, schemeOverride + defer func() { hostOverride, schemeOverride = oldH, oldS }() + hostOverride, schemeOverride = "", "" + + f, err := ForgeForDomain("forgejo.local:3000") + if err != nil { + t.Fatalf("ForgeForDomain: %v", err) + } + p, ok := f.(forges.APIBaseURLProvider) + if !ok { + t.Fatalf("forge does not implement APIBaseURLProvider") + } + if got := p.APIBaseURL(); got != "http://forgejo.local:3000/api/v1" { + t.Errorf("APIBaseURL = %q, want http://forgejo.local:3000/api/v1", got) + } +} + func TestRemoteDefaultsToOrigin(t *testing.T) { if remoteName != "origin" { t.Errorf("default remote should be origin, got %q", remoteName) @@ -400,6 +562,7 @@ func TestRemoteSelectsCorrectGitURL(t *testing.T) { mustGit(t, "init", "-q") mustGit(t, "remote", "add", "origin", "https://gitea.example.com/owner/origin-repo.git") mustGit(t, "remote", "add", "mirror", "https://github.com/owner/mirror-repo.git") + mustGit(t, "remote", "add", "local", "http://172.30.0.10:3000/owner/local-repo.git") old := remoteName defer func() { remoteName = old }() @@ -411,6 +574,7 @@ func TestRemoteSelectsCorrectGitURL(t *testing.T) { }{ {"origin", "gitea.example.com", "origin-repo"}, {"mirror", "github.com", "mirror-repo"}, + {"local", "172.30.0.10:3000", "local-repo"}, } for _, tt := range tests {