This completely rewrites the PR lookup mechanism so that the caller must specify the GraphQL fields to query for each PR. Additionally, this fixes some export problems with `pr view --json`. Features: - Each pr command now gets assigned a concept of a Finder. This makes it easier to stub the PR in tests without having to stub the underlying HTTP calls or git invocations. - `pr view --web` is much faster since it only fetches the "url" field. - `pr diff 123` now skips a whole API call where a whole PR was unnecessarily preloaded just to access its diff in a subsequent call. - PullRequestGraphQL query builder is now used to construct queries. - A bunch of individual commands are now freed of having to know about concepts such as BaseRepo, Branch, Config, or Remotes.
417 lines
11 KiB
Go
417 lines
11 KiB
Go
package view
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/api"
|
|
"github.com/cli/cli/pkg/cmd/pr/shared"
|
|
"github.com/cli/cli/pkg/cmdutil"
|
|
"github.com/cli/cli/pkg/iostreams"
|
|
"github.com/cli/cli/pkg/markdown"
|
|
"github.com/cli/cli/utils"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type browser interface {
|
|
Browse(string) error
|
|
}
|
|
|
|
type ViewOptions struct {
|
|
IO *iostreams.IOStreams
|
|
Browser browser
|
|
|
|
Finder shared.PRFinder
|
|
Exporter cmdutil.Exporter
|
|
|
|
SelectorArg string
|
|
BrowserMode bool
|
|
Comments bool
|
|
}
|
|
|
|
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
|
|
opts := &ViewOptions{
|
|
IO: f.IOStreams,
|
|
Browser: f.Browser,
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "view [<number> | <url> | <branch>]",
|
|
Short: "View a pull request",
|
|
Long: heredoc.Doc(`
|
|
Display the title, body, and other information about a pull request.
|
|
|
|
Without an argument, the pull request that belongs to the current branch
|
|
is displayed.
|
|
|
|
With '--web', open the pull request in a web browser instead.
|
|
`),
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts.Finder = shared.NewFinder(f)
|
|
|
|
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
|
|
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
|
|
}
|
|
|
|
if len(args) > 0 {
|
|
opts.SelectorArg = args[0]
|
|
}
|
|
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return viewRun(opts)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser")
|
|
cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments")
|
|
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields)
|
|
|
|
return cmd
|
|
}
|
|
|
|
var defaultFields = []string{
|
|
"url", "number", "title", "state", "body", "author",
|
|
"isDraft", "maintainerCanModify", "mergeable", "additions", "deletions",
|
|
"baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository",
|
|
"reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone",
|
|
"comments", // TODO: fetch only 1 last comment unless `opts.Comments` was set
|
|
"reactionGroups",
|
|
}
|
|
|
|
func viewRun(opts *ViewOptions) error {
|
|
findOptions := shared.FindOptions{
|
|
Selector: opts.SelectorArg,
|
|
Fields: defaultFields,
|
|
}
|
|
if opts.BrowserMode {
|
|
findOptions.Fields = []string{"url"}
|
|
} else if opts.Exporter != nil {
|
|
findOptions.Fields = opts.Exporter.Fields()
|
|
}
|
|
pr, _, err := opts.Finder.Find(findOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY()
|
|
|
|
if opts.BrowserMode {
|
|
openURL := pr.URL
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
|
|
}
|
|
return opts.Browser.Browse(openURL)
|
|
}
|
|
|
|
opts.IO.DetectTerminalTheme()
|
|
|
|
err = opts.IO.StartPager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer opts.IO.StopPager()
|
|
|
|
if opts.Exporter != nil {
|
|
exportPR := pr.ExportData(opts.Exporter.Fields())
|
|
return opts.Exporter.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled())
|
|
}
|
|
|
|
if connectedToTerminal {
|
|
return printHumanPrPreview(opts, pr)
|
|
}
|
|
|
|
if opts.Comments {
|
|
fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments, pr.DisplayableReviews()))
|
|
return nil
|
|
}
|
|
|
|
return printRawPrPreview(opts.IO, pr)
|
|
}
|
|
|
|
func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
|
|
out := io.Out
|
|
cs := io.ColorScheme()
|
|
|
|
reviewers := prReviewerList(*pr, cs)
|
|
assignees := prAssigneeList(*pr)
|
|
labels := prLabelList(*pr)
|
|
projects := prProjectList(*pr)
|
|
|
|
fmt.Fprintf(out, "title:\t%s\n", pr.Title)
|
|
fmt.Fprintf(out, "state:\t%s\n", prStateWithDraft(pr))
|
|
fmt.Fprintf(out, "author:\t%s\n", pr.Author.Login)
|
|
fmt.Fprintf(out, "labels:\t%s\n", labels)
|
|
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
|
|
fmt.Fprintf(out, "reviewers:\t%s\n", reviewers)
|
|
fmt.Fprintf(out, "projects:\t%s\n", projects)
|
|
fmt.Fprintf(out, "milestone:\t%s\n", pr.Milestone.Title)
|
|
fmt.Fprintf(out, "number:\t%d\n", pr.Number)
|
|
fmt.Fprintf(out, "url:\t%s\n", pr.URL)
|
|
fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions)))
|
|
fmt.Fprintf(out, "deletions:\t%s\n", cs.Red(strconv.Itoa(pr.Deletions)))
|
|
|
|
fmt.Fprintln(out, "--")
|
|
fmt.Fprintln(out, pr.Body)
|
|
|
|
return nil
|
|
}
|
|
|
|
func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
|
|
out := opts.IO.Out
|
|
cs := opts.IO.ColorScheme()
|
|
|
|
// Header (Title and State)
|
|
fmt.Fprintln(out, cs.Bold(pr.Title))
|
|
fmt.Fprintf(out,
|
|
"%s • %s wants to merge %s into %s from %s • %s %s \n",
|
|
shared.StateTitleWithColor(cs, *pr),
|
|
pr.Author.Login,
|
|
utils.Pluralize(pr.Commits.TotalCount, "commit"),
|
|
pr.BaseRefName,
|
|
pr.HeadRefName,
|
|
cs.Green("+"+strconv.Itoa(pr.Additions)),
|
|
cs.Red("-"+strconv.Itoa(pr.Deletions)),
|
|
)
|
|
|
|
// Reactions
|
|
if reactions := shared.ReactionGroupList(pr.ReactionGroups); reactions != "" {
|
|
fmt.Fprint(out, reactions)
|
|
fmt.Fprintln(out)
|
|
}
|
|
|
|
// Metadata
|
|
if reviewers := prReviewerList(*pr, cs); reviewers != "" {
|
|
fmt.Fprint(out, cs.Bold("Reviewers: "))
|
|
fmt.Fprintln(out, reviewers)
|
|
}
|
|
if assignees := prAssigneeList(*pr); assignees != "" {
|
|
fmt.Fprint(out, cs.Bold("Assignees: "))
|
|
fmt.Fprintln(out, assignees)
|
|
}
|
|
if labels := prLabelList(*pr); labels != "" {
|
|
fmt.Fprint(out, cs.Bold("Labels: "))
|
|
fmt.Fprintln(out, labels)
|
|
}
|
|
if projects := prProjectList(*pr); projects != "" {
|
|
fmt.Fprint(out, cs.Bold("Projects: "))
|
|
fmt.Fprintln(out, projects)
|
|
}
|
|
if pr.Milestone.Title != "" {
|
|
fmt.Fprint(out, cs.Bold("Milestone: "))
|
|
fmt.Fprintln(out, pr.Milestone.Title)
|
|
}
|
|
|
|
// Body
|
|
var md string
|
|
var err error
|
|
if pr.Body == "" {
|
|
md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided"))
|
|
} else {
|
|
style := markdown.GetStyle(opts.IO.TerminalTheme())
|
|
md, err = markdown.Render(pr.Body, style)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
fmt.Fprintf(out, "\n%s\n", md)
|
|
|
|
// Reviews and Comments
|
|
if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 {
|
|
preview := !opts.Comments
|
|
comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Fprint(out, comments)
|
|
}
|
|
|
|
// Footer
|
|
fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL)
|
|
|
|
return nil
|
|
}
|
|
|
|
const (
|
|
requestedReviewState = "REQUESTED" // This is our own state for review request
|
|
approvedReviewState = "APPROVED"
|
|
changesRequestedReviewState = "CHANGES_REQUESTED"
|
|
commentedReviewState = "COMMENTED"
|
|
dismissedReviewState = "DISMISSED"
|
|
pendingReviewState = "PENDING"
|
|
)
|
|
|
|
type reviewerState struct {
|
|
Name string
|
|
State string
|
|
}
|
|
|
|
// formattedReviewerState formats a reviewerState with state color
|
|
func formattedReviewerState(cs *iostreams.ColorScheme, reviewer *reviewerState) string {
|
|
state := reviewer.State
|
|
if state == dismissedReviewState {
|
|
// Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes
|
|
// sense when displayed in an events timeline but not in the final tally.
|
|
state = commentedReviewState
|
|
}
|
|
|
|
var colorFunc func(string) string
|
|
switch state {
|
|
case requestedReviewState:
|
|
colorFunc = cs.Yellow
|
|
case approvedReviewState:
|
|
colorFunc = cs.Green
|
|
case changesRequestedReviewState:
|
|
colorFunc = cs.Red
|
|
default:
|
|
colorFunc = func(str string) string { return str } // Do nothing
|
|
}
|
|
|
|
return fmt.Sprintf("%s (%s)", reviewer.Name, colorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " ")))
|
|
}
|
|
|
|
// prReviewerList generates a reviewer list with their last state
|
|
func prReviewerList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
|
|
reviewerStates := parseReviewers(pr)
|
|
reviewers := make([]string, 0, len(reviewerStates))
|
|
|
|
sortReviewerStates(reviewerStates)
|
|
|
|
for _, reviewer := range reviewerStates {
|
|
reviewers = append(reviewers, formattedReviewerState(cs, reviewer))
|
|
}
|
|
|
|
reviewerList := strings.Join(reviewers, ", ")
|
|
|
|
return reviewerList
|
|
}
|
|
|
|
const teamTypeName = "Team"
|
|
|
|
const ghostName = "ghost"
|
|
|
|
// parseReviewers parses given Reviews and ReviewRequests
|
|
func parseReviewers(pr api.PullRequest) []*reviewerState {
|
|
reviewerStates := make(map[string]*reviewerState)
|
|
|
|
for _, review := range pr.Reviews.Nodes {
|
|
if review.Author.Login != pr.Author.Login {
|
|
name := review.Author.Login
|
|
if name == "" {
|
|
name = ghostName
|
|
}
|
|
reviewerStates[name] = &reviewerState{
|
|
Name: name,
|
|
State: review.State,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Overwrite reviewer's state if a review request for the same reviewer exists.
|
|
for _, reviewRequest := range pr.ReviewRequests.Nodes {
|
|
name := reviewRequest.RequestedReviewer.Login
|
|
if reviewRequest.RequestedReviewer.TypeName == teamTypeName {
|
|
name = reviewRequest.RequestedReviewer.Name
|
|
}
|
|
reviewerStates[name] = &reviewerState{
|
|
Name: name,
|
|
State: requestedReviewState,
|
|
}
|
|
}
|
|
|
|
// Convert map to slice for ease of sort
|
|
result := make([]*reviewerState, 0, len(reviewerStates))
|
|
for _, reviewer := range reviewerStates {
|
|
if reviewer.State == pendingReviewState {
|
|
continue
|
|
}
|
|
result = append(result, reviewer)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// sortReviewerStates puts completed reviews before review requests and sorts names alphabetically
|
|
func sortReviewerStates(reviewerStates []*reviewerState) {
|
|
sort.Slice(reviewerStates, func(i, j int) bool {
|
|
if reviewerStates[i].State == requestedReviewState &&
|
|
reviewerStates[j].State != requestedReviewState {
|
|
return false
|
|
}
|
|
if reviewerStates[j].State == requestedReviewState &&
|
|
reviewerStates[i].State != requestedReviewState {
|
|
return true
|
|
}
|
|
|
|
return reviewerStates[i].Name < reviewerStates[j].Name
|
|
})
|
|
}
|
|
|
|
func prAssigneeList(pr api.PullRequest) string {
|
|
if len(pr.Assignees.Nodes) == 0 {
|
|
return ""
|
|
}
|
|
|
|
AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes))
|
|
for _, assignee := range pr.Assignees.Nodes {
|
|
AssigneeNames = append(AssigneeNames, assignee.Login)
|
|
}
|
|
|
|
list := strings.Join(AssigneeNames, ", ")
|
|
if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) {
|
|
list += ", …"
|
|
}
|
|
return list
|
|
}
|
|
|
|
func prLabelList(pr api.PullRequest) string {
|
|
if len(pr.Labels.Nodes) == 0 {
|
|
return ""
|
|
}
|
|
|
|
labelNames := make([]string, 0, len(pr.Labels.Nodes))
|
|
for _, label := range pr.Labels.Nodes {
|
|
labelNames = append(labelNames, label.Name)
|
|
}
|
|
|
|
list := strings.Join(labelNames, ", ")
|
|
if pr.Labels.TotalCount > len(pr.Labels.Nodes) {
|
|
list += ", …"
|
|
}
|
|
return list
|
|
}
|
|
|
|
func prProjectList(pr api.PullRequest) string {
|
|
if len(pr.ProjectCards.Nodes) == 0 {
|
|
return ""
|
|
}
|
|
|
|
projectNames := make([]string, 0, len(pr.ProjectCards.Nodes))
|
|
for _, project := range pr.ProjectCards.Nodes {
|
|
colName := project.Column.Name
|
|
if colName == "" {
|
|
colName = "Awaiting triage"
|
|
}
|
|
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
|
|
}
|
|
|
|
list := strings.Join(projectNames, ", ")
|
|
if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) {
|
|
list += ", …"
|
|
}
|
|
return list
|
|
}
|
|
|
|
func prStateWithDraft(pr *api.PullRequest) string {
|
|
if pr.IsDraft && pr.State == "OPEN" {
|
|
return "DRAFT"
|
|
}
|
|
|
|
return pr.State
|
|
}
|