feat(status): show the current branch's pull request, mentions and runs from the clone

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 <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:55:13 +02:00
1 parent 4895aa089d
commit 78f2ef5280
5 files changed
+211 -48

No files matched your search

-2
View File
@@ -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
+143 -32
View File
@@ -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
}
+41 -2
View File
@@ -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()))
}
+26 -11
View File
@@ -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
}
+1 -1
View File
@@ -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)