mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
feat(app): parse issue, pull request and run references
resolveRef accepts N, #N, [HOST/]OWNER/REPO#N and issue, pull request or run URLs; a URL or qualified number selects its repository for the invocation and must agree with --repo. resolveRefs does the same for several references. pullSelector also accepts a head branch or OWNER:BRANCH and, without an argument, finds the pull request of the checked-out branch: the one pr checkout recorded in branch.<name>.tea-pr, else the open pull request whose head is the branch's upstream in the upstream remote's repository, else one whose head has the local branch's name, else N of a pr/N branch. This lets pr commands find the pull request of a branch pr checkout created. target.expandMe resolves @me in user-valued flags to the login tea acts as, and git.CurrentBranchConfig reads the branch configuration involved. Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
4 files changed
+621
No files matched your search
@@ -0,0 +1,307 @@
|
|||||||
|
// 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
|
||||||
|
// Job is the job of a run URL ending in /jobs/M, else 0.
|
||||||
|
Job 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, "/"), "/")
|
||||||
|
positive := func(i int) int64 {
|
||||||
|
if i >= len(segments) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(segments[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 && segments[i] == "actions" && i+2 < len(segments) && segments[i+1] == "runs" && positive(i+2) > 0:
|
||||||
|
result.Number = positive(i + 2)
|
||||||
|
if i+4 < len(segments) && segments[i+3] == "jobs" {
|
||||||
|
result.Job = positive(i + 4)
|
||||||
|
}
|
||||||
|
case kind != runRef && (segments[i] == "issues" || segments[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.deps.Settings.Load()
|
||||||
|
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] == "" {
|
||||||
|
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,
|
||||||
|
// 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if branch.PullNumber > 0 && (branch.BaseRemote == "" || a.remoteOwns(ctx, t, branch.BaseRemote)) {
|
||||||
|
return t, branch.PullNumber, nil
|
||||||
|
}
|
||||||
|
pulls, err := (services.Pulls{Client: t.client, Repository: t.Repo}).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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.dev/tea/internal/clierr"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRefAcceptsNumbersQualifiedNumbersAndURLs(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
raw string
|
||||||
|
kind refKind
|
||||||
|
want ref
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{raw: "12", want: ref{Number: 12}},
|
||||||
|
{raw: "#12", want: ref{Number: 12}},
|
||||||
|
{raw: " 12 ", want: ref{Number: 12}},
|
||||||
|
{raw: "alice/project#12", want: ref{Number: 12, Repo: "alice/project"}},
|
||||||
|
{raw: "localhost:3001/alice/project#12", want: ref{Number: 12, Repo: "localhost:3001/alice/project"}},
|
||||||
|
{raw: "project#12", want: ref{Number: 12, Repo: "project"}},
|
||||||
|
{raw: "https://gitea.com/alice/project/issues/3", want: ref{Number: 3, Repo: "https://gitea.com/alice/project"}},
|
||||||
|
{raw: "https://gitea.com/alice/project/issues/3#issuecomment-79", want: ref{Number: 3, Repo: "https://gitea.com/alice/project"}},
|
||||||
|
{raw: "http://localhost:3001/base/alice/project/pulls/7/files?style=split", kind: pullRef, want: ref{Number: 7, Repo: "http://localhost:3001/base/alice/project"}},
|
||||||
|
{raw: "https://gitea.com/alice/issues/issues/4", want: ref{Number: 4, Repo: "https://gitea.com/alice/issues"}},
|
||||||
|
{raw: "https://gitea.com/alice/project/pulls/7", kind: issueRef, want: ref{Number: 7, Repo: "https://gitea.com/alice/project"}},
|
||||||
|
{raw: "https://gitea.com/alice/project/actions/runs/34", kind: runRef, want: ref{Number: 34, Repo: "https://gitea.com/alice/project"}},
|
||||||
|
{raw: "https://gitea.com/alice/project/actions/runs/34/jobs/2", kind: runRef, want: ref{Number: 34, Job: 2, Repo: "https://gitea.com/alice/project"}},
|
||||||
|
{raw: "#34", kind: runRef, want: ref{Number: 34}},
|
||||||
|
{raw: "0", err: `invalid issue "0": use a number, #number, OWNER/REPO#number or URL`},
|
||||||
|
{raw: "#abc", kind: pullRef, err: `invalid pull request "#abc"`},
|
||||||
|
{raw: "alice/project#", err: `invalid issue "alice/project#"`},
|
||||||
|
{raw: "alice/project#3", kind: runRef, err: `invalid run "alice/project#3": use a run ID or URL`},
|
||||||
|
{raw: "https://gitea.com/alice/project", err: `"https://gitea.com/alice/project" is not an issue URL`},
|
||||||
|
{raw: "https://gitea.com/alice/project/issues/3", kind: runRef, err: "is not a run URL"},
|
||||||
|
{raw: "ftp://gitea.com/alice/project/issues/3", err: "is not an issue URL"},
|
||||||
|
} {
|
||||||
|
got, err := parseRef(tc.raw, tc.kind)
|
||||||
|
if tc.err != "" {
|
||||||
|
require.ErrorContains(t, err, tc.err, tc.raw)
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err), tc.raw)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
require.NoError(t, err, tc.raw)
|
||||||
|
require.Equal(t, tc.want, got, tc.raw)
|
||||||
|
}
|
||||||
|
for _, branch := range []string{"feature/login", "bob:fix", "main"} {
|
||||||
|
_, err := parseRef(branch, pullRef)
|
||||||
|
require.ErrorIs(t, err, errNotRef, branch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refApplication returns an application authenticated against a server listing the open pull
|
||||||
|
// requests #3 (feat/one in alice/project), #4 (feat/two), #17 (fix in bob/project) and two
|
||||||
|
// pull requests from branch dup.
|
||||||
|
func refApplication(t *testing.T, gitResults map[string]string) (*application, string) {
|
||||||
|
t.Helper()
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "/api/v1/repos/alice/project/pulls", r.URL.Path)
|
||||||
|
if r.URL.Query().Get("page") != "1" {
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[
|
||||||
|
{"number":3,"head":{"ref":"feat/one","repo":{"full_name":"alice/project"}}},
|
||||||
|
{"number":4,"head":{"ref":"feat/two","repo":{"full_name":"alice/project"}}},
|
||||||
|
{"number":17,"head":{"ref":"fix","repo":{"full_name":"bob/project"}}},
|
||||||
|
{"number":20,"head":{"ref":"dup","repo":{"full_name":"alice/project"}}},
|
||||||
|
{"number":21,"head":{"ref":"dup","repo":{"full_name":"carol/project"}}}
|
||||||
|
]`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
deps, _, _ := testDeps(t)
|
||||||
|
deps.Getenv = serverEnv(server.URL, map[string]string{"TEA_REPO": "alice/project"})
|
||||||
|
results := map[string]string{
|
||||||
|
"remote get-url origin": server.URL + "/alice/project.git",
|
||||||
|
"remote get-url pulls/bob/project": server.URL + "/bob/project.git",
|
||||||
|
"remote get-url fork": server.URL + "/bob/project.git",
|
||||||
|
"remote get-url pulls/carol/project": server.URL + "/carol/project.git",
|
||||||
|
}
|
||||||
|
for key, value := range gitResults {
|
||||||
|
results[key] = value
|
||||||
|
}
|
||||||
|
deps.Git = &fakeGit{results: results}
|
||||||
|
return &application{deps: deps}, server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
func commandContext() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
cmd.SetContext(context.Background())
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullSelectorDefaultsToTheCheckedOutBranch(t *testing.T) {
|
||||||
|
const head = "symbolic-ref --short HEAD"
|
||||||
|
branch := func(name string, keys map[string]string) map[string]string {
|
||||||
|
results := map[string]string{head: name}
|
||||||
|
for key, value := range keys {
|
||||||
|
results["config --get branch."+name+"."+key] = value
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
git map[string]string
|
||||||
|
want int64
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"recorded by pr checkout", branch("pr/17", map[string]string{"tea-pr": "17", "tea-base-remote": "origin", "remote": "pulls/bob/project", "merge": "refs/heads/fix"}), 17, ""},
|
||||||
|
{"head branch name kept by pr checkout", branch("fix", map[string]string{"tea-pr": "17", "tea-base-remote": "origin"}), 17, ""},
|
||||||
|
{"recorded for another repository", branch("pr/9", map[string]string{"tea-pr": "9", "tea-base-remote": "fork", "remote": "fork", "merge": "refs/heads/fix"}), 17, ""},
|
||||||
|
{"upstream branch and owner", branch("local", map[string]string{"remote": "origin", "merge": "refs/heads/feat/one"}), 3, ""},
|
||||||
|
{"upstream owner tells forks apart", branch("dup", map[string]string{"remote": "pulls/carol/project", "merge": "refs/heads/dup"}), 21, ""},
|
||||||
|
{"local branch name", branch("feat/two", nil), 4, ""},
|
||||||
|
{"pr/N branch", branch("pr/99", nil), 99, ""},
|
||||||
|
{"no pull request", branch("main", nil), 0, `no open pull request for branch "main" in alice/project; pass a number, URL or branch`},
|
||||||
|
{"ambiguous branch", branch("dup", nil), 0, `branch "dup" has several open pull requests in alice/project (#20, #21); pass a number`},
|
||||||
|
{"detached", map[string]string{}, 0, "not on a branch; pass a pull request number, URL or branch"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
a, _ := refApplication(t, tc.git)
|
||||||
|
_, n, err := a.pullSelector(commandContext(), nil)
|
||||||
|
if tc.err != "" {
|
||||||
|
require.EqualError(t, err, tc.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.want, n)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPullSelectorAcceptsNumbersURLsAndBranches(t *testing.T) {
|
||||||
|
a, serverURL := refApplication(t, nil)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
arg string
|
||||||
|
want int64
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"5", 5, ""},
|
||||||
|
{"#5", 5, ""},
|
||||||
|
{serverURL + "/alice/project/pulls/5", 5, ""},
|
||||||
|
{"alice/project#5", 5, ""},
|
||||||
|
{"feat/one", 3, ""},
|
||||||
|
{"bob:fix", 17, ""},
|
||||||
|
{"alice:fix", 0, `no open pull request for branch "alice:fix" in alice/project; pass a number, URL or branch`},
|
||||||
|
{"carol:dup", 21, ""},
|
||||||
|
{"dup", 0, "several open pull requests"},
|
||||||
|
{serverURL + "/alice/project", 0, "is not a pull request URL"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.arg, func(t *testing.T) {
|
||||||
|
resolved, n, err := a.pullSelector(commandContext(), []string{tc.arg})
|
||||||
|
if tc.err != "" {
|
||||||
|
require.ErrorContains(t, err, tc.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc.want, n)
|
||||||
|
require.Equal(t, "alice/project", resolved.Repo)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveRefSelectsTheReferencedRepository(t *testing.T) {
|
||||||
|
a, serverURL := refApplication(t, nil)
|
||||||
|
resolved, n, err := a.resolveRef(commandContext(), serverURL+"/bob/tool/issues/8", issueRef)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, int64(8), n)
|
||||||
|
require.Equal(t, "bob/tool", resolved.Repo)
|
||||||
|
require.Equal(t, serverURL, resolved.Host)
|
||||||
|
|
||||||
|
resolved, numbers, err := a.resolveRefs(commandContext(), []string{"bob/tool#1", "#2", serverURL + "/bob/tool/issues/3"}, issueRef)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []int64{1, 2, 3}, numbers)
|
||||||
|
require.Equal(t, "bob/tool", resolved.Repo)
|
||||||
|
|
||||||
|
_, _, err = a.resolveRefs(commandContext(), []string{"bob/tool#1", "alice/project#2"}, issueRef)
|
||||||
|
require.EqualError(t, err, "bob/tool#1 and alice/project#2 are in different repositories; run one command per repository")
|
||||||
|
_, _, err = a.resolveRefs(commandContext(), []string{"1", "#1"}, issueRef)
|
||||||
|
require.EqualError(t, err, "#1 is selected more than once; supply unique numbers")
|
||||||
|
_, _, err = a.resolveRef(commandContext(), "feature", issueRef)
|
||||||
|
require.EqualError(t, err, `invalid issue "feature": use a number, #number, OWNER/REPO#number or URL`)
|
||||||
|
|
||||||
|
a.options.repo = "BOB/tool"
|
||||||
|
resolved, _, err = a.resolveRef(commandContext(), "bob/tool#1", issueRef)
|
||||||
|
require.NoError(t, err, "the same repository as --repo")
|
||||||
|
require.Equal(t, "bob/tool", resolved.Repo)
|
||||||
|
a.options.repo = "bob/other"
|
||||||
|
_, _, err = a.resolveRef(commandContext(), serverURL+"/bob/tool/issues/1", issueRef)
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err))
|
||||||
|
require.EqualError(t, err, serverURL+"/bob/tool/issues/1 points to bob/tool but --repo is bob/other")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExpandMeUsesTheLoginTeaActsAs(t *testing.T) {
|
||||||
|
alice := target{User: "alice"}
|
||||||
|
require.Equal(t, "alice", alice.expandMe("@me"))
|
||||||
|
require.Equal(t, "bob", alice.expandMe("bob"))
|
||||||
|
assignees := []string{"@me", "carol"}
|
||||||
|
alice.expandMeAll(assignees)
|
||||||
|
require.Equal(t, []string{"alice", "carol"}, assignees)
|
||||||
|
a, _ := refApplication(t, nil)
|
||||||
|
a.options.sudo = "bob"
|
||||||
|
resolved, err := a.resolve(context.Background(), true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "bob", resolved.expandMe("@me"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrNotOnBranch reports a detached HEAD or a directory outside a checkout.
|
||||||
|
var ErrNotOnBranch = errors.New("not on a branch")
|
||||||
|
|
||||||
|
// BranchConfig is the configuration of a local branch that locates its pull request.
|
||||||
|
type BranchConfig struct {
|
||||||
|
// Name is the local branch.
|
||||||
|
Name string
|
||||||
|
// Remote and Merge are the remote and branch the branch tracks, Merge without refs/heads/;
|
||||||
|
// both are empty for a branch that tracks nothing or another local branch.
|
||||||
|
Remote, Merge string
|
||||||
|
// PullNumber and BaseRemote are what pr checkout recorded: the pull request the branch was
|
||||||
|
// checked out from and the remote of that pull request's base repository.
|
||||||
|
PullNumber int64
|
||||||
|
BaseRemote string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentBranchConfig reads the configuration of the checked-out branch, failing with
|
||||||
|
// ErrNotOnBranch when HEAD is detached or dir is not a checkout.
|
||||||
|
func CurrentBranchConfig(ctx context.Context, runner Runner, dir string) (BranchConfig, error) {
|
||||||
|
name, err := runner.Output(ctx, dir, false, "symbolic-ref", "--short", "HEAD")
|
||||||
|
if err != nil || name == "" {
|
||||||
|
return BranchConfig{}, ErrNotOnBranch
|
||||||
|
}
|
||||||
|
return ReadBranchConfig(ctx, runner, dir, name), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBranchConfig reads branch.<name>.remote, .merge and the keys pr checkout records.
|
||||||
|
// Missing or malformed keys stay empty.
|
||||||
|
func ReadBranchConfig(ctx context.Context, runner Runner, dir, name string) BranchConfig {
|
||||||
|
get := func(key string) string {
|
||||||
|
value, err := runner.Output(ctx, dir, false, "config", "--get", "branch."+name+"."+key)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
config := BranchConfig{Name: name, Remote: get("remote"), BaseRemote: get(pullBaseRemoteKey)}
|
||||||
|
if config.Remote == "." {
|
||||||
|
config.Remote = ""
|
||||||
|
}
|
||||||
|
if config.Remote != "" {
|
||||||
|
config.Merge = strings.TrimPrefix(get("merge"), "refs/heads/")
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseInt(get(pullNumberKey), 10, 64); err == nil && n > 0 {
|
||||||
|
config.PullNumber = n
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright 2026 The Gitea Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCurrentBranchConfig(t *testing.T) {
|
||||||
|
const head = "symbolic-ref --short HEAD"
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
runner discoverRunner
|
||||||
|
want BranchConfig
|
||||||
|
err error
|
||||||
|
}{
|
||||||
|
{name: "detached", runner: discoverRunner{}, err: ErrNotOnBranch},
|
||||||
|
{name: "untracked", runner: discoverRunner{head: "topic"}, want: BranchConfig{Name: "topic"}},
|
||||||
|
{name: "tracking", runner: discoverRunner{
|
||||||
|
head: "feat/one", "config --get branch.feat/one.remote": "origin", "config --get branch.feat/one.merge": "refs/heads/feat/one",
|
||||||
|
}, want: BranchConfig{Name: "feat/one", Remote: "origin", Merge: "feat/one"}},
|
||||||
|
{name: "local upstream", runner: discoverRunner{
|
||||||
|
head: "topic", "config --get branch.topic.remote": ".", "config --get branch.topic.merge": "refs/heads/main",
|
||||||
|
}, want: BranchConfig{Name: "topic"}},
|
||||||
|
{name: "pr checkout", runner: discoverRunner{
|
||||||
|
head: "pr/17", "config --get branch.pr/17.remote": "pulls/bob/project", "config --get branch.pr/17.merge": "refs/heads/bob/fix",
|
||||||
|
"config --get branch.pr/17.tea-pr": "17", "config --get branch.pr/17.tea-base-remote": "origin",
|
||||||
|
}, want: BranchConfig{Name: "pr/17", Remote: "pulls/bob/project", Merge: "bob/fix", PullNumber: 17, BaseRemote: "origin"}},
|
||||||
|
{name: "malformed pull number", runner: discoverRunner{head: "x", "config --get branch.x.tea-pr": "seventeen"}, want: BranchConfig{Name: "x"}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
config, err := CurrentBranchConfig(context.Background(), test.runner, "/work")
|
||||||
|
require.ErrorIs(t, err, test.err)
|
||||||
|
require.Equal(t, test.want, config)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user