github.com/andrewhsu/cli/v2@v2.0.1-0.20210910131313-d4b4061f5b89/pkg/cmd/pr/review/review.go (about) 1 package review 2 3 import ( 4 "errors" 5 "fmt" 6 "net/http" 7 8 "github.com/AlecAivazis/survey/v2" 9 "github.com/MakeNowJust/heredoc" 10 "github.com/andrewhsu/cli/v2/api" 11 "github.com/andrewhsu/cli/v2/internal/config" 12 "github.com/andrewhsu/cli/v2/pkg/cmd/pr/shared" 13 "github.com/andrewhsu/cli/v2/pkg/cmdutil" 14 "github.com/andrewhsu/cli/v2/pkg/iostreams" 15 "github.com/andrewhsu/cli/v2/pkg/markdown" 16 "github.com/andrewhsu/cli/v2/pkg/prompt" 17 "github.com/andrewhsu/cli/v2/pkg/surveyext" 18 "github.com/spf13/cobra" 19 ) 20 21 type ReviewOptions struct { 22 HttpClient func() (*http.Client, error) 23 Config func() (config.Config, error) 24 IO *iostreams.IOStreams 25 26 Finder shared.PRFinder 27 28 SelectorArg string 29 InteractiveMode bool 30 ReviewType api.PullRequestReviewState 31 Body string 32 } 33 34 func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Command { 35 opts := &ReviewOptions{ 36 IO: f.IOStreams, 37 HttpClient: f.HttpClient, 38 Config: f.Config, 39 } 40 41 var ( 42 flagApprove bool 43 flagRequestChanges bool 44 flagComment bool 45 ) 46 47 var bodyFile string 48 49 cmd := &cobra.Command{ 50 Use: "review [<number> | <url> | <branch>]", 51 Short: "Add a review to a pull request", 52 Long: heredoc.Doc(` 53 Add a review to a pull request. 54 55 Without an argument, the pull request that belongs to the current branch is reviewed. 56 `), 57 Example: heredoc.Doc(` 58 # approve the pull request of the current branch 59 $ gh pr review --approve 60 61 # leave a review comment for the current branch 62 $ gh pr review --comment -b "interesting" 63 64 # add a review for a specific pull request 65 $ gh pr review 123 66 67 # request changes on a specific pull request 68 $ gh pr review 123 -r -b "needs more ASCII art" 69 `), 70 Args: cobra.MaximumNArgs(1), 71 RunE: func(cmd *cobra.Command, args []string) error { 72 opts.Finder = shared.NewFinder(f) 73 74 if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { 75 return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} 76 } 77 78 if len(args) > 0 { 79 opts.SelectorArg = args[0] 80 } 81 82 bodyProvided := cmd.Flags().Changed("body") 83 bodyFileProvided := bodyFile != "" 84 85 if err := cmdutil.MutuallyExclusive( 86 "specify only one of `--body` or `--body-file`", 87 bodyProvided, 88 bodyFileProvided, 89 ); err != nil { 90 return err 91 } 92 if bodyFileProvided { 93 b, err := cmdutil.ReadFile(bodyFile, opts.IO.In) 94 if err != nil { 95 return err 96 } 97 opts.Body = string(b) 98 } 99 100 found := 0 101 if flagApprove { 102 found++ 103 opts.ReviewType = api.ReviewApprove 104 } 105 if flagRequestChanges { 106 found++ 107 opts.ReviewType = api.ReviewRequestChanges 108 if opts.Body == "" { 109 return &cmdutil.FlagError{Err: errors.New("body cannot be blank for request-changes review")} 110 } 111 } 112 if flagComment { 113 found++ 114 opts.ReviewType = api.ReviewComment 115 if opts.Body == "" { 116 return &cmdutil.FlagError{Err: errors.New("body cannot be blank for comment review")} 117 } 118 } 119 120 if found == 0 && opts.Body == "" { 121 if !opts.IO.CanPrompt() { 122 return &cmdutil.FlagError{Err: errors.New("--approve, --request-changes, or --comment required when not running interactively")} 123 } 124 opts.InteractiveMode = true 125 } else if found == 0 && opts.Body != "" { 126 return &cmdutil.FlagError{Err: errors.New("--body unsupported without --approve, --request-changes, or --comment")} 127 } else if found > 1 { 128 return &cmdutil.FlagError{Err: errors.New("need exactly one of --approve, --request-changes, or --comment")} 129 } 130 131 if runF != nil { 132 return runF(opts) 133 } 134 return reviewRun(opts) 135 }, 136 } 137 138 cmd.Flags().BoolVarP(&flagApprove, "approve", "a", false, "Approve pull request") 139 cmd.Flags().BoolVarP(&flagRequestChanges, "request-changes", "r", false, "Request changes on a pull request") 140 cmd.Flags().BoolVarP(&flagComment, "comment", "c", false, "Comment on a pull request") 141 cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Specify the body of a review") 142 cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file`") 143 144 return cmd 145 } 146 147 func reviewRun(opts *ReviewOptions) error { 148 findOptions := shared.FindOptions{ 149 Selector: opts.SelectorArg, 150 Fields: []string{"id", "number"}, 151 } 152 pr, baseRepo, err := opts.Finder.Find(findOptions) 153 if err != nil { 154 return err 155 } 156 157 var reviewData *api.PullRequestReviewInput 158 if opts.InteractiveMode { 159 editorCommand, err := cmdutil.DetermineEditor(opts.Config) 160 if err != nil { 161 return err 162 } 163 reviewData, err = reviewSurvey(opts.IO, editorCommand) 164 if err != nil { 165 return err 166 } 167 if reviewData == nil && err == nil { 168 fmt.Fprint(opts.IO.ErrOut, "Discarding.\n") 169 return nil 170 } 171 } else { 172 reviewData = &api.PullRequestReviewInput{ 173 State: opts.ReviewType, 174 Body: opts.Body, 175 } 176 } 177 178 httpClient, err := opts.HttpClient() 179 if err != nil { 180 return err 181 } 182 apiClient := api.NewClientFromHTTP(httpClient) 183 184 err = api.AddReview(apiClient, baseRepo, pr, reviewData) 185 if err != nil { 186 return fmt.Errorf("failed to create review: %w", err) 187 } 188 189 if !opts.IO.IsStdoutTTY() || !opts.IO.IsStderrTTY() { 190 return nil 191 } 192 193 cs := opts.IO.ColorScheme() 194 195 switch reviewData.State { 196 case api.ReviewComment: 197 fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request #%d\n", cs.Gray("-"), pr.Number) 198 case api.ReviewApprove: 199 fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request #%d\n", cs.SuccessIcon(), pr.Number) 200 case api.ReviewRequestChanges: 201 fmt.Fprintf(opts.IO.ErrOut, "%s Requested changes to pull request #%d\n", cs.Red("+"), pr.Number) 202 } 203 204 return nil 205 } 206 207 func reviewSurvey(io *iostreams.IOStreams, editorCommand string) (*api.PullRequestReviewInput, error) { 208 typeAnswers := struct { 209 ReviewType string 210 }{} 211 typeQs := []*survey.Question{ 212 { 213 Name: "reviewType", 214 Prompt: &survey.Select{ 215 Message: "What kind of review do you want to give?", 216 Options: []string{ 217 "Comment", 218 "Approve", 219 "Request changes", 220 }, 221 }, 222 }, 223 } 224 225 err := prompt.SurveyAsk(typeQs, &typeAnswers) 226 if err != nil { 227 return nil, err 228 } 229 230 var reviewState api.PullRequestReviewState 231 232 switch typeAnswers.ReviewType { 233 case "Approve": 234 reviewState = api.ReviewApprove 235 case "Request changes": 236 reviewState = api.ReviewRequestChanges 237 case "Comment": 238 reviewState = api.ReviewComment 239 default: 240 panic("unreachable state") 241 } 242 243 bodyAnswers := struct { 244 Body string 245 }{} 246 247 blankAllowed := false 248 if reviewState == api.ReviewApprove { 249 blankAllowed = true 250 } 251 252 bodyQs := []*survey.Question{ 253 { 254 Name: "body", 255 Prompt: &surveyext.GhEditor{ 256 BlankAllowed: blankAllowed, 257 EditorCommand: editorCommand, 258 Editor: &survey.Editor{ 259 Message: "Review body", 260 FileName: "*.md", 261 }, 262 }, 263 }, 264 } 265 266 err = prompt.SurveyAsk(bodyQs, &bodyAnswers) 267 if err != nil { 268 return nil, err 269 } 270 271 if bodyAnswers.Body == "" && (reviewState == api.ReviewComment || reviewState == api.ReviewRequestChanges) { 272 return nil, errors.New("this type of review cannot be blank") 273 } 274 275 if len(bodyAnswers.Body) > 0 { 276 style := markdown.GetStyle(io.DetectTerminalTheme()) 277 renderedBody, err := markdown.Render(bodyAnswers.Body, style) 278 if err != nil { 279 return nil, err 280 } 281 282 fmt.Fprintf(io.Out, "Got:\n%s", renderedBody) 283 } 284 285 confirm := false 286 confirmQs := []*survey.Question{ 287 { 288 Name: "confirm", 289 Prompt: &survey.Confirm{ 290 Message: "Submit?", 291 Default: true, 292 }, 293 }, 294 } 295 296 err = prompt.SurveyAsk(confirmQs, &confirm) 297 if err != nil { 298 return nil, err 299 } 300 301 if !confirm { 302 return nil, nil 303 } 304 305 return &api.PullRequestReviewInput{ 306 Body: bodyAnswers.Body, 307 State: reviewState, 308 }, nil 309 }