internal/glrepo/repo.go (219 lines of code) (raw):
package glrepo
import (
"errors"
"fmt"
"net/url"
"slices"
"strings"
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/internal/config"
gitlab "gitlab.com/gitlab-org/api/client-go"
"gitlab.com/gitlab-org/cli/pkg/git"
"gitlab.com/gitlab-org/cli/pkg/glinstance"
"gitlab.com/gitlab-org/cli/pkg/utils"
)
// RemoteURL returns correct git clone URL of a repo
// based on the user's git_protocol preference
func RemoteURL(project *gitlab.Project, protocol string) string {
if protocol == "ssh" {
return project.SSHURLToRepo
}
return project.HTTPURLToRepo
}
// FullName returns the the repo with its namespace (like profclems/glab). Respects group and subgroups names
func FullNameFromURL(remoteURL string) (string, error) {
parts := strings.Split(remoteURL, "//")
if len(parts) == 1 {
// scp-like short syntax (e.g. git@gitlab.com...)
part := parts[0]
parts = strings.Split(part, ":")
} else if len(parts) == 2 {
// other protocols (e.g. ssh://, http://, git://)
part := parts[1]
parts = strings.SplitN(part, "/", 2)
} else {
return "", errors.New("cannot parse remote: " + remoteURL)
}
if len(parts) != 2 {
return "", errors.New("cannot parse remote: " + remoteURL)
}
repo := parts[1]
repo = strings.TrimSuffix(repo, "/")
repo = strings.TrimSuffix(repo, ".git")
return repo, nil
}
// Interface describes an object that represents a GitLab repository
// Contains methods for these methods representing these placeholders for a
// project path with :host/:group/:namespace/:repo
// RepoHost = :host, RepoOwner = :group/:namespace, RepoNamespace = :namespace,
// FullName = :group/:namespace/:repo, RepoGroup = :group, RepoName = :repo
type Interface interface {
RepoName() string
RepoOwner() string
RepoNamespace() string
RepoGroup() string
RepoHost() string
FullName() string
Project(*gitlab.Client) (*gitlab.Project, error)
}
// New instantiates a GitLab repository from owner and repo name arguments
func New(owner, repo string) Interface {
return NewWithHost(owner, repo, glinstance.OverridableDefault())
}
// NewWithGroup instantiates a GitLab repository from group, namespace and repo name arguments
func NewWithGroup(group, namespace, repo, hostname string) Interface {
owner := fmt.Sprintf("%s/%s", group, namespace)
if hostname == "" {
return New(owner, repo)
}
return NewWithHost(owner, repo, hostname)
}
// NewWithHost is like New with an explicit host name
func NewWithHost(owner, repo, hostname string) Interface {
rp := &glRepo{
owner: owner,
name: repo,
fullname: fmt.Sprintf("%s/%s", owner, repo),
hostname: normalizeHostname(hostname),
}
if ri := strings.SplitN(owner, "/", 2); len(ri) == 2 {
rp.group = ri[0]
rp.namespace = ri[1]
} else {
rp.namespace = owner
}
return rp
}
// FromFullName extracts the GitLab repository information from the following
// formats: "OWNER/REPO", "HOST/OWNER/REPO", "HOST/GROUP/NAMESPACE/REPO", and a full URL.
func FromFullName(nwo string) (Interface, error) {
nwo = strings.TrimSpace(nwo)
// check if it's a valid git URL and parse it
if git.IsValidURL(nwo) {
u, err := git.ParseURL(nwo)
if err != nil {
return nil, err
}
return FromURL(u)
}
// check if it is valid URL and parse it
if utils.IsValidURL(nwo) {
u, _ := url.Parse(nwo)
return FromURL(u)
}
repo := nwo[strings.LastIndex(nwo, "/")+1:]
nwoWithoutRepo := strings.TrimSuffix(nwo[:strings.LastIndex(nwo, "/")+1], "/")
parts := strings.SplitN(nwoWithoutRepo, "/", 2)
if repo == "" {
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/[NAMESPACE/]REPO" format, got %q`, nwo)
}
if slices.Contains(parts, "") {
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/[NAMESPACE/]REPO" format, got %q`, nwo)
}
switch len(parts) {
case 2: // GROUP/NAMESPACE/REPO or HOST/OWNER/REPO or //HOST/GROUP/NAMESPACE/REPO
// First, checks if the first part matches the default instance host (i.e. gitlab.com) or the
// overridden default host (mostly from the GITLAB_HOST env variable)
if parts[0] == glinstance.Default() || parts[0] == glinstance.OverridableDefault() {
return NewWithHost(parts[1], repo, normalizeHostname(parts[0])), nil
}
// Dots (.) are allowed in group names by GitLab.
// So we check if the first part contains a dot.
// However, it could be that the user is specifying a hostname but we can't be sure of that
// So we check in the list of authenticated hosts and see if it matches any
// if not, we assume it is a group name that contains a dot
if strings.ContainsRune(parts[0], '.') {
var rI Interface
cfg, err := config.Init()
if err == nil {
hosts, _ := cfg.Hosts()
if slices.Contains(hosts, parts[0]) {
rI = NewWithHost(parts[1], repo, normalizeHostname(parts[0]))
}
if rI != nil {
return rI, nil
}
}
}
// if the first part is not a valid URL, and does not match an
// authenticated hostname then we assume it is in
// the format GROUP/NAMESPACE/REPO
return NewWithGroup(parts[0], parts[1], repo, ""), nil
case 1: // OWNER/REPO
return New(parts[0], repo), nil
default:
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/[NAMESPACE/]REPO" format, got %q`, nwo)
}
}
// FromURL extracts the GitLab repository information from a git remote URL
func FromURL(u *url.URL) (Interface, error) {
if u.Hostname() == "" {
return nil, fmt.Errorf("no hostname detected")
}
var urlPath string
var repo string
var pathWithoutRepo string
var apiHost string
cfg, err := config.ParseDefaultConfig()
// an error is fine here, there might not be a config available
if err == nil {
apiHost, _ = cfg.Get(u.Hostname(), "api_host")
}
if apiHost != "" {
parts := strings.SplitN(apiHost, "/", 2)
if len(parts) > 1 {
gitSubdirectory := strings.Replace(apiHost, parts[0], "", 1)
urlPath = strings.Replace(apiHost+u.Path, apiHost+gitSubdirectory, "", 1)
} else {
urlPath = strings.Replace(apiHost+u.Path, apiHost, "", 1)
}
urlPath = strings.Trim(strings.TrimSuffix(urlPath, ".git"), "/")
pathWithoutRepo = strings.TrimSuffix(urlPath[:strings.LastIndex(urlPath, "/")+1], "/")
pathWithoutRepo = strings.TrimPrefix(pathWithoutRepo, "/")
} else {
urlPath = strings.Trim(strings.TrimSuffix(u.Path, ".git"), "/")
pathWithoutRepo = strings.TrimSuffix(urlPath[:strings.LastIndex(urlPath, "/")+1], "/")
}
repo = urlPath[strings.LastIndex(urlPath, "/")+1:]
if repo != "" && pathWithoutRepo != "" {
parts := strings.SplitN(pathWithoutRepo, "/", 2)
if len(parts) == 1 {
return NewWithHost(parts[0], repo, u.Hostname()), nil
}
if len(parts) == 2 {
return NewWithGroup(parts[0], parts[1], repo, u.Hostname()), nil
}
}
return nil, fmt.Errorf("invalid path: %s", u.Path)
}
func normalizeHostname(h string) string {
return strings.ToLower(strings.TrimPrefix(h, "www."))
}
// IsSame compares two GitLab repositories
func IsSame(a, b Interface) bool {
if a == nil || b == nil {
return false
}
return strings.EqualFold(a.FullName(), b.FullName()) &&
normalizeHostname(a.RepoHost()) == normalizeHostname(b.RepoHost())
}
type glRepo struct {
group string
owner string
name string
fullname string
hostname string
namespace string
project *Project
}
type Project struct {
*gitlab.Project
// for cache invalidation
fullname string
// for cache invalidation
hostname string
}
func (r glRepo) Project(client *gitlab.Client) (*gitlab.Project, error) {
if r.project != nil && r.project.fullname == r.fullname && r.project.hostname == r.hostname {
return r.project.Project, nil
}
p, err := api.GetProject(client, r.fullname)
if err != nil {
return nil, err
}
r.project = &Project{
Project: p,
fullname: r.fullname,
hostname: r.hostname,
}
return r.project.Project, err
}
// RepoNamespace returns the namespace of the project. Eg. if project path is :group/:namespace:/repo
// RepoNamespace returns the :namespace
func (r glRepo) RepoNamespace() string {
return r.namespace
}
// RepoGroup returns the group namespace of the project. Eg. if project path is :group/:namespace:/repo
// RepoGroup returns the :group
func (r glRepo) RepoGroup() string {
return r.group
}
// RepoOwner returns the group and namespace in the form "group/namespace". Returns "namespace" if group is not present
func (r glRepo) RepoOwner() string {
if r.group != "" {
return r.group + "/" + r.namespace
}
return r.owner
}
// RepoName returns the repo name without the path or namespace.
func (r glRepo) RepoName() string {
return r.name
}
// RepoHost returns the hostname
func (r glRepo) RepoHost() string {
return r.hostname
}
// FullName returns the full project path :group/:namespace/:repo or :namespace/:repo if group is not present
func (r glRepo) FullName() string {
return r.fullname
}