package view import ( "fmt" "io" "net/http" "strings" "time" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" "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 ViewOptions struct { HttpClient func() (*http.Client, error) Config func() (config.Config, error) IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) SelectorArg string WebMode bool } func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { opts := &ViewOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, Config: f.Config, } cmd := &cobra.Command{ Use: "view { | }", 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. `), Example: heredoc.Doc(` `), 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") return cmd } func viewRun(opts *ViewOptions) error { httpClient, err := opts.HttpClient() if err != nil { return err } apiClient := api.NewClientFromHTTP(httpClient) issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) if err != nil { return err } openURL := issue.URL if opts.WebMode { if opts.IO.IsStdoutTTY() { fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } opts.IO.DetectTerminalTheme() err = opts.IO.StartPager() if err != nil { return err } defer opts.IO.StopPager() if opts.IO.IsStdoutTTY() { return printHumanIssuePreview(opts.IO, issue) } 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(io *iostreams.IOStreams, issue *api.Issue) error { out := io.Out now := time.Now() ago := now.Sub(issue.CreatedAt) // Header (Title and State) fmt.Fprintln(out, utils.Bold(issue.Title)) fmt.Fprint(out, issueStateTitleWithColor(issue.State)) fmt.Fprintln(out, utils.Gray(fmt.Sprintf( " • %s opened %s • %s", issue.Author.Login, utils.FuzzyAgo(ago), utils.Pluralize(issue.Comments.TotalCount, "comment"), ))) // Metadata fmt.Fprintln(out) if assignees := issueAssigneeList(*issue); assignees != "" { fmt.Fprint(out, utils.Bold("Assignees: ")) fmt.Fprintln(out, assignees) } if labels := shared.IssueLabelList(*issue); labels != "" { fmt.Fprint(out, utils.Bold("Labels: ")) fmt.Fprintln(out, labels) } if projects := issueProjectList(*issue); projects != "" { fmt.Fprint(out, utils.Bold("Projects: ")) fmt.Fprintln(out, projects) } if issue.Milestone.Title != "" { fmt.Fprint(out, utils.Bold("Milestone: ")) fmt.Fprintln(out, issue.Milestone.Title) } // Body if issue.Body != "" { fmt.Fprintln(out) style := markdown.GetStyle(io.TerminalTheme()) md, err := markdown.Render(issue.Body, style) if err != nil { return err } fmt.Fprintln(out, md) } fmt.Fprintln(out) // Footer fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL) return nil } func issueStateTitleWithColor(state string) string { colorFunc := prShared.ColorFuncForState(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 }