cli/pkg/cmd/issue/view/view.go
Mislav Marohnić 111e8dbcf2 Pass web browser to each individual command
This removes sensitivity to the BROWSER environment variable in tests
and makes it easier to verify the URL that the browser was invoked with
without having to stub sub-processes.
2021-03-19 21:22:37 +01:00

259 lines
6.4 KiB
Go

package view
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/issue/shared"
issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/markdown"
"github.com/cli/cli/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
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")
return cmd
}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
issue, repo, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
if err != nil {
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)
}
if opts.Comments {
opts.IO.StartProgressIndicator()
comments, err := api.CommentsForIssue(apiClient, repo, issue)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
issue.Comments = *comments
}
opts.IO.DetectTerminalTheme()
err = opts.IO.StartPager()
if err != nil {
return err
}
defer opts.IO.StopPager()
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 printRawIssuePreview(out io.Writer, issue *api.Issue) error {
assignees := issueAssigneeList(*issue)
labels := shared.IssueLabelList(*issue)
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)
fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title)
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.Fprintln(out, cs.Bold(issue.Title))
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 := shared.IssueLabelList(*issue); 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.Title != "" {
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
}