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