Files
gitea-tea/internal/app/discussion_view.go
T
bircni 2826726557 refactor(issue,pr): trim help, restating comments and single-use helpers
Cut issue, pr, label, milestone and comment examples to at most three, drop
doc comments that only restate the name, fold the comment and attachment
argument lookups into one closure each, and inline label helpers with one
caller.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-12 20:48:00 +02:00

336 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
}
func stateBadge(state string) terminal.Cell {
return terminal.Cell{Text: titleCase(state), Color: stateColors[state]}
}
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)
}
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, details.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 {
failing, pending, passed := services.Tally(checks)
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) + " • " + terminal.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(terminal.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())
}