Replace copilotDisplayName with actorDisplayName(typeName, login, name) which handles all actor types: known bots get friendly names (e.g. Copilot → 'Copilot (AI)'), regular bots return login, users with names return 'login (Name)', others return login. All DisplayName() methods on Author, CommentAuthor, GitHubUser, AssignableUser, AssignableBot, RequestedReviewer, and ReviewerBot now delegate to actorDisplayName with their available fields. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
483 lines
13 KiB
Go
483 lines
13 KiB
Go
package view
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/api"
|
|
"github.com/cli/cli/v2/internal/browser"
|
|
fd "github.com/cli/cli/v2/internal/featuredetection"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/internal/text"
|
|
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/cli/cli/v2/pkg/markdown"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type ViewOptions struct {
|
|
IO *iostreams.IOStreams
|
|
Browser browser.Browser
|
|
// TODO projectsV1Deprecation
|
|
// Remove this detector since it is only used for test validation.
|
|
Detector fd.Detector
|
|
|
|
Finder shared.PRFinder
|
|
Exporter cmdutil.Exporter
|
|
|
|
SelectorArg string
|
|
BrowserMode bool
|
|
Comments bool
|
|
|
|
Now func() time.Time
|
|
}
|
|
|
|
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
|
|
opts := &ViewOptions{
|
|
IO: f.IOStreams,
|
|
Browser: f.Browser,
|
|
Now: time.Now,
|
|
}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "view [<number> | <url> | <branch>]",
|
|
Short: "View a pull request",
|
|
Long: heredoc.Docf(`
|
|
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 %[1]s--web%[1]s flag, 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.FlagErrorf("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", "autoMergeRequest",
|
|
"isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount",
|
|
"baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository",
|
|
"reviewRequests", "reviews", "assignees", "labels", "projectCards", "projectItems", "milestone",
|
|
"comments", "reactionGroups", "createdAt", "statusCheckRollup",
|
|
}
|
|
|
|
func viewRun(opts *ViewOptions) error {
|
|
findOptions := shared.FindOptions{
|
|
Selector: opts.SelectorArg,
|
|
Fields: defaultFields,
|
|
Detector: opts.Detector,
|
|
}
|
|
if opts.BrowserMode {
|
|
findOptions.Fields = []string{"url"}
|
|
} else if opts.Exporter != nil {
|
|
findOptions.Fields = opts.Exporter.Fields()
|
|
}
|
|
pr, baseRepo, err := opts.Finder.Find(findOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
connectedToTerminal := opts.IO.IsStdoutTTY()
|
|
|
|
if opts.BrowserMode {
|
|
openURL := pr.URL
|
|
if connectedToTerminal {
|
|
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL))
|
|
}
|
|
return opts.Browser.Browse(openURL)
|
|
}
|
|
|
|
opts.IO.DetectTerminalTheme()
|
|
if err := opts.IO.StartPager(); err == nil {
|
|
defer opts.IO.StopPager()
|
|
} else {
|
|
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
|
|
}
|
|
|
|
if opts.Exporter != nil {
|
|
return opts.Exporter.Write(opts.IO, pr)
|
|
}
|
|
|
|
if connectedToTerminal {
|
|
return printHumanPrPreview(opts, baseRepo, 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, cs)
|
|
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.DisplayName())
|
|
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)
|
|
var milestoneTitle string
|
|
if pr.Milestone != nil {
|
|
milestoneTitle = pr.Milestone.Title
|
|
}
|
|
fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
|
|
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)))
|
|
var autoMerge string
|
|
if pr.AutoMergeRequest == nil {
|
|
autoMerge = "disabled"
|
|
} else {
|
|
autoMerge = fmt.Sprintf("enabled\t%s\t%s",
|
|
pr.AutoMergeRequest.EnabledBy.Login,
|
|
strings.ToLower(pr.AutoMergeRequest.MergeMethod))
|
|
}
|
|
fmt.Fprintf(out, "auto-merge:\t%s\n", autoMerge)
|
|
|
|
fmt.Fprintln(out, "--")
|
|
fmt.Fprintln(out, pr.Body)
|
|
|
|
return nil
|
|
}
|
|
|
|
func printHumanPrPreview(opts *ViewOptions, baseRepo ghrepo.Interface, pr *api.PullRequest) error {
|
|
out := opts.IO.Out
|
|
cs := opts.IO.ColorScheme()
|
|
|
|
// Header (Title and State)
|
|
fmt.Fprintf(out, "%s %s#%d\n", cs.Bold(pr.Title), ghrepo.FullName(baseRepo), pr.Number)
|
|
fmt.Fprintf(out,
|
|
"%s • %s wants to merge %s into %s from %s • %s\n",
|
|
shared.StateTitleWithColor(cs, *pr),
|
|
pr.Author.DisplayName(),
|
|
text.Pluralize(pr.Commits.TotalCount, "commit"),
|
|
pr.BaseRefName,
|
|
pr.HeadRefName,
|
|
text.FuzzyAgo(opts.Now(), pr.CreatedAt),
|
|
)
|
|
|
|
// added/removed
|
|
fmt.Fprintf(out,
|
|
"%s %s",
|
|
cs.Green("+"+strconv.Itoa(pr.Additions)),
|
|
cs.Red("-"+strconv.Itoa(pr.Deletions)),
|
|
)
|
|
|
|
// checks
|
|
checks := pr.ChecksStatus()
|
|
if summary := shared.PrCheckStatusSummaryWithColor(cs, checks); summary != "" {
|
|
fmt.Fprintf(out, " • %s\n", summary)
|
|
} else {
|
|
fmt.Fprintln(out)
|
|
}
|
|
|
|
// 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, cs); 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 != nil {
|
|
fmt.Fprint(out, cs.Bold("Milestone: "))
|
|
fmt.Fprintln(out, pr.Milestone.Title)
|
|
}
|
|
|
|
// Auto-Merge status
|
|
autoMerge := pr.AutoMergeRequest
|
|
if autoMerge != nil {
|
|
var mergeMethod string
|
|
switch autoMerge.MergeMethod {
|
|
case "MERGE":
|
|
mergeMethod = "a merge commit"
|
|
case "REBASE":
|
|
mergeMethod = "rebase and merge"
|
|
case "SQUASH":
|
|
mergeMethod = "squash and merge"
|
|
default:
|
|
mergeMethod = fmt.Sprintf("an unknown merge method (%s)", autoMerge.MergeMethod)
|
|
}
|
|
fmt.Fprintf(out,
|
|
"%s %s by %s, using %s\n",
|
|
cs.Bold("Auto-merge:"),
|
|
cs.Green("enabled"),
|
|
autoMerge.EnabledBy.Login,
|
|
mergeMethod,
|
|
)
|
|
}
|
|
|
|
// Body
|
|
var md string
|
|
var err error
|
|
if pr.Body == "" {
|
|
md = fmt.Sprintf("\n %s\n\n", cs.Muted("No description provided"))
|
|
} else {
|
|
md, err = markdown.Render(pr.Body,
|
|
markdown.WithTheme(opts.IO.TerminalTheme()),
|
|
markdown.WithWrap(opts.IO.TerminalWidth()))
|
|
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.Muted("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 {
|
|
var displayState string
|
|
switch reviewer.State {
|
|
case requestedReviewState:
|
|
displayState = cs.Yellow("Requested")
|
|
case approvedReviewState:
|
|
displayState = cs.Green("Approved")
|
|
case changesRequestedReviewState:
|
|
displayState = cs.Red("Changes requested")
|
|
case commentedReviewState, dismissedReviewState:
|
|
// Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes
|
|
// sense when displayed in an events timeline but not in the final tally.
|
|
displayState = "Commented"
|
|
default:
|
|
displayState = text.Title(reviewer.State)
|
|
}
|
|
|
|
return fmt.Sprintf("%s (%s)", reviewer.Name, displayState)
|
|
}
|
|
|
|
// 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 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.AuthorLogin()
|
|
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.DisplayName()
|
|
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.DisplayName())
|
|
}
|
|
|
|
list := strings.Join(AssigneeNames, ", ")
|
|
if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) {
|
|
list += ", …"
|
|
}
|
|
return list
|
|
}
|
|
|
|
func prLabelList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
|
|
if len(pr.Labels.Nodes) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// ignore case sort
|
|
sort.SliceStable(pr.Labels.Nodes, func(i, j int) bool {
|
|
return strings.ToLower(pr.Labels.Nodes[i].Name) < strings.ToLower(pr.Labels.Nodes[j].Name)
|
|
})
|
|
|
|
labelNames := make([]string, 0, len(pr.Labels.Nodes))
|
|
for _, label := range pr.Labels.Nodes {
|
|
labelNames = append(labelNames, cs.Label(label.Color, label.Name))
|
|
}
|
|
|
|
list := strings.Join(labelNames, ", ")
|
|
if pr.Labels.TotalCount > len(pr.Labels.Nodes) {
|
|
list += ", …"
|
|
}
|
|
return list
|
|
}
|
|
|
|
func prProjectList(pr api.PullRequest) string {
|
|
totalCount := pr.ProjectCards.TotalCount + pr.ProjectItems.TotalCount
|
|
count := len(pr.ProjectCards.Nodes) + len(pr.ProjectItems.Nodes)
|
|
|
|
if count == 0 {
|
|
return ""
|
|
}
|
|
|
|
projectNames := make([]string, 0, len(pr.ProjectCards.Nodes))
|
|
|
|
for _, project := range pr.ProjectItems.Nodes {
|
|
colName := project.Status.Name
|
|
if colName == "" {
|
|
colName = "No Status"
|
|
}
|
|
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Title, colName))
|
|
}
|
|
|
|
for _, project := range pr.ProjectCards.Nodes {
|
|
if project == nil {
|
|
continue
|
|
}
|
|
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 totalCount > count {
|
|
list += ", …"
|
|
}
|
|
return list
|
|
}
|
|
|
|
func prStateWithDraft(pr *api.PullRequest) string {
|
|
if pr.IsDraft && pr.State == "OPEN" {
|
|
return "DRAFT"
|
|
}
|
|
|
|
return pr.State
|
|
}
|