jira-cli/internal/view/issue.go

460 lines
11 KiB
Go
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package view
import (
"fmt"
"io"
"os"
"sort"
"strings"
"github.com/charmbracelet/glamour"
"github.com/fatih/color"
"github.com/ankitpokhrel/jira-cli/internal/cmdutil"
"github.com/ankitpokhrel/jira-cli/pkg/adf"
"github.com/ankitpokhrel/jira-cli/pkg/jira"
"github.com/ankitpokhrel/jira-cli/pkg/md"
"github.com/ankitpokhrel/jira-cli/pkg/tui"
)
const defaultSummaryLength = 73 // +1 to take ellipsis '…' into account.
type fragment struct {
Body string
Parse bool
}
func newBlankFragment(n int) fragment {
var buf strings.Builder
for i := 0; i < n; i++ {
buf.WriteRune('\n')
}
return fragment{
Body: buf.String(),
Parse: false,
}
}
type issueComment struct {
meta string
body string
}
// IssueOption is filtering options for an issue.
type IssueOption struct {
NumComments uint
}
// Issue is a list view for issues.
type Issue struct {
Server string
Data *jira.Issue
Display DisplayFormat
Options IssueOption
}
// Render renders the view.
func (i Issue) Render() error {
if i.Display.Plain || tui.IsDumbTerminal() || tui.IsNotTTY() {
return i.renderPlain(os.Stdout)
}
r, err := MDRenderer()
if err != nil {
return err
}
out, err := i.RenderedOut(r)
if err != nil {
return err
}
return tui.PagerOut(out)
}
// RenderedOut translates raw data to the format we want to display in.
func (i Issue) RenderedOut(renderer *glamour.TermRenderer) (string, error) {
var res strings.Builder
for _, p := range i.fragments() {
if p.Parse {
out, err := renderer.Render(p.Body)
if err != nil {
return "", err
}
res.WriteString(out)
} else {
res.WriteString(p.Body)
}
}
return res.String(), nil
}
func (i Issue) String() string {
var s strings.Builder
s.WriteString(i.header())
desc := i.description()
if desc != "" {
s.WriteString(fmt.Sprintf("\n\n%s\n\n%s", i.separator("Description"), desc))
}
if len(i.Data.Fields.Subtasks) > 0 {
s.WriteString(
fmt.Sprintf(
"\n\n%s\n\n%s\n",
i.separator(fmt.Sprintf("%d Subtasks", len(i.Data.Fields.Subtasks))),
i.subtasks(),
),
)
}
if len(i.Data.Fields.IssueLinks) > 0 {
s.WriteString(fmt.Sprintf("\n\n%s\n\n%s\n", i.separator("Linked Issues"), i.linkedIssues()))
}
total := i.Data.Fields.Comment.Total
if total > 0 && i.Options.NumComments > 0 {
sep := fmt.Sprintf("%d Comments", total)
s.WriteString(fmt.Sprintf("\n\n%s", i.separator(sep)))
for _, comment := range i.comments() {
s.WriteString(fmt.Sprintf("\n\n%s\n\n%s\n", comment.meta, comment.body))
}
}
s.WriteString(i.footer())
return s.String()
}
func (i Issue) fragments() []fragment {
scraps := []fragment{
{Body: i.header(), Parse: true},
}
desc := i.description()
if desc != "" {
scraps = append(
scraps,
newBlankFragment(1),
fragment{Body: i.separator("Description")},
newBlankFragment(2),
fragment{Body: desc, Parse: true},
)
}
if len(i.Data.Fields.Subtasks) > 0 {
scraps = append(
scraps,
newBlankFragment(1),
fragment{Body: i.separator(fmt.Sprintf("%d Subtasks", len(i.Data.Fields.Subtasks)))},
newBlankFragment(2),
fragment{Body: i.subtasks()},
newBlankFragment(1),
)
}
if len(i.Data.Fields.IssueLinks) > 0 {
scraps = append(
scraps,
newBlankFragment(1),
fragment{Body: i.separator("Linked Issues")},
newBlankFragment(2),
fragment{Body: i.linkedIssues()},
newBlankFragment(1),
)
}
if i.Data.Fields.Comment.Total > 0 && i.Options.NumComments > 0 {
scraps = append(
scraps,
newBlankFragment(1),
fragment{Body: i.separator(fmt.Sprintf("%d Comments", i.Data.Fields.Comment.Total))},
newBlankFragment(2),
)
for _, comment := range i.comments() {
scraps = append(
scraps,
fragment{Body: comment.meta},
newBlankFragment(1),
fragment{Body: comment.body, Parse: true},
)
}
}
return append(scraps, newBlankFragment(1), fragment{Body: i.footer()}, newBlankFragment(2))
}
func (i Issue) separator(msg string) string {
pad := func(m string) string {
if m != "" {
return fmt.Sprintf(" %s ", m)
}
return m
}
if i.Display.Plain {
sep := "------------------------"
return fmt.Sprintf("%s%s%s", sep, pad(msg), sep)
}
sep := "————————————————————————"
if msg == "" {
return gray(fmt.Sprintf("%s%s", sep, sep))
}
return gray(fmt.Sprintf("%s%s%s", sep, pad(msg), sep))
}
func (i Issue) header() string {
as := i.Data.Fields.Assignee.Name
if as == "" {
as = "Unassigned"
}
st, sti := i.Data.Fields.Status.Name, "🚧"
if st == "Done" {
sti = "✅"
}
lbl := "None"
if len(i.Data.Fields.Labels) > 0 {
lbl = strings.Join(i.Data.Fields.Labels, ", ")
}
components := make([]string, 0, len(i.Data.Fields.Components))
for _, c := range i.Data.Fields.Components {
components = append(components, c.Name)
}
cmpt := "None"
if len(components) > 0 {
cmpt = strings.Join(components, ", ")
}
it, iti := i.Data.Fields.IssueType.Name, "⭐"
if it == "Bug" {
iti = "🐞"
}
wch := fmt.Sprintf("%d watchers", i.Data.Fields.Watches.WatchCount)
if i.Data.Fields.Watches.WatchCount == 1 && i.Data.Fields.Watches.IsWatching {
wch = "You are watching"
} else if i.Data.Fields.Watches.IsWatching {
wch = fmt.Sprintf("You + %d watchers", i.Data.Fields.Watches.WatchCount-1)
}
return fmt.Sprintf(
"%s %s %s %s ⌛ %s 👷 %s 🔑️ %s 💭 %d comments \U0001F9F5 %d linked\n# %s\n⏱ %s 🔎 %s 🚀 %s 📦 %s 🏷️ %s 👀 %s",
iti, it, sti, st, cmdutil.FormatDateTimeHuman(i.Data.Fields.Updated, jira.RFC3339), as, i.Data.Key,
i.Data.Fields.Comment.Total, len(i.Data.Fields.IssueLinks),
i.Data.Fields.Summary,
cmdutil.FormatDateTimeHuman(i.Data.Fields.Created, jira.RFC3339), i.Data.Fields.Reporter.Name,
i.Data.Fields.Priority.Name, cmpt, lbl, wch,
)
}
func (i Issue) description() string {
if i.Data.Fields.Description == nil {
return ""
}
var desc string
if adfNode, ok := i.Data.Fields.Description.(*adf.ADF); ok {
desc = adf.NewTranslator(adfNode, adf.NewMarkdownTranslator()).Translate()
} else {
desc = i.Data.Fields.Description.(string)
desc = md.FromJiraMD(desc)
}
return desc
}
func (i Issue) subtasks() string {
if len(i.Data.Fields.Subtasks) == 0 {
return ""
}
var (
subtasks strings.Builder
summaryLen = defaultSummaryLength
maxKeyLen int
maxSummaryLen int
maxStatusLen int
maxPriorityLen int
)
for idx := range i.Data.Fields.Subtasks {
task := i.Data.Fields.Subtasks[idx]
maxKeyLen = max(len(task.Key), maxKeyLen)
maxSummaryLen = max(len(task.Fields.Summary), maxSummaryLen)
maxStatusLen = max(len(task.Fields.Status.Name), maxStatusLen)
maxPriorityLen = max(len(task.Fields.Priority.Name), maxPriorityLen)
}
if maxSummaryLen < summaryLen {
summaryLen = maxSummaryLen
}
subtasks.WriteString(
fmt.Sprintf("\n %s\n\n", coloredOut("SUBTASKS", color.FgWhite, color.Bold)),
)
for idx := range i.Data.Fields.Subtasks {
task := i.Data.Fields.Subtasks[idx]
subtasks.WriteString(
fmt.Sprintf(
" %s %s • %s • %s\n",
coloredOut(pad(task.Key, maxKeyLen), color.FgGreen, color.Bold),
shortenAndPad(task.Fields.Summary, summaryLen),
pad(task.Fields.Priority.Name, maxPriorityLen),
pad(task.Fields.Status.Name, maxStatusLen),
),
)
}
return subtasks.String()
}
func (i Issue) linkedIssues() string {
if len(i.Data.Fields.IssueLinks) == 0 {
return ""
}
var (
linked strings.Builder
keys = make([]string, 0)
linkMap = make(map[string][]*jira.Issue, len(i.Data.Fields.IssueLinks))
summaryLen = defaultSummaryLength
maxKeyLen int
maxSummaryLen int
maxTypeLen int
maxStatusLen int
maxPriorityLen int
)
for _, link := range i.Data.Fields.IssueLinks {
var (
linkType string
linkedIssue *jira.Issue
)
if link.InwardIssue != nil {
linkType = link.LinkType.Inward
linkedIssue = link.InwardIssue
} else if link.OutwardIssue != nil {
linkType = link.LinkType.Outward
linkedIssue = link.OutwardIssue
}
if linkedIssue == nil {
continue
}
if _, ok := linkMap[linkType]; !ok {
keys = append(keys, linkType)
}
linkMap[linkType] = append(linkMap[linkType], linkedIssue)
maxKeyLen = max(len(linkedIssue.Key), maxKeyLen)
maxSummaryLen = max(len(linkedIssue.Fields.Summary), maxSummaryLen)
maxTypeLen = max(len(linkedIssue.Fields.IssueType.Name), maxTypeLen)
maxStatusLen = max(len(linkedIssue.Fields.Status.Name), maxStatusLen)
maxPriorityLen = max(len(linkedIssue.Fields.Priority.Name), maxPriorityLen)
}
if maxSummaryLen < summaryLen {
summaryLen = maxSummaryLen
}
// We are sorting keys to respect the order we see in the UI.
sort.Strings(keys)
for _, k := range keys {
linked.WriteString(
fmt.Sprintf("\n %s\n\n", coloredOut(strings.ToUpper(k), color.FgWhite, color.Bold)),
)
for _, iss := range linkMap[k] {
linked.WriteString(
fmt.Sprintf(
" %s %s • %s • %s • %s\n",
coloredOut(pad(iss.Key, maxKeyLen), color.FgGreen, color.Bold),
shortenAndPad(iss.Fields.Summary, summaryLen),
pad(iss.Fields.IssueType.Name, maxTypeLen),
pad(iss.Fields.Priority.Name, maxPriorityLen),
pad(iss.Fields.Status.Name, maxStatusLen),
),
)
}
}
return linked.String()
}
func (i Issue) comments() []issueComment {
total := i.Data.Fields.Comment.Total
comments := make([]issueComment, 0, total)
if total == 0 {
return comments
}
limit := int(i.Options.NumComments)
if limit > total {
limit = total
}
for idx := total - 1; idx >= total-limit; idx-- {
c := i.Data.Fields.Comment.Comments[idx]
var body string
if adfNode, ok := c.Body.(*adf.ADF); ok {
body = adf.NewTranslator(adfNode, adf.NewMarkdownTranslator()).Translate()
} else {
body = c.Body.(string)
body = md.FromJiraMD(body)
}
authorName := func() string {
if c.Author.DisplayName != "" {
return c.Author.DisplayName
}
return c.Author.Name
}
meta := fmt.Sprintf(
"\n %s • %s",
coloredOut(authorName(), color.FgWhite, color.Bold),
coloredOut(cmdutil.FormatDateTimeHuman(c.Created, jira.RFC3339), color.FgWhite, color.Bold),
)
if idx == total-1 {
meta += fmt.Sprintf(" • %s", coloredOut("Latest comment", color.FgCyan, color.Bold))
}
comments = append(comments, issueComment{
meta: meta,
body: body,
})
}
return comments
}
func (i Issue) footer() string {
var out strings.Builder
nc := int(i.Options.NumComments)
if i.Data.Fields.Comment.Total > 0 && nc > 0 && nc < i.Data.Fields.Comment.Total {
if i.Display.Plain {
out.WriteString("\n")
}
out.WriteString(fmt.Sprintf("%s\n", gray("Use --comments <limit> with `jira issue view` to load more comments")))
}
if i.Display.Plain {
out.WriteString("\n")
}
out.WriteString(gray(fmt.Sprintf("View this issue on Jira: %s", cmdutil.GenerateServerBrowseURL(i.Server, i.Data.Key))))
return out.String()
}
// renderPlain renders the issue in plain view.
func (i Issue) renderPlain(w io.Writer) error {
r, err := glamour.NewTermRenderer(
glamour.WithStandardStyle("notty"),
glamour.WithWordWrap(wordWrap),
)
if err != nil {
return err
}
out, err := r.Render(i.String())
if err != nil {
return err
}
_, err = fmt.Fprint(w, out)
return err
}