package agent import ( "fmt" "strconv" "strings" "time" ) // PRRef identifies a single pull request by repository and number. type PRRef struct { Owner string Repo string Number int } // String renders the ref in canonical owner/repo#N form. func (r PRRef) String() string { return fmt.Sprintf("%s/%s#%d", r.Owner, r.Repo, r.Number) } // RepoPath returns the "owner/repo" portion used in Gitea API URLs. func (r PRRef) RepoPath() string { return r.Owner + "/" + r.Repo } // ParsePRRef parses "owner/repo#N" or "owner/repo:N" into a PRRef. func ParsePRRef(s string) (PRRef, error) { s = strings.TrimSpace(s) sep := strings.IndexAny(s, "#:") if sep < 0 { return PRRef{}, fmt.Errorf("invalid PR reference %q: expected owner/repo#N or owner/repo:N", s) } repoPart := s[:sep] numPart := s[sep+1:] owner, repo, ok := strings.Cut(repoPart, "/") if !ok || owner == "" || repo == "" { return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s) } if strings.Contains(repo, "/") { return PRRef{}, fmt.Errorf("invalid PR reference %q: repo must be owner/repo", s) } n, err := strconv.Atoi(numPart) if err != nil || n <= 0 { return PRRef{}, fmt.Errorf("invalid PR reference %q: PR number must be a positive integer", s) } return PRRef{Owner: owner, Repo: repo, Number: n}, nil } // ParseDurationFlag parses a duration flag value, accepting either a Go // duration string ("30s", "1h30m") or a bare integer read as seconds ("15"). // flag names the flag so the error says which value was rejected. func ParseDurationFlag(flag, value string) (time.Duration, error) { s := strings.TrimSpace(value) d, err := time.ParseDuration(s) if err != nil { n, nerr := strconv.Atoi(s) if nerr != nil { return 0, fmt.Errorf("invalid --%s value %q: want a duration such as 30s, 2m or 1h30m, or a bare number of seconds such as 15", flag, value) } d = time.Duration(n) * time.Second } if d <= 0 { return 0, fmt.Errorf("invalid --%s value %q: must be greater than zero", flag, value) } return d, nil } // RemoteHost returns the host a git remote URL points at, or "" for a local // path remote. func RemoteHost(remote string) string { s := strings.TrimSpace(remote) if _, after, ok := strings.Cut(s, "://"); ok { host, _, _ := strings.Cut(after, "/") if _, bare, ok := strings.Cut(host, "@"); ok { host = bare } return host } if strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") { return "" } host, _, ok := strings.Cut(s, ":") if !ok { return "" } if _, bare, ok := strings.Cut(host, "@"); ok { host = bare } return host } // RepoPathFromRemoteURL extracts the "owner/repo" API path from a git remote // URL, accepting both https and scp-style ssh forms. func RepoPathFromRemoteURL(remote string) (string, error) { s := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSpace(remote), "/"), ".git") switch { case strings.Contains(s, "://"): _, after, _ := strings.Cut(s, "://") _, path, ok := strings.Cut(after, "/") if !ok { return "", fmt.Errorf("remote URL %q has no repo path", remote) } s = path case strings.Contains(s, ":"): _, s, _ = strings.Cut(s, ":") } parts := strings.Split(strings.Trim(s, "/"), "/") if len(parts) < 2 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" { return "", fmt.Errorf("remote URL %q is not owner/repo shaped", remote) } return parts[len(parts)-2] + "/" + parts[len(parts)-1], nil } // ParseRepo validates and splits an "owner/repo" string. func ParseRepo(s string) (owner, repo string, err error) { s = strings.TrimSpace(s) owner, repo, ok := strings.Cut(s, "/") if !ok || owner == "" || repo == "" || strings.Contains(repo, "/") { return "", "", fmt.Errorf("invalid repo %q: expected owner/repo", s) } return owner, repo, nil }