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
+206 -43

No files matched your search

-2
View File
@@ -461,10 +461,8 @@ const (
pr comment edit pr comment edit
pr comment list pr comment list
pr comment view pr comment view
pr status
search issue search issue
search pr search pr
status
time create time create
time delete time delete
time list time list
+140 -29
View File
@@ -6,6 +6,7 @@ package app
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings"
"gitea.dev/tea/internal/services" "gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal" "gitea.dev/tea/internal/terminal"
@@ -15,67 +16,177 @@ import (
func (a *application) statusCommand(pullsOnly bool) *cobra.Command { func (a *application) statusCommand(pullsOnly bool) *cobra.Command {
var limit int var limit int
var runs bool 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 { 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 { command := &cobra.Command{Use: "status", Short: short, Long: long, Example: example, Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
target, err := a.resolve(cmd.Context(), false) t, err := a.resolve(cmd.Context(), false)
if err != nil { if err != nil {
return err 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 { if pullsOnly {
result, err := service.Pulls(cmd.Context(), limit) result, err := service.Pulls(cmd.Context(), limit)
if err != nil { if err != nil {
return err return err
} }
current, details, branch := a.currentPullStatus(cmd)
result.CurrentBranch = current
if a.options.json != "" { if a.options.json != "" {
return a.writeJSON(cmd, result) 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 { if err != nil {
return err return err
} }
if a.options.json != "" { if a.options.json != "" {
return a.writeJSON(cmd, result) return a.writeJSON(cmd, result)
} }
color := a.colorEnabled() text := fmt.Sprintf("%s on %s\n\n", terminal.Emphasis(terminal.SafeLine(result.User), d.Color), terminal.SafeLine(result.Host)) +
text := fmt.Sprintf("%s on %s\n", result.User, result.Host) + statusSection("Assigned issues", result.AssignedIssues, statusRow, color) + pullStatusText(result.PullRequests, color) 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 { if result.RunsIncluded {
text += statusSection("Failing runs in "+result.Repository, result.FailingRuns, func(run services.Run) []terminal.Cell { heading := "Failing runs in " + result.Repository
return terminal.Row(strconv.FormatInt(run.ID, 10), run.Title, run.Conclusion, run.URL) if runsSource != "" && t.Repo == "" {
}, color) heading += " (from " + strings.Replace(runsSource, "remote ", "git remote ", 1) + ")"
} }
return a.writeHuman(cmd, text) 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, 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 { 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 return command
} }
func pullStatusText(result services.PullStatus, color bool) string { // currentPullStatus reads the pull request of the checked-out branch with its checks and reviews.
return statusSection("Authored pull requests", result.Authored, statusRow, color) + // Outside a checkout, or with --repo, it reports none; branch names the checked-out branch.
statusSection("Assigned pull requests", result.Assigned, statusRow, color) + func (a *application) currentPullStatus(cmd *cobra.Command) (*services.Pull, pullDetails, string) {
statusSection("Review requested", result.ReviewRequested, statusRow, color) var details pullDetails
if a.options.repo != "" {
return nil, details, ""
}
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
} }
// statusSection renders a heading over an indented table, or None when items is empty. // currentBranchSection shows the pull request of the checked-out branch and how it stands:
func statusSection[T any](name string, items []T, row func(T) []terminal.Cell, color bool) string { // "[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 { if len(items) == 0 {
return name + "\n None\n" return text + " " + terminal.Colorize("muted", "Nothing here", d.Color) + "\n\n"
} }
return name + "\n" + table(items, func(item T) []terminal.Cell { rows := make([][]terminal.Cell, len(items))
cells := row(item) columns := []terminal.Column{{}}
cells[0].Text = " " + cells[0].Text for i, item := range items {
return cells rows[i] = row(item)
}, color) }
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"
} }
func statusRow(item services.SearchItem) []terminal.Cell { // indented narrows the display by the two-space indent of status sections.
return terminal.Row(fmt.Sprintf("%s#%d", item.Repository, item.Number), item.Title, item.URL) 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() defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL) 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.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.Empty(t, diagnostics.String())
require.Equal(t, 1, runs) require.Equal(t, 1, runs)
actionsEnabled = false actionsEnabled = false
@@ -51,7 +51,7 @@ func TestStatusJSONAndActionsScope(t *testing.T) {
out.Reset() out.Reset()
require.NoError(t, Execute(context.Background(), deps, args)) require.NoError(t, Execute(context.Background(), deps, args))
if args[0] == "pr" { 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 { } else {
require.JSONEq(t, `{"runs_included":false,"failing_runs":[]}`, out.String()) 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, out.String())
require.Empty(t, diagnostics.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()))
}
+24 -9
View File
@@ -6,13 +6,16 @@ package services
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"strings" "strings"
"gitea.dev/tea/internal/transport" "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 { type PullStatus struct {
CurrentBranch *Pull `json:"current_branch"`
Authored []SearchItem `json:"authored"` Authored []SearchItem `json:"authored"`
Assigned []SearchItem `json:"assigned"` Assigned []SearchItem `json:"assigned"`
ReviewRequested []SearchItem `json:"review_requested"` ReviewRequested []SearchItem `json:"review_requested"`
@@ -25,6 +28,8 @@ type AccountStatus struct {
Repository string `json:"repository"` Repository string `json:"repository"`
AssignedIssues []SearchItem `json:"assigned_issues"` AssignedIssues []SearchItem `json:"assigned_issues"`
PullRequests PullStatus `json:"pull_requests"` PullRequests PullStatus `json:"pull_requests"`
// Mentions are the open issues and pull requests that mention the account.
Mentions []SearchItem `json:"mentions"`
FailingRuns []Run `json:"failing_runs"` FailingRuns []Run `json:"failing_runs"`
RunsIncluded bool `json:"runs_included"` RunsIncluded bool `json:"runs_included"`
} }
@@ -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) return result, fmt.Errorf("review requests exceeded %d pages", maxListPages)
} }
// Summary reports open assigned issues and pull requests. Actions results are // Summary reports open assigned issues and pull requests and mentions. Failing Actions runs of
// included only when requested and a repository has been selected. // runsRepository are included when it is set and has Actions enabled.
func (s Status) Summary(ctx context.Context, limit int, runs bool) (AccountStatus, error) { func (s Status) Summary(ctx context.Context, limit int, runsRepository string) (AccountStatus, error) {
result := AccountStatus{Host: s.Client.BaseURL, Repository: s.Repository, AssignedIssues: []SearchItem{}, FailingRuns: []Run{}} result := AccountStatus{Host: s.Client.BaseURL, Repository: runsRepository, AssignedIssues: []SearchItem{}, Mentions: []SearchItem{}, FailingRuns: []Run{}}
if limit < 1 || limit > 100 { if limit < 1 || limit > 100 {
return result, fmt.Errorf("status limit must be 1..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 { if err != nil {
return result, err 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 { if err != nil {
return result, err return result, err
} }
if runs && s.Repository != "" { for _, pulls := range []bool{false, true} {
repository, err := (Repositories{Client: s.Client}).View(ctx, s.Repository) 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 { if err != nil {
return result, err return result, err
} }
if !repository.ActionsEnabled { if !repository.ActionsEnabled {
return result, nil 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 { if err != nil {
return result, err return result, err
} }
+1 -1
View File
@@ -71,7 +71,7 @@ func TestStatusSummarySkipsRunsWhenActionsAreDisabled(t *testing.T) {
w.WriteHeader(http.StatusTeapot) 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.NoError(t, err)
require.False(t, result.RunsIncluded) require.False(t, result.RunsIncluded)
require.Empty(t, result.FailingRuns) require.Empty(t, result.FailingRuns)