mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
13 files changed
+817
-302
No files matched your search
+241
-123
@@ -4,8 +4,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,35 +16,89 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// discussionView holds the fields `issue view` and `pr view` render.
|
||||
// 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 {
|
||||
kind, title, state, author, action, createdAt, url, body string
|
||||
number, comments int64
|
||||
labels []services.Label
|
||||
fields [][2]string
|
||||
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 and meta are empty for plain comments.
|
||||
// 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, createdAt, heading, body string
|
||||
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{
|
||||
kind: "issue", title: item.Title, state: item.State, author: item.Author, action: "opened",
|
||||
createdAt: item.CreatedAt, url: item.URL, body: item.Body, number: item.Number, comments: item.Comments, labels: item.Labels,
|
||||
fields: [][2]string{
|
||||
{"Assignees", strings.Join(item.Assignees, ", ")},
|
||||
{"Milestone", milestoneTitle(item.Milestone)},
|
||||
{"Blocked by", issueReferences(repo, dependencies.BlockedBy)},
|
||||
{"Blocks", issueReferences(repo, dependencies.Blocks)},
|
||||
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))
|
||||
@@ -59,52 +115,146 @@ func issueReferences(repo string, refs []services.IssueReference) string {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
|
||||
func pullView(item services.Pull) discussionView {
|
||||
state := pullState(item)
|
||||
v := discussionView{
|
||||
kind: "pull request", title: item.Title, state: state, author: item.Author,
|
||||
action: fmt.Sprintf("wants to merge into %s from %s •", item.Base, item.Head),
|
||||
createdAt: item.CreatedAt, url: item.URL, body: item.Body, number: item.Number, comments: item.Comments, labels: item.Labels,
|
||||
fields: [][2]string{
|
||||
{"Reviewers", strings.Join(slices.Concat(item.Reviewers, item.ReviewerTeams), ", ")},
|
||||
{"Assignees", strings.Join(item.Assignees, ", ")},
|
||||
{"Milestone", milestoneTitle(item.Milestone)},
|
||||
},
|
||||
extraComments: pullReviewComments,
|
||||
}
|
||||
if state == "open" {
|
||||
mergeable := "no"
|
||||
if item.Mergeable {
|
||||
mergeable = "yes"
|
||||
}
|
||||
v.fields = append(v.fields, [2]string{"Mergeable", mergeable})
|
||||
}
|
||||
return v
|
||||
// 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
|
||||
}
|
||||
|
||||
// pullReviewComments returns submitted review bodies and inline review comments.
|
||||
func pullReviewComments(cmd *cobra.Command, target target, n int64) ([]discussionComment, error) {
|
||||
reviews, inline, err := (services.Reviews{Client: target.client, Repository: target.Repo}).Thread(cmd.Context(), n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
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 {
|
||||
if strings.TrimSpace(review.Body) == "" || review.State == "PENDING" {
|
||||
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: "reviewed (" + review.State + ")", meta: [2]string{"review", review.State},
|
||||
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,
|
||||
author: comment.Author, createdAt: comment.CreatedAt, body: comment.Body, id: comment.ID,
|
||||
heading: "commented on " + location, meta: [2]string{"path", location},
|
||||
})
|
||||
}
|
||||
@@ -118,8 +268,9 @@ func milestoneTitle(m *services.IssueMilestone) string {
|
||||
return m.Title
|
||||
}
|
||||
|
||||
// writeDiscussion renders a gh-style detail view: a readable summary on a
|
||||
// terminal and tab-separated key/value lines followed by the body otherwise.
|
||||
// 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 {
|
||||
@@ -128,7 +279,7 @@ func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discu
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
comments = append(comments, discussionComment{author: item.Author, createdAt: item.CreatedAt, body: item.Body})
|
||||
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)
|
||||
@@ -136,99 +287,66 @@ func (a *application) writeDiscussion(cmd *cobra.Command, target target, v discu
|
||||
return err
|
||||
}
|
||||
comments = append(comments, extra...)
|
||||
slices.SortStableFunc(comments, func(x, y discussionComment) int { return compareTimes(x.createdAt, y.createdAt) })
|
||||
slices.SortStableFunc(comments, func(x, y discussionComment) int {
|
||||
return terminal.ParseTime(x.createdAt).Compare(terminal.ParseTime(y.createdAt))
|
||||
})
|
||||
}
|
||||
}
|
||||
v.title, v.body = terminal.SafeLine(v.title), terminal.SafeText(v.body)
|
||||
for i := range v.fields {
|
||||
v.fields[i][1] = terminal.SafeLine(v.fields[i][1])
|
||||
d := a.display()
|
||||
now := d.Now
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
for i := range comments {
|
||||
c := &comments[i]
|
||||
c.heading, c.meta[1], c.body = terminal.SafeLine(c.heading), terminal.SafeLine(c.meta[1]), terminal.SafeText(c.body)
|
||||
}
|
||||
color := a.deps.Streams.OutTTY && a.colorEnabled()
|
||||
labels := make([]string, len(v.labels))
|
||||
for i, label := range v.labels {
|
||||
labels[i] = terminal.Colorize(label.Color, terminal.SafeLine(label.Name), color)
|
||||
}
|
||||
var b strings.Builder
|
||||
if !a.deps.Streams.OutTTY {
|
||||
fmt.Fprintf(&b, "title:\t%s\nstate:\t%s\nauthor:\t%s\nlabels:\t%s\n", v.title, v.state, v.author, strings.Join(labels, ", "))
|
||||
for _, field := range v.fields {
|
||||
fmt.Fprintf(&b, "%s:\t%s\n", strings.ToLower(field[0]), field[1])
|
||||
}
|
||||
fmt.Fprintf(&b, "comments:\t%d\nnumber:\t%d\nurl:\t%s\n--\n%s\n", v.comments, v.number, v.url, v.body)
|
||||
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", comment.author, comment.createdAt)
|
||||
if comment.meta[0] != "" {
|
||||
fmt.Fprintf(&b, "%s:\t%s\n", comment.meta[0], comment.meta[1])
|
||||
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)
|
||||
}
|
||||
fmt.Fprintf(&b, "--\n%s\n", comment.body)
|
||||
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
|
||||
}
|
||||
now := a.deps.Now()
|
||||
fmt.Fprintf(&b, "%s #%d\n", terminal.Emphasis(v.title, color), v.number)
|
||||
fmt.Fprintf(&b, "%s • %s %s %s • %s\n", terminal.Colorize(stateColors[v.state], titleCase(v.state), color), v.author, v.action, relativeTime(now, v.createdAt), plural(int(v.comments), "comment"))
|
||||
for _, field := range append([][2]string{{"Labels", strings.Join(labels, ", ")}}, v.fields...) {
|
||||
if field[1] != "" {
|
||||
fmt.Fprintf(&b, "%s: %s\n", terminal.Emphasis(field[0], color), field[1])
|
||||
}
|
||||
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")
|
||||
}
|
||||
body := strings.TrimSpace(v.body)
|
||||
if body == "" {
|
||||
body = "No description provided"
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%s\n\n", body)
|
||||
for _, comment := range comments {
|
||||
heading := ""
|
||||
heading := []string{terminal.Emphasis(terminal.SafeLine(comment.author), d.Color)}
|
||||
if comment.heading != "" {
|
||||
heading = 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")
|
||||
}
|
||||
fmt.Fprintf(&b, "%s • %s%s\n%s\n\n", terminal.Emphasis(comment.author, color), heading, relativeTime(now, comment.createdAt), strings.TrimSpace(comment.body))
|
||||
}
|
||||
fmt.Fprintf(&b, "View this %s on Gitea: %s\n", v.kind, v.url)
|
||||
b.WriteString("\n" + muted("View this "+v.view.Kind+" on Gitea: "+terminal.SafeLine(v.view.URL)) + "\n")
|
||||
return a.writeHuman(cmd, b.String())
|
||||
}
|
||||
|
||||
// compareTimes orders API timestamps chronologically, falling back to text order.
|
||||
func compareTimes(x, y string) int {
|
||||
tx, errX := time.Parse(time.RFC3339, x)
|
||||
ty, errY := time.Parse(time.RFC3339, y)
|
||||
if errX != nil || errY != nil {
|
||||
return strings.Compare(x, y)
|
||||
}
|
||||
return tx.Compare(ty)
|
||||
}
|
||||
|
||||
// relativeTime formats an API timestamp like gh: "about 3 days ago".
|
||||
func relativeTime(now time.Time, raw string) string {
|
||||
t, err := time.Parse(time.RFC3339, raw)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
const day = 24 * time.Hour
|
||||
var n time.Duration
|
||||
var unit string
|
||||
switch d := now.Sub(t); {
|
||||
case d < time.Minute:
|
||||
return "less than a minute ago"
|
||||
case d < time.Hour:
|
||||
n, unit = d/time.Minute, "minute"
|
||||
case d < day:
|
||||
n, unit = d/time.Hour, "hour"
|
||||
case d < 30*day:
|
||||
n, unit = d/day, "day"
|
||||
case d < 365*day:
|
||||
n, unit = d/(30*day), "month"
|
||||
default:
|
||||
n, unit = d/(365*day), "year"
|
||||
}
|
||||
return "about " + plural(int(n), unit) + " ago"
|
||||
}
|
||||
|
||||
func plural(n int, unit string) string {
|
||||
if n == 1 {
|
||||
return "1 " + unit
|
||||
|
||||
Reference in new issue
Block a user