- Supports passing a PR as argument, not just issues - Makes it non-fatal when project cards were not able to load - Cleans up legacy method for fetching issues
316 lines
8.1 KiB
Go
316 lines
8.1 KiB
Go
package view
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/api"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
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/cli/cli/v2/utils"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type browser interface {
|
|
Browse(string) error
|
|
}
|
|
|
|
type ViewOptions struct {
|
|
HttpClient func() (*http.Client, error)
|
|
IO *iostreams.IOStreams
|
|
BaseRepo func() (ghrepo.Interface, error)
|
|
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.Doc(`
|
|
Display the title, body, and other information about an issue.
|
|
|
|
With '--web', 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",
|
|
}
|
|
|
|
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.StartProgressIndicator()
|
|
issue, 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", utils.DisplayURL(openURL))
|
|
}
|
|
return opts.Browser.Browse(openURL)
|
|
}
|
|
|
|
opts.IO.DetectTerminalTheme()
|
|
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, 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, 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, err
|
|
}
|
|
|
|
if fieldSet.Contains("comments") {
|
|
err = preloadIssueComments(client, repo, issue)
|
|
}
|
|
return issue, 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, issue *api.Issue) error {
|
|
out := opts.IO.Out
|
|
now := opts.Now()
|
|
ago := now.Sub(issue.CreatedAt)
|
|
cs := opts.IO.ColorScheme()
|
|
|
|
// Header (Title and State)
|
|
fmt.Fprintf(out, "%s #%d\n", cs.Bold(issue.Title), issue.Number)
|
|
fmt.Fprintf(out,
|
|
"%s • %s opened %s • %s\n",
|
|
issueStateTitleWithColor(cs, issue.State),
|
|
issue.Author.Login,
|
|
utils.FuzzyAgo(ago),
|
|
utils.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 {
|
|
style := markdown.GetStyle(opts.IO.TerminalTheme())
|
|
md, err = markdown.Render(issue.Body, style)
|
|
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, state string) string {
|
|
colorFunc := cs.ColorFromString(prShared.ColorForState(state))
|
|
return colorFunc(strings.Title(strings.ToLower(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 ""
|
|
}
|
|
|
|
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.HexToRGB(label.Color, label.Name)
|
|
}
|
|
}
|
|
|
|
return strings.Join(labelNames, ", ")
|
|
}
|