diff --git a/api/queries_issue.go b/api/queries_issue.go index 22f08287b..11c169e0e 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -24,44 +24,52 @@ type IssuesAndTotalCount struct { } type Issue struct { - ID string - Number int - Title string - URL string - State string - Closed bool - Body string - CreatedAt time.Time - UpdatedAt time.Time - Comments Comments - Author Author - Assignees struct { - Nodes []struct { - Login string - } - TotalCount int + ID string + Number int + Title string + URL string + State string + Closed bool + Body string + CreatedAt time.Time + UpdatedAt time.Time + Comments Comments + Author Author + Assignees Assignees + Labels Labels + ProjectCards ProjectCards + Milestone Milestone + ReactionGroups ReactionGroups +} + +type Assignees struct { + Nodes []struct { + Login string } - Labels struct { - Nodes []struct { + TotalCount int +} + +type Labels struct { + Nodes []struct { + Name string + } + TotalCount int +} + +type ProjectCards struct { + Nodes []struct { + Project struct { Name string } - TotalCount int - } - ProjectCards struct { - Nodes []struct { - Project struct { - Name string - } - Column struct { - Name string - } + Column struct { + Name string } - TotalCount int } - Milestone struct { - Title string - } - ReactionGroups ReactionGroups + TotalCount int +} + +type Milestone struct { + Title string } type IssuesDisabledError struct { @@ -488,6 +496,20 @@ func IssueDelete(client *Client, repo ghrepo.Interface, issue Issue) error { return err } +func IssueUpdate(client *Client, repo ghrepo.Interface, params githubv4.UpdateIssueInput) error { + var mutation struct { + UpdateIssue struct { + Issue struct { + ID string + } + } `graphql:"updateIssue(input: $input)"` + } + variables := map[string]interface{}{"input": params} + gql := graphQLClient(client.http, repo.RepoHost()) + err := gql.MutateNamed(context.Background(), "IssueUpdate", &mutation, variables) + return err +} + // milestoneNodeIdToDatabaseId extracts the REST Database ID from the GraphQL Node ID // This conversion is necessary since the GraphQL API requires the use of the milestone's database ID // for querying the related issues. diff --git a/api/queries_pr.go b/api/queries_pr.go index 8b526bdc6..53935ccbe 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -80,16 +80,6 @@ type PullRequest struct { } } } - ReviewRequests struct { - Nodes []struct { - RequestedReviewer struct { - TypeName string `json:"__typename"` - Login string - Name string - } - } - TotalCount int - } Assignees struct { Nodes []struct { Login string @@ -119,6 +109,18 @@ type PullRequest struct { Comments Comments ReactionGroups ReactionGroups Reviews PullRequestReviews + ReviewRequests ReviewRequests +} + +type ReviewRequests struct { + Nodes []struct { + RequestedReviewer struct { + TypeName string `json:"__typename"` + Login string + Name string + } + } + TotalCount int } type NotFoundError struct { diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go new file mode 100644 index 000000000..cfb0ad23e --- /dev/null +++ b/pkg/cmd/issue/edit/edit.go @@ -0,0 +1,236 @@ +package edit + +import ( + "errors" + "fmt" + "net/http" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + shared "github.com/cli/cli/pkg/cmd/issue/shared" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/shurcooL/githubv4" + "github.com/spf13/cobra" +) + +type EditOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + OpenInBrowser func(string) error + DetermineEditor func() (string, error) + FieldsToEditSurvey func(*prShared.EditableOptions) error + EditableSurvey func(string, *prShared.EditableOptions) error + FetchOptions func(*api.Client, ghrepo.Interface, *prShared.EditableOptions) error + + SelectorArg string + Interactive bool + WebMode bool + + prShared.EditableOptions +} + +func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command { + opts := &EditOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + OpenInBrowser: utils.OpenInBrowser, + DetermineEditor: func() (string, error) { return cmdutil.DetermineEditor(f.Config) }, + FieldsToEditSurvey: prShared.FieldsToEditSurvey, + EditableSurvey: prShared.EditableSurvey, + FetchOptions: prShared.FetchOptions, + } + + cmd := &cobra.Command{ + Use: "edit { | }", + Short: "Edit an issue", + Example: heredoc.Doc(` + $ gh issue edit 23 --title "I found a bug" --body "Nothing works" + $ gh issue edit 23 --label "bug,help wanted" + $ gh issue edit 23 --label bug --label "help wanted" + $ gh issue edit 23 --assignee monalisa,hubot + $ gh issue edit 23 --assignee @me + $ gh issue edit 23 --project "Roadmap" + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.SelectorArg = args[0] + + flags := cmd.Flags() + if flags.Changed("title") { + opts.EditableOptions.TitleEdited = true + } + if flags.Changed("body") { + opts.EditableOptions.BodyEdited = true + } + if flags.Changed("assignee") { + opts.EditableOptions.AssigneesEdited = true + } + if flags.Changed("label") { + opts.EditableOptions.LabelsEdited = true + } + if flags.Changed("project") { + opts.EditableOptions.ProjectsEdited = true + } + if flags.Changed("milestone") { + opts.EditableOptions.MilestoneEdited = true + } + + if !opts.EditableOptions.Dirty() { + opts.Interactive = true + } + + if opts.Interactive && !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("--tile, --body, --assignee, --label, --project, --milestone, or --web required when not running interactively")} + } + + if runF != nil { + return runF(opts) + } + + return editRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the browser to create an issue") + cmd.Flags().StringVarP(&opts.EditableOptions.Title, "title", "t", "", "Supply a title. Will prompt for one otherwise.") + cmd.Flags().StringVarP(&opts.EditableOptions.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.") + cmd.Flags().StringSliceVarP(&opts.EditableOptions.Assignees, "assignee", "a", nil, "Assign people by their `login`. Use \"@me\" to self-assign.") + cmd.Flags().StringSliceVarP(&opts.EditableOptions.Labels, "label", "l", nil, "Add labels by `name`") + cmd.Flags().StringSliceVarP(&opts.EditableOptions.Projects, "project", "p", nil, "Add the issue to projects by `name`") + cmd.Flags().StringVarP(&opts.EditableOptions.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`") + + return cmd +} + +func editRun(opts *EditOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + issue, repo, err := shared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg) + if err != nil { + return err + } + + if opts.WebMode { + openURL := issue.URL + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } + return opts.OpenInBrowser(openURL) + } + + editOptions := opts.EditableOptions + editOptions.TitleDefault = issue.Title + editOptions.BodyDefault = issue.Body + editOptions.AssigneesDefault = issue.Assignees + editOptions.LabelsDefault = issue.Labels + editOptions.ProjectsDefault = issue.ProjectCards + editOptions.MilestoneDefault = issue.Milestone + + if opts.Interactive { + err = opts.FieldsToEditSurvey(&editOptions) + if err != nil { + return err + } + } + + opts.IO.StartProgressIndicator() + err = opts.FetchOptions(apiClient, repo, &editOptions) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.Interactive { + editorCommand, err := opts.DetermineEditor() + if err != nil { + return err + } + err = opts.EditableSurvey(editorCommand, &editOptions) + if err != nil { + return err + } + } + + opts.IO.StartProgressIndicator() + err = updateIssue(apiClient, repo, issue.ID, editOptions) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + fmt.Fprintln(opts.IO.Out, issue.URL) + + return nil +} + +func updateIssue(client *api.Client, repo ghrepo.Interface, id string, options prShared.EditableOptions) error { + params := githubv4.UpdateIssueInput{ID: id} + if options.TitleEdited { + title := githubv4.String(options.Title) + params.Title = &title + } + if options.BodyEdited { + body := githubv4.String(options.Body) + params.Body = &body + } + if options.AssigneesEdited { + meReplacer := prShared.NewMeReplacer(client, repo.RepoHost()) + assignees, err := meReplacer.ReplaceSlice(options.Assignees) + if err != nil { + return err + } + ids, err := options.Metadata.MembersToIDs(assignees) + if err != nil { + return err + } + assigneeIDs := make([]githubv4.ID, len(ids)) + for i, v := range ids { + assigneeIDs[i] = v + } + params.AssigneeIDs = &assigneeIDs + } + if options.LabelsEdited { + ids, err := options.Metadata.LabelsToIDs(options.Labels) + if err != nil { + return err + } + labelIDs := make([]githubv4.ID, len(ids)) + for i, v := range ids { + labelIDs[i] = v + } + params.LabelIDs = &labelIDs + } + if options.ProjectsEdited { + ids, err := options.Metadata.ProjectsToIDs(options.Projects) + if err != nil { + return err + } + projectIDs := make([]githubv4.ID, len(ids)) + for i, v := range ids { + projectIDs[i] = v + } + params.ProjectIDs = &projectIDs + } + if options.MilestoneEdited { + id, err := options.Metadata.MilestoneToID(options.Milestone) + if err != nil { + return err + } + milestoneID := githubv4.ID(id) + params.MilestoneID = &milestoneID + } + return api.IssueUpdate(client, repo, params) +} diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go new file mode 100644 index 000000000..15616d008 --- /dev/null +++ b/pkg/cmd/issue/edit/edit_test.go @@ -0,0 +1,349 @@ +package edit + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + prShared "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdEdit(t *testing.T) { + tests := []struct { + name string + input string + output EditOptions + wantsErr bool + }{ + { + name: "no argument", + input: "", + output: EditOptions{}, + wantsErr: true, + }, + { + name: "issue number argument", + input: "23", + output: EditOptions{ + SelectorArg: "23", + Interactive: true, + }, + wantsErr: false, + }, + { + name: "web flag", + input: "23 --web", + output: EditOptions{ + SelectorArg: "23", + Interactive: true, + WebMode: true, + }, + wantsErr: false, + }, + { + name: "title flag", + input: "23 --title test", + output: EditOptions{ + SelectorArg: "23", + EditableOptions: prShared.EditableOptions{ + Title: "test", + TitleEdited: true, + }, + }, + wantsErr: false, + }, + { + name: "body flag", + input: "23 --body test", + output: EditOptions{ + SelectorArg: "23", + EditableOptions: prShared.EditableOptions{ + Body: "test", + BodyEdited: true, + }, + }, + wantsErr: false, + }, + { + name: "assignee flag", + input: "23 --assignee monalisa,hubot", + output: EditOptions{ + SelectorArg: "23", + EditableOptions: prShared.EditableOptions{ + Assignees: []string{"monalisa", "hubot"}, + AssigneesEdited: true, + }, + }, + wantsErr: false, + }, + { + name: "label flag", + input: "23 --label feature,TODO,bug", + output: EditOptions{ + SelectorArg: "23", + EditableOptions: prShared.EditableOptions{ + Labels: []string{"feature", "TODO", "bug"}, + LabelsEdited: true, + }, + }, + wantsErr: false, + }, + { + name: "project flag", + input: "23 --project Cleanup,Roadmap", + output: EditOptions{ + SelectorArg: "23", + EditableOptions: prShared.EditableOptions{ + Projects: []string{"Cleanup", "Roadmap"}, + ProjectsEdited: true, + }, + }, + wantsErr: false, + }, + { + name: "milestone flag", + input: "23 --milestone GA", + output: EditOptions{ + SelectorArg: "23", + EditableOptions: prShared.EditableOptions{ + Milestone: "GA", + MilestoneEdited: true, + }, + }, + wantsErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.input) + assert.NoError(t, err) + + var gotOpts *EditOptions + cmd := NewCmdEdit(f, func(opts *EditOptions) error { + gotOpts = opts + return nil + }) + cmd.Flags().BoolP("help", "x", false, "") + + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg) + assert.Equal(t, tt.output.WebMode, gotOpts.WebMode) + assert.Equal(t, tt.output.Interactive, gotOpts.Interactive) + assert.Equal(t, tt.output.EditableOptions, gotOpts.EditableOptions) + }) + } +} + +func Test_editRun(t *testing.T) { + tests := []struct { + name string + input *EditOptions + httpStubs func(*testing.T, *httpmock.Registry) + stdout string + stderr string + }{ + { + name: "web mode", + input: &EditOptions{ + SelectorArg: "123", + WebMode: true, + OpenInBrowser: func(string) error { return nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + }, + stderr: "Opening github.com/OWNER/REPO/issue/123 in your browser.\n", + }, + { + name: "non-interactive", + input: &EditOptions{ + SelectorArg: "123", + Interactive: false, + EditableOptions: prShared.EditableOptions{ + Title: "new title", + TitleEdited: true, + Body: "new body", + BodyEdited: true, + Assignees: []string{"monalisa", "hubot"}, + AssigneesEdited: true, + Labels: []string{"feature", "TODO", "bug"}, + LabelsEdited: true, + Projects: []string{"Cleanup", "Roadmap"}, + ProjectsEdited: true, + Milestone: "GA", + MilestoneEdited: true, + }, + FetchOptions: prShared.FetchOptions, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + mockRepoMetadata(t, reg) + mockIssueUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + { + name: "interactive", + input: &EditOptions{ + SelectorArg: "123", + Interactive: true, + FieldsToEditSurvey: func(eo *prShared.EditableOptions) error { + eo.TitleEdited = true + eo.BodyEdited = true + eo.AssigneesEdited = true + eo.LabelsEdited = true + eo.ProjectsEdited = true + eo.MilestoneEdited = true + return nil + }, + EditableSurvey: func(_ string, eo *prShared.EditableOptions) error { + eo.Title = "new title" + eo.Body = "new body" + eo.Assignees = []string{"monalisa", "hubot"} + eo.Labels = []string{"feature", "TODO", "bug"} + eo.Projects = []string{"Cleanup", "Roadmap"} + eo.Milestone = "GA" + return nil + }, + FetchOptions: prShared.FetchOptions, + DetermineEditor: func() (string, error) { return "vim", nil }, + }, + httpStubs: func(t *testing.T, reg *httpmock.Registry) { + mockIssueGet(t, reg) + mockRepoMetadata(t, reg) + mockIssueUpdate(t, reg) + }, + stdout: "https://github.com/OWNER/REPO/issue/123\n", + }, + } + for _, tt := range tests { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) + io.SetStderrTTY(true) + + reg := &httpmock.Registry{} + defer reg.Verify(t) + tt.httpStubs(t, reg) + + httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil } + baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil } + + tt.input.IO = io + tt.input.HttpClient = httpClient + tt.input.BaseRepo = baseRepo + + t.Run(tt.name, func(t *testing.T) { + err := editRun(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.stdout, stdout.String()) + assert.Equal(t, tt.stderr, stderr.String()) + }) + } +} + +func mockIssueGet(_ *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query IssueByNumber\b`), + httpmock.StringResponse(` + { "data": { "repository": { "hasIssuesEnabled": true, "issue": { + "number": 123, + "url": "https://github.com/OWNER/REPO/issue/123" + } } } }`), + ) +} + +func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryAssignableUsers\b`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryLabelList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "labels": { + "nodes": [ + { "name": "feature", "id": "FEATUREID" }, + { "name": "TODO", "id": "TODOID" }, + { "name": "bug", "id": "BUGID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryMilestoneList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query RepositoryProjectList\b`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [ + { "name": "Cleanup", "id": "CLEANUPID" }, + { "name": "Roadmap", "id": "ROADMAPID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + reg.Register( + httpmock.GraphQL(`query OrganizationProjectList\b`), + httpmock.StringResponse(` + { "data": { "organization": { "projects": { + "nodes": [ + { "name": "Triage", "id": "TRIAGEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) +} + +func mockIssueUpdate(t *testing.T, reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`mutation IssueUpdate\b`), + httpmock.GraphQLMutation(` + { "data": { "updateIssue": { "issue": { + "id": "123" + } } } }`, + func(inputs map[string]interface{}) {}), + ) +} diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go index 24b96fb1b..d2af35c0a 100644 --- a/pkg/cmd/issue/issue.go +++ b/pkg/cmd/issue/issue.go @@ -6,6 +6,7 @@ import ( cmdComment "github.com/cli/cli/pkg/cmd/issue/comment" cmdCreate "github.com/cli/cli/pkg/cmd/issue/create" cmdDelete "github.com/cli/cli/pkg/cmd/issue/delete" + cmdEdit "github.com/cli/cli/pkg/cmd/issue/edit" cmdList "github.com/cli/cli/pkg/cmd/issue/list" cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen" cmdStatus "github.com/cli/cli/pkg/cmd/issue/status" @@ -44,6 +45,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdView.NewCmdView(f, nil)) cmd.AddCommand(cmdComment.NewCmdComment(f, nil)) cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + cmd.AddCommand(cmdEdit.NewCmdEdit(f, nil)) return cmd } diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go new file mode 100644 index 000000000..36bd0387c --- /dev/null +++ b/pkg/cmd/pr/shared/editable.go @@ -0,0 +1,310 @@ +package shared + +import ( + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/surveyext" +) + +type EditableOptions struct { + Title string + TitleDefault string + TitleEdited bool + + Body string + BodyDefault string + BodyEdited bool + + Reviewers []string + ReviewersDefault api.ReviewRequests + ReviewersOptions []string + ReviewersEdited bool + ReviewersAllowed bool + + Assignees []string + AssigneesDefault api.Assignees + AssigneesOptions []string + AssigneesEdited bool + + Labels []string + LabelsDefault api.Labels + LabelsOptions []string + LabelsEdited bool + + Projects []string + ProjectsDefault api.ProjectCards + ProjectsOptions []string + ProjectsEdited bool + + Milestone string + MilestoneDefault api.Milestone + MilestoneOptions []string + MilestoneEdited bool + + Metadata api.RepoMetadataResult +} + +func (e EditableOptions) Dirty() bool { + return e.TitleEdited || + e.BodyEdited || + e.ReviewersEdited || + e.AssigneesEdited || + e.LabelsEdited || + e.ProjectsEdited || + e.MilestoneEdited +} + +func EditableSurvey(editorCommand string, options *EditableOptions) error { + if options.TitleEdited { + title, err := titleSurvey(options.TitleDefault) + if err != nil { + return err + } + options.Title = title + } + if options.BodyEdited { + body, err := bodySurvey(options.BodyDefault, editorCommand) + if err != nil { + return err + } + options.Body = body + } + if options.AssigneesEdited { + assignees, err := assigneesSurvey(options.AssigneesDefault, options.AssigneesOptions) + if err != nil { + return err + } + options.Assignees = assignees + } + if options.LabelsEdited { + labels, err := labelsSurvey(options.LabelsDefault, options.LabelsOptions) + if err != nil { + return err + } + options.Labels = labels + } + if options.ProjectsEdited { + projects, err := projectsSurvey(options.ProjectsDefault, options.ProjectsOptions) + if err != nil { + return err + } + options.Projects = projects + } + if options.MilestoneEdited { + milestone, err := milestoneSurvey(options.MilestoneDefault, options.MilestoneOptions) + if err != nil { + return err + } + options.Milestone = milestone + } + confirm, err := confirmSurvey() + if err != nil { + return err + } + if !confirm { + return fmt.Errorf("Discarding...") + } + + return nil +} + +func FieldsToEditSurvey(options *EditableOptions) error { + contains := func(s []string, str string) bool { + for _, v := range s { + if v == str { + return true + } + } + return false + } + + results := []string{} + opts := []string{"Title", "Body"} + if options.ReviewersAllowed { + opts = append(opts, "Reviewers") + } + opts = append(opts, "Assignees", "Labels", "Projects", "Milestone") + q := &survey.MultiSelect{ + Message: "What would you like to edit?", + Options: opts, + } + err := survey.AskOne(q, &results) + if err != nil { + return err + } + + if contains(results, "Title") { + options.TitleEdited = true + } + if contains(results, "Body") { + options.BodyEdited = true + } + if contains(results, "Reviewers") { + options.ReviewersEdited = true + } + if contains(results, "Assignees") { + options.AssigneesEdited = true + } + if contains(results, "Labels") { + options.LabelsEdited = true + } + if contains(results, "Projects") { + options.ProjectsEdited = true + } + if contains(results, "Milestone") { + options.MilestoneEdited = true + } + + return nil +} + +func FetchOptions(client *api.Client, repo ghrepo.Interface, options *EditableOptions) error { + input := api.RepoMetadataInput{ + Reviewers: options.ReviewersEdited, + Assignees: options.AssigneesEdited, + Labels: options.LabelsEdited, + Projects: options.ProjectsEdited, + Milestones: options.MilestoneEdited, + } + metadata, err := api.RepoMetadata(client, repo, input) + if err != nil { + return err + } + + var users []string + for _, u := range metadata.AssignableUsers { + users = append(users, u.Login) + } + var teams []string + for _, t := range metadata.Teams { + teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug)) + } + var labels []string + for _, l := range metadata.Labels { + labels = append(labels, l.Name) + } + var projects []string + for _, l := range metadata.Projects { + projects = append(projects, l.Name) + } + milestones := []string{noMilestone} + for _, m := range metadata.Milestones { + milestones = append(milestones, m.Title) + } + + options.Metadata = *metadata + options.ReviewersOptions = append(users, teams...) + options.AssigneesOptions = users + options.LabelsOptions = labels + options.ProjectsOptions = projects + options.MilestoneOptions = milestones + + return nil +} + +func titleSurvey(title string) (string, error) { + var result string + q := &survey.Input{ + Message: "Title", + Default: title, + } + err := survey.AskOne(q, &result) + return result, err +} + +func bodySurvey(body, editorCommand string) (string, error) { + var result string + q := &surveyext.GhEditor{ + BlankAllowed: true, + EditorCommand: editorCommand, + Editor: &survey.Editor{Message: "Body", + FileName: "*.md", + Default: body, + HideDefault: true, + AppendDefault: true, + }, + } + err := survey.AskOne(q, &result) + return result, err +} + +func assigneesSurvey(assignees api.Assignees, assigneesOpts []string) ([]string, error) { + if len(assigneesOpts) == 0 { + return nil, nil + } + logins := []string{} + for _, a := range assignees.Nodes { + logins = append(logins, a.Login) + } + var results []string + q := &survey.MultiSelect{ + Message: "Assignees", + Options: assigneesOpts, + Default: logins, + } + err := survey.AskOne(q, &results) + return results, err +} + +func labelsSurvey(labels api.Labels, labelOpts []string) ([]string, error) { + if len(labelOpts) == 0 { + return nil, nil + } + names := []string{} + for _, l := range labels.Nodes { + names = append(names, l.Name) + } + var results []string + q := &survey.MultiSelect{ + Message: "Labels", + Options: labelOpts, + Default: names, + } + err := survey.AskOne(q, &results) + return results, err +} + +func projectsSurvey(projectCards api.ProjectCards, projectsOpts []string) ([]string, error) { + if len(projectsOpts) == 0 { + return nil, nil + } + names := []string{} + for _, c := range projectCards.Nodes { + names = append(names, c.Project.Name) + } + var results []string + q := &survey.MultiSelect{ + Message: "Projects", + Options: projectsOpts, + Default: names, + } + err := survey.AskOne(q, &results) + return results, err +} + +func milestoneSurvey(milestone api.Milestone, milestoneOpts []string) (string, error) { + if len(milestoneOpts) == 0 { + return "", nil + } + var result string + q := &survey.Select{ + Message: "Milestone", + Options: milestoneOpts, + Default: milestone.Title, + } + err := survey.AskOne(q, &result) + return result, err +} + +func confirmSurvey() (bool, error) { + var result bool + q := &survey.Confirm{ + Message: "Submit?", + Default: true, + } + err := survey.AskOne(q, &result) + return result, err +}