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 }