github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/pr/view/view.go (about) 1 package view 2 3 import ( 4 "fmt" 5 "sort" 6 "strconv" 7 "strings" 8 "time" 9 10 "github.com/MakeNowJust/heredoc" 11 "github.com/ungtb10d/cli/v2/api" 12 "github.com/ungtb10d/cli/v2/internal/browser" 13 "github.com/ungtb10d/cli/v2/internal/text" 14 "github.com/ungtb10d/cli/v2/pkg/cmd/pr/shared" 15 "github.com/ungtb10d/cli/v2/pkg/cmdutil" 16 "github.com/ungtb10d/cli/v2/pkg/iostreams" 17 "github.com/ungtb10d/cli/v2/pkg/markdown" 18 "github.com/spf13/cobra" 19 ) 20 21 type ViewOptions struct { 22 IO *iostreams.IOStreams 23 Browser browser.Browser 24 25 Finder shared.PRFinder 26 Exporter cmdutil.Exporter 27 28 SelectorArg string 29 BrowserMode bool 30 Comments bool 31 32 Now func() time.Time 33 } 34 35 func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { 36 opts := &ViewOptions{ 37 IO: f.IOStreams, 38 Browser: f.Browser, 39 Now: time.Now, 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.FlagErrorf("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", "createdAt", "statusCheckRollup", 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() 103 104 if opts.BrowserMode { 105 openURL := pr.URL 106 if connectedToTerminal { 107 fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL)) 108 } 109 return opts.Browser.Browse(openURL) 110 } 111 112 opts.IO.DetectTerminalTheme() 113 if err := opts.IO.StartPager(); err == nil { 114 defer opts.IO.StopPager() 115 } else { 116 fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) 117 } 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 135 func 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 167 func 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\n", 175 shared.StateTitleWithColor(cs, *pr), 176 pr.Author.Login, 177 text.Pluralize(pr.Commits.TotalCount, "commit"), 178 pr.BaseRefName, 179 pr.HeadRefName, 180 text.FuzzyAgo(opts.Now(), pr.CreatedAt), 181 ) 182 183 // added/removed 184 fmt.Fprintf(out, 185 "%s %s", 186 cs.Green("+"+strconv.Itoa(pr.Additions)), 187 cs.Red("-"+strconv.Itoa(pr.Deletions)), 188 ) 189 190 // checks 191 checks := pr.ChecksStatus() 192 if summary := shared.PrCheckStatusSummaryWithColor(cs, checks); summary != "" { 193 fmt.Fprintf(out, " • %s\n", summary) 194 } else { 195 fmt.Fprintln(out) 196 } 197 198 // Reactions 199 if reactions := shared.ReactionGroupList(pr.ReactionGroups); reactions != "" { 200 fmt.Fprint(out, reactions) 201 fmt.Fprintln(out) 202 } 203 204 // Metadata 205 if reviewers := prReviewerList(*pr, cs); reviewers != "" { 206 fmt.Fprint(out, cs.Bold("Reviewers: ")) 207 fmt.Fprintln(out, reviewers) 208 } 209 if assignees := prAssigneeList(*pr); assignees != "" { 210 fmt.Fprint(out, cs.Bold("Assignees: ")) 211 fmt.Fprintln(out, assignees) 212 } 213 if labels := prLabelList(*pr, cs); labels != "" { 214 fmt.Fprint(out, cs.Bold("Labels: ")) 215 fmt.Fprintln(out, labels) 216 } 217 if projects := prProjectList(*pr); projects != "" { 218 fmt.Fprint(out, cs.Bold("Projects: ")) 219 fmt.Fprintln(out, projects) 220 } 221 if pr.Milestone != nil { 222 fmt.Fprint(out, cs.Bold("Milestone: ")) 223 fmt.Fprintln(out, pr.Milestone.Title) 224 } 225 226 // Body 227 var md string 228 var err error 229 if pr.Body == "" { 230 md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided")) 231 } else { 232 md, err = markdown.Render(pr.Body, 233 markdown.WithTheme(opts.IO.TerminalTheme()), 234 markdown.WithWrap(opts.IO.TerminalWidth())) 235 if err != nil { 236 return err 237 } 238 } 239 fmt.Fprintf(out, "\n%s\n", md) 240 241 // Reviews and Comments 242 if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 { 243 preview := !opts.Comments 244 comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview) 245 if err != nil { 246 return err 247 } 248 fmt.Fprint(out, comments) 249 } 250 251 // Footer 252 fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL) 253 254 return nil 255 } 256 257 const ( 258 requestedReviewState = "REQUESTED" // This is our own state for review request 259 approvedReviewState = "APPROVED" 260 changesRequestedReviewState = "CHANGES_REQUESTED" 261 commentedReviewState = "COMMENTED" 262 dismissedReviewState = "DISMISSED" 263 pendingReviewState = "PENDING" 264 ) 265 266 type reviewerState struct { 267 Name string 268 State string 269 } 270 271 // formattedReviewerState formats a reviewerState with state color 272 func formattedReviewerState(cs *iostreams.ColorScheme, reviewer *reviewerState) string { 273 var displayState string 274 switch reviewer.State { 275 case requestedReviewState: 276 displayState = cs.Yellow("Requested") 277 case approvedReviewState: 278 displayState = cs.Green("Approved") 279 case changesRequestedReviewState: 280 displayState = cs.Red("Changes requested") 281 case commentedReviewState, dismissedReviewState: 282 // Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes 283 // sense when displayed in an events timeline but not in the final tally. 284 displayState = "Commented" 285 default: 286 displayState = text.Title(reviewer.State) 287 } 288 289 return fmt.Sprintf("%s (%s)", reviewer.Name, displayState) 290 } 291 292 // prReviewerList generates a reviewer list with their last state 293 func prReviewerList(pr api.PullRequest, cs *iostreams.ColorScheme) string { 294 reviewerStates := parseReviewers(pr) 295 reviewers := make([]string, 0, len(reviewerStates)) 296 297 sortReviewerStates(reviewerStates) 298 299 for _, reviewer := range reviewerStates { 300 reviewers = append(reviewers, formattedReviewerState(cs, reviewer)) 301 } 302 303 reviewerList := strings.Join(reviewers, ", ") 304 305 return reviewerList 306 } 307 308 const ghostName = "ghost" 309 310 // parseReviewers parses given Reviews and ReviewRequests 311 func parseReviewers(pr api.PullRequest) []*reviewerState { 312 reviewerStates := make(map[string]*reviewerState) 313 314 for _, review := range pr.Reviews.Nodes { 315 if review.Author.Login != pr.Author.Login { 316 name := review.Author.Login 317 if name == "" { 318 name = ghostName 319 } 320 reviewerStates[name] = &reviewerState{ 321 Name: name, 322 State: review.State, 323 } 324 } 325 } 326 327 // Overwrite reviewer's state if a review request for the same reviewer exists. 328 for _, reviewRequest := range pr.ReviewRequests.Nodes { 329 name := reviewRequest.RequestedReviewer.LoginOrSlug() 330 reviewerStates[name] = &reviewerState{ 331 Name: name, 332 State: requestedReviewState, 333 } 334 } 335 336 // Convert map to slice for ease of sort 337 result := make([]*reviewerState, 0, len(reviewerStates)) 338 for _, reviewer := range reviewerStates { 339 if reviewer.State == pendingReviewState { 340 continue 341 } 342 result = append(result, reviewer) 343 } 344 345 return result 346 } 347 348 // sortReviewerStates puts completed reviews before review requests and sorts names alphabetically 349 func sortReviewerStates(reviewerStates []*reviewerState) { 350 sort.Slice(reviewerStates, func(i, j int) bool { 351 if reviewerStates[i].State == requestedReviewState && 352 reviewerStates[j].State != requestedReviewState { 353 return false 354 } 355 if reviewerStates[j].State == requestedReviewState && 356 reviewerStates[i].State != requestedReviewState { 357 return true 358 } 359 360 return reviewerStates[i].Name < reviewerStates[j].Name 361 }) 362 } 363 364 func prAssigneeList(pr api.PullRequest) string { 365 if len(pr.Assignees.Nodes) == 0 { 366 return "" 367 } 368 369 AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes)) 370 for _, assignee := range pr.Assignees.Nodes { 371 AssigneeNames = append(AssigneeNames, assignee.Login) 372 } 373 374 list := strings.Join(AssigneeNames, ", ") 375 if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) { 376 list += ", …" 377 } 378 return list 379 } 380 381 func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string { 382 if len(pr.Labels.Nodes) == 0 { 383 return "" 384 } 385 386 labelNames := make([]string, 0, len(pr.Labels.Nodes)) 387 for _, label := range pr.Labels.Nodes { 388 labelNames = append(labelNames, cs.HexToRGB(label.Color, label.Name)) 389 } 390 391 list := strings.Join(labelNames, ", ") 392 if pr.Labels.TotalCount > len(pr.Labels.Nodes) { 393 list += ", …" 394 } 395 return list 396 } 397 398 func prProjectList(pr api.PullRequest) string { 399 if len(pr.ProjectCards.Nodes) == 0 { 400 return "" 401 } 402 403 projectNames := make([]string, 0, len(pr.ProjectCards.Nodes)) 404 for _, project := range pr.ProjectCards.Nodes { 405 colName := project.Column.Name 406 if colName == "" { 407 colName = "Awaiting triage" 408 } 409 projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName)) 410 } 411 412 list := strings.Join(projectNames, ", ") 413 if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) { 414 list += ", …" 415 } 416 return list 417 } 418 419 func prStateWithDraft(pr *api.PullRequest) string { 420 if pr.IsDraft && pr.State == "OPEN" { 421 return "DRAFT" 422 } 423 424 return pr.State 425 }