Files
gitea-tea/internal/app/discussion_view.go
T
bircni 7930f80865 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>
2026-10-11 23:54:47 +02:00

356 lines
12 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"cmp"
"fmt"
"slices"
"strconv"
"strings"
"time"
"gitea.dev/tea/internal/services"
"gitea.dev/tea/internal/terminal"
"github.com/spf13/cobra"
)
// discussionView is what issue view and pr view show: a terminal.View with the discussion's
// comments, which --comments adds before the footer.
type discussionView struct {
view terminal.View
number, comments int64
// summary describes the author and history after the state badge, relative to now, such as
// "bob opened about 2 hours ago"; fields are the view's fields at that time.
summary func(now time.Time) string
fields func(now time.Time) []terminal.Field
// extraComments adds kind-specific entries, such as pull request reviews, to --comments.
extraComments func(*cobra.Command, target, int64) ([]discussionComment, error)
}
// discussionComment is one --comments entry. heading words what a review did, such as "approved
// these changes"; plain comments have none, but an ID. meta is a key and value for piped output.
type discussionComment struct {
author, heading, createdAt, body string
id int64
meta [2]string
}
// stateBadge shows a discussion state, such as Open or Merged, in its color.
func stateBadge(state string) terminal.Cell {
return terminal.Cell{Text: titleCase(state), Color: stateColors[state]}
}
// labelCells shows labels in their colors.
func labelCells(labels []services.Label) []terminal.Cell {
cells := make([]terminal.Cell, len(labels))
for i, label := range labels {
cells[i] = terminal.Cell{Text: label.Name, Color: label.Color}
}
return cells
}
// whenText words an API time relative to now, or returns it unchanged when it is no time.
func whenText(now time.Time, raw string) string {
if t := terminal.ParseTime(raw); !t.IsZero() {
return terminal.RelativeTime(now, t)
}
return raw
}
func issueView(item services.Issue, repo string, dependencies services.IssueDependencies) discussionView {
due := ""
if item.Deadline != nil {
due = *item.Deadline
}
return discussionView{
number: item.Number, comments: item.Comments,
summary: func(now time.Time) string { return item.Author + " opened " + whenText(now, item.CreatedAt) },
fields: func(now time.Time) []terminal.Field {
return []terminal.Field{
{Label: "State", Value: item.State, PipedOnly: true},
{Label: "Author", Value: item.Author, PipedOnly: true},
{Label: "Labels", Cells: labelCells(item.Labels)},
{Label: "Assignees", Value: strings.Join(item.Assignees, ", ")},
{Label: "Milestone", Value: milestoneTitle(item.Milestone)},
{Label: "Due", Value: dueText(now, due), TTYOnly: true},
{Label: "Due", Value: due, PipedOnly: true},
{Label: "Ref", Value: item.Ref},
{Label: "Blocked by", Value: issueReferences(repo, dependencies.BlockedBy)},
{Label: "Blocks", Value: issueReferences(repo, dependencies.Blocks)},
{Label: "Comments", Value: strconv.FormatInt(item.Comments, 10), PipedOnly: true},
{Label: "Number", Value: strconv.FormatInt(item.Number, 10), PipedOnly: true},
}
},
view: terminal.View{
Title: item.Title, ID: "#" + strconv.FormatInt(item.Number, 10), Badges: []terminal.Cell{stateBadge(item.State)},
Body: item.Body, Kind: "issue", URL: item.URL,
},
}
}
// dueText words a due date for people: "Dec 24, 2026 (in about 2 months)".
func dueText(now time.Time, raw string) string {
due := terminal.ParseTime(raw)
if due.IsZero() {
return raw
}
return due.UTC().Format("Jan 2, 2006") + " (" + terminal.RelativeTime(now, due) + ")"
}
// issueReferences lists issues as #N, qualifying those from other repositories.
func issueReferences(repo string, refs []services.IssueReference) string {
names := make([]string, len(refs))
for i, ref := range refs {
switch {
case ref.Number < 1:
names[i] = "hidden"
case ref.Repository == "" || strings.EqualFold(ref.Repository, repo):
names[i] = fmt.Sprintf("#%d", ref.Number)
default:
names[i] = fmt.Sprintf("%s#%d", ref.Repository, ref.Number)
}
}
return strings.Join(names, ", ")
}
// pullDetails are what pr view reads besides the pull request: the reviews, and the checks of an
// open pull request's head.
type pullDetails struct {
reviews []services.Review
checks []services.Check
}
func pullView(item services.Pull, repo string, details pullDetails) discussionView {
state := pullState(item)
head := pullHead(item, repo)
summary := func(now time.Time) string {
return fmt.Sprintf("%s wants to merge into %s from %s • %s", item.Author, item.Base, head, whenText(now, item.CreatedAt))
}
if item.Merged {
summary = func(now time.Time) string {
merged := cmp.Or(item.MergedBy, item.Author) + " merged into " + item.Base + " from " + head
if item.MergedAt != nil {
merged += " • " + whenText(now, *item.MergedAt)
}
return merged
}
}
mergeable := ""
switch {
case state == "draft":
mergeable = fmt.Sprintf("no — draft; mark it ready with: tea pr ready %d", item.Number)
case state != "open":
case item.Mergeable:
mergeable = "yes"
default:
mergeable = "no — conflicts with " + item.Base
}
checks, checksHint := checkSummary(details.checks), ""
if checks != "" {
checksHint = fmt.Sprintf("%s (tea pr checks %d)", checks, item.Number)
}
reviews := details.reviews
return discussionView{
number: item.Number, comments: item.Comments, summary: summary,
view: terminal.View{
Title: item.Title, ID: "#" + strconv.FormatInt(item.Number, 10), Badges: []terminal.Cell{stateBadge(state)},
Body: item.Body, Kind: "pull request", URL: item.URL,
},
fields: func(time.Time) []terminal.Field {
return []terminal.Field{
{Label: "State", Value: state, PipedOnly: true},
{Label: "Author", Value: item.Author, PipedOnly: true},
{Label: "Base", Value: item.Base, PipedOnly: true},
{Label: "Head", Value: head, PipedOnly: true},
{Label: "Labels", Cells: labelCells(item.Labels)},
{Label: "Assignees", Value: strings.Join(item.Assignees, ", ")},
{Label: "Reviewers", Value: reviewerSummary(item, details.reviews)},
{Label: "Milestone", Value: milestoneTitle(item.Milestone)},
{Label: "Checks", Value: checksHint, TTYOnly: true},
{Label: "Checks", Value: checks, PipedOnly: true},
{Label: "Mergeable", Value: mergeable},
{Label: "Comments", Value: strconv.FormatInt(item.Comments, 10), PipedOnly: true},
{Label: "Number", Value: strconv.FormatInt(item.Number, 10), PipedOnly: true},
}
},
extraComments: func(cmd *cobra.Command, t target, n int64) ([]discussionComment, error) {
return pullReviewComments(cmd, t, n, reviews)
},
}
}
// reviewerSummary lists each reviewer with their latest review state, then the requested reviewers
// and teams that have not reviewed yet: "bob (Approved), carol (Requested)".
func reviewerSummary(item services.Pull, reviews []services.Review) string {
var names []string
seen := map[string]bool{}
for _, review := range services.LatestReviews(reviews) {
seen[strings.ToLower(review.Author)] = true
names = append(names, review.Author+" ("+reviewStateCell(review).Text+")")
}
for _, name := range slices.Concat(item.Reviewers, item.ReviewerTeams) {
if !seen[strings.ToLower(name)] {
names = append(names, name+" (Requested)")
}
}
return strings.Join(names, ", ")
}
// checkSummary counts checks by outcome: "1 failing, 2 pending, 3 successful", or "" without checks.
func checkSummary(checks []services.Check) string {
var failing, pending, passed int
for _, check := range checks {
switch {
case check.Pending():
pending++
case check.Passed():
passed++
default:
failing++
}
}
var parts []string
for _, count := range []struct {
n int
word string
}{{failing, "failing"}, {pending, "pending"}, {passed, "successful"}} {
if count.n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", count.n, count.word))
}
}
return strings.Join(parts, ", ")
}
// reviewHeadings word what a review did in --comments, by its state.
var reviewHeadings = map[string]string{
"APPROVED": "approved these changes", "REQUEST_CHANGES": "requested changes", "COMMENT": "reviewed",
}
// pullReviewComments returns the submitted reviews, including those without a body, and the inline
// review comments of pull request n.
func pullReviewComments(cmd *cobra.Command, t target, n int64, reviews []services.Review) ([]discussionComment, error) {
var result []discussionComment
for _, review := range reviews {
heading, ok := reviewHeadings[review.State]
if !ok {
continue
}
if review.Dismissed {
heading += " (dismissed)"
}
result = append(result, discussionComment{
author: review.Author, createdAt: review.SubmittedAt, body: review.Body, heading: heading, meta: [2]string{"review", review.State},
})
}
inline, err := (services.Reviews{Client: t.client, Repository: t.Repo}).Inline(cmd.Context(), n, reviews)
if err != nil {
return nil, err
}
for _, comment := range inline {
location := comment.Path
if comment.Line > 0 {
location = fmt.Sprintf("%s:%d", comment.Path, comment.Line)
}
result = append(result, discussionComment{
author: comment.Author, createdAt: comment.CreatedAt, body: comment.Body, id: comment.ID,
heading: "commented on " + location, meta: [2]string{"path", location},
})
}
return result, nil
}
func milestoneTitle(m *services.IssueMilestone) string {
if m == nil {
return ""
}
return m.Title
}
// writeDiscussion renders an issue or pull request like gh: a terminal.View, then the comments when
// withComments is set, or a hint at them, and the footer. Piped, the comments follow the view as
// key/value blocks.
func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discussionView, withComments bool) error {
var comments []discussionComment
if withComments {
items, err := (services.Comments{Client: target.client, Repository: target.Repo}).List(cmd.Context(), v.number)
if err != nil {
return err
}
for _, item := range items {
comments = append(comments, discussionComment{author: item.Author, createdAt: item.CreatedAt, body: item.Body, id: item.ID})
}
if v.extraComments != nil {
extra, err := v.extraComments(cmd, target, v.number)
if err != nil {
return err
}
comments = append(comments, extra...)
slices.SortStableFunc(comments, func(x, y discussionComment) int {
return terminal.ParseTime(x.createdAt).Compare(terminal.ParseTime(y.createdAt))
})
}
}
d := a.display()
now := d.Now
if now.IsZero() {
now = time.Now()
}
view := v.view
view.Fields = v.fields(now)
if !d.TTY {
var b strings.Builder
b.WriteString(view.Render(d))
for _, comment := range comments {
fmt.Fprintf(&b, "--\nauthor:\t%s\ncreated_at:\t%s\n", terminal.SafeLine(comment.author), terminal.SafeLine(comment.createdAt))
if comment.id > 0 {
fmt.Fprintf(&b, "id:\t%d\n", comment.id)
}
if comment.meta[0] != "" {
fmt.Fprintf(&b, "%s:\t%s\n", comment.meta[0], terminal.SafeLine(comment.meta[1]))
}
fmt.Fprintf(&b, "--\n%s\n", terminal.SafeText(comment.body))
}
_, err := fmt.Fprint(cmd.OutOrStdout(), b.String())
return err
}
view.Meta = v.summary(now) + " • " + plural(int(v.comments), "comment")
view.URL = ""
muted := func(text string) string { return terminal.Colorize("muted", text, d.Color) }
var b strings.Builder
b.WriteString(view.Render(d))
if !withComments && v.comments > 0 {
b.WriteString("\n" + muted(plural(int(v.comments), "comment")+" — use --comments to view them") + "\n")
}
for _, comment := range comments {
heading := []string{terminal.Emphasis(terminal.SafeLine(comment.author), d.Color)}
if comment.heading != "" {
heading[0] += " " + terminal.SafeLine(comment.heading)
}
heading = append(heading, whenText(now, comment.createdAt))
if comment.id > 0 && comment.meta[0] == "" {
heading = append(heading, "#"+strconv.FormatInt(comment.id, 10))
}
body := strings.TrimSpace(comment.body)
if d.Markdown && body != "" {
body = terminal.Markdown(body, d)
} else {
body = terminal.SafeText(body)
}
b.WriteString("\n" + strings.Join(heading, " • ") + "\n")
if body != "" {
b.WriteString(body + "\n")
}
}
b.WriteString("\n" + muted("View this "+v.view.Kind+" on Gitea: "+terminal.SafeLine(v.view.URL)) + "\n")
return a.writeHuman(cmd, b.String())
}
func plural(n int, unit string) string {
if n == 1 {
return "1 " + unit
}
return fmt.Sprintf("%d %ss", n, unit)
}