github.com/cli/cli@v1.14.1-0.20210902173923-1af6a669e342/pkg/cmd/issue/view/view.go (about)

     1  package view
     2  
     3  import (
     4  	"fmt"
     5  	"io"
     6  	"net/http"
     7  	"strings"
     8  	"time"
     9  
    10  	"github.com/MakeNowJust/heredoc"
    11  	"github.com/cli/cli/api"
    12  	"github.com/cli/cli/internal/ghrepo"
    13  	issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
    14  	prShared "github.com/cli/cli/pkg/cmd/pr/shared"
    15  	"github.com/cli/cli/pkg/cmdutil"
    16  	"github.com/cli/cli/pkg/iostreams"
    17  	"github.com/cli/cli/pkg/markdown"
    18  	"github.com/cli/cli/pkg/set"
    19  	"github.com/cli/cli/utils"
    20  	"github.com/spf13/cobra"
    21  )
    22  
    23  type browser interface {
    24  	Browse(string) error
    25  }
    26  
    27  type ViewOptions struct {
    28  	HttpClient func() (*http.Client, error)
    29  	IO         *iostreams.IOStreams
    30  	BaseRepo   func() (ghrepo.Interface, error)
    31  	Browser    browser
    32  
    33  	SelectorArg string
    34  	WebMode     bool
    35  	Comments    bool
    36  	Exporter    cmdutil.Exporter
    37  
    38  	Now func() time.Time
    39  }
    40  
    41  func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
    42  	opts := &ViewOptions{
    43  		IO:         f.IOStreams,
    44  		HttpClient: f.HttpClient,
    45  		Browser:    f.Browser,
    46  		Now:        time.Now,
    47  	}
    48  
    49  	cmd := &cobra.Command{
    50  		Use:   "view {<number> | <url>}",
    51  		Short: "View an issue",
    52  		Long: heredoc.Doc(`
    53  			Display the title, body, and other information about an issue.
    54  
    55  			With '--web', open the issue in a web browser instead.
    56  		`),
    57  		Args: cobra.ExactArgs(1),
    58  		RunE: func(cmd *cobra.Command, args []string) error {
    59  			// support `-R, --repo` override
    60  			opts.BaseRepo = f.BaseRepo
    61  
    62  			if len(args) > 0 {
    63  				opts.SelectorArg = args[0]
    64  			}
    65  
    66  			if runF != nil {
    67  				return runF(opts)
    68  			}
    69  			return viewRun(opts)
    70  		},
    71  	}
    72  
    73  	cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser")
    74  	cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View issue comments")
    75  	cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.IssueFields)
    76  
    77  	return cmd
    78  }
    79  
    80  func viewRun(opts *ViewOptions) error {
    81  	httpClient, err := opts.HttpClient()
    82  	if err != nil {
    83  		return err
    84  	}
    85  
    86  	loadComments := opts.Comments
    87  	if !loadComments && opts.Exporter != nil {
    88  		fields := set.NewStringSet()
    89  		fields.AddValues(opts.Exporter.Fields())
    90  		loadComments = fields.Contains("comments")
    91  	}
    92  
    93  	opts.IO.StartProgressIndicator()
    94  	issue, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, loadComments)
    95  	opts.IO.StopProgressIndicator()
    96  	if err != nil {
    97  		return err
    98  	}
    99  
   100  	if opts.WebMode {
   101  		openURL := issue.URL
   102  		if opts.IO.IsStdoutTTY() {
   103  			fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
   104  		}
   105  		return opts.Browser.Browse(openURL)
   106  	}
   107  
   108  	opts.IO.DetectTerminalTheme()
   109  	if err := opts.IO.StartPager(); err != nil {
   110  		fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
   111  	}
   112  	defer opts.IO.StopPager()
   113  
   114  	if opts.Exporter != nil {
   115  		return opts.Exporter.Write(opts.IO, issue)
   116  	}
   117  
   118  	if opts.IO.IsStdoutTTY() {
   119  		return printHumanIssuePreview(opts, issue)
   120  	}
   121  
   122  	if opts.Comments {
   123  		fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{}))
   124  		return nil
   125  	}
   126  
   127  	return printRawIssuePreview(opts.IO.Out, issue)
   128  }
   129  
   130  func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, loadComments bool) (*api.Issue, error) {
   131  	apiClient := api.NewClientFromHTTP(client)
   132  	issue, repo, err := issueShared.IssueFromArg(apiClient, baseRepoFn, selector)
   133  	if err != nil {
   134  		return issue, err
   135  	}
   136  
   137  	if loadComments {
   138  		err = preloadIssueComments(client, repo, issue)
   139  	}
   140  	return issue, err
   141  }
   142  
   143  func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
   144  	assignees := issueAssigneeList(*issue)
   145  	labels := issueLabelList(issue, nil)
   146  	projects := issueProjectList(*issue)
   147  
   148  	// Print empty strings for empty values so the number of metadata lines is consistent when
   149  	// processing many issues with head and grep.
   150  	fmt.Fprintf(out, "title:\t%s\n", issue.Title)
   151  	fmt.Fprintf(out, "state:\t%s\n", issue.State)
   152  	fmt.Fprintf(out, "author:\t%s\n", issue.Author.Login)
   153  	fmt.Fprintf(out, "labels:\t%s\n", labels)
   154  	fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount)
   155  	fmt.Fprintf(out, "assignees:\t%s\n", assignees)
   156  	fmt.Fprintf(out, "projects:\t%s\n", projects)
   157  	var milestoneTitle string
   158  	if issue.Milestone != nil {
   159  		milestoneTitle = issue.Milestone.Title
   160  	}
   161  	fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
   162  	fmt.Fprintf(out, "number:\t%d\n", issue.Number)
   163  	fmt.Fprintln(out, "--")
   164  	fmt.Fprintln(out, issue.Body)
   165  	return nil
   166  }
   167  
   168  func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error {
   169  	out := opts.IO.Out
   170  	now := opts.Now()
   171  	ago := now.Sub(issue.CreatedAt)
   172  	cs := opts.IO.ColorScheme()
   173  
   174  	// Header (Title and State)
   175  	fmt.Fprintf(out, "%s #%d\n", cs.Bold(issue.Title), issue.Number)
   176  	fmt.Fprintf(out,
   177  		"%s • %s opened %s • %s\n",
   178  		issueStateTitleWithColor(cs, issue.State),
   179  		issue.Author.Login,
   180  		utils.FuzzyAgo(ago),
   181  		utils.Pluralize(issue.Comments.TotalCount, "comment"),
   182  	)
   183  
   184  	// Reactions
   185  	if reactions := prShared.ReactionGroupList(issue.ReactionGroups); reactions != "" {
   186  		fmt.Fprint(out, reactions)
   187  		fmt.Fprintln(out)
   188  	}
   189  
   190  	// Metadata
   191  	if assignees := issueAssigneeList(*issue); assignees != "" {
   192  		fmt.Fprint(out, cs.Bold("Assignees: "))
   193  		fmt.Fprintln(out, assignees)
   194  	}
   195  	if labels := issueLabelList(issue, cs); labels != "" {
   196  		fmt.Fprint(out, cs.Bold("Labels: "))
   197  		fmt.Fprintln(out, labels)
   198  	}
   199  	if projects := issueProjectList(*issue); projects != "" {
   200  		fmt.Fprint(out, cs.Bold("Projects: "))
   201  		fmt.Fprintln(out, projects)
   202  	}
   203  	if issue.Milestone != nil {
   204  		fmt.Fprint(out, cs.Bold("Milestone: "))
   205  		fmt.Fprintln(out, issue.Milestone.Title)
   206  	}
   207  
   208  	// Body
   209  	var md string
   210  	var err error
   211  	if issue.Body == "" {
   212  		md = fmt.Sprintf("\n  %s\n\n", cs.Gray("No description provided"))
   213  	} else {
   214  		style := markdown.GetStyle(opts.IO.TerminalTheme())
   215  		md, err = markdown.Render(issue.Body, style)
   216  		if err != nil {
   217  			return err
   218  		}
   219  	}
   220  	fmt.Fprintf(out, "\n%s\n", md)
   221  
   222  	// Comments
   223  	if issue.Comments.TotalCount > 0 {
   224  		preview := !opts.Comments
   225  		comments, err := prShared.CommentList(opts.IO, issue.Comments, api.PullRequestReviews{}, preview)
   226  		if err != nil {
   227  			return err
   228  		}
   229  		fmt.Fprint(out, comments)
   230  	}
   231  
   232  	// Footer
   233  	fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL)
   234  
   235  	return nil
   236  }
   237  
   238  func issueStateTitleWithColor(cs *iostreams.ColorScheme, state string) string {
   239  	colorFunc := cs.ColorFromString(prShared.ColorForState(state))
   240  	return colorFunc(strings.Title(strings.ToLower(state)))
   241  }
   242  
   243  func issueAssigneeList(issue api.Issue) string {
   244  	if len(issue.Assignees.Nodes) == 0 {
   245  		return ""
   246  	}
   247  
   248  	AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes))
   249  	for _, assignee := range issue.Assignees.Nodes {
   250  		AssigneeNames = append(AssigneeNames, assignee.Login)
   251  	}
   252  
   253  	list := strings.Join(AssigneeNames, ", ")
   254  	if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) {
   255  		list += ", …"
   256  	}
   257  	return list
   258  }
   259  
   260  func issueProjectList(issue api.Issue) string {
   261  	if len(issue.ProjectCards.Nodes) == 0 {
   262  		return ""
   263  	}
   264  
   265  	projectNames := make([]string, 0, len(issue.ProjectCards.Nodes))
   266  	for _, project := range issue.ProjectCards.Nodes {
   267  		colName := project.Column.Name
   268  		if colName == "" {
   269  			colName = "Awaiting triage"
   270  		}
   271  		projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
   272  	}
   273  
   274  	list := strings.Join(projectNames, ", ")
   275  	if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) {
   276  		list += ", …"
   277  	}
   278  	return list
   279  }
   280  
   281  func issueLabelList(issue *api.Issue, cs *iostreams.ColorScheme) string {
   282  	if len(issue.Labels.Nodes) == 0 {
   283  		return ""
   284  	}
   285  
   286  	labelNames := make([]string, len(issue.Labels.Nodes))
   287  	for i, label := range issue.Labels.Nodes {
   288  		if cs == nil {
   289  			labelNames[i] = label.Name
   290  		} else {
   291  			labelNames[i] = cs.HexToRGB(label.Color, label.Name)
   292  		}
   293  	}
   294  
   295  	return strings.Join(labelNames, ", ")
   296  }