package vcs import ( "net/url" "strings" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" ) // ErrUnsupportedHost is returned by ParseRepoRef when the remote points at a // host other than the one the v1 Gitea provider serves. var ErrUnsupportedHost = errs.New(errs.CodeInvalidInput, "vcs: unsupported git host for PR operations") // ParseRepoRef derives owner/name from a workspace GitRemoteURL. It accepts: // // https://host/owner/name(.git) // http://host/owner/name(.git) // ssh://git@host/owner/name(.git) // git@host:owner/name(.git) // // wantHost is the bare host the provider serves (e.g. "git.jerryyan.net"); when // non-empty the remote host must match it (port stripped, case-insensitive), // otherwise ErrUnsupportedHost is returned. wantHost empty skips the host // check (host-agnostic parse, used in tests). func ParseRepoRef(remoteURL, wantHost string) (RepoRef, error) { remoteURL = strings.TrimSpace(remoteURL) if remoteURL == "" { return RepoRef{}, errs.New(errs.CodeInvalidInput, "vcs: empty remote url") } host, path, err := splitHostPath(remoteURL) if err != nil { return RepoRef{}, err } if wantHost != "" && !sameHost(host, wantHost) { return RepoRef{}, ErrUnsupportedHost } owner, name, err := splitOwnerName(path) if err != nil { return RepoRef{}, err } return RepoRef{Owner: owner, Name: name}, nil } // splitHostPath extracts the bare host and the repo path from a remote URL, // handling both scheme URLs and the scp-like git@host:owner/name form. func splitHostPath(remoteURL string) (host, path string, err error) { // scp-like: git@host:owner/name.git (no scheme, single colon before path) if !strings.Contains(remoteURL, "://") && strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") { at := strings.Index(remoteURL, "@") rest := remoteURL[at+1:] colon := strings.Index(rest, ":") if colon < 0 { return "", "", errs.New(errs.CodeInvalidInput, "vcs: malformed scp remote url") } return rest[:colon], rest[colon+1:], nil } u, perr := url.Parse(remoteURL) if perr != nil || u.Host == "" { return "", "", errs.Wrap(perr, errs.CodeInvalidInput, "vcs: cannot parse remote url") } return u.Hostname(), u.Path, nil } // splitOwnerName turns "/owner/name.git" or "owner/name.git" into (owner, name). func splitOwnerName(path string) (owner, name string, err error) { path = strings.Trim(path, "/") path = strings.TrimSuffix(path, ".git") parts := strings.Split(path, "/") if len(parts) < 2 || parts[0] == "" || parts[len(parts)-1] == "" { return "", "", errs.New(errs.CodeInvalidInput, "vcs: remote url must contain owner/name") } // Gitea repos are owner/name; deeper paths are not valid repo refs. return parts[0], parts[len(parts)-1], nil } // sameHost compares hosts case-insensitively, ignoring any :port suffix. func sameHost(a, b string) bool { return strings.EqualFold(stripPort(a), stripPort(b)) } func stripPort(h string) string { if i := strings.LastIndex(h, ":"); i >= 0 { return h[:i] } return h }