mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
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:
5 files changed
+206
-43
No files matched your search
@@ -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
|
||||
|
||||
+140
-29
@@ -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) + ")"
|
||||
}
|
||||
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 {
|
||||
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)
|
||||
// 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, ""
|
||||
}
|
||||
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.
|
||||
func statusSection[T any](name string, items []T, row func(T) []terminal.Cell, color bool) string {
|
||||
// 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 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 {
|
||||
cells := row(item)
|
||||
cells[0].Text = " " + cells[0].Text
|
||||
return cells
|
||||
}, color)
|
||||
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"
|
||||
}
|
||||
|
||||
func statusRow(item services.SearchItem) []terminal.Cell {
|
||||
return terminal.Row(fmt.Sprintf("%s#%d", item.Repository, item.Number), item.Title, item.URL)
|
||||
// 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
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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,6 +28,8 @@ type AccountStatus struct {
|
||||
Repository string `json:"repository"`
|
||||
AssignedIssues []SearchItem `json:"assigned_issues"`
|
||||
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"`
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user