github.com/andrewhsu/cli/v2@v2.0.1-0.20210910131313-d4b4061f5b89/pkg/cmd/pr/view/view.go (about) 1 package view 2 3 import ( 4 "errors" 5 "fmt" 6 "sort" 7 "strconv" 8 "strings" 9 10 "github.com/MakeNowJust/heredoc" 11 "github.com/andrewhsu/cli/v2/api" 12 "github.com/andrewhsu/cli/v2/pkg/cmd/pr/shared" 13 "github.com/andrewhsu/cli/v2/pkg/cmdutil" 14 "github.com/andrewhsu/cli/v2/pkg/iostreams" 15 "github.com/andrewhsu/cli/v2/pkg/markdown" 16 "github.com/andrewhsu/cli/v2/utils" 17 "github.com/spf13/cobra" 18 ) 19 20 type browser interface { 21 Browse(string) error 22 } 23 24 type ViewOptions struct { 25 IO *iostreams.IOStreams 26 Browser browser 27 28 Finder shared.PRFinder 29 Exporter cmdutil.Exporter 30 31 SelectorArg string 32 BrowserMode bool 33 Comments bool 34 } 35 36 func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { 37 opts := &ViewOptions{ 38 IO: f.IOStreams, 39 Browser: f.Browser, 40 } 41 42 cmd := &cobra.Command{ 43 Use: "view [<number> | <url> | <branch>]", 44 Short: "View a pull request", 45 Long: heredoc.Doc(` 46 Display the title, body, and other information about a pull request. 47 48 Without an argument, the pull request that belongs to the current branch 49 is displayed. 50 51 With '--web', open the pull request in a web browser instead. 52 `), 53 Args: cobra.MaximumNArgs(1), 54 RunE: func(cmd *cobra.Command, args []string) error { 55 opts.Finder = shared.NewFinder(f) 56 57 if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { 58 return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} 59 } 60 61 if len(args) > 0 { 62 opts.SelectorArg = args[0] 63 } 64 65 if runF != nil { 66 return runF(opts) 67 } 68 return viewRun(opts) 69 }, 70 } 71 72 cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser") 73 cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments") 74 cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields) 75 76 return cmd 77 } 78 79 var defaultFields = []string{ 80 "url", "number", "title", "state", "body", "author", 81 "isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount", 82 "baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository", 83 "reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone", 84 "comments", "reactionGroups", 85 } 86 87 func viewRun(opts *ViewOptions) error { 88 findOptions := shared.FindOptions{ 89 Selector: opts.SelectorArg, 90 Fields: defaultFields, 91 } 92 if opts.BrowserMode { 93 findOptions.Fields = []string{"url"} 94 } else if opts.Exporter != nil { 95 findOptions.Fields = opts.Exporter.Fields() 96 } 97 pr, _, err := opts.Finder.Find(findOptions) 98 if err != nil { 99 return err 100 } 101 102 connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() 103 104 if opts.BrowserMode { 105 openURL := pr.URL 106 if connectedToTerminal { 107 fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) 108 } 109 return opts.Browser.Browse(openURL) 110 } 111 112 opts.IO.DetectTerminalTheme() 113 114 err = opts.IO.StartPager() 115 if err != nil { 116 return err 117 } 118 defer opts.IO.StopPager() 119 120 if opts.Exporter != nil { 121 return opts.Exporter.Write(opts.IO, pr) 122 } 123 124 if connectedToTerminal { 125 return printHumanPrPreview(opts, pr) 126 } 127 128 if opts.Comments { 129 fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments, pr.DisplayableReviews())) 130 return nil 131 } 132 133 return printRawPrPreview(opts.IO, pr) 134 } 135 136 func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error { 137 out := io.Out 138 cs := io.ColorScheme() 139 140 reviewers := prReviewerList(*pr, cs) 141 assignees := prAssigneeList(*pr) 142 labels := prLabelList(*pr, cs) 143 projects := prProjectList(*pr) 144 145 fmt.Fprintf(out, "title:\t%s\n", pr.Title) 146 fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr)) 147 fmt.Fprintf(out, "author:\t%s\n", pr.Author.Login) 148 fmt.Fprintf(out, "labels:\t%s\n", labels) 149 fmt.Fprintf(out, "assignees:\t%s\n", assignees) 150 fmt.Fprintf(out, "reviewers:\t%s\n", reviewers) 151 fmt.Fprintf(out, "projects:\t%s\n", projects) 152 var milestoneTitle string 153 if pr.Milestone != nil { 154 milestoneTitle = pr.Milestone.Title 155 } 156 fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle) 157 fmt.Fprintf(out, "number:\t%d\n", pr.Number) 158 fmt.Fprintf(out, "url:\t%s\n", pr.URL) 159 fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions))) 160 fmt.Fprintf(out, "deletions:\t%s\n", cs.Red(strconv.Itoa(pr.Deletions))) 161 162 fmt.Fprintln(out, "--") 163 fmt.Fprintln(out, pr.Body) 164 165 return nil 166 } 167 168 func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error { 169 out := opts.IO.Out 170 cs := opts.IO.ColorScheme() 171 172 // Header (Title and State) 173 fmt.Fprintf(out, "%s #%d\n", cs.Bold(pr.Title), pr.Number) 174 fmt.Fprintf(out, 175 "%s • %s wants to merge %s into %s from %s • %s %s \n", 176 shared.StateTitleWithColor(cs, *pr), 177 pr.Author.Login, 178 utils.Pluralize(pr.Commits.TotalCount, "commit"), 179 pr.BaseRefName, 180 pr.HeadRefName, 181 cs.Green("+"+strconv.Itoa(pr.Additions)), 182 cs.Red("-"+strconv.Itoa(pr.Deletions)), 183 ) 184 185 // Reactions 186 if reactions := shared.ReactionGroupList(pr.ReactionGroups); reactions != "" { 187 fmt.Fprint(out, reactions) 188 fmt.Fprintln(out) 189 } 190 191 // Metadata 192 if reviewers := prReviewerList(*pr, cs); reviewers != "" { 193 fmt.Fprint(out, cs.Bold("Reviewers: ")) 194 fmt.Fprintln(out, reviewers) 195 } 196 if assignees := prAssigneeList(*pr); assignees != "" { 197 fmt.Fprint(out, cs.Bold("Assignees: ")) 198 fmt.Fprintln(out, assignees) 199 } 200 if labels := prLabelList(*pr, cs); labels != "" { 201 fmt.Fprint(out, cs.Bold("Labels: ")) 202 fmt.Fprintln(out, labels) 203 } 204 if projects := prProjectList(*pr); projects != "" { 205 fmt.Fprint(out, cs.Bold("Projects: ")) 206 fmt.Fprintln(out, projects) 207 } 208 if pr.Milestone != nil { 209 fmt.Fprint(out, cs.Bold("Milestone: ")) 210 fmt.Fprintln(out, pr.Milestone.Title) 211 } 212 213 // Body 214 var md string 215 var err error 216 if pr.Body == "" { 217 md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) 218 } else { 219 style := markdown.GetStyle(opts.IO.TerminalTheme()) 220 md, err = markdown.Render(pr.Body, style) 221 if err != nil { 222 return err 223 } 224 } 225 fmt.Fprintf(out, "\n%s\n", md) 226 227 // Reviews and Comments 228 if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 { 229 preview := !opts.Comments 230 comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview) 231 if err != nil { 232 return err 233 } 234 fmt.Fprint(out, comments) 235 } 236 237 // Footer 238 fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL) 239 240 return nil 241 } 242 243 const ( 244 requestedReviewState = "REQUESTED" // This is our own state for review request 245 approvedReviewState = "APPROVED" 246 changesRequestedReviewState = "CHANGES_REQUESTED" 247 commentedReviewState = "COMMENTED" 248 dismissedReviewState = "DISMISSED" 249 pendingReviewState = "PENDING" 250 ) 251 252 type reviewerState struct { 253 Name string 254 State string 255 } 256 257 // formattedReviewerState formats a reviewerState with state color 258 func formattedReviewerState(cs *iostreams.ColorScheme, reviewer *reviewerState) string { 259 state := reviewer.State 260 if state == dismissedReviewState { 261 // Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes 262 // sense when displayed in an events timeline but not in the final tally. 263 state = commentedReviewState 264 } 265 266 var colorFunc func(string) string 267 switch state { 268 case requestedReviewState: 269 colorFunc = cs.Yellow 270 case approvedReviewState: 271 colorFunc = cs.Green 272 case changesRequestedReviewState: 273 colorFunc = cs.Red 274 default: 275 colorFunc = func(str string) string { return str } // Do nothing 276 } 277 278 return fmt.Sprintf("%s (%s)", reviewer.Name, colorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " "))) 279 } 280 281 // prReviewerList generates a reviewer list with their last state 282 func prReviewerList(pr api.PullRequest, cs *iostreams.ColorScheme) string { 283 reviewerStates := parseReviewers(pr) 284 reviewers := make([]string, 0, len(reviewerStates)) 285 286 sortReviewerStates(reviewerStates) 287 288 for _, reviewer := range reviewerStates { 289 reviewers = append(reviewers, formattedReviewerState(cs, reviewer)) 290 } 291 292 reviewerList := strings.Join(reviewers, ", ") 293 294 return reviewerList 295 } 296 297 const ghostName = "ghost" 298 299 // parseReviewers parses given Reviews and ReviewRequests 300 func parseReviewers(pr api.PullRequest) []*reviewerState { 301 reviewerStates := make(map[string]*reviewerState) 302 303 for _, review := range pr.Reviews.Nodes { 304 if review.Author.Login != pr.Author.Login { 305 name := review.Author.Login 306 if name == "" { 307 name = ghostName 308 } 309 reviewerStates[name] = &reviewerState{ 310 Name: name, 311 State: review.State, 312 } 313 } 314 } 315 316 // Overwrite reviewer's state if a review request for the same reviewer exists. 317 for _, reviewRequest := range pr.ReviewRequests.Nodes { 318 name := reviewRequest.RequestedReviewer.LoginOrSlug() 319 reviewerStates[name] = &reviewerState{ 320 Name: name, 321 State: requestedReviewState, 322 } 323 } 324 325 // Convert map to slice for ease of sort 326 result := make([]*reviewerState, 0, len(reviewerStates)) 327 for _, reviewer := range reviewerStates { 328 if reviewer.State == pendingReviewState { 329 continue 330 } 331 result = append(result, reviewer) 332 } 333 334 return result 335 } 336 337 // sortReviewerStates puts completed reviews before review requests and sorts names alphabetically 338 func sortReviewerStates(reviewerStates []*reviewerState) { 339 sort.Slice(reviewerStates, func(i, j int) bool { 340 if reviewerStates[i].State == requestedReviewState && 341 reviewerStates[j].State != requestedReviewState { 342 return false 343 } 344 if reviewerStates[j].State == requestedReviewState && 345 reviewerStates[i].State != requestedReviewState { 346 return true 347 } 348 349 return reviewerStates[i].Name < reviewerStates[j].Name 350 }) 351 } 352 353 func prAssigneeList(pr api.PullRequest) string { 354 if len(pr.Assignees.Nodes) == 0 { 355 return "" 356 } 357 358 AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes)) 359 for _, assignee := range pr.Assignees.Nodes { 360 AssigneeNames = append(AssigneeNames, assignee.Login) 361 } 362 363 list := strings.Join(AssigneeNames, ", ") 364 if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) { 365 list += ", …" 366 } 367 return list 368 } 369 370 func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string { 371 if len(pr.Labels.Nodes) == 0 { 372 return "" 373 } 374 375 labelNames := make([]string, 0, len(pr.Labels.Nodes)) 376 for _, label := range pr.Labels.Nodes { 377 labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) 378 } 379 380 list := strings.Join(labelNames, ", ") 381 if pr.Labels.TotalCount > len(pr.Labels.Nodes) { 382 list += ", …" 383 } 384 return list 385 } 386 387 func prProjectList(pr api.PullRequest) string { 388 if len(pr.ProjectCards.Nodes) == 0 { 389 return "" 390 } 391 392 projectNames := make([]string, 0, len(pr.ProjectCards.Nodes)) 393 for _, project := range pr.ProjectCards.Nodes { 394 colName := project.Column.Name 395 if colName == "" { 396 colName = "Awaiting triage" 397 } 398 projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) 399 } 400 401 list := strings.Join(projectNames, ", ") 402 if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) { 403 list += ", …" 404 } 405 return list 406 } 407 408 func prStateWithDraft(pr *api.PullRequest) string { 409 if pr.IsDraft && pr.State == "OPEN" { 410 return "DRAFT" 411 } 412 413 return pr.State 414 }