cmd.Output() will return an error when the git command ran successfully but had no output. To handle this, we can check Stderr, as we expect it to be populated for any ExitErrors or otherwise when there is a command failure. This allows for propagation of this error handling up the call chain, so we are now returning errors if the call to git fails instead of just handing off an empty BranchConfig and suppressing the errors. Additionally, I've removed some more naked returns that I found in pkg/cmd/pr/create.go createRun
326 lines
9.3 KiB
Go
326 lines
9.3 KiB
Go
package status
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/cli/cli/v2/api"
|
||
ghContext "github.com/cli/cli/v2/context"
|
||
"github.com/cli/cli/v2/git"
|
||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||
"github.com/cli/cli/v2/internal/gh"
|
||
"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/spf13/cobra"
|
||
)
|
||
|
||
type StatusOptions struct {
|
||
HttpClient func() (*http.Client, error)
|
||
GitClient *git.Client
|
||
Config func() (gh.Config, error)
|
||
IO *iostreams.IOStreams
|
||
BaseRepo func() (ghrepo.Interface, error)
|
||
Remotes func() (ghContext.Remotes, error)
|
||
Branch func() (string, error)
|
||
|
||
HasRepoOverride bool
|
||
Exporter cmdutil.Exporter
|
||
ConflictStatus bool
|
||
|
||
Detector fd.Detector
|
||
}
|
||
|
||
func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command {
|
||
opts := &StatusOptions{
|
||
IO: f.IOStreams,
|
||
HttpClient: f.HttpClient,
|
||
GitClient: f.GitClient,
|
||
Config: f.Config,
|
||
Remotes: f.Remotes,
|
||
Branch: f.Branch,
|
||
}
|
||
|
||
cmd := &cobra.Command{
|
||
Use: "status",
|
||
Short: "Show status of relevant pull requests",
|
||
Args: cmdutil.NoArgsQuoteReminder,
|
||
RunE: func(cmd *cobra.Command, args []string) error {
|
||
// support `-R, --repo` override
|
||
opts.BaseRepo = f.BaseRepo
|
||
opts.HasRepoOverride = cmd.Flags().Changed("repo")
|
||
|
||
if runF != nil {
|
||
return runF(opts)
|
||
}
|
||
return statusRun(opts)
|
||
},
|
||
}
|
||
|
||
cmd.Flags().BoolVarP(&opts.ConflictStatus, "conflict-status", "c", false, "Display the merge conflict status of each pull request")
|
||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields)
|
||
|
||
return cmd
|
||
}
|
||
|
||
func statusRun(opts *StatusOptions) error {
|
||
ctx := context.Background()
|
||
httpClient, err := opts.HttpClient()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
baseRepo, err := opts.BaseRepo()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var currentBranch string
|
||
var currentPRNumber int
|
||
var currentPRHeadRef string
|
||
|
||
if !opts.HasRepoOverride {
|
||
currentBranch, err = opts.Branch()
|
||
if err != nil && !errors.Is(err, git.ErrNotOnAnyBranch) {
|
||
return fmt.Errorf("could not query for pull request for current branch: %w", err)
|
||
}
|
||
|
||
remotes, _ := opts.Remotes()
|
||
branchConfig, err := opts.GitClient.ReadBranchConfig(ctx, currentBranch)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(branchConfig, baseRepo, currentBranch, remotes)
|
||
if err != nil {
|
||
return fmt.Errorf("could not query for pull request for current branch: %w", err)
|
||
}
|
||
}
|
||
|
||
options := requestOptions{
|
||
Username: "@me",
|
||
CurrentPR: currentPRNumber,
|
||
HeadRef: currentPRHeadRef,
|
||
ConflictStatus: opts.ConflictStatus,
|
||
}
|
||
if opts.Exporter != nil {
|
||
options.Fields = opts.Exporter.Fields()
|
||
}
|
||
|
||
if opts.Detector == nil {
|
||
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
|
||
opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
|
||
}
|
||
prFeatures, err := opts.Detector.PullRequestFeatures()
|
||
if err != nil {
|
||
return err
|
||
}
|
||
options.CheckRunAndStatusContextCountsSupported = prFeatures.CheckRunAndStatusContextCounts
|
||
|
||
prPayload, err := pullRequestStatus(httpClient, baseRepo, options)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
err = opts.IO.StartPager()
|
||
if err != nil {
|
||
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
|
||
}
|
||
defer opts.IO.StopPager()
|
||
|
||
if opts.Exporter != nil {
|
||
data := map[string]interface{}{
|
||
"currentBranch": nil,
|
||
"createdBy": prPayload.ViewerCreated.PullRequests,
|
||
"needsReview": prPayload.ReviewRequested.PullRequests,
|
||
}
|
||
if prPayload.CurrentPR != nil {
|
||
data["currentBranch"] = prPayload.CurrentPR
|
||
}
|
||
return opts.Exporter.Write(opts.IO, data)
|
||
}
|
||
|
||
out := opts.IO.Out
|
||
cs := opts.IO.ColorScheme()
|
||
|
||
fmt.Fprintln(out, "")
|
||
fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo))
|
||
fmt.Fprintln(out, "")
|
||
|
||
if !opts.HasRepoOverride {
|
||
shared.PrintHeader(opts.IO, "Current branch")
|
||
currentPR := prPayload.CurrentPR
|
||
if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch {
|
||
currentPR = nil
|
||
}
|
||
if currentPR != nil {
|
||
printPrs(opts.IO, 1, *currentPR)
|
||
} else if currentPRHeadRef == "" {
|
||
shared.PrintMessage(opts.IO, " There is no current branch")
|
||
} else {
|
||
shared.PrintMessage(opts.IO, fmt.Sprintf(" There is no pull request associated with %s", cs.Cyan("["+currentPRHeadRef+"]")))
|
||
}
|
||
fmt.Fprintln(out)
|
||
}
|
||
|
||
shared.PrintHeader(opts.IO, "Created by you")
|
||
if prPayload.ViewerCreated.TotalCount > 0 {
|
||
printPrs(opts.IO, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...)
|
||
} else {
|
||
shared.PrintMessage(opts.IO, " You have no open pull requests")
|
||
}
|
||
fmt.Fprintln(out)
|
||
|
||
shared.PrintHeader(opts.IO, "Requesting a code review from you")
|
||
if prPayload.ReviewRequested.TotalCount > 0 {
|
||
printPrs(opts.IO, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...)
|
||
} else {
|
||
shared.PrintMessage(opts.IO, " You have no pull requests to review")
|
||
}
|
||
fmt.Fprintln(out)
|
||
|
||
return nil
|
||
}
|
||
|
||
func prSelectorForCurrentBranch(branchConfig git.BranchConfig, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (int, string, error) {
|
||
// the branch is configured to merge a special PR head ref
|
||
prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
|
||
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
|
||
prNumber, err := strconv.Atoi(m[1])
|
||
if err != nil {
|
||
return 0, "", err
|
||
}
|
||
return prNumber, prHeadRef, nil
|
||
}
|
||
|
||
var branchOwner string
|
||
if branchConfig.RemoteURL != nil {
|
||
// the branch merges from a remote specified by URL
|
||
r, err := ghrepo.FromURL(branchConfig.RemoteURL)
|
||
if err != nil {
|
||
return 0, prHeadRef, err
|
||
}
|
||
branchOwner = r.RepoOwner()
|
||
} else if branchConfig.RemoteName != "" {
|
||
// the branch merges from a remote specified by name
|
||
r, err := rem.FindByName(branchConfig.RemoteName)
|
||
if err != nil {
|
||
return 0, prHeadRef, err
|
||
}
|
||
branchOwner = r.RepoOwner()
|
||
}
|
||
|
||
if branchOwner != "" {
|
||
selector := prHeadRef
|
||
if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
|
||
selector = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
|
||
}
|
||
// prepend `OWNER:` if this branch is pushed to a fork
|
||
if !strings.EqualFold(branchOwner, baseRepo.RepoOwner()) {
|
||
selector = fmt.Sprintf("%s:%s", branchOwner, selector)
|
||
}
|
||
return 0, selector, nil
|
||
}
|
||
|
||
return 0, prHeadRef, nil
|
||
}
|
||
|
||
func totalApprovals(pr *api.PullRequest) int {
|
||
approvals := 0
|
||
for _, review := range pr.LatestReviews.Nodes {
|
||
if review.State == "APPROVED" {
|
||
approvals++
|
||
}
|
||
}
|
||
return approvals
|
||
}
|
||
|
||
func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
|
||
w := io.Out
|
||
cs := io.ColorScheme()
|
||
|
||
for _, pr := range prs {
|
||
prNumber := fmt.Sprintf("#%d", pr.Number)
|
||
|
||
prStateColorFunc := cs.ColorFromString(shared.ColorForPRState(pr))
|
||
|
||
fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, text.RemoveExcessiveWhitespace(pr.Title)), cs.Cyan("["+pr.HeadLabel()+"]"))
|
||
|
||
checks := pr.ChecksStatus()
|
||
reviews := pr.ReviewStatus()
|
||
|
||
if pr.State == "OPEN" {
|
||
reviewStatus := reviews.ChangesRequested || reviews.Approved || reviews.ReviewRequired
|
||
if checks.Total > 0 || reviewStatus {
|
||
// show checks & reviews on their own line
|
||
fmt.Fprintf(w, "\n ")
|
||
}
|
||
|
||
if checks.Total > 0 {
|
||
summary := shared.PrCheckStatusSummaryWithColor(cs, checks)
|
||
fmt.Fprint(w, summary)
|
||
}
|
||
|
||
if checks.Total > 0 && reviewStatus {
|
||
// add padding between checks & reviews
|
||
fmt.Fprint(w, " ")
|
||
}
|
||
|
||
if reviews.ChangesRequested {
|
||
fmt.Fprint(w, cs.Red("+ Changes requested"))
|
||
} else if reviews.ReviewRequired {
|
||
fmt.Fprint(w, cs.Yellow("- Review required"))
|
||
} else if reviews.Approved {
|
||
numRequiredApprovals := pr.BaseRef.BranchProtectionRule.RequiredApprovingReviewCount
|
||
gotApprovals := totalApprovals(&pr)
|
||
s := fmt.Sprintf("%d", gotApprovals)
|
||
if numRequiredApprovals > 0 {
|
||
s = fmt.Sprintf("%d/%d", gotApprovals, numRequiredApprovals)
|
||
}
|
||
fmt.Fprint(w, cs.Green(fmt.Sprintf("✓ %s Approved", s)))
|
||
}
|
||
|
||
if pr.Mergeable == api.PullRequestMergeableMergeable {
|
||
// prefer "No merge conflicts" to "Mergeable" as there is more to mergeability
|
||
// than the git status. Missing or failing required checks prevent merging
|
||
// even though a PR is technically mergeable, which is often a source of confusion.
|
||
fmt.Fprintf(w, " %s", cs.Green("✓ No merge conflicts"))
|
||
} else if pr.Mergeable == api.PullRequestMergeableConflicting {
|
||
fmt.Fprintf(w, " %s", cs.Red("× Merge conflicts"))
|
||
} else if pr.Mergeable == api.PullRequestMergeableUnknown {
|
||
fmt.Fprintf(w, " %s", cs.Yellow("! Merge conflict status unknown"))
|
||
}
|
||
|
||
if pr.BaseRef.BranchProtectionRule.RequiresStrictStatusChecks {
|
||
switch pr.MergeStateStatus {
|
||
case "BEHIND":
|
||
fmt.Fprintf(w, " %s", cs.Yellow("- Not up to date"))
|
||
case "UNKNOWN", "DIRTY":
|
||
// do not print anything
|
||
default:
|
||
fmt.Fprintf(w, " %s", cs.Green("✓ Up to date"))
|
||
}
|
||
}
|
||
|
||
if pr.AutoMergeRequest != nil {
|
||
fmt.Fprintf(w, " %s", cs.Green("✓ Auto-merge enabled"))
|
||
}
|
||
|
||
} else {
|
||
fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
|
||
}
|
||
|
||
fmt.Fprint(w, "\n")
|
||
}
|
||
remaining := totalCount - len(prs)
|
||
if remaining > 0 {
|
||
fmt.Fprintf(w, cs.Gray(" And %d more\n"), remaining)
|
||
}
|
||
}
|