1package edit 2 3import ( 4 "fmt" 5 "net/http" 6 7 "github.com/MakeNowJust/heredoc" 8 "github.com/cli/cli/v2/api" 9 "github.com/cli/cli/v2/internal/config" 10 "github.com/cli/cli/v2/internal/ghrepo" 11 shared "github.com/cli/cli/v2/pkg/cmd/pr/shared" 12 "github.com/cli/cli/v2/pkg/cmdutil" 13 "github.com/cli/cli/v2/pkg/iostreams" 14 "github.com/shurcooL/githubv4" 15 "github.com/spf13/cobra" 16 "golang.org/x/sync/errgroup" 17) 18 19type EditOptions struct { 20 HttpClient func() (*http.Client, error) 21 IO *iostreams.IOStreams 22 23 Finder shared.PRFinder 24 Surveyor Surveyor 25 Fetcher EditableOptionsFetcher 26 EditorRetriever EditorRetriever 27 28 SelectorArg string 29 Interactive bool 30 31 shared.Editable 32} 33 34func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command { 35 opts := &EditOptions{ 36 IO: f.IOStreams, 37 HttpClient: f.HttpClient, 38 Surveyor: surveyor{}, 39 Fetcher: fetcher{}, 40 EditorRetriever: editorRetriever{config: f.Config}, 41 } 42 43 var bodyFile string 44 45 cmd := &cobra.Command{ 46 Use: "edit [<number> | <url> | <branch>]", 47 Short: "Edit a pull request", 48 Long: heredoc.Doc(` 49 Edit a pull request. 50 51 Without an argument, the pull request that belongs to the current branch 52 is selected. 53 `), 54 Example: heredoc.Doc(` 55 $ gh pr edit 23 --title "I found a bug" --body "Nothing works" 56 $ gh pr edit 23 --add-label "bug,help wanted" --remove-label "core" 57 $ gh pr edit 23 --add-reviewer monalisa,hubot --remove-reviewer myorg/team-name 58 $ gh pr edit 23 --add-assignee "@me" --remove-assignee monalisa,hubot 59 $ gh pr edit 23 --add-project "Roadmap" --remove-project v1,v2 60 $ gh pr edit 23 --milestone "Version 1" 61 `), 62 Args: cobra.MaximumNArgs(1), 63 RunE: func(cmd *cobra.Command, args []string) error { 64 opts.Finder = shared.NewFinder(f) 65 66 if len(args) > 0 { 67 opts.SelectorArg = args[0] 68 } 69 70 flags := cmd.Flags() 71 72 bodyProvided := flags.Changed("body") 73 bodyFileProvided := bodyFile != "" 74 75 if err := cmdutil.MutuallyExclusive( 76 "specify only one of `--body` or `--body-file`", 77 bodyProvided, 78 bodyFileProvided, 79 ); err != nil { 80 return err 81 } 82 if bodyProvided || bodyFileProvided { 83 opts.Editable.Body.Edited = true 84 if bodyFileProvided { 85 b, err := cmdutil.ReadFile(bodyFile, opts.IO.In) 86 if err != nil { 87 return err 88 } 89 opts.Editable.Body.Value = string(b) 90 } 91 } 92 93 if flags.Changed("title") { 94 opts.Editable.Title.Edited = true 95 } 96 if flags.Changed("body") { 97 opts.Editable.Body.Edited = true 98 } 99 if flags.Changed("base") { 100 opts.Editable.Base.Edited = true 101 } 102 if flags.Changed("add-reviewer") || flags.Changed("remove-reviewer") { 103 opts.Editable.Reviewers.Edited = true 104 } 105 if flags.Changed("add-assignee") || flags.Changed("remove-assignee") { 106 opts.Editable.Assignees.Edited = true 107 } 108 if flags.Changed("add-label") || flags.Changed("remove-label") { 109 opts.Editable.Labels.Edited = true 110 } 111 if flags.Changed("add-project") || flags.Changed("remove-project") { 112 opts.Editable.Projects.Edited = true 113 } 114 if flags.Changed("milestone") { 115 opts.Editable.Milestone.Edited = true 116 } 117 118 if !opts.Editable.Dirty() { 119 opts.Interactive = true 120 } 121 122 if opts.Interactive && !opts.IO.CanPrompt() { 123 return cmdutil.FlagErrorf("--tile, --body, --reviewer, --assignee, --label, --project, or --milestone required when not running interactively") 124 } 125 126 if runF != nil { 127 return runF(opts) 128 } 129 130 return editRun(opts) 131 }, 132 } 133 134 cmd.Flags().StringVarP(&opts.Editable.Title.Value, "title", "t", "", "Set the new title.") 135 cmd.Flags().StringVarP(&opts.Editable.Body.Value, "body", "b", "", "Set the new body.") 136 cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)") 137 cmd.Flags().StringVarP(&opts.Editable.Base.Value, "base", "B", "", "Change the base `branch` for this pull request") 138 cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Add, "add-reviewer", nil, "Add reviewers by their `login`.") 139 cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Remove, "remove-reviewer", nil, "Remove reviewers by their `login`.") 140 cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself.") 141 cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself.") 142 cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`") 143 cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`") 144 cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the pull request to projects by `name`") 145 cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `name`") 146 cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`") 147 148 return cmd 149} 150 151func editRun(opts *EditOptions) error { 152 findOptions := shared.FindOptions{ 153 Selector: opts.SelectorArg, 154 Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "milestone"}, 155 } 156 pr, repo, err := opts.Finder.Find(findOptions) 157 if err != nil { 158 return err 159 } 160 161 editable := opts.Editable 162 editable.Reviewers.Allowed = true 163 editable.Title.Default = pr.Title 164 editable.Body.Default = pr.Body 165 editable.Base.Default = pr.BaseRefName 166 editable.Reviewers.Default = pr.ReviewRequests.Logins() 167 editable.Assignees.Default = pr.Assignees.Logins() 168 editable.Labels.Default = pr.Labels.Names() 169 editable.Projects.Default = pr.ProjectCards.ProjectNames() 170 if pr.Milestone != nil { 171 editable.Milestone.Default = pr.Milestone.Title 172 } 173 174 if opts.Interactive { 175 err = opts.Surveyor.FieldsToEdit(&editable) 176 if err != nil { 177 return err 178 } 179 } 180 181 httpClient, err := opts.HttpClient() 182 if err != nil { 183 return err 184 } 185 apiClient := api.NewClientFromHTTP(httpClient) 186 187 opts.IO.StartProgressIndicator() 188 err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable) 189 opts.IO.StopProgressIndicator() 190 if err != nil { 191 return err 192 } 193 194 if opts.Interactive { 195 editorCommand, err := opts.EditorRetriever.Retrieve() 196 if err != nil { 197 return err 198 } 199 err = opts.Surveyor.EditFields(&editable, editorCommand) 200 if err != nil { 201 return err 202 } 203 } 204 205 opts.IO.StartProgressIndicator() 206 err = updatePullRequest(httpClient, repo, pr.ID, editable) 207 opts.IO.StopProgressIndicator() 208 if err != nil { 209 return err 210 } 211 212 fmt.Fprintln(opts.IO.Out, pr.URL) 213 214 return nil 215} 216 217func updatePullRequest(httpClient *http.Client, repo ghrepo.Interface, id string, editable shared.Editable) error { 218 var wg errgroup.Group 219 wg.Go(func() error { 220 return shared.UpdateIssue(httpClient, repo, id, true, editable) 221 }) 222 if editable.Reviewers.Edited { 223 wg.Go(func() error { 224 return updatePullRequestReviews(httpClient, repo, id, editable) 225 }) 226 } 227 return wg.Wait() 228} 229 230func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, id string, editable shared.Editable) error { 231 userIds, teamIds, err := editable.ReviewerIds() 232 if err != nil { 233 return err 234 } 235 union := githubv4.Boolean(false) 236 reviewsRequestParams := githubv4.RequestReviewsInput{ 237 PullRequestID: id, 238 Union: &union, 239 UserIDs: ghIds(userIds), 240 TeamIDs: ghIds(teamIds), 241 } 242 client := api.NewClientFromHTTP(httpClient) 243 return api.UpdatePullRequestReviews(client, repo, reviewsRequestParams) 244} 245 246type Surveyor interface { 247 FieldsToEdit(*shared.Editable) error 248 EditFields(*shared.Editable, string) error 249} 250 251type surveyor struct{} 252 253func (s surveyor) FieldsToEdit(editable *shared.Editable) error { 254 return shared.FieldsToEditSurvey(editable) 255} 256 257func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error { 258 return shared.EditFieldsSurvey(editable, editorCmd) 259} 260 261type EditableOptionsFetcher interface { 262 EditableOptionsFetch(*api.Client, ghrepo.Interface, *shared.Editable) error 263} 264 265type fetcher struct{} 266 267func (f fetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable) error { 268 return shared.FetchOptions(client, repo, opts) 269} 270 271type EditorRetriever interface { 272 Retrieve() (string, error) 273} 274 275type editorRetriever struct { 276 config func() (config.Config, error) 277} 278 279func (e editorRetriever) Retrieve() (string, error) { 280 return cmdutil.DetermineEditor(e.config) 281} 282 283func ghIds(s *[]string) *[]githubv4.ID { 284 if s == nil { 285 return nil 286 } 287 ids := make([]githubv4.ID, len(*s)) 288 for i, v := range *s { 289 ids[i] = v 290 } 291 return &ids 292} 293