feat(pr): list pull requests in a table and render issue and pr views as views

tea pr list prints a header, a "Showing N of M" line and the ID, TITLE,
BRANCH, STATE, AUTHOR and CREATED columns on a terminal, fit to its
width, and tab-separated rows when piped. Fork heads read
OWNER:BRANCH, drafts and merged pull requests have their own state,
and STATE appears on a terminal only when --state is not open. New
filters: --state merged, -d/--draft, -H/--head, -a/--assignee,
-S/--search and -w/--web, plus the gh shorthands -s, -A, -B, -l and
-m; --milestone takes a title or an ID, and @me works for --author and
--assignee. An empty list says so on stderr. draft, merged_at and
merged_by are exported in JSON, and reviewers, reviewer_teams and
merged_by, which Gitea's list leaves out, are read when --json names
them.

issue view and pr view render through terminal.View. Pull requests
show Draft and Merged badges, "alice merged into main from fix" with
the merge time, each reviewer's latest state ("bob (Approved), carol
(Requested)"), a checks summary pointing at tea pr checks, and why a
pull request cannot be merged. --comments includes reviews without a
body ("dave approved these changes") and comment IDs. Issues show
their due date and ref, and both kinds hint at --comments when there
are comments. pr view accepts #N, URLs and branch names, and its JSON
adds review_decision. Check links are absolute.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:54:47 +02:00
1 parent c5cf6b9ed9
commit 7930f80865
13 files changed
+817 -302

No files matched your search

+85 -35
View File
@@ -12,17 +12,23 @@ import (
"github.com/stretchr/testify/require"
)
// plainTTY makes deps render for a terminal without color or markdown, so views compare as text.
func plainTTY(deps *Dependencies, serverURL string) {
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(serverURL, map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw"})
}
func TestDiscussionViews(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/issues/3":
_, _ = w.Write([]byte(`{"number":3,"title":"Crash\u001b[2J","body":"Steps:\r\n\u001b]52;c;aGk=\u0007run\u202e\r","html_url":"https://example.org/i/3","state":"open","user":{"login":"bob"},"created_at":"2026-01-01T00:00:00Z","labels":[{"name":"bug","color":"ff0000"}],"assignees":[{"login":"alice"}],"comments":1}`))
case "/api/v1/repos/alice/project/issues/3/dependencies", "/api/v1/repos/alice/project/issues/3/blocks":
_, _ = w.Write([]byte(`{"number":3,"title":"Crash\u001b[2J","body":"Steps:\r\n\u001b]52;c;aGk=\u0007run‮\r","html_url":"https://example.org/i/3","state":"open","user":{"login":"bob"},"created_at":"2026-01-01T00:00:00Z","labels":[{"name":"bug","color":"ff0000"}],"assignees":[{"login":"alice"}],"comments":1}`))
case "/api/v1/repos/alice/project/issues/3/dependencies", "/api/v1/repos/alice/project/issues/3/blocks", "/api/v1/repos/alice/project/pulls/4/reviews":
_, _ = w.Write([]byte(`[]`))
case "/api/v1/repos/alice/project/issues/3/comments":
_, _ = w.Write([]byte(`[{"body":"Confirmed\u001b[31m\n\tok","user":{"login":"carol"},"created_at":"2026-01-02T00:00:00Z"}]`))
_, _ = w.Write([]byte(`[{"id":79,"body":"Confirmed\u001b[31m\n\tok","user":{"login":"carol"},"created_at":"2026-01-02T00:00:00Z"}]`))
case "/api/v1/repos/alice/project/pulls/4":
_, _ = w.Write([]byte(`{"number":4,"title":"Fix","html_url":"https://example.org/p/4","state":"closed","merged":true,"user":{"login":"bob"},"created_at":"2026-01-03T00:00:00Z","base":{"ref":"main"},"head":{"ref":"fix"},"requested_reviewers":[{"login":"alice"}]}`))
_, _ = w.Write([]byte(`{"number":4,"title":"Fix","html_url":"https://example.org/p/4","state":"closed","merged":true,"merged_at":"2026-01-03T12:00:00Z","merged_by":{"login":"alice"},"user":{"login":"bob"},"created_at":"2026-01-03T00:00:00Z","base":{"ref":"main"},"head":{"ref":"fix"},"requested_reviewers":[{"login":"carol"}]}`))
default:
t.Errorf("unexpected %s %s", r.Method, r.URL.Path)
}
@@ -31,12 +37,19 @@ func TestDiscussionViews(t *testing.T) {
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) }
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "view", "3", "-R", "alice/project", "--comments"}))
require.Equal(t, "title:\tCrash[2J\nstate:\topen\nauthor:\tbob\nlabels:\tbug\nassignees:\talice\nmilestone:\t\nblocked by:\t\nblocks:\t\ncomments:\t1\nnumber:\t3\nurl:\thttps://example.org/i/3\n--\nSteps:\n]52;c;aGk=run\n--\nauthor:\tcarol\ncreated_at:\t2026-01-02T00:00:00Z\n--\nConfirmed[31m\n\tok\n", out.String())
require.Equal(t, "title:\tCrash[2J\nstate:\topen\nauthor:\tbob\nlabels:\tbug\nassignees:\talice\nmilestone:\t\ndue:\t\nref:\t\nblocked by:\t\nblocks:\t\ncomments:\t1\nnumber:\t3\nurl:\thttps://example.org/i/3\n--\nSteps:\n]52;c;aGk=run\n"+
"--\nauthor:\tcarol\ncreated_at:\t2026-01-02T00:00:00Z\nid:\t79\n--\nConfirmed[31m\n\tok\n", out.String())
out.Reset()
plainTTY(&deps, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "view", "3", "-R", "alice/project"}))
require.Equal(t, "Crash[2J #3\nOpen • bob opened about 3 days ago • 1 comment\nLabels: bug\nAssignees: alice\n\nSteps:\n]52;c;aGk=run\n\n"+
"1 comment — use --comments to view them\n\nView this issue on Gitea: https://example.org/i/3\n", out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "view", "3", "-R", "alice/project", "--comments"}))
require.Contains(t, out.String(), "\n\ncarol • about 2 days ago • #79\nConfirmed[31m\n\tok\n\nView this issue on Gitea:")
out.Reset()
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", "4", "-R", "alice/project"}))
require.Equal(t, "Fix #4\nMerged • bob wants to merge into main from fix • about 1 day ago • 0 comments\nReviewers: alice\n\nNo description provided\n\nView this pull request on Gitea: https://example.org/p/4\n", out.String())
require.Equal(t, "Fix #4\nMerged • alice merged into main from fix • about 12 hours ago • 0 comments\nReviewers: carol (Requested)\n\nNo description provided\n\nView this pull request on Gitea: https://example.org/p/4\n", out.String())
}
func TestIssueViewDependencies(t *testing.T) {
@@ -46,7 +59,7 @@ func TestIssueViewDependencies(t *testing.T) {
paths = append(paths, r.URL.Path)
switch {
case r.URL.Path == "/api/v1/repos/alice/project/issues/3":
_, _ = w.Write([]byte(`{"number":3,"title":"Crash","html_url":"https://example.org/i/3","state":"open","user":{"login":"bob"},"created_at":"2026-01-03T00:00:00Z"}`))
_, _ = w.Write([]byte(`{"number":3,"title":"Crash","html_url":"https://example.org/i/3","state":"open","user":{"login":"bob"},"created_at":"2026-01-03T00:00:00Z","due_date":"2026-12-24T23:59:59Z","ref":"main"}`))
case !supported:
w.WriteHeader(http.StatusNotFound)
case r.URL.Query().Get("page") != "1":
@@ -62,15 +75,14 @@ func TestIssueViewDependencies(t *testing.T) {
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) }
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
plainTTY(&deps, server.URL)
args := []string{"issue", "view", "3", "-R", "alice/project"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "Crash #3\nOpen • bob opened about 1 day ago • 0 comments\nBlocked by: #1, bob/lib#5, hidden\nBlocks: #9\n\nNo description provided\n\nView this issue on Gitea: https://example.org/i/3\n", out.String())
require.Equal(t, "Crash #3\nOpen • bob opened about 1 day ago • 0 comments\nDue: Dec 24, 2026 (in about 11 months)\nRef: main\nBlocked by: #1, bob/lib#5, hidden\nBlocks: #9\n\nNo description provided\n\nView this issue on Gitea: https://example.org/i/3\n", out.String())
out.Reset()
deps.Streams.OutTTY = false
require.NoError(t, Execute(context.Background(), deps, args))
require.Contains(t, out.String(), "\nblocked by:\t#1, bob/lib#5, hidden\nblocks:\t#9\n")
require.Contains(t, out.String(), "\ndue:\t2026-12-24T23:59:59Z\nref:\tmain\nblocked by:\t#1, bob/lib#5, hidden\nblocks:\t#9\n")
out.Reset()
supported, paths = false, nil
require.NoError(t, Execute(context.Background(), deps, args))
@@ -82,20 +94,6 @@ func TestIssueViewDependencies(t *testing.T) {
require.Equal(t, []string{"/api/v1/repos/alice/project/issues/3"}, paths)
}
func TestRelativeTime(t *testing.T) {
now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for raw, want := range map[string]string{
"2025-12-31T23:59:30Z": "less than a minute ago",
"2025-12-31T23:59:00Z": "about 1 minute ago",
"2025-12-31T21:00:00Z": "about 3 hours ago",
"2025-11-01T00:00:00Z": "about 2 months ago",
"2023-12-01T00:00:00Z": "about 2 years ago",
"not a time": "not a time",
} {
require.Equal(t, want, relativeTime(now, raw), raw)
}
}
func TestPullViewCommentsIncludeReviews(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
@@ -125,19 +123,71 @@ func TestPullViewCommentsIncludeReviews(t *testing.T) {
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) }
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", "4", "-R", "alice/project", "--comments"}))
require.Equal(t, "title:\tFix\nstate:\topen\nauthor:\tbob\nlabels:\t\nreviewers:\t\nassignees:\t\nmilestone:\t\nmergeable:\tyes\ncomments:\t1\nnumber:\t4\nurl:\thttps://example.org/p/4\n--\n\n"+
require.Equal(t, "title:\tFix\nstate:\topen\nauthor:\tbob\nbase:\tmain\nhead:\tfix\nlabels:\t\nassignees:\t\nreviewers:\talice (Approved), dave (Approved)\nmilestone:\t\nchecks:\t\nmergeable:\tyes\ncomments:\t1\nnumber:\t4\nurl:\thttps://example.org/p/4\n--\n\n"+
"--\nauthor:\talice\ncreated_at:\t2026-01-01T12:00:00Z\nreview:\tREQUEST_CHANGES\n--\nPlease rename\n"+
"--\nauthor:\talice\ncreated_at:\t2026-01-01T12:00:00Z\npath:\tmain.go:12\n--\nTypo here\n"+
"--\nauthor:\talice\ncreated_at:\t2026-01-01T12:00:00Z\nid:\t9\npath:\tmain.go:12\n--\nTypo here\n"+
"--\nauthor:\tcarol\ncreated_at:\t2026-01-02T00:00:00Z\n--\nLooks close\n"+
"--\nauthor:\tdave\ncreated_at:\t2026-01-03T00:00:00Z\nreview:\tAPPROVED\n--\n\n"+
"--\nauthor:\talice\ncreated_at:\t2026-01-03T06:00:00Z\nreview:\tAPPROVED\n--\nGreat\n", out.String())
out.Reset()
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
plainTTY(&deps, server.URL)
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", "4", "-R", "alice/project", "--comments"}))
require.Equal(t, "Fix #4\nOpen • bob wants to merge into main from fix • about 3 days ago • 1 comment\nMergeable: yes\n\nNo description provided\n\n"+
"alice • reviewed (REQUEST_CHANGES) • about 2 days ago\nPlease rename\n\n"+
"alice • commented on main.go:12 • about 2 days ago\nTypo here\n\n"+
require.Equal(t, "Fix #4\nOpen • bob wants to merge into main from fix • about 3 days ago • 1 comment\nReviewers: alice (Approved), dave (Approved)\nMergeable: yes\n\nNo description provided\n\n"+
"alice requested changes • about 2 days ago\nPlease rename\n\n"+
"alice commented on main.go:12 • about 2 days ago\nTypo here\n\n"+
"carol • about 2 days ago\nLooks close\n\n"+
"alice • reviewed (APPROVED) • about 18 hours ago\nGreat\n\n"+
"dave approved these changes • about 1 day ago\n\n"+
"alice approved these changes • about 18 hours ago\nGreat\n\n"+
"View this pull request on Gitea: https://example.org/p/4\n", out.String())
}
func TestPullViewStates(t *testing.T) {
for _, tc := range []struct {
name, pull, statuses, reviews, want, decision string
}{
{
name: "draft",
pull: `{"number":15,"title":"WIP: experiment","state":"open","draft":true,"mergeable":false,"user":{"login":"alice"},"base":{"ref":"main"},"head":{"ref":"experiment"}}`,
want: "WIP: experiment #15\nDraft • alice wants to merge into main from experiment • about 1 day ago • 0 comments\nMergeable: no — draft; mark it ready with: tea pr ready 15\n",
decision: "",
},
{
name: "fork with checks and conflicts",
pull: `{"number":17,"title":"Improve greeting","state":"open","mergeable":false,"user":{"login":"bob"},"base":{"ref":"main"},"head":{"ref":"greet","sha":"abc","repo":{"full_name":"bob/project"}},"requested_reviewers":[{"login":"carol"}]}`,
statuses: `[{"id":1,"context":"ci / test","status":"pending"},{"id":2,"context":"lint","status":"failure"}]`,
reviews: `[{"id":4,"state":"REQUEST_CHANGES","user":{"login":"alice"}}]`,
want: "Improve greeting #17\nOpen • bob wants to merge into main from bob:greet • about 1 day ago • 0 comments\nReviewers: alice (Changes requested), carol (Requested)\nChecks: 1 failing, 1 pending (tea pr checks 17)\nMergeable: no — conflicts with main\n",
decision: "changes_requested",
},
} {
t.Run(tc.name, func(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/15" || r.URL.Path == "/api/v1/repos/alice/project/pulls/17":
_, _ = w.Write([]byte(tc.pull[:len(tc.pull)-1] + `,"created_at":"2026-01-03T00:00:00Z","html_url":"https://example.org/p"}`))
case r.URL.Query().Get("page") != "1":
_, _ = w.Write([]byte(`[]`))
case r.URL.Path == "/api/v1/repos/alice/project/statuses/abc":
_, _ = w.Write([]byte(tc.statuses))
case tc.reviews == "":
_, _ = w.Write([]byte(`[]`))
default:
_, _ = w.Write([]byte(tc.reviews))
}
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Now = func() time.Time { return time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC) }
plainTTY(&deps, server.URL)
number := map[string]string{"draft": "15"}[tc.name]
if number == "" {
number = "17"
}
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", number, "-R", "alice/project"}))
require.Equal(t, tc.want+"\nNo description provided\n\nView this pull request on Gitea: https://example.org/p\n", out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "view", number, "-R", "alice/project", "--json=draft,review_decision"}))
require.Contains(t, out.String(), `"review_decision": "`+tc.decision+`"`)
})
}
}