From 21d58085f4e5d60a18d161da81fb0d9298dbe4ed Mon Sep 17 00:00:00 2001 From: Stefan Haller Date: Thu, 7 May 2026 22:49:13 +0200 Subject: [PATCH] Expose service info (provider, host, owner, repo) from the hosting service The github pull-request fetcher needs to know whether a given remote is a github-type service, which host its API lives on, and which owner/repo to query against. Today the fetcher hardcodes the first two ("does the URL contain github.com" and "https://api.github.com/graphql") and re-derives owner/repo from the remote, which precludes GitHub Enterprise and makes the fetch entry point take more arguments than it needs. Add an accessor on the hosting service manager that exposes the already- resolved service domain together with the parsed owner/repo, so callers can answer all of these questions without reaching into the manager's internals. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/commands/git_commands/hosting_service.go | 4 + .../hosting_service/hosting_service.go | 36 ++++++ .../hosting_service/hosting_service_test.go | 104 ++++++++++++++++++ 3 files changed, 144 insertions(+) diff --git a/pkg/commands/git_commands/hosting_service.go b/pkg/commands/git_commands/hosting_service.go index 7d9772127..5deceea8e 100644 --- a/pkg/commands/git_commands/hosting_service.go +++ b/pkg/commands/git_commands/hosting_service.go @@ -25,6 +25,10 @@ func (self *HostingService) GetRepoNameFromRemoteURL(remoteURL string) (string, return self.getHostingServiceMgr(remoteURL).GetRepoName() } +func (self *HostingService) GetServiceInfo(remoteURL string) (hosting_service.ServiceInfo, error) { + return self.getHostingServiceMgr(remoteURL).GetServiceInfo() +} + // getting this on every request rather than storing it in state in case our remoteURL changes // from one invocation to the next. Note however that we're currently caching config // results so we might want to invalidate the cache here if it becomes a problem. diff --git a/pkg/commands/hosting_service/hosting_service.go b/pkg/commands/hosting_service/hosting_service.go index 620d0d0a7..ad1f072c5 100644 --- a/pkg/commands/hosting_service/hosting_service.go +++ b/pkg/commands/hosting_service/hosting_service.go @@ -73,6 +73,42 @@ func (self *HostingServiceMgr) GetRepoName() (string, error) { return repoName, nil } +// ServiceInfo holds the resolved hosting service for a remote URL. Owner +// comes from the "owner" named regex capture, which only exists for +// owner/repo-shaped providers (github, gitlab, bitbucket, gitea, codeberg); +// it's empty for azuredevops and bitbucketServer, whose URLs are organised +// differently. Repository is populated for every provider, but RepoName may +// have more than two segments (e.g. "org/project/repo" for azuredevops). +type ServiceInfo struct { + Provider string // e.g. "github" + WebDomain string // e.g. "github.com", or "git.acme.com" for an on-prem instance + Owner string // e.g. "jesseduffield" + Repository string // e.g. "lazygit" + RepoName string // e.g. "jesseduffield/lazygit" +} + +// GetServiceInfo identifies which hosting service the configured remote URL +// belongs to and returns enough information to talk to its web/API host. +func (self *HostingServiceMgr) GetServiceInfo() (ServiceInfo, error) { + serviceDomain, err := self.getServiceDomain(self.remoteURL) + if err != nil { + return ServiceInfo{}, err + } + + matches, err := serviceDomain.serviceDefinition.parseRemoteUrl(self.remoteURL) + if err != nil { + return ServiceInfo{}, err + } + + return ServiceInfo{ + Provider: serviceDomain.serviceDefinition.provider, + WebDomain: serviceDomain.webDomain, + Owner: matches["owner"], + Repository: matches["repo"], + RepoName: utils.ResolvePlaceholderString(serviceDomain.serviceDefinition.repoNameTemplate, matches), + }, nil +} + func (self *HostingServiceMgr) getService() (*Service, error) { serviceDomain, err := self.getServiceDomain(self.remoteURL) if err != nil { diff --git a/pkg/commands/hosting_service/hosting_service_test.go b/pkg/commands/hosting_service/hosting_service_test.go index c2fabcd0d..f150f22eb 100644 --- a/pkg/commands/hosting_service/hosting_service_test.go +++ b/pkg/commands/hosting_service/hosting_service_test.go @@ -577,3 +577,107 @@ func TestGetPullRequestURL(t *testing.T) { }) } } + +func TestGetServiceInfo(t *testing.T) { + scenarios := []struct { + name string + remoteURL string + configServiceDomains map[string]string + expected ServiceInfo + }{ + { + name: "github.com SSH", + remoteURL: "git@github.com:jesseduffield/lazygit.git", + expected: ServiceInfo{ + Provider: "github", + WebDomain: "github.com", + Owner: "jesseduffield", + Repository: "lazygit", + RepoName: "jesseduffield/lazygit", + }, + }, + { + name: "github enterprise with same git and web host", + remoteURL: "git@github.example.com:my-org/my-repo.git", + configServiceDomains: map[string]string{ + "github.example.com": "github:github.example.com", + }, + expected: ServiceInfo{ + Provider: "github", + WebDomain: "github.example.com", + Owner: "my-org", + Repository: "my-repo", + RepoName: "my-org/my-repo", + }, + }, + { + name: "github enterprise with distinct git and web hosts", + remoteURL: "git@git.example.com:my-org/my-repo.git", + configServiceDomains: map[string]string{ + "git.example.com": "github:ghe.example.com", + }, + expected: ServiceInfo{ + Provider: "github", + WebDomain: "ghe.example.com", + Owner: "my-org", + Repository: "my-repo", + RepoName: "my-org/my-repo", + }, + }, + { + name: "github enterprise with web host port", + remoteURL: "git@git.example.com:my-org/my-repo.git", + configServiceDomains: map[string]string{ + "git.example.com": "github:ghe.example.com:8443", + }, + expected: ServiceInfo{ + Provider: "github", + WebDomain: "ghe.example.com:8443", + Owner: "my-org", + Repository: "my-repo", + RepoName: "my-org/my-repo", + }, + }, + { + // azuredevops uses org/project/repo named captures rather than + // owner/repo, so Owner is unpopulated and RepoName has three + // segments rather than the usual two. + name: "azuredevops", + remoteURL: "https://myorg@dev.azure.com/myorg/myproject/_git/myrepo", + expected: ServiceInfo{ + Provider: "azuredevops", + WebDomain: "dev.azure.com", + Repository: "myrepo", + RepoName: "myorg/myproject/myrepo", + }, + }, + { + // bitbucketServer uses project/repo named captures, so Owner is + // unpopulated and RepoName is project/repo rather than owner/repo. + name: "bitbucketServer", + remoteURL: "https://mycompany.bitbucket.com/scm/myproject/myrepo.git", + configServiceDomains: map[string]string{ + "mycompany.bitbucket.com": "bitbucketServer:mycompany.bitbucket.com", + }, + expected: ServiceInfo{ + Provider: "bitbucketServer", + WebDomain: "mycompany.bitbucket.com", + Repository: "myrepo", + RepoName: "myproject/myrepo", + }, + }, + } + + for _, s := range scenarios { + t.Run(s.name, func(t *testing.T) { + tr := i18n.EnglishTranslationSet() + log := &fakes.FakeFieldLogger{} + mgr := NewHostingServiceMgr(log, tr, s.remoteURL, s.configServiceDomains) + + info, err := mgr.GetServiceInfo() + + assert.NoError(t, err) + assert.Equal(t, s.expected, info) + }) + } +}