Files
gitea-tea/internal/app/ref.go
T
2026-10-12 20:29:37 +02:00

322 lines
11 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"cmp"
"context"
"errors"
"fmt"
"net/url"
"slices"
"strconv"
"strings"
"gitea.dev/tea/internal/clierr"
"gitea.dev/tea/internal/git"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/settings"
"github.com/spf13/cobra"
)
// refKind is what a reference argument addresses, which decides the URLs it accepts.
type refKind int
const (
// issueRef is an issue or pull request number; it accepts issue and pull request URLs.
issueRef refKind = iota
// pullRef is a pull request number; it accepts pull request and issue URLs.
pullRef
// runRef is an Actions run ID; it accepts run and job URLs.
runRef
)
// ref is a parsed reference argument.
type ref struct {
Number int64
// Repo is the repository the reference names, as a repository argument (a URL,
// [HOST/]OWNER/REPO or NAME); it is empty for N and #N.
Repo string
}
// errNotRef marks an argument that is not a number, URL or OWNER/REPO#N, such as a branch name.
var errNotRef = errors.New("not a reference")
// parseRef parses N, #N, [HOST/]OWNER/REPO#N, or a URL of what kind addresses:
// https://HOST/OWNER/REPO/(issues|pulls)/N[#...] for issues and pull requests, and
// https://HOST/OWNER/REPO/actions/runs/N[/jobs/M] for runs.
func parseRef(raw string, kind refKind) (ref, error) {
raw = strings.TrimSpace(raw)
if strings.Contains(raw, "://") {
return parseRefURL(raw, kind)
}
repo, value, hashed := strings.Cut(raw, "#")
if !hashed {
repo, value = "", raw
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil && !hashed {
return ref{}, errNotRef
}
if err != nil || n < 1 || (repo != "" && kind == runRef) {
return ref{}, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid %s %q: use %s", kind.noun(), raw, kind.forms()))
}
return ref{Number: n, Repo: repo}, nil
}
func parseRefURL(raw string, kind refKind) (ref, error) {
invalid := clierr.Wrap(clierr.Usage, fmt.Errorf("%q is not %s URL; use %s", raw, kind.article(), kind.forms()))
u, err := url.Parse(raw)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil {
return ref{}, invalid
}
segments := strings.Split(strings.Trim(u.Path, "/"), "/")
at := func(i int) string {
if i < len(segments) {
return segments[i]
}
return ""
}
positive := func(i int) int64 {
n, err := strconv.ParseInt(at(i), 10, 64)
if err != nil || n < 1 {
return 0
}
return n
}
// The repository is the path before the marker, so instances below a base path work too.
for i := 2; i < len(segments); i++ {
result := ref{}
switch {
case kind == runRef && at(i) == "actions" && at(i+1) == "runs" && positive(i+2) > 0:
// Since Gitea 1.26 the web URL holds the run ID, which the API takes too; a trailing
// /jobs/M only selects a job on the web page.
result.Number = positive(i + 2)
case kind != runRef && (at(i) == "issues" || at(i) == "pulls") && positive(i+1) > 0:
result.Number = positive(i + 1)
default:
continue
}
repository := url.URL{Scheme: u.Scheme, Host: u.Host, Path: "/" + strings.Join(segments[:i], "/")}
result.Repo = repository.String()
return result, nil
}
return ref{}, invalid
}
func (k refKind) noun() string {
switch k {
case pullRef:
return "pull request"
case runRef:
return "run"
}
return "issue"
}
// article returns noun with its indefinite article, such as "an issue".
func (k refKind) article() string {
if k == issueRef {
return "an issue"
}
return "a " + k.noun()
}
// forms lists the reference forms of kind for error messages.
func (k refKind) forms() string {
if k == runRef {
return "a run ID or URL"
}
return "a number, #number, OWNER/REPO#number or URL"
}
// resolveRef resolves raw, an issue, pull request or run reference, and the repository it
// belongs to. A URL or OWNER/REPO#N selects that repository for this invocation and must
// agree with --repo; N and #N address the repository tea targets otherwise.
func (a *application) resolveRef(cmd *cobra.Command, raw string, kind refKind) (target, int64, error) {
t, numbers, err := a.resolveRefs(cmd, []string{raw}, kind)
if err != nil {
return t, 0, err
}
return t, numbers[0], nil
}
// resolveRefs is resolveRef for several references, which must name the same repository and
// distinct numbers.
func (a *application) resolveRefs(cmd *cobra.Command, raws []string, kind refKind) (target, []int64, error) {
numbers := make([]int64, len(raws))
var selected ref
source := ""
for i, raw := range raws {
parsed, err := parseRef(raw, kind)
if errors.Is(err, errNotRef) {
return target{}, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("invalid %s %q: use %s", kind.noun(), raw, kind.forms()))
}
if err != nil {
return target{}, nil, err
}
if slices.Contains(numbers[:i], parsed.Number) {
return target{}, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("#%d is selected more than once; supply unique numbers", parsed.Number))
}
numbers[i] = parsed.Number
if parsed.Repo == "" {
continue
}
if selected.Repo != "" && !a.sameRepository(selected.Repo, parsed.Repo) {
return target{}, nil, clierr.Wrap(clierr.Usage, fmt.Errorf("%s and %s are in different repositories; run one command per repository", source, raw))
}
selected, source = parsed, raw
}
t, err := a.resolveRequest(cmd.Context(), targetRequest{requireRepo: true, authenticate: true, repo: selected.Repo, repoSource: source})
return t, numbers, err
}
// sameRepository reports whether two repository arguments name the same repository.
func (a *application) sameRepository(first, second string) bool {
s, err := a.settings()
if err != nil {
return strings.EqualFold(first, second)
}
one, oneErr := settings.ParseRepository(first, s.Hosts.Hosts)
two, twoErr := settings.ParseRepository(second, s.Hosts.Hosts)
return oneErr == nil && twoErr == nil && strings.EqualFold(one.Slug(), two.Slug()) && (one.Host == "" || two.Host == "" || one.Host == two.Host)
}
// pullSelector resolves the pull request a pr command addresses: N, #N, OWNER/REPO#N, a pull
// request URL, a head branch, OWNER:BRANCH, or, without an argument, the pull request of the
// checked-out branch.
func (a *application) pullSelector(cmd *cobra.Command, args []string) (target, int64, error) {
if len(args) == 0 || args[0] == "" {
// The checked-out branch says nothing about a repository chosen with --repo.
if a.options.repo != "" {
return target{}, 0, clierr.Wrap(clierr.Usage, errors.New("pass a pull request number, URL or branch with --repo"))
}
return a.currentPull(cmd.Context())
}
raw := args[0]
if _, err := parseRef(raw, pullRef); !errors.Is(err, errNotRef) {
return a.resolveRef(cmd, raw, pullRef)
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return t, 0, err
}
owner, branch, qualified := strings.Cut(raw, ":")
if !qualified {
owner, branch = "", raw
}
pulls, err := (services.Pulls{Client: t.client, Repository: t.Repo}).ListOpen(cmd.Context())
if err != nil {
return t, 0, err
}
n, err := pullForBranch(pulls, owner, branch, t.Repo)
if err != nil {
return t, 0, err
}
if n == 0 {
return t, 0, fmt.Errorf("no open pull request for branch %q in %s; pass a number, URL or branch", raw, t.Repo)
}
return t, n, nil
}
// currentPull finds the pull request of the checked-out branch: the one pr checkout recorded
// while it is open, else the open pull request whose head is the branch's upstream (and the
// upstream remote's owner), else one whose head has the local branch's name, else N from a pr/N
// branch, else the recorded pull request even though it is no longer open.
func (a *application) currentPull(ctx context.Context) (target, int64, error) {
branch, err := git.CurrentBranchConfig(ctx, a.gitProbe(), a.options.cwd)
if err != nil {
return target{}, 0, clierr.Wrap(clierr.Usage, errors.New("not on a branch; pass a pull request number, URL or branch"))
}
t, err := a.resolve(ctx, true)
if err != nil {
return t, 0, err
}
service := services.Pulls{Client: t.client, Repository: t.Repo}
// A recorded pull request that was merged or closed only counts when no open one has the branch.
recorded := int64(0)
if branch.PullNumber > 0 && (branch.BaseRemote == "" || a.remoteOwns(ctx, t, branch.BaseRemote)) {
if pull, err := service.View(ctx, branch.PullNumber); err != nil || pull.State == "open" {
return t, branch.PullNumber, nil
}
recorded = branch.PullNumber
}
pulls, err := service.ListOpen(ctx)
if err != nil {
return t, 0, err
}
if branch.Merge != "" {
owner := ""
if remoteURL, err := a.gitProbe().Output(ctx, a.options.cwd, false, "remote", "get-url", branch.Remote); err == nil {
if _, repo, err := settings.RepositoryURL(remoteURL, t.hosts()); err == nil {
owner, _, _ = strings.Cut(repo, "/")
}
}
if n, err := pullForBranch(pulls, owner, branch.Merge, t.Repo); n > 0 || err != nil {
return t, n, err
}
}
if n, err := pullForBranch(pulls, "", branch.Name, t.Repo); n > 0 || err != nil {
return t, n, err
}
if number, ok := strings.CutPrefix(branch.Name, "pr/"); ok {
if n, err := strconv.ParseInt(number, 10, 64); err == nil && n > 0 {
return t, n, nil
}
}
if recorded > 0 {
return t, recorded, nil
}
return t, 0, fmt.Errorf("no open pull request for branch %q in %s; pass a number, URL or branch", branch.Name, t.Repo)
}
// remoteOwns reports whether the Git remote named remote points at the target's repository.
func (a *application) remoteOwns(ctx context.Context, t target, remote string) bool {
raw, err := a.gitProbe().Output(ctx, a.options.cwd, false, "remote", "get-url", remote)
return err == nil && t.ownsRemote(raw)
}
// pullForBranch returns the number of the open pull request whose head is branch in a
// repository of owner (any owner when empty), 0 when none is, and an error when several are.
func pullForBranch(pulls []services.Pull, owner, branch, repo string) (int64, error) {
var matches []string
var number int64
for _, pull := range pulls {
headOwner, _, _ := strings.Cut(pull.HeadRepo, "/")
if pull.Head != branch || (owner != "" && !strings.EqualFold(headOwner, owner)) {
continue
}
matches = append(matches, "#"+strconv.FormatInt(pull.Number, 10))
number = pull.Number
}
if len(matches) > 1 {
return 0, fmt.Errorf("branch %q has several open pull requests in %s (%s); pass a number", branch, repo, strings.Join(matches, ", "))
}
return number, nil
}
// login returns the account tea acts as: the --sudo user, else the authenticated account.
func (t target) login() string {
if t.client != nil && t.client.Sudo != "" {
return t.client.Sudo
}
return t.User
}
// expandMe returns the login tea acts as for "@me" in a user-valued flag such as --assignee,
// and value unchanged otherwise.
func (t target) expandMe(value string) string {
if strings.TrimSpace(value) == "@me" {
return cmp.Or(t.login(), value)
}
return value
}
// expandMeAll applies expandMe to every entry of a user-list flag, in place.
func (t target) expandMeAll(values []string) {
for i, value := range values {
values[i] = t.expandMe(value)
}
}