github.com/ungtb10d/cli/v2@v2.0.0-20221110210412-98537dd9d6a1/pkg/cmd/pr/status/status.go (about)

     1  package status
     2  
     3  import (
     4  	"context"
     5  	"errors"
     6  	"fmt"
     7  	"net/http"
     8  	"regexp"
     9  	"strconv"
    10  	"strings"
    11  
    12  	"github.com/ungtb10d/cli/v2/api"
    13  	ghContext "github.com/ungtb10d/cli/v2/context"
    14  	"github.com/ungtb10d/cli/v2/git"
    15  	"github.com/ungtb10d/cli/v2/internal/config"
    16  	"github.com/ungtb10d/cli/v2/internal/ghrepo"
    17  	"github.com/ungtb10d/cli/v2/internal/text"
    18  	"github.com/ungtb10d/cli/v2/pkg/cmd/pr/shared"
    19  	"github.com/ungtb10d/cli/v2/pkg/cmdutil"
    20  	"github.com/ungtb10d/cli/v2/pkg/iostreams"
    21  	"github.com/spf13/cobra"
    22  )
    23  
    24  type StatusOptions struct {
    25  	HttpClient func() (*http.Client, error)
    26  	GitClient  *git.Client
    27  	Config     func() (config.Config, error)
    28  	IO         *iostreams.IOStreams
    29  	BaseRepo   func() (ghrepo.Interface, error)
    30  	Remotes    func() (ghContext.Remotes, error)
    31  	Branch     func() (string, error)
    32  
    33  	HasRepoOverride bool
    34  	Exporter        cmdutil.Exporter
    35  	ConflictStatus  bool
    36  }
    37  
    38  func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
    39  	opts := &StatusOptions{
    40  		IO:         f.IOStreams,
    41  		HttpClient: f.HttpClient,
    42  		GitClient:  f.GitClient,
    43  		Config:     f.Config,
    44  		Remotes:    f.Remotes,
    45  		Branch:     f.Branch,
    46  	}
    47  
    48  	cmd := &cobra.Command{
    49  		Use:   "status",
    50  		Short: "Show status of relevant pull requests",
    51  		Args:  cmdutil.NoArgsQuoteReminder,
    52  		RunE: func(cmd *cobra.Command, args []string) error {
    53  			// support `-R, --repo` override
    54  			opts.BaseRepo = f.BaseRepo
    55  			opts.HasRepoOverride = cmd.Flags().Changed("repo")
    56  
    57  			if runF != nil {
    58  				return runF(opts)
    59  			}
    60  			return statusRun(opts)
    61  		},
    62  	}
    63  
    64  	cmd.Flags().BoolVarP(&opts.ConflictStatus, "conflict-status", "c", false, "Display the merge conflict status of each pull request")
    65  	cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields)
    66  
    67  	return cmd
    68  }
    69  
    70  func statusRun(opts *StatusOptions) error {
    71  	httpClient, err := opts.HttpClient()
    72  	if err != nil {
    73  		return err
    74  	}
    75  
    76  	baseRepo, err := opts.BaseRepo()
    77  	if err != nil {
    78  		return err
    79  	}
    80  
    81  	var currentBranch string
    82  	var currentPRNumber int
    83  	var currentPRHeadRef string
    84  
    85  	if !opts.HasRepoOverride {
    86  		currentBranch, err = opts.Branch()
    87  		if err != nil && !errors.Is(err, git.ErrNotOnAnyBranch) {
    88  			return fmt.Errorf("could not query for pull request for current branch: %w", err)
    89  		}
    90  
    91  		remotes, _ := opts.Remotes()
    92  		currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(opts.GitClient, baseRepo, currentBranch, remotes)
    93  		if err != nil {
    94  			return fmt.Errorf("could not query for pull request for current branch: %w", err)
    95  		}
    96  	}
    97  
    98  	options := requestOptions{
    99  		Username:       "@me",
   100  		CurrentPR:      currentPRNumber,
   101  		HeadRef:        currentPRHeadRef,
   102  		ConflictStatus: opts.ConflictStatus,
   103  	}
   104  	if opts.Exporter != nil {
   105  		options.Fields = opts.Exporter.Fields()
   106  	}
   107  
   108  	prPayload, err := pullRequestStatus(httpClient, baseRepo, options)
   109  	if err != nil {
   110  		return err
   111  	}
   112  
   113  	err = opts.IO.StartPager()
   114  	if err != nil {
   115  		fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
   116  	}
   117  	defer opts.IO.StopPager()
   118  
   119  	if opts.Exporter != nil {
   120  		data := map[string]interface{}{
   121  			"currentBranch": nil,
   122  			"createdBy":     prPayload.ViewerCreated.PullRequests,
   123  			"needsReview":   prPayload.ReviewRequested.PullRequests,
   124  		}
   125  		if prPayload.CurrentPR != nil {
   126  			data["currentBranch"] = prPayload.CurrentPR
   127  		}
   128  		return opts.Exporter.Write(opts.IO, data)
   129  	}
   130  
   131  	out := opts.IO.Out
   132  	cs := opts.IO.ColorScheme()
   133  
   134  	fmt.Fprintln(out, "")
   135  	fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo))
   136  	fmt.Fprintln(out, "")
   137  
   138  	shared.PrintHeader(opts.IO, "Current branch")
   139  	currentPR := prPayload.CurrentPR
   140  	if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch {
   141  		currentPR = nil
   142  	}
   143  	if currentPR != nil {
   144  		printPrs(opts.IO, 1, *currentPR)
   145  	} else if currentPRHeadRef == "" {
   146  		shared.PrintMessage(opts.IO, "  There is no current branch")
   147  	} else {
   148  		shared.PrintMessage(opts.IO, fmt.Sprintf("  There is no pull request associated with %s", cs.Cyan("["+currentPRHeadRef+"]")))
   149  	}
   150  	fmt.Fprintln(out)
   151  
   152  	shared.PrintHeader(opts.IO, "Created by you")
   153  	if prPayload.ViewerCreated.TotalCount > 0 {
   154  		printPrs(opts.IO, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...)
   155  	} else {
   156  		shared.PrintMessage(opts.IO, "  You have no open pull requests")
   157  	}
   158  	fmt.Fprintln(out)
   159  
   160  	shared.PrintHeader(opts.IO, "Requesting a code review from you")
   161  	if prPayload.ReviewRequested.TotalCount > 0 {
   162  		printPrs(opts.IO, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...)
   163  	} else {
   164  		shared.PrintMessage(opts.IO, "  You have no pull requests to review")
   165  	}
   166  	fmt.Fprintln(out)
   167  
   168  	return nil
   169  }
   170  
   171  func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (prNumber int, selector string, err error) {
   172  	selector = prHeadRef
   173  	branchConfig := gitClient.ReadBranchConfig(context.Background(), prHeadRef)
   174  
   175  	// the branch is configured to merge a special PR head ref
   176  	prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
   177  	if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
   178  		prNumber, _ = strconv.Atoi(m[1])
   179  		return
   180  	}
   181  
   182  	var branchOwner string
   183  	if branchConfig.RemoteURL != nil {
   184  		// the branch merges from a remote specified by URL
   185  		if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
   186  			branchOwner = r.RepoOwner()
   187  		}
   188  	} else if branchConfig.RemoteName != "" {
   189  		// the branch merges from a remote specified by name
   190  		if r, err := rem.FindByName(branchConfig.RemoteName); err == nil {
   191  			branchOwner = r.RepoOwner()
   192  		}
   193  	}
   194  
   195  	if branchOwner != "" {
   196  		if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
   197  			selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
   198  		}
   199  		// prepend `OWNER:` if this branch is pushed to a fork
   200  		if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) {
   201  			selector = fmt.Sprintf("%s:%s", branchOwner, selector)
   202  		}
   203  	}
   204  
   205  	return
   206  }
   207  
   208  func totalApprovals(pr *api.PullRequest) int {
   209  	approvals := 0
   210  	for _, review := range pr.LatestReviews.Nodes {
   211  		if review.State == "APPROVED" {
   212  			approvals++
   213  		}
   214  	}
   215  	return approvals
   216  }
   217  
   218  func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
   219  	w := io.Out
   220  	cs := io.ColorScheme()
   221  
   222  	for _, pr := range prs {
   223  		prNumber := fmt.Sprintf("#%d", pr.Number)
   224  
   225  		prStateColorFunc := cs.ColorFromString(shared.ColorForPRState(pr))
   226  
   227  		fmt.Fprintf(w, "  %s  %s %s", prStateColorFunc(prNumber), text.Truncate(50, text.RemoveExcessiveWhitespace(pr.Title)), cs.Cyan("["+pr.HeadLabel()+"]"))
   228  
   229  		checks := pr.ChecksStatus()
   230  		reviews := pr.ReviewStatus()
   231  
   232  		if pr.State == "OPEN" {
   233  			reviewStatus := reviews.ChangesRequested || reviews.Approved || reviews.ReviewRequired
   234  			if checks.Total > 0 || reviewStatus {
   235  				// show checks & reviews on their own line
   236  				fmt.Fprintf(w, "\n  ")
   237  			}
   238  
   239  			if checks.Total > 0 {
   240  				summary := shared.PrCheckStatusSummaryWithColor(cs, checks)
   241  				fmt.Fprint(w, summary)
   242  			}
   243  
   244  			if checks.Total > 0 && reviewStatus {
   245  				// add padding between checks & reviews
   246  				fmt.Fprint(w, " ")
   247  			}
   248  
   249  			if reviews.ChangesRequested {
   250  				fmt.Fprint(w, cs.Red("+ Changes requested"))
   251  			} else if reviews.ReviewRequired {
   252  				fmt.Fprint(w, cs.Yellow("- Review required"))
   253  			} else if reviews.Approved {
   254  				numRequiredApprovals := pr.BaseRef.BranchProtectionRule.RequiredApprovingReviewCount
   255  				gotApprovals := totalApprovals(&pr)
   256  				s := fmt.Sprintf("%d", gotApprovals)
   257  				if numRequiredApprovals > 0 {
   258  					s = fmt.Sprintf("%d/%d", gotApprovals, numRequiredApprovals)
   259  				}
   260  				fmt.Fprint(w, cs.Green(fmt.Sprintf("✓ %s Approved", s)))
   261  			}
   262  
   263  			if pr.Mergeable == "MERGEABLE" {
   264  				// prefer "No merge conflicts" to "Mergeable" as there is more to mergeability
   265  				// than the git status. Missing or failing required checks prevent merging
   266  				// even though a PR is technically mergeable, which is often a source of confusion.
   267  				fmt.Fprintf(w, " %s", cs.Green("✓ No merge conflicts"))
   268  			} else if pr.Mergeable == "CONFLICTING" {
   269  				fmt.Fprintf(w, " %s", cs.Red("× Merge conflicts"))
   270  			} else if pr.Mergeable == "UNKNOWN" {
   271  				fmt.Fprintf(w, " %s", cs.Yellow("! Merge conflict status unknown"))
   272  			}
   273  
   274  			if pr.BaseRef.BranchProtectionRule.RequiresStrictStatusChecks {
   275  				switch pr.MergeStateStatus {
   276  				case "BEHIND":
   277  					fmt.Fprintf(w, " %s", cs.Yellow("- Not up to date"))
   278  				case "UNKNOWN", "DIRTY":
   279  					// do not print anything
   280  				default:
   281  					fmt.Fprintf(w, " %s", cs.Green("✓ Up to date"))
   282  				}
   283  			}
   284  
   285  		} else {
   286  			fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
   287  		}
   288  
   289  		fmt.Fprint(w, "\n")
   290  	}
   291  	remaining := totalCount - len(prs)
   292  	if remaining > 0 {
   293  		fmt.Fprintf(w, cs.Gray("  And %d more\n"), remaining)
   294  	}
   295  }