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