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  }