From 78f2ef5280f1b9bae159e78383ccb01d8d340278 Mon Sep 17 00:00:00 2001 From: bircni Date: Sun, 11 Oct 2026 23:48:37 +0200 Subject: [PATCH] feat(status): show the current branch's pull request, mentions and runs from the clone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tea pr status starts with the pull request of the checked-out branch, "[feat/one] - Checks: 2 pending · Review: Approved", then lists the pull requests created by you, assigned to you and requesting your review under bold headings, as linked #N or OWNER/REPO#N refs with truncated titles instead of URL columns, and "Nothing here" for an empty section. Its JSON adds current_branch. tea status uses the same layout, adds a Mentions section (open issues and pull requests that mention you; JSON "mentions"), and lists the failing runs of the clone's repository without -R, naming the remote it came from. Piped output keeps the URLs. Signed-off-by: bircni --- internal/app/app_test.go | 2 - internal/app/status.go | 175 +++++++++++++++++++++++++------ internal/app/status_test.go | 43 +++++++- internal/services/status.go | 37 +++++-- internal/services/status_test.go | 2 +- 5 files changed, 211 insertions(+), 48 deletions(-) diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 5dda3c84..c7d66ea0 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -461,10 +461,8 @@ const ( pr comment edit pr comment list pr comment view - pr status search issue search pr - status time create time delete time list diff --git a/internal/app/status.go b/internal/app/status.go index 3ee1c69d..c60d2c51 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -6,6 +6,7 @@ package app import ( "fmt" "strconv" + "strings" "gitea.dev/tea/internal/services" "gitea.dev/tea/internal/terminal" @@ -15,67 +16,177 @@ import ( func (a *application) statusCommand(pullsOnly bool) *cobra.Command { var limit int var runs bool - short := "Show assigned issues, pull requests, and selected repository failures" + short, example := "Show your work across repositories", `$ tea status +$ tea status -R alice/project --runs=false` + long := `Show the open issues and pull requests assigned to you, the reviews requested from you, and +the open discussions that mention you. In a clone, it also lists the failing Actions runs of +the clone's repository; -R scopes everything to one repository.` if pullsOnly { - short = "Show your open authored, assigned, and requested-review pull requests" + short, example = "Show the status of your pull requests", `$ tea pr status +$ tea pr status -R alice/project` + long = `Show the pull request of the current branch with its checks and review state, then the open +pull requests you created, those assigned to you, and those that request your review.` } - command := &cobra.Command{Use: "status", Short: short, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { - target, err := a.resolve(cmd.Context(), false) + command := &cobra.Command{Use: "status", Short: short, Long: long, Example: example, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error { + t, err := a.resolve(cmd.Context(), false) if err != nil { return err } - service := services.Status{Client: target.client, Repository: target.Repo} + service := services.Status{Client: t.client, Repository: t.Repo} + d := a.display() if pullsOnly { result, err := service.Pulls(cmd.Context(), limit) if err != nil { return err } + current, details, branch := a.currentPullStatus(cmd) + result.CurrentBranch = current if a.options.json != "" { return a.writeJSON(cmd, result) } - return a.writeHuman(cmd, pullStatusText(result, a.colorEnabled())) + return a.writeHuman(cmd, currentBranchSection(d, branch, current, details)+pullStatusText(d, result, t.Repo)) } - result, err := service.Summary(cmd.Context(), limit, runs) + runsRepo, runsSource := t.Repo, t.RepoSource + if runs && runsRepo == "" { + if discovered, err := a.resolveTarget(cmd.Context(), true, false); err == nil && discovered.Host == t.Host { + runsRepo, runsSource = discovered.Repo, discovered.RepoSource + } + } + if !runs { + runsRepo = "" + } + result, err := service.Summary(cmd.Context(), limit, runsRepo) if err != nil { return err } if a.options.json != "" { return a.writeJSON(cmd, result) } - color := a.colorEnabled() - text := fmt.Sprintf("%s on %s\n", result.User, result.Host) + statusSection("Assigned issues", result.AssignedIssues, statusRow, color) + pullStatusText(result.PullRequests, color) + text := fmt.Sprintf("%s on %s\n\n", terminal.Emphasis(terminal.SafeLine(result.User), d.Color), terminal.SafeLine(result.Host)) + + statusSection(d, "Assigned issues", result.AssignedIssues, t.Repo) + + statusSection(d, "Assigned pull requests", result.PullRequests.Assigned, t.Repo) + + statusSection(d, "Review requests", result.PullRequests.ReviewRequested, t.Repo) + + statusSection(d, "Mentions", result.Mentions, t.Repo) if result.RunsIncluded { - text += statusSection("Failing runs in "+result.Repository, result.FailingRuns, func(run services.Run) []terminal.Cell { - return terminal.Row(strconv.FormatInt(run.ID, 10), run.Title, run.Conclusion, run.URL) - }, color) + heading := "Failing runs in " + result.Repository + if runsSource != "" && t.Repo == "" { + heading += " (from " + strings.Replace(runsSource, "remote ", "git remote ", 1) + ")" + } + text += section(d, heading, result.FailingRuns, func(run services.Run) []terminal.Cell { + return []terminal.Cell{{Text: strconv.FormatInt(run.ID, 10), Link: run.URL}, terminal.StatusIcon(run.Status, run.Conclusion), {Text: run.Title}, {Text: run.Conclusion}, {Text: run.URL}} + }) } - return a.writeHuman(cmd, text) + return a.writeHuman(cmd, strings.TrimSuffix(text, "\n")) }} - positiveIntVar(command, &limit, "limit", 30, 100, "Maximum results in each category (1..100)") + positiveIntVar(command, &limit, "limit", 30, 100, "Maximum results in each section (1..100)") if !pullsOnly { - command.Flags().BoolVar(&runs, "runs", true, "Include failed or timed-out Actions runs when --repo or TEA_REPO is selected") + command.Flags().BoolVar(&runs, "runs", true, "Include the failing Actions runs of the selected or discovered repository") } return command } -func pullStatusText(result services.PullStatus, color bool) string { - return statusSection("Authored pull requests", result.Authored, statusRow, color) + - statusSection("Assigned pull requests", result.Assigned, statusRow, color) + - statusSection("Review requested", result.ReviewRequested, statusRow, color) -} - -// statusSection renders a heading over an indented table, or None when items is empty. -func statusSection[T any](name string, items []T, row func(T) []terminal.Cell, color bool) string { - if len(items) == 0 { - return name + "\n None\n" +// currentPullStatus reads the pull request of the checked-out branch with its checks and reviews. +// Outside a checkout, or with --repo, it reports none; branch names the checked-out branch. +func (a *application) currentPullStatus(cmd *cobra.Command) (*services.Pull, pullDetails, string) { + var details pullDetails + if a.options.repo != "" { + return nil, details, "" } - return name + "\n" + table(items, func(item T) []terminal.Cell { - cells := row(item) - cells[0].Text = " " + cells[0].Text - return cells - }, color) + branch, err := a.currentBranch(cmd.Context()) + if err != nil { + return nil, details, "" + } + t, n, err := a.pullSelector(cmd, nil) + if err != nil { + return nil, details, branch + } + service := services.Pulls{Client: t.client, Repository: t.Repo} + pull, err := service.View(cmd.Context(), n) + if err != nil { + return nil, details, branch + } + if details.reviews, err = (services.Reviews{Client: t.client, Repository: t.Repo}).All(cmd.Context(), n); err == nil { + pull.ReviewDecision = services.ReviewDecision(services.LatestReviews(details.reviews), len(pull.Reviewers)+len(pull.ReviewerTeams) > 0) + } + if pull.HeadSHA != "" { + details.checks, _ = service.CommitChecks(cmd.Context(), pull.HeadSHA) + } + return &pull, details, branch } -func statusRow(item services.SearchItem) []terminal.Cell { - return terminal.Row(fmt.Sprintf("%s#%d", item.Repository, item.Number), item.Title, item.URL) +// currentBranchSection shows the pull request of the checked-out branch and how it stands: +// "[feat/one] - Checks: 2 pending · Review: Approved". +func currentBranchSection(d terminal.Display, branch string, pull *services.Pull, details pullDetails) string { + if branch == "" { + return "" + } + heading := terminal.Emphasis("Current branch", d.Color && d.TTY) + "\n" + if pull == nil { + return heading + " " + terminal.Colorize("muted", "There is no pull request associated with ["+terminal.SafeLine(branch)+"]", d.Color) + "\n\n" + } + state := pullState(*pull) + row := terminal.Table([]terminal.Column{{Prefix: "#"}, {Flex: true}, {}}, [][]terminal.Cell{{ + {Text: strconv.FormatInt(pull.Number, 10), Link: pull.URL, Color: stateColors[state]}, {Text: pull.Title}, {Text: "[" + pull.Head + "]", Color: "cyan"}, + }}, indented(d)) + facts := []string{} + if checks := checkSummary(details.checks); checks != "" { + facts = append(facts, "Checks: "+checks) + } + decision := map[string]string{"approved": "Approved", "changes_requested": "Changes requested", "review_required": "Review required"}[pull.ReviewDecision] + if decision != "" { + facts = append(facts, "Review: "+decision) + } + if state != "open" { + facts = append(facts, titleCase(state)) + } + text := heading + indent(row) + if len(facts) > 0 { + text += " - " + strings.Join(facts, " · ") + "\n" + } + return text + "\n" +} + +func pullStatusText(d terminal.Display, result services.PullStatus, repo string) string { + return statusSection(d, "Created by you", result.Authored, repo) + + statusSection(d, "Assigned to you", result.Assigned, repo) + + statusSection(d, "Requesting a code review from you", result.ReviewRequested, repo) +} + +// statusSection lists issues or pull requests under a heading, as #N within repo and OWNER/REPO#N +// elsewhere. +func statusSection(d terminal.Display, heading string, items []services.SearchItem, repo string) string { + return section(d, heading, items, func(item services.SearchItem) []terminal.Cell { + ref := fmt.Sprintf("%s#%d", item.Repository, item.Number) + if repo != "" && strings.EqualFold(item.Repository, repo) { + ref = fmt.Sprintf("#%d", item.Number) + } + return []terminal.Cell{{Text: ref, Link: item.URL}, {Text: item.Title}, {Text: item.URL}} + }) +} + +// section renders a bold heading over an indented table without header, or "Nothing here". The +// last cell of a row is a URL that only piped output keeps. +func section[T any](d terminal.Display, heading string, items []T, row func(T) []terminal.Cell) string { + text := terminal.Emphasis(heading, d.Color && d.TTY) + "\n" + if len(items) == 0 { + return text + " " + terminal.Colorize("muted", "Nothing here", d.Color) + "\n\n" + } + rows := make([][]terminal.Cell, len(items)) + columns := []terminal.Column{{}} + for i, item := range items { + rows[i] = row(item) + } + for range len(rows[0]) - 2 { + columns = append(columns, terminal.Column{Flex: true}) + } + columns = append(columns, terminal.Column{PipedOnly: true}) + return text + indent(terminal.Table(columns, rows, indented(d))) + "\n" +} + +// indented narrows the display by the two-space indent of status sections. +func indented(d terminal.Display) terminal.Display { + if d.Width > 2 { + d.Width -= 2 + } + return d } diff --git a/internal/app/status_test.go b/internal/app/status_test.go index d62a8f0a..12a195d0 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -38,7 +38,7 @@ func TestStatusJSONAndActionsScope(t *testing.T) { defer server.Close() deps, out, diagnostics := serverDeps(t, server.URL) require.NoError(t, Execute(context.Background(), deps, []string{"status", "-R", "alice/project", "--json=user,pull_requests,runs_included,failing_runs", "--jq", "{user, pull_requests, runs_included, run_ids: [.failing_runs[].id]}"})) - require.JSONEq(t, `{"user":"alice","pull_requests":{"authored":[],"assigned":[],"review_requested":[]},"runs_included":true,"run_ids":[7]}`, out.String()) + require.JSONEq(t, `{"user":"alice","pull_requests":{"current_branch":null,"authored":[],"assigned":[],"review_requested":[]},"runs_included":true,"run_ids":[7]}`, out.String()) require.Empty(t, diagnostics.String()) require.Equal(t, 1, runs) actionsEnabled = false @@ -51,7 +51,7 @@ func TestStatusJSONAndActionsScope(t *testing.T) { out.Reset() require.NoError(t, Execute(context.Background(), deps, args)) if args[0] == "pr" { - require.JSONEq(t, `{"authored":[],"assigned":[],"review_requested":[]}`, out.String()) + require.JSONEq(t, `{"current_branch":null,"authored":[],"assigned":[],"review_requested":[]}`, out.String()) } else { require.JSONEq(t, `{"runs_included":false,"failing_runs":[]}`, out.String()) } @@ -75,3 +75,42 @@ func TestStatusFailureDoesNotPublishPartialJSON(t *testing.T) { require.Empty(t, out.String()) require.Empty(t, diagnostics.String()) } + +func TestPullStatusShowsTheCurrentBranch(t *testing.T) { + server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v1/repos/alice/project/pulls/1": + _, _ = w.Write([]byte(`{"number":1,"title":"Add one.txt","state":"open","head":{"ref":"feat/one","sha":"abc"},"html_url":"http://gitea/alice/project/pulls/1"}`)) + case r.URL.Query().Get("page") != "" && r.URL.Query().Get("page") != "1": + _, _ = w.Write([]byte(`[]`)) + case r.URL.Path == "/api/v1/repos/alice/project/pulls/1/reviews": + _, _ = w.Write([]byte(`[{"id":5,"state":"APPROVED","user":{"login":"bob"}}]`)) + case r.URL.Path == "/api/v1/repos/alice/project/statuses/abc": + _, _ = w.Write([]byte(`[{"id":1,"context":"ci","status":"pending"},{"id":2,"context":"lint","status":"pending"}]`)) + case r.URL.Query().Get("created_by") == "alice": + _, _ = w.Write([]byte(`[{"id":1,"number":1,"title":"Add one.txt","repository":{"full_name":"alice/project"},"html_url":"http://gitea/alice/project/pulls/1"}]`)) + default: + _, _ = w.Write([]byte(`[]`)) + } + })) + defer server.Close() + deps, out, _ := serverDeps(t, server.URL) + plainTTY(&deps, server.URL) + deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1", "TEA_REPO": "alice/project"}) + deps.Git = &fakeGit{results: map[string]string{"symbolic-ref --short HEAD": "feat/one", "config --get branch.feat/one.tea-pr": "1"}} + require.NoError(t, Execute(context.Background(), deps, []string{"pr", "status"})) + require.Equal(t, `Current branch + #1 Add one.txt [feat/one] + - Checks: 2 pending · Review: Approved + +Created by you + #1 Add one.txt + +Assigned to you + Nothing here + +Requesting a code review from you + Nothing here + +`, stripLinks(out.String())) +} diff --git a/internal/services/status.go b/internal/services/status.go index 5a836cd7..d3233635 100644 --- a/internal/services/status.go +++ b/internal/services/status.go @@ -6,13 +6,16 @@ package services import ( "context" "fmt" + "slices" "strings" "gitea.dev/tea/internal/transport" ) -// PullStatus groups open pull requests by the selected account's participation. +// PullStatus groups open pull requests by the selected account's participation. CurrentBranch is +// the pull request of the checked-out branch, which commands fill when they know it. type PullStatus struct { + CurrentBranch *Pull `json:"current_branch"` Authored []SearchItem `json:"authored"` Assigned []SearchItem `json:"assigned"` ReviewRequested []SearchItem `json:"review_requested"` @@ -25,8 +28,10 @@ type AccountStatus struct { Repository string `json:"repository"` AssignedIssues []SearchItem `json:"assigned_issues"` PullRequests PullStatus `json:"pull_requests"` - FailingRuns []Run `json:"failing_runs"` - RunsIncluded bool `json:"runs_included"` + // Mentions are the open issues and pull requests that mention the account. + Mentions []SearchItem `json:"mentions"` + FailingRuns []Run `json:"failing_runs"` + RunsIncluded bool `json:"runs_included"` } // Status reuses search and Actions workflows. @@ -82,10 +87,10 @@ func (s Status) Pulls(ctx context.Context, limit int) (PullStatus, error) { return result, fmt.Errorf("review requests exceeded %d pages", maxListPages) } -// Summary reports open assigned issues and pull requests. Actions results are -// included only when requested and a repository has been selected. -func (s Status) Summary(ctx context.Context, limit int, runs bool) (AccountStatus, error) { - result := AccountStatus{Host: s.Client.BaseURL, Repository: s.Repository, AssignedIssues: []SearchItem{}, FailingRuns: []Run{}} +// Summary reports open assigned issues and pull requests and mentions. Failing Actions runs of +// runsRepository are included when it is set and has Actions enabled. +func (s Status) Summary(ctx context.Context, limit int, runsRepository string) (AccountStatus, error) { + result := AccountStatus{Host: s.Client.BaseURL, Repository: runsRepository, AssignedIssues: []SearchItem{}, Mentions: []SearchItem{}, FailingRuns: []Run{}} if limit < 1 || limit > 100 { return result, fmt.Errorf("status limit must be 1..100") } @@ -98,19 +103,29 @@ func (s Status) Summary(ctx context.Context, limit int, runs bool) (AccountStatu if err != nil { return result, err } - result.AssignedIssues, err = (Issues{Client: s.Client, Repository: s.Repository}).Search(ctx, DiscussionSearchOptions{State: "open", Assigned: true, Limit: limit, Page: 1}) + scoped := Issues{Client: s.Client, Repository: s.Repository} + result.AssignedIssues, err = scoped.Search(ctx, DiscussionSearchOptions{State: "open", Assigned: true, Limit: limit, Page: 1}) if err != nil { return result, err } - if runs && s.Repository != "" { - repository, err := (Repositories{Client: s.Client}).View(ctx, s.Repository) + for _, pulls := range []bool{false, true} { + mentions, err := scoped.Search(ctx, DiscussionSearchOptions{State: "open", Mentioned: true, Pulls: pulls, Limit: limit, Page: 1}) + if err != nil { + return result, err + } + result.Mentions = append(result.Mentions, mentions...) + } + slices.SortStableFunc(result.Mentions, func(x, y SearchItem) int { return strings.Compare(y.UpdatedAt, x.UpdatedAt) }) + result.Mentions = result.Mentions[:min(len(result.Mentions), limit)] + if runsRepository != "" { + repository, err := (Repositories{Client: s.Client}).View(ctx, runsRepository) if err != nil { return result, err } if !repository.ActionsEnabled { return result, nil } - failing, err := (Actions{Client: s.Client, Repository: s.Repository}).Runs(ctx, RunListOptions{Limit: limit, Page: 1, Statuses: []string{"failure", "timed_out"}}) + failing, err := (Actions{Client: s.Client, Repository: runsRepository}).Runs(ctx, RunListOptions{Limit: limit, Page: 1, Statuses: []string{"failure", "timed_out"}}) if err != nil { return result, err } diff --git a/internal/services/status_test.go b/internal/services/status_test.go index 4f9b40f9..5055245f 100644 --- a/internal/services/status_test.go +++ b/internal/services/status_test.go @@ -71,7 +71,7 @@ func TestStatusSummarySkipsRunsWhenActionsAreDisabled(t *testing.T) { w.WriteHeader(http.StatusTeapot) } })) - result, err := (Status{Client: client, Repository: "alice/project"}).Summary(context.Background(), 30, true) + result, err := (Status{Client: client, Repository: "alice/project"}).Summary(context.Background(), 30, "alice/project") require.NoError(t, err) require.False(t, result.RunsIncluded) require.Empty(t, result.FailingRuns)