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