cli/pkg/cmd/issue/view/view.go
Andy Feller 3eca268a7f Introduce color_labels support, update commands
This commit implements the actual changes around configuration setting / environment variable logic for displaying labels using their RGB hex color code in terminals with truecolor support.

One of the subtler changes in this commit is renaming generic ColorScheme.HexToRGB logic to render truecolor to ColorScheme.Label as this feature was being used exclusively for labels.  This is due to confusion about introducing the new `color_labels` config on top of generic coloring logic.
2025-04-02 18:24:20 -04:00

325 lines
8.6 KiB
Go

package view
import (
"errors"
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/text"
issueShared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/markdown"
"github.com/cli/cli/v2/pkg/set"
"github.com/spf13/cobra"
)
type ViewOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Browser browser.Browser
SelectorArg string
WebMode bool
Comments bool
Exporter cmdutil.Exporter
Now func() time.Time
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Browser: f.Browser,
Now: time.Now,
}
cmd := &cobra.Command{
Use: "view {<number> | <url>}",
Short: "View an issue",
Long: heredoc.Docf(`
Display the title, body, and other information about an issue.
With %[1]s--web%[1]s flag, open the issue in a web browser instead.
`, "`"),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if len(args) > 0 {
opts.SelectorArg = args[0]
}
if runF != nil {
return runF(opts)
}
return viewRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser")
cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View issue comments")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.IssueFields)
return cmd
}
var defaultFields = []string{
"number", "url", "state", "createdAt", "title", "body", "author", "milestone",
"assignees", "labels", "projectCards", "reactionGroups", "lastComment", "stateReason",
}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
lookupFields := set.NewStringSet()
if opts.Exporter != nil {
lookupFields.AddValues(opts.Exporter.Fields())
} else if opts.WebMode {
lookupFields.Add("url")
} else {
lookupFields.AddValues(defaultFields)
if opts.Comments {
lookupFields.Add("comments")
lookupFields.Remove("lastComment")
}
}
opts.IO.DetectTerminalTheme()
opts.IO.StartProgressIndicator()
issue, baseRepo, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields.ToSlice())
opts.IO.StopProgressIndicator()
if err != nil {
var loadErr *issueShared.PartialLoadError
if opts.Exporter == nil && errors.As(err, &loadErr) {
fmt.Fprintf(opts.IO.ErrOut, "warning: %s\n", loadErr.Error())
} else {
return err
}
}
if opts.WebMode {
openURL := issue.URL
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL))
}
return opts.Browser.Browse(openURL)
}
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO, issue)
}
if opts.IO.IsStdoutTTY() {
return printHumanIssuePreview(opts, baseRepo, issue)
}
if opts.Comments {
fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{}))
return nil
}
return printRawIssuePreview(opts.IO.Out, issue)
}
func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, fields []string) (*api.Issue, ghrepo.Interface, error) {
fieldSet := set.NewStringSet()
fieldSet.AddValues(fields)
fieldSet.Add("id")
issue, repo, err := issueShared.IssueFromArgWithFields(client, baseRepoFn, selector, fieldSet.ToSlice())
if err != nil {
return issue, repo, err
}
if fieldSet.Contains("comments") {
// FIXME: this re-fetches the comments connection even though the initial set of 100 were
// fetched in the previous request.
err = preloadIssueComments(client, repo, issue)
}
return issue, repo, err
}
func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
assignees := issueAssigneeList(*issue)
labels := issueLabelList(issue, nil)
projects := issueProjectList(*issue)
// Print empty strings for empty values so the number of metadata lines is consistent when
// processing many issues with head and grep.
fmt.Fprintf(out, "title:\t%s\n", issue.Title)
fmt.Fprintf(out, "state:\t%s\n", issue.State)
fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login)
fmt.Fprintf(out, "labels:\t%s\n", labels)
fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount)
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
fmt.Fprintf(out, "projects:\t%s\n", projects)
var milestoneTitle string
if issue.Milestone != nil {
milestoneTitle = issue.Milestone.Title
}
fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
fmt.Fprintf(out, "number:\t%d\n", issue.Number)
fmt.Fprintln(out, "--")
fmt.Fprintln(out, issue.Body)
return nil
}
func printHumanIssuePreview(opts *ViewOptions, baseRepo ghrepo.Interface, issue *api.Issue) error {
out := opts.IO.Out
cs := opts.IO.ColorScheme()
// Header (Title and State)
fmt.Fprintf(out, "%s %s#%d\n", cs.Bold(issue.Title), ghrepo.FullName(baseRepo), issue.Number)
fmt.Fprintf(out,
"%s • %s opened %s • %s\n",
issueStateTitleWithColor(cs, issue),
issue.Author.Login,
text.FuzzyAgo(opts.Now(), issue.CreatedAt),
text.Pluralize(issue.Comments.TotalCount, "comment"),
)
// Reactions
if reactions := prShared.ReactionGroupList(issue.ReactionGroups); reactions != "" {
fmt.Fprint(out, reactions)
fmt.Fprintln(out)
}
// Metadata
if assignees := issueAssigneeList(*issue); assignees != "" {
fmt.Fprint(out, cs.Bold("Assignees: "))
fmt.Fprintln(out, assignees)
}
if labels := issueLabelList(issue, cs); labels != "" {
fmt.Fprint(out, cs.Bold("Labels: "))
fmt.Fprintln(out, labels)
}
if projects := issueProjectList(*issue); projects != "" {
fmt.Fprint(out, cs.Bold("Projects: "))
fmt.Fprintln(out, projects)
}
if issue.Milestone != nil {
fmt.Fprint(out, cs.Bold("Milestone: "))
fmt.Fprintln(out, issue.Milestone.Title)
}
// Body
var md string
var err error
if issue.Body == "" {
md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided"))
} else {
md, err = markdown.Render(issue.Body,
markdown.WithTheme(opts.IO.TerminalTheme()),
markdown.WithWrap(opts.IO.TerminalWidth()))
if err != nil {
return err
}
}
fmt.Fprintf(out, "\n%s\n", md)
// Comments
if issue.Comments.TotalCount > 0 {
preview := !opts.Comments
comments, err := prShared.CommentList(opts.IO, issue.Comments, api.PullRequestReviews{}, preview)
if err != nil {
return err
}
fmt.Fprint(out, comments)
}
// Footer
fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL)
return nil
}
func issueStateTitleWithColor(cs *iostreams.ColorScheme, issue *api.Issue) string {
colorFunc := cs.ColorFromString(prShared.ColorForIssueState(*issue))
state := "Open"
if issue.State == "CLOSED" {
state = "Closed"
}
return colorFunc(state)
}
func issueAssigneeList(issue api.Issue) string {
if len(issue.Assignees.Nodes) == 0 {
return ""
}
AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes))
for _, assignee := range issue.Assignees.Nodes {
AssigneeNames = append(AssigneeNames, assignee.Login)
}
list := strings.Join(AssigneeNames, ", ")
if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) {
list += ", …"
}
return list
}
func issueProjectList(issue api.Issue) string {
if len(issue.ProjectCards.Nodes) == 0 {
return ""
}
projectNames := make([]string, 0, len(issue.ProjectCards.Nodes))
for _, project := range issue.ProjectCards.Nodes {
colName := project.Column.Name
if colName == "" {
colName = "Awaiting triage"
}
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
}
list := strings.Join(projectNames, ", ")
if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) {
list += ", …"
}
return list
}
func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string {
if len(issue.Labels.Nodes) == 0 {
return ""
}
// ignore case sort
sort.SliceStable(issue.Labels.Nodes, func(i, j int) bool {
return strings.ToLower(issue.Labels.Nodes[i].Name) < strings.ToLower(issue.Labels.Nodes[j].Name)
})
labelNames := make([]string, len(issue.Labels.Nodes))
for i, label := range issue.Labels.Nodes {
if cs == nil {
labelNames[i] = label.Name
} else {
labelNames[i] = cs.Label(label.Color, label.Name)
}
}
return strings.Join(labelNames, ", ")
}