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  }