cli/pkg/cmd/pr/view/view.go
Kynan Ware 7198d270b4 Add generic actorDisplayName for all actor display names
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>
2026-03-06 11:55:09 -07:00

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
}