package hosting_service import ( "net/url" "regexp" "strings" "github.com/go-errors/errors" "github.com/jesseduffield/lazygit/pkg/i18n" "github.com/jesseduffield/lazygit/pkg/utils" "github.com/samber/lo" "github.com/sirupsen/logrus" "golang.org/x/exp/slices" ) // This package is for handling logic specific to a git hosting service like github, gitlab, bitbucket, gitea, etc. // Different git hosting services have different URL formats for when you want to open a PR or view a commit, // and this package's responsibility is to determine which service you're using based on the remote URL, // and then which URL you need for whatever use case you have. type HostingServiceMgr struct { log logrus.FieldLogger tr *i18n.TranslationSet remoteURL string // e.g. https://github.com/jesseduffield/lazygit // see https://github.com/jesseduffield/lazygit/blob/master/docs/Config.md#custom-pull-request-urls configServiceDomains map[string]string } // NewHostingServiceMgr creates new instance of PullRequest func NewHostingServiceMgr(log logrus.FieldLogger, tr *i18n.TranslationSet, remoteURL string, configServiceDomains map[string]string) *HostingServiceMgr { return &HostingServiceMgr{ log: log, tr: tr, remoteURL: remoteURL, configServiceDomains: configServiceDomains, } } func (self *HostingServiceMgr) GetPullRequestURL(from string, to string) (string, error) { gitService, err := self.getService() if err != nil { return "", err } if to == "" { return gitService.getPullRequestURLIntoDefaultBranch(url.QueryEscape(from)), nil } return gitService.getPullRequestURLIntoTargetBranch(url.QueryEscape(from), url.QueryEscape(to)), nil } func (self *HostingServiceMgr) GetCommitURL(commitHash string) (string, error) { gitService, err := self.getService() if err != nil { return "", err } pullRequestURL := gitService.getCommitURL(commitHash) return pullRequestURL, nil } // e.g. 'jesseduffield/lazygit' func (self *HostingServiceMgr) GetRepoName() (string, error) { gitService, err := self.getService() if err != nil { return "", err } repoName := gitService.repoName 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 { return nil, err } repoURL, err := serviceDomain.serviceDefinition.getRepoURLFromRemoteURL(self.remoteURL, serviceDomain.webDomain) if err != nil { return nil, err } repoName, err := serviceDomain.serviceDefinition.getRepoNameFromRemoteURL(self.remoteURL) if err != nil { return nil, err } return &Service{ repoURL: repoURL, repoName: repoName, ServiceDefinition: serviceDomain.serviceDefinition, }, nil } func (self *HostingServiceMgr) getServiceDomain(repoURL string) (*ServiceDomain, error) { candidateServiceDomains := self.getCandidateServiceDomains() for _, serviceDomain := range candidateServiceDomains { if strings.Contains(repoURL, serviceDomain.gitDomain) { return &serviceDomain, nil } } return nil, errors.New(self.tr.UnsupportedGitService) } func (self *HostingServiceMgr) getCandidateServiceDomains() []ServiceDomain { serviceDefinitionByProvider := map[string]ServiceDefinition{} for _, serviceDefinition := range serviceDefinitions { serviceDefinitionByProvider[serviceDefinition.provider] = serviceDefinition } serviceDomains := slices.Clone(defaultServiceDomains) for gitDomain, typeAndDomain := range self.configServiceDomains { provider, webDomain, success := strings.Cut(typeAndDomain, ":") // we allow for one ':' for specifying the TCP port if !success || strings.Count(webDomain, ":") > 1 { self.log.Errorf("Unexpected format for git service: '%s'. Expected something like 'github.com:github.com'", typeAndDomain) continue } serviceDefinition, ok := serviceDefinitionByProvider[provider] if !ok { providerNames := lo.Map(serviceDefinitions, func(serviceDefinition ServiceDefinition, _ int) string { return serviceDefinition.provider }) self.log.Errorf("Unknown git service type: '%s'. Expected one of %s", provider, strings.Join(providerNames, ", ")) continue } serviceDomains = append(serviceDomains, ServiceDomain{ gitDomain: gitDomain, webDomain: webDomain, serviceDefinition: serviceDefinition, }) } return serviceDomains } // a service domains pairs a service definition with the actual domain it's being served from. // Sometimes the git service is hosted in a custom domains so although it'll use say // the github service definition, it'll actually be served from e.g. my-custom-github.com type ServiceDomain struct { gitDomain string // the one that appears in the git remote url webDomain string // the one that appears in the web url serviceDefinition ServiceDefinition } type ServiceDefinition struct { provider string pullRequestURLIntoDefaultBranch string pullRequestURLIntoTargetBranch string commitURL string urlRegexps []*regexp.Regexp // can expect 'webdomain' to be passed in. Otherwise, you get to pick what we match in the regex repoURLTemplate string repoNameTemplate string } func (self ServiceDefinition) getRepoURLFromRemoteURL(url string, webDomain string) (string, error) { matches, err := self.parseRemoteUrl(url) if err != nil { return "", err } matches["webDomain"] = webDomain return utils.ResolvePlaceholderString(self.repoURLTemplate, matches), nil } func (self ServiceDefinition) getRepoNameFromRemoteURL(url string) (string, error) { matches, err := self.parseRemoteUrl(url) if err != nil { return "", err } return utils.ResolvePlaceholderString(self.repoNameTemplate, matches), nil } func (self ServiceDefinition) parseRemoteUrl(url string) (map[string]string, error) { for _, re := range self.urlRegexps { matches := utils.FindNamedMatches(re, url) if matches != nil { return matches, nil } } return nil, errors.New("Failed to parse repo information from url") } // RepoInformation holds the owner and repository name parsed from a remote URL. type RepoInformation struct { Owner string Repository string } // GetRepoInfoFromURL parses a remote URL (SSH or HTTPS) and extracts the // owner and repository name using the default URL regex patterns. func GetRepoInfoFromURL(url string) (RepoInformation, error) { for _, re := range defaultUrlRegexps { matches := utils.FindNamedMatches(re, url) if matches != nil { return RepoInformation{ Owner: matches["owner"], Repository: matches["repo"], }, nil } } return RepoInformation{}, errors.New("Failed to parse repo information from url") } type Service struct { repoURL string // e.g. 'jesseduffield/lazygit' repoName string ServiceDefinition } func (self *Service) getPullRequestURLIntoDefaultBranch(from string) string { return self.resolveUrl(self.pullRequestURLIntoDefaultBranch, map[string]string{"From": from}) } func (self *Service) getPullRequestURLIntoTargetBranch(from string, to string) string { return self.resolveUrl(self.pullRequestURLIntoTargetBranch, map[string]string{"From": from, "To": to}) } func (self *Service) getCommitURL(commitHash string) string { return self.resolveUrl(self.commitURL, map[string]string{"CommitHash": commitHash}) } func (self *Service) resolveUrl(templateString string, args map[string]string) string { return self.repoURL + utils.ResolvePlaceholderString(templateString, args) }