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  }