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 }