From de59f6a1bf82e1ee9d7563cd1535db6ab2caf55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 15 Apr 2020 18:28:52 +0200 Subject: [PATCH 01/18] Add flags to add additional metadata to `issue create` - `-a, --assignee` (accepts multiple) - `-l, --label` (accepts multiple) - `-p, --project` (accepts multiple) - `-m, --milestone` --- api/queries_repo.go | 47 +++++++++++++++++++++++ command/issue.go | 90 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) diff --git a/api/queries_repo.go b/api/queries_repo.go index b8ad760d0ed..abd96cbaf93 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -388,6 +388,53 @@ func RepositoryReadme(client *Client, fullName string) (string, error) { } +type RepoMetadataResult struct { + AssignableUsers struct { + Nodes []struct { + ID string + Login string + } + } `graphql:"assignableUsers(first: 100)"` + Labels struct { + Nodes []struct { + ID string + Name string + } + } `graphql:"labels(first: 100)"` + Projects struct { + Nodes []struct { + ID string + Name string + } + } `graphql:"projects(first: 100, states: [OPEN])"` + Milestones struct { + Nodes []struct { + ID string + Title string + } + } `graphql:"milestones(first: 100, states: [OPEN])"` +} + +// RepoMetadata pre-fetches the metadata for attaching to issues and pull requests +func RepoMetadata(client *Client, repo ghrepo.Interface) (*RepoMetadataResult, error) { + var query struct { + Repository RepoMetadataResult `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + } + + v4 := githubv4.NewClient(client.http) + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + + return &query.Repository, nil +} + func isMarkdownFile(filename string) bool { // kind of gross, but i'm assuming that 90% of the time the suffix will just be .md. it didn't // seem worth executing a regex for this given that assumption. diff --git a/command/issue.go b/command/issue.go index 2f4e145af97..7236297a0bc 100644 --- a/command/issue.go +++ b/command/issue.go @@ -29,6 +29,10 @@ func init() { issueCreateCmd.Flags().StringP("body", "b", "", "Supply a body. Will prompt for one otherwise.") issueCreateCmd.Flags().BoolP("web", "w", false, "Open the browser to create an issue") + issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign a person by their `login`") + issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Add a label by `name`") + issueCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the issue to a project by `name`") + issueCreateCmd.Flags().StringP("milestone", "m", "", "Add the issue to a milestone by `name`") issueCmd.AddCommand(issueListCmd) issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") @@ -350,6 +354,23 @@ func issueCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("could not parse body: %w", err) } + assignees, err := cmd.Flags().GetStringSlice("assignee") + if err != nil { + return fmt.Errorf("could not parse assignees: %w", err) + } + labelNames, err := cmd.Flags().GetStringSlice("label") + if err != nil { + return fmt.Errorf("could not parse labels: %w", err) + } + projectNames, err := cmd.Flags().GetStringSlice("project") + if err != nil { + return fmt.Errorf("could not parse projects: %w", err) + } + milestoneTitle, err := cmd.Flags().GetString("milestone") + if err != nil { + return fmt.Errorf("could not parse milestone: %w", err) + } + if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { // TODO: move URL generation into GitHubRepository openURL := fmt.Sprintf("https://github.com/%s/issues/new", ghrepo.FullName(baseRepo)) @@ -423,6 +444,75 @@ func issueCreate(cmd *cobra.Command, args []string) error { "body": body, } + if len(assignees) > 0 || len(labelNames) > 0 || len(projectNames) > 0 || milestoneTitle != "" { + metadata, err := api.RepoMetadata(apiClient, baseRepo) + if err != nil { + return err + } + + var assigneeIDs []string + for _, assigneeLogin := range assignees { + var found bool + for _, u := range metadata.AssignableUsers.Nodes { + if strings.EqualFold(assigneeLogin, u.Login) { + assigneeIDs = append(assigneeIDs, u.ID) + found = true + break + } + } + if !found { + return fmt.Errorf("could not find user to assign: '%s'", assigneeLogin) + } + } + params["assigneeIds"] = assigneeIDs + + var labelIDs []string + for _, labelName := range labelNames { + var found bool + for _, l := range metadata.Labels.Nodes { + if strings.EqualFold(labelName, l.Name) { + labelIDs = append(labelIDs, l.ID) + found = true + break + } + } + if !found { + return fmt.Errorf("could not find label '%s'", labelName) + } + } + params["labelIds"] = labelIDs + + var projectIDs []string + for _, projectName := range projectNames { + var found bool + for _, p := range metadata.Projects.Nodes { + if strings.EqualFold(projectName, p.Name) { + projectIDs = append(projectIDs, p.ID) + found = true + break + } + } + if !found { + return fmt.Errorf("could not find project '%s'", projectName) + } + } + params["projectIds"] = projectIDs + + if milestoneTitle != "" { + var milestoneID string + for _, m := range metadata.Milestones.Nodes { + if strings.EqualFold(milestoneTitle, m.Title) { + milestoneID = m.ID + break + } + } + if milestoneID == "" { + return fmt.Errorf("could not find milestone '%s'", milestoneTitle) + } + params["milestoneId"] = milestoneID + } + } + newIssue, err := api.IssueCreate(apiClient, repo, params) if err != nil { return err From d3a89b8744047371bb96ecdae1f9c146fe4a58e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 17 Apr 2020 20:23:57 +0200 Subject: [PATCH 02/18] Expand `issue create` metadata flags to `pr create` - Includes support `pr create --reviewer ` - Hide "Preview in browser" menu option when any metadata are set --- api/queries_pr.go | 66 ++++++++++++++++++++++- api/queries_repo.go | 63 ++++++++++++++++++++++ command/issue.go | 100 +++++++++++++---------------------- command/pr_create.go | 47 +++++++++++++++- command/title_body_survey.go | 24 +++++---- 5 files changed, 225 insertions(+), 75 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 0b2fd378a83..922896b2716 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -20,6 +20,7 @@ type PullRequestAndTotalCount struct { } type PullRequest struct { + ID string Number int Title string State string @@ -558,6 +559,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter mutation CreatePullRequest($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { + id url } } @@ -567,7 +569,10 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter "repositoryId": repo.ID, } for key, val := range params { - inputParams[key] = val + switch key { + case "title", "body", "draft", "baseRefName", "headRefName": + inputParams[key] = val + } } variables := map[string]interface{}{ "input": inputParams, @@ -583,8 +588,65 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter if err != nil { return nil, err } + pr := &result.CreatePullRequest.PullRequest + + // metadata parameters aren't currently available in `createPullRequest`, + // but they are in `updatePullRequest` + updateParams := make(map[string]interface{}) + for key, val := range params { + switch key { + case "assigneeIds", "labelIds", "projectIds", "milestoneId": + if !isBlank(val) { + updateParams[key] = val + } + } + } + if len(updateParams) > 0 { + updateQuery := ` + mutation UpdatePullRequest($input: CreatePullRequestInput!) { + updatePullRequest(input: $input) + }` + updateParams["pullRequestId"] = pr.ID + variables := map[string]interface{}{ + "input": inputParams, + } + err := client.GraphQL(updateQuery, variables, &result) + if err != nil { + return nil, err + } + } - return &result.CreatePullRequest.PullRequest, nil + // reviewers are requested in yet another additional mutation + if ids, ok := params["reviewerIds"]; ok && !isBlank(ids) { + reviewQuery := ` + mutation RequestReviews($input: CreatePullRequestInput!) { + requestReviews(input: $input) + }` + reviewParams := map[string]interface{}{ + "pullRequestId": pr.ID, + "userIds": ids, + } + variables := map[string]interface{}{ + "input": reviewParams, + } + err := client.GraphQL(reviewQuery, variables, &result) + if err != nil { + return nil, err + } + } + + return pr, nil +} + +func isBlank(v interface{}) bool { + switch vv := v.(type) { + case string: + return vv != "" + case []string: + return vv != nil && len(vv) > 0 + default: + return true + } } func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) { diff --git a/api/queries_repo.go b/api/queries_repo.go index abd96cbaf93..6168a457307 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -415,6 +415,69 @@ type RepoMetadataResult struct { } `graphql:"milestones(first: 100, states: [OPEN])"` } +func (m *RepoMetadataResult) AssigneesToIDs(names []string) ([]string, error) { + var ids []string + for _, assigneeLogin := range names { + found := false + for _, u := range m.AssignableUsers.Nodes { + if strings.EqualFold(assigneeLogin, u.Login) { + ids = append(ids, u.ID) + found = true + break + } + } + if !found { + return nil, fmt.Errorf("'%s' not found", assigneeLogin) + } + } + return ids, nil +} + +func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) { + var ids []string + for _, labelName := range names { + found := false + for _, l := range m.Labels.Nodes { + if strings.EqualFold(labelName, l.Name) { + ids = append(ids, l.ID) + found = true + break + } + } + if !found { + return nil, fmt.Errorf("'%s' not found", labelName) + } + } + return ids, nil +} + +func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) { + var ids []string + for _, projectName := range names { + found := false + for _, p := range m.Projects.Nodes { + if strings.EqualFold(projectName, p.Name) { + ids = append(ids, p.ID) + found = true + break + } + } + if !found { + return nil, fmt.Errorf("'%s' not found", projectName) + } + } + return ids, nil +} + +func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) { + for _, m := range m.Milestones.Nodes { + if strings.EqualFold(title, m.Title) { + return m.ID, nil + } + } + return "", errors.New("not found") +} + // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests func RepoMetadata(client *Client, repo ghrepo.Interface) (*RepoMetadataResult, error) { var query struct { diff --git a/command/issue.go b/command/issue.go index 7236297a0bc..9e09d8a0e19 100644 --- a/command/issue.go +++ b/command/issue.go @@ -371,6 +371,8 @@ func issueCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("could not parse milestone: %w", err) } + hasMetadata := len(assignees) > 0 || len(labelNames) > 0 || len(projectNames) > 0 || milestoneTitle != "" + if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { // TODO: move URL generation into GitHubRepository openURL := fmt.Sprintf("https://github.com/%s/issues/new", ghrepo.FullName(baseRepo)) @@ -407,7 +409,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { interactive := title == "" || body == "" if interactive { - tb, err := titleBodySurvey(cmd, title, body, defaults{}, templateFiles) + tb, err := titleBodySurvey(cmd, title, body, defaults{}, templateFiles, !hasMetadata) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } @@ -444,72 +446,14 @@ func issueCreate(cmd *cobra.Command, args []string) error { "body": body, } - if len(assignees) > 0 || len(labelNames) > 0 || len(projectNames) > 0 || milestoneTitle != "" { + if hasMetadata { metadata, err := api.RepoMetadata(apiClient, baseRepo) if err != nil { return err } - - var assigneeIDs []string - for _, assigneeLogin := range assignees { - var found bool - for _, u := range metadata.AssignableUsers.Nodes { - if strings.EqualFold(assigneeLogin, u.Login) { - assigneeIDs = append(assigneeIDs, u.ID) - found = true - break - } - } - if !found { - return fmt.Errorf("could not find user to assign: '%s'", assigneeLogin) - } - } - params["assigneeIds"] = assigneeIDs - - var labelIDs []string - for _, labelName := range labelNames { - var found bool - for _, l := range metadata.Labels.Nodes { - if strings.EqualFold(labelName, l.Name) { - labelIDs = append(labelIDs, l.ID) - found = true - break - } - } - if !found { - return fmt.Errorf("could not find label '%s'", labelName) - } - } - params["labelIds"] = labelIDs - - var projectIDs []string - for _, projectName := range projectNames { - var found bool - for _, p := range metadata.Projects.Nodes { - if strings.EqualFold(projectName, p.Name) { - projectIDs = append(projectIDs, p.ID) - found = true - break - } - } - if !found { - return fmt.Errorf("could not find project '%s'", projectName) - } - } - params["projectIds"] = projectIDs - - if milestoneTitle != "" { - var milestoneID string - for _, m := range metadata.Milestones.Nodes { - if strings.EqualFold(milestoneTitle, m.Title) { - milestoneID = m.ID - break - } - } - if milestoneID == "" { - return fmt.Errorf("could not find milestone '%s'", milestoneTitle) - } - params["milestoneId"] = milestoneID + err = addMetadataToIssueParams(params, metadata, assignees, labelNames, projectNames, milestoneTitle) + if err != nil { + return err } } @@ -526,6 +470,36 @@ func issueCreate(cmd *cobra.Command, args []string) error { return nil } +func addMetadataToIssueParams(params map[string]interface{}, metadata *api.RepoMetadataResult, assignees, labelNames, projectNames []string, milestoneTitle string) error { + assigneeIDs, err := metadata.AssigneesToIDs(assignees) + if err != nil { + return fmt.Errorf("could not assign user: %w", err) + } + params["assigneeIds"] = assigneeIDs + + labelIDs, err := metadata.LabelsToIDs(labelNames) + if err != nil { + return fmt.Errorf("could not add label: %w", err) + } + params["labelIds"] = labelIDs + + projectIDs, err := metadata.ProjectsToIDs(projectNames) + if err != nil { + return fmt.Errorf("could not add to project: %w", err) + } + params["projectIds"] = projectIDs + + if milestoneTitle != "" { + milestoneID, err := metadata.MilestoneToID(milestoneTitle) + if err != nil { + return fmt.Errorf("could not add to milestone '%s': %w", milestoneTitle, err) + } + params["milestoneId"] = milestoneID + } + + return nil +} + func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) { table := utils.NewTablePrinter(w) for _, issue := range issues { diff --git a/command/pr_create.go b/command/pr_create.go index 96b3e27dfc6..0b4ffa0423f 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -124,6 +124,29 @@ func prCreate(cmd *cobra.Command, _ []string) error { return fmt.Errorf("could not parse body: %w", err) } + reviewers, err := cmd.Flags().GetStringSlice("reviewer") + if err != nil { + return fmt.Errorf("could not parse reviewers: %w", err) + } + assignees, err := cmd.Flags().GetStringSlice("assignee") + if err != nil { + return fmt.Errorf("could not parse assignees: %w", err) + } + labelNames, err := cmd.Flags().GetStringSlice("label") + if err != nil { + return fmt.Errorf("could not parse labels: %w", err) + } + projectNames, err := cmd.Flags().GetStringSlice("project") + if err != nil { + return fmt.Errorf("could not parse projects: %w", err) + } + milestoneTitle, err := cmd.Flags().GetString("milestone") + if err != nil { + return fmt.Errorf("could not parse milestone: %w", err) + } + + hasMetadata := len(reviewers) > 0 || len(assignees) > 0 || len(labelNames) > 0 || len(projectNames) > 0 || milestoneTitle != "" + baseTrackingBranch := baseBranch if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil { baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch) @@ -187,7 +210,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE") } - tb, err := titleBodySurvey(cmd, title, body, defs, templateFiles) + tb, err := titleBodySurvey(cmd, title, body, defs, templateFiles, !hasMetadata) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } @@ -293,6 +316,22 @@ func prCreate(cmd *cobra.Command, _ []string) error { "headRefName": headBranchLabel, } + if hasMetadata { + metadata, err := api.RepoMetadata(client, baseRepo) + if err != nil { + return err + } + err = addMetadataToIssueParams(params, metadata, assignees, labelNames, projectNames, milestoneTitle) + if err != nil { + return err + } + reviewerIDs, err := metadata.AssigneesToIDs(reviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["reviewerIds"] = reviewerIDs + } + pr, err := api.CreatePullRequest(client, baseRepo, params) if err != nil { return fmt.Errorf("failed to create pull request: %w", err) @@ -385,4 +424,10 @@ func init() { "The branch into which you want your code merged") prCreateCmd.Flags().BoolP("web", "w", false, "Open the web browser to create a pull request") prCreateCmd.Flags().BoolP("fill", "f", false, "Do not prompt for title/body and just use commit info") + + prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request a review from someone by their `login`") + prCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign a person by their `login`") + prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add a label by `name`") + prCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the pull request to a project by `name`") + prCreateCmd.Flags().StringP("milestone", "m", "", "Add the pull request to a milestone by `name`") } diff --git a/command/title_body_survey.go b/command/title_body_survey.go index 8d4dd4d4747..27b6c9687f2 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -27,7 +27,13 @@ var SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey return survey.Ask(qs, response, opts...) } -func confirmSubmission() (Action, error) { +func confirmSubmission(allowPreview bool) (Action, error) { + options := []string{} + if allowPreview { + options = append(options, "Preview in browser") + } + options = append(options, "Submit", "Cancel") + confirmAnswers := struct { Confirmation int }{} @@ -36,11 +42,7 @@ func confirmSubmission() (Action, error) { Name: "confirmation", Prompt: &survey.Select{ Message: "What's next?", - Options: []string{ - "Preview in browser", - "Submit", - "Cancel", - }, + Options: options, }, }, } @@ -50,7 +52,11 @@ func confirmSubmission() (Action, error) { return -1, fmt.Errorf("could not prompt: %w", err) } - return Action(confirmAnswers.Confirmation), nil + choice := confirmAnswers.Confirmation + if !allowPreview { + choice++ + } + return Action(choice), nil } func selectTemplate(templatePaths []string) (string, error) { @@ -81,7 +87,7 @@ func selectTemplate(templatePaths []string) (string, error) { return string(templateContents), nil } -func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, defs defaults, templatePaths []string) (*titleBody, error) { +func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, defs defaults, templatePaths []string, allowPreview bool) (*titleBody, error) { var inProgress titleBody inProgress.Title = defs.Title templateContents := "" @@ -136,7 +142,7 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def inProgress.Body = templateContents } - confirmA, err := confirmSubmission() + confirmA, err := confirmSubmission(allowPreview) if err != nil { return nil, fmt.Errorf("unable to confirm: %w", err) } From 39c4a5bc1b2e8c9f7830aed4189ec5c768ef9145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 17 Apr 2020 20:29:18 +0200 Subject: [PATCH 03/18] Remove unnecessary nil check --- api/queries_pr.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 922896b2716..85e8b89707d 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -643,7 +643,7 @@ func isBlank(v interface{}) bool { case string: return vv != "" case []string: - return vv != nil && len(vv) > 0 + return len(vv) > 0 default: return true } From a7d061709d061adffd0b884041e7b2109913c91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Tue, 21 Apr 2020 19:14:57 +0200 Subject: [PATCH 04/18] Add ability to prefetch granulated repository metadata --- api/queries_org.go | 93 +++++++++++++++- api/queries_repo.go | 258 ++++++++++++++++++++++++++++++++++++------- command/issue.go | 11 +- command/pr_create.go | 5 +- 4 files changed, 325 insertions(+), 42 deletions(-) diff --git a/api/queries_org.go b/api/queries_org.go index 21d1f528c44..6e5b8ae0c35 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -1,6 +1,11 @@ package api -import "fmt" +import ( + "context" + "fmt" + + "github.com/shurcooL/githubv4" +) // using API v3 here because the equivalent in GraphQL needs `read:org` scope func resolveOrganization(client *Client, orgName string) (string, error) { @@ -22,3 +27,89 @@ func resolveOrganizationTeam(client *Client, orgName, teamSlug string) (string, err := client.REST("GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response) return response.Organization.NodeID, response.NodeID, err } + +type OrgProject struct { + ID string + Name string +} + +// OrganizationProjects fetches all open projects for an organization +func OrganizationProjects(client *Client, owner string) ([]OrgProject, error) { + var query struct { + Organization struct { + Projects struct { + Nodes []OrgProject + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projects(states: [OPEN], first: 100, after: $endCursor)"` + } `graphql:"organization(login: $owner)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(owner), + "endCursor": (*githubv4.String)(nil), + } + + v4 := githubv4.NewClient(client.http) + + var projects []OrgProject + for { + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + + projects = append(projects, query.Organization.Projects.Nodes...) + if !query.Organization.Projects.PageInfo.HasNextPage { + break + } + variables["endCursor"] = query.Organization.Projects.PageInfo.EndCursor + } + + return projects, nil +} + +type OrgTeam struct { + ID string + Name string +} + +// OrganizationTeams fetches all the teams in an organization +func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) { + var query struct { + Organization struct { + Teams struct { + Nodes []OrgTeam + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"teams(first: 100, after: $endCursor)"` + } `graphql:"organization(login: $owner)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(owner), + "endCursor": (*githubv4.String)(nil), + } + + v4 := githubv4.NewClient(client.http) + + var teams []OrgTeam + for { + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + + teams = append(teams, query.Organization.Teams.Nodes...) + if !query.Organization.Teams.PageInfo.HasNextPage { + break + } + variables["endCursor"] = query.Organization.Teams.PageInfo.EndCursor + } + + return teams, nil +} diff --git a/api/queries_repo.go b/api/queries_repo.go index 6168a457307..98e522ab2ca 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -389,37 +389,17 @@ func RepositoryReadme(client *Client, fullName string) (string, error) { } type RepoMetadataResult struct { - AssignableUsers struct { - Nodes []struct { - ID string - Login string - } - } `graphql:"assignableUsers(first: 100)"` - Labels struct { - Nodes []struct { - ID string - Name string - } - } `graphql:"labels(first: 100)"` - Projects struct { - Nodes []struct { - ID string - Name string - } - } `graphql:"projects(first: 100, states: [OPEN])"` - Milestones struct { - Nodes []struct { - ID string - Title string - } - } `graphql:"milestones(first: 100, states: [OPEN])"` + AssignableUsers []RepoAssignee + Labels []RepoLabel + Projects []RepoProject + Milestones []RepoMilestone } -func (m *RepoMetadataResult) AssigneesToIDs(names []string) ([]string, error) { +func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { var ids []string for _, assigneeLogin := range names { found := false - for _, u := range m.AssignableUsers.Nodes { + for _, u := range m.AssignableUsers { if strings.EqualFold(assigneeLogin, u.Login) { ids = append(ids, u.ID) found = true @@ -437,7 +417,7 @@ func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) { var ids []string for _, labelName := range names { found := false - for _, l := range m.Labels.Nodes { + for _, l := range m.Labels { if strings.EqualFold(labelName, l.Name) { ids = append(ids, l.ID) found = true @@ -455,7 +435,7 @@ func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) { var ids []string for _, projectName := range names { found := false - for _, p := range m.Projects.Nodes { + for _, p := range m.Projects { if strings.EqualFold(projectName, p.Name) { ids = append(ids, p.ID) found = true @@ -470,7 +450,7 @@ func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) { } func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) { - for _, m := range m.Milestones.Nodes { + for _, m := range m.Milestones { if strings.EqualFold(title, m.Title) { return m.ID, nil } @@ -478,24 +458,228 @@ func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) { return "", errors.New("not found") } +type RepoMetadataInput struct { + Assignees bool + Reviewers bool + Labels bool + Projects bool + Milestones bool +} + // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests -func RepoMetadata(client *Client, repo ghrepo.Interface) (*RepoMetadataResult, error) { +func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput) (*RepoMetadataResult, error) { + result := RepoMetadataResult{} + + if input.Assignees { + users, err := RepoAssignableUsers(client, repo) + if err != nil { + return nil, fmt.Errorf("error fetching assignees: %w", err) + } + result.AssignableUsers = users + } + if input.Reviewers { + // TODO + } + if input.Labels { + labels, err := RepoLabels(client, repo) + if err != nil { + return nil, fmt.Errorf("error fetching labels: %w", err) + } + result.Labels = labels + } + if input.Projects { + // TODO: org-level projects + projects, err := RepoProjects(client, repo) + if err != nil { + return nil, fmt.Errorf("error fetching projects: %w", err) + } + result.Projects = projects + } + if input.Assignees { + milestones, err := RepoMilestones(client, repo) + if err != nil { + return nil, fmt.Errorf("error fetching milestones: %w", err) + } + result.Milestones = milestones + } + + return &result, nil +} + +type RepoProject struct { + ID string + Name string +} + +// RepoProjects fetches all open projects for a repository +func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) { var query struct { - Repository RepoMetadataResult `graphql:"repository(owner: $owner, name: $name)"` + Repository struct { + Projects struct { + Nodes []RepoProject + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projects(states: [OPEN], first: 100, after: $endCursor)"` + } `graphql:"repository(owner: $owner, name: $name)"` } variables := map[string]interface{}{ - "owner": githubv4.String(repo.RepoOwner()), - "name": githubv4.String(repo.RepoName()), + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), } v4 := githubv4.NewClient(client.http) - err := v4.Query(context.Background(), &query, variables) - if err != nil { - return nil, err + + var projects []RepoProject + for { + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + + projects = append(projects, query.Repository.Projects.Nodes...) + if !query.Repository.Projects.PageInfo.HasNextPage { + break + } + variables["endCursor"] = query.Repository.Projects.PageInfo.EndCursor + } + + return projects, nil +} + +type RepoAssignee struct { + ID string + Login string +} + +// RepoAssignableUsers fetches all the assignable users for a repository +func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) { + var query struct { + Repository struct { + AssignableUsers struct { + Nodes []RepoAssignee + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"assignableUsers(first: 100, after: $endCursor)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + } + + v4 := githubv4.NewClient(client.http) + + var users []RepoAssignee + for { + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + + users = append(users, query.Repository.AssignableUsers.Nodes...) + if !query.Repository.AssignableUsers.PageInfo.HasNextPage { + break + } + variables["endCursor"] = query.Repository.AssignableUsers.PageInfo.EndCursor + } + + return users, nil +} + +type RepoLabel struct { + ID string + Name string +} + +// RepoLabels fetches all the labels in a repository +func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) { + var query struct { + Repository struct { + Labels struct { + Nodes []RepoLabel + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"labels(first: 100, after: $endCursor)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + } + + v4 := githubv4.NewClient(client.http) + + var labels []RepoLabel + for { + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + + labels = append(labels, query.Repository.Labels.Nodes...) + if !query.Repository.Labels.PageInfo.HasNextPage { + break + } + variables["endCursor"] = query.Repository.Labels.PageInfo.EndCursor + } + + return labels, nil +} + +type RepoMilestone struct { + ID string + Title string +} + +// RepoMilestones fetches all open milestones in a repository +func RepoMilestones(client *Client, repo ghrepo.Interface) ([]RepoMilestone, error) { + var query struct { + Repository struct { + Milestones struct { + Nodes []RepoMilestone + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"milestones(states: [OPEN], first: 100, after: $endCursor)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "endCursor": (*githubv4.String)(nil), + } + + v4 := githubv4.NewClient(client.http) + + var milestones []RepoMilestone + for { + err := v4.Query(context.Background(), &query, variables) + if err != nil { + return nil, err + } + + milestones = append(milestones, query.Repository.Milestones.Nodes...) + if !query.Repository.Milestones.PageInfo.HasNextPage { + break + } + variables["endCursor"] = query.Repository.Milestones.PageInfo.EndCursor } - return &query.Repository, nil + return milestones, nil } func isMarkdownFile(filename string) bool { diff --git a/command/issue.go b/command/issue.go index 9e09d8a0e19..486f99cd22a 100644 --- a/command/issue.go +++ b/command/issue.go @@ -447,7 +447,14 @@ func issueCreate(cmd *cobra.Command, args []string) error { } if hasMetadata { - metadata, err := api.RepoMetadata(apiClient, baseRepo) + metadataInput := api.RepoMetadataInput{ + Assignees: len(assignees) > 0, + Labels: len(labelNames) > 0, + Projects: len(projectNames) > 0, + Milestones: milestoneTitle != "", + } + + metadata, err := api.RepoMetadata(apiClient, baseRepo, metadataInput) if err != nil { return err } @@ -471,7 +478,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { } func addMetadataToIssueParams(params map[string]interface{}, metadata *api.RepoMetadataResult, assignees, labelNames, projectNames []string, milestoneTitle string) error { - assigneeIDs, err := metadata.AssigneesToIDs(assignees) + assigneeIDs, err := metadata.MembersToIDs(assignees) if err != nil { return fmt.Errorf("could not assign user: %w", err) } diff --git a/command/pr_create.go b/command/pr_create.go index 0b4ffa0423f..76953e94e06 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -317,7 +317,8 @@ func prCreate(cmd *cobra.Command, _ []string) error { } if hasMetadata { - metadata, err := api.RepoMetadata(client, baseRepo) + // TODO: input + metadata, err := api.RepoMetadata(client, baseRepo, api.RepoMetadataInput{}) if err != nil { return err } @@ -325,7 +326,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { if err != nil { return err } - reviewerIDs, err := metadata.AssigneesToIDs(reviewers) + reviewerIDs, err := metadata.MembersToIDs(reviewers) if err != nil { return fmt.Errorf("could not request reviewer: %w", err) } From 6ed50c66ea35e2d3727c46e83ac380f5ef4f460c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Wed, 22 Apr 2020 19:15:44 +0200 Subject: [PATCH 05/18] Prefetch metadata in parallel --- api/queries_repo.go | 69 ++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index 98e522ab2ca..152e791cbd5 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -469,41 +469,66 @@ type RepoMetadataInput struct { // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput) (*RepoMetadataResult, error) { result := RepoMetadataResult{} + errc := make(chan error) + count := 0 if input.Assignees { - users, err := RepoAssignableUsers(client, repo) - if err != nil { - return nil, fmt.Errorf("error fetching assignees: %w", err) - } - result.AssignableUsers = users + count++ + go func() { + users, err := RepoAssignableUsers(client, repo) + if err != nil { + err = fmt.Errorf("error fetching assignees: %w", err) + } + result.AssignableUsers = users + errc <- err + }() } if input.Reviewers { // TODO } if input.Labels { - labels, err := RepoLabels(client, repo) - if err != nil { - return nil, fmt.Errorf("error fetching labels: %w", err) - } - result.Labels = labels + count++ + go func() { + labels, err := RepoLabels(client, repo) + if err != nil { + err = fmt.Errorf("error fetching labels: %w", err) + } + result.Labels = labels + errc <- err + }() } if input.Projects { - // TODO: org-level projects - projects, err := RepoProjects(client, repo) - if err != nil { - return nil, fmt.Errorf("error fetching projects: %w", err) - } - result.Projects = projects + count++ + go func() { + // TODO: org-level projects + projects, err := RepoProjects(client, repo) + if err != nil { + err = fmt.Errorf("error fetching projects: %w", err) + } + result.Projects = projects + errc <- err + }() + } + if input.Milestones { + count++ + go func() { + milestones, err := RepoMilestones(client, repo) + if err != nil { + err = fmt.Errorf("error fetching milestones: %w", err) + } + result.Milestones = milestones + errc <- err + }() } - if input.Assignees { - milestones, err := RepoMilestones(client, repo) - if err != nil { - return nil, fmt.Errorf("error fetching milestones: %w", err) + + var err error + for i := 0; i < count; i++ { + if e := <-errc; e != nil { + err = e } - result.Milestones = milestones } - return &result, nil + return &result, err } type RepoProject struct { From 42baf4c8c57dbc5bd9f191557db13cad0ba090c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 27 Apr 2020 14:09:05 +0200 Subject: [PATCH 06/18] Support org projects and team reviewers --- api/queries_org.go | 16 ++++++--------- api/queries_pr.go | 21 +++++++++++-------- api/queries_repo.go | 48 +++++++++++++++++++++++++++++++++++++++----- command/pr_create.go | 32 +++++++++++++++++++++++++---- 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/api/queries_org.go b/api/queries_org.go index 6e5b8ae0c35..af0acf8b689 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -28,17 +28,12 @@ func resolveOrganizationTeam(client *Client, orgName, teamSlug string) (string, return response.Organization.NodeID, response.NodeID, err } -type OrgProject struct { - ID string - Name string -} - // OrganizationProjects fetches all open projects for an organization -func OrganizationProjects(client *Client, owner string) ([]OrgProject, error) { +func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) { var query struct { Organization struct { Projects struct { - Nodes []OrgProject + Nodes []RepoProject PageInfo struct { HasNextPage bool EndCursor string @@ -54,7 +49,7 @@ func OrganizationProjects(client *Client, owner string) ([]OrgProject, error) { v4 := githubv4.NewClient(client.http) - var projects []OrgProject + var projects []RepoProject for { err := v4.Query(context.Background(), &query, variables) if err != nil { @@ -72,8 +67,9 @@ func OrganizationProjects(client *Client, owner string) ([]OrgProject, error) { } type OrgTeam struct { - ID string - Name string + ID string + Slug string + Description string } // OrganizationTeams fetches all the teams in an organization diff --git a/api/queries_pr.go b/api/queries_pr.go index 85e8b89707d..200c826c0d4 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -604,11 +604,11 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter if len(updateParams) > 0 { updateQuery := ` mutation UpdatePullRequest($input: CreatePullRequestInput!) { - updatePullRequest(input: $input) + updatePullRequest(input: $input) { clientMutationId } }` updateParams["pullRequestId"] = pr.ID variables := map[string]interface{}{ - "input": inputParams, + "input": updateParams, } err := client.GraphQL(updateQuery, variables, &result) if err != nil { @@ -617,15 +617,20 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } // reviewers are requested in yet another additional mutation - if ids, ok := params["reviewerIds"]; ok && !isBlank(ids) { + reviewParams := make(map[string]interface{}) + if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) { + reviewParams["userIds"] = ids + } + if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) { + reviewParams["teamIds"] = ids + } + + if len(reviewParams) > 0 { reviewQuery := ` mutation RequestReviews($input: CreatePullRequestInput!) { - requestReviews(input: $input) + requestReviews(input: $input) { clientMutationId } }` - reviewParams := map[string]interface{}{ - "pullRequestId": pr.ID, - "userIds": ids, - } + reviewParams["pullRequestId"] = pr.ID variables := map[string]interface{}{ "input": reviewParams, } diff --git a/api/queries_repo.go b/api/queries_repo.go index 152e791cbd5..b10f8cd2ed5 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -393,6 +393,7 @@ type RepoMetadataResult struct { Labels []RepoLabel Projects []RepoProject Milestones []RepoMilestone + Teams []OrgTeam } func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { @@ -413,6 +414,25 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { return ids, nil } +func (m *RepoMetadataResult) TeamsToIDs(names []string) ([]string, error) { + var ids []string + for _, teamSlug := range names { + found := false + slug := teamSlug[strings.IndexRune(teamSlug, '/')+1:] + for _, t := range m.Teams { + if strings.EqualFold(slug, t.Slug) { + ids = append(ids, t.ID) + found = true + break + } + } + if !found { + return nil, fmt.Errorf("'%s' not found", teamSlug) + } + } + return ids, nil +} + func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) { var ids []string for _, labelName := range names { @@ -472,7 +492,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput errc := make(chan error) count := 0 - if input.Assignees { + if input.Assignees || input.Reviewers { count++ go func() { users, err := RepoAssignableUsers(client, repo) @@ -484,7 +504,17 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput }() } if input.Reviewers { - // TODO + count++ + go func() { + teams, err := OrganizationTeams(client, repo.RepoOwner()) + // TODO: better detection of non-org repos + if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") { + errc <- fmt.Errorf("error fetching organization teams: %w", err) + return + } + result.Teams = teams + errc <- nil + }() } if input.Labels { count++ @@ -500,13 +530,21 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput if input.Projects { count++ go func() { - // TODO: org-level projects projects, err := RepoProjects(client, repo) if err != nil { - err = fmt.Errorf("error fetching projects: %w", err) + errc <- fmt.Errorf("error fetching projects: %w", err) + return } result.Projects = projects - errc <- err + + orgProjects, err := OrganizationProjects(client, repo.RepoOwner()) + // TODO: better detection of non-org repos + if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") { + errc <- fmt.Errorf("error fetching organization projects: %w", err) + return + } + result.Projects = append(result.Projects, orgProjects...) + errc <- nil }() } if input.Milestones { diff --git a/command/pr_create.go b/command/pr_create.go index 76953e94e06..560dd3f432c 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -317,8 +317,15 @@ func prCreate(cmd *cobra.Command, _ []string) error { } if hasMetadata { - // TODO: input - metadata, err := api.RepoMetadata(client, baseRepo, api.RepoMetadataInput{}) + metadataInput := api.RepoMetadataInput{ + Reviewers: len(reviewers) > 0, + Assignees: len(assignees) > 0, + Labels: len(labelNames) > 0, + Projects: len(projectNames) > 0, + Milestones: milestoneTitle != "", + } + + metadata, err := api.RepoMetadata(client, baseRepo, metadataInput) if err != nil { return err } @@ -326,11 +333,28 @@ func prCreate(cmd *cobra.Command, _ []string) error { if err != nil { return err } - reviewerIDs, err := metadata.MembersToIDs(reviewers) + + var userReviewers []string + var teamReviewers []string + for _, r := range reviewers { + if strings.ContainsRune(r, '/') { + teamReviewers = append(teamReviewers, r) + } else { + userReviewers = append(teamReviewers, r) + } + } + + userReviewerIDs, err := metadata.MembersToIDs(userReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["userReviewerIds"] = userReviewerIDs + + teamReviewerIDs, err := metadata.TeamsToIDs(teamReviewers) if err != nil { return fmt.Errorf("could not request reviewer: %w", err) } - params["reviewerIds"] = reviewerIDs + params["teamReviewerIds"] = teamReviewerIDs } pr, err := api.CreatePullRequest(client, baseRepo, params) From c6d8a4c1511d450a4b0b53a2a69322d15b891654 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 27 Apr 2020 14:31:12 +0200 Subject: [PATCH 07/18] Fix mutations --- api/queries_org.go | 5 ++--- api/queries_pr.go | 8 ++++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/api/queries_org.go b/api/queries_org.go index af0acf8b689..07c83b514ed 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -67,9 +67,8 @@ func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) { } type OrgTeam struct { - ID string - Slug string - Description string + ID string + Slug string } // OrganizationTeams fetches all the teams in an organization diff --git a/api/queries_pr.go b/api/queries_pr.go index 200c826c0d4..616b453fd44 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -603,7 +603,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } if len(updateParams) > 0 { updateQuery := ` - mutation UpdatePullRequest($input: CreatePullRequestInput!) { + mutation UpdatePullRequest($input: UpdatePullRequestInput!) { updatePullRequest(input: $input) { clientMutationId } }` updateParams["pullRequestId"] = pr.ID @@ -627,7 +627,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter if len(reviewParams) > 0 { reviewQuery := ` - mutation RequestReviews($input: CreatePullRequestInput!) { + mutation RequestReviews($input: RequestReviewsInput!) { requestReviews(input: $input) { clientMutationId } }` reviewParams["pullRequestId"] = pr.ID @@ -646,9 +646,9 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter func isBlank(v interface{}) bool { switch vv := v.(type) { case string: - return vv != "" + return vv == "" case []string: - return len(vv) > 0 + return len(vv) == 0 default: return true } From aeb08529e7bbbd0434884e8c5bb8b9e479a7a8f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 27 Apr 2020 18:58:25 +0200 Subject: [PATCH 08/18] Add wizard that prompts for issue/pr metadata on create --- command/issue.go | 35 +++--- command/pr_create.go | 45 ++++--- command/pr_create_test.go | 6 +- command/title_body_survey.go | 219 ++++++++++++++++++++++++++++++----- 4 files changed, 243 insertions(+), 62 deletions(-) diff --git a/command/issue.go b/command/issue.go index 486f99cd22a..29a3bb34d57 100644 --- a/command/issue.go +++ b/command/issue.go @@ -371,8 +371,6 @@ func issueCreate(cmd *cobra.Command, args []string) error { return fmt.Errorf("could not parse milestone: %w", err) } - hasMetadata := len(assignees) > 0 || len(labelNames) > 0 || len(projectNames) > 0 || milestoneTitle != "" - if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb { // TODO: move URL generation into GitHubRepository openURL := fmt.Sprintf("https://github.com/%s/issues/new", ghrepo.FullName(baseRepo)) @@ -405,11 +403,17 @@ func issueCreate(cmd *cobra.Command, args []string) error { } action := SubmitAction + tb := titleBody{ + Assignees: assignees, + Labels: labelNames, + Projects: projectNames, + Milestone: milestoneTitle, + } interactive := title == "" || body == "" if interactive { - tb, err := titleBodySurvey(cmd, title, body, defaults{}, templateFiles, !hasMetadata) + err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, templateFiles, false) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } @@ -446,19 +450,22 @@ func issueCreate(cmd *cobra.Command, args []string) error { "body": body, } - if hasMetadata { - metadataInput := api.RepoMetadataInput{ - Assignees: len(assignees) > 0, - Labels: len(labelNames) > 0, - Projects: len(projectNames) > 0, - Milestones: milestoneTitle != "", + if tb.HasMetadata() { + if tb.MetadataResult == nil { + metadataInput := api.RepoMetadataInput{ + Assignees: len(tb.Assignees) > 0, + Labels: len(tb.Labels) > 0, + Projects: len(tb.Projects) > 0, + Milestones: tb.Milestone != "", + } + + tb.MetadataResult, err = api.RepoMetadata(apiClient, baseRepo, metadataInput) + if err != nil { + return err + } } - metadata, err := api.RepoMetadata(apiClient, baseRepo, metadataInput) - if err != nil { - return err - } - err = addMetadataToIssueParams(params, metadata, assignees, labelNames, projectNames, milestoneTitle) + err = addMetadataToIssueParams(params, tb.MetadataResult, tb.Assignees, tb.Labels, tb.Projects, tb.Milestone) if err != nil { return err } diff --git a/command/pr_create.go b/command/pr_create.go index afd8d1101d2..d24a0527755 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -145,8 +145,6 @@ func prCreate(cmd *cobra.Command, _ []string) error { return fmt.Errorf("could not parse milestone: %w", err) } - hasMetadata := len(reviewers) > 0 || len(assignees) > 0 || len(labelNames) > 0 || len(projectNames) > 0 || milestoneTitle != "" - baseTrackingBranch := baseBranch if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil { baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch) @@ -202,6 +200,14 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } + tb := titleBody{ + Reviewers: reviewers, + Assignees: assignees, + Labels: labelNames, + Projects: projectNames, + Milestone: milestoneTitle, + } + // TODO: only drop into interactive mode if stdin & stdout are a tty if !isWeb && !autofill && (title == "" || body == "") { var templateFiles []string @@ -210,7 +216,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE") } - tb, err := titleBodySurvey(cmd, title, body, defs, templateFiles, !hasMetadata) + err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, templateFiles, true) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } @@ -316,27 +322,30 @@ func prCreate(cmd *cobra.Command, _ []string) error { "headRefName": headBranchLabel, } - if hasMetadata { - metadataInput := api.RepoMetadataInput{ - Reviewers: len(reviewers) > 0, - Assignees: len(assignees) > 0, - Labels: len(labelNames) > 0, - Projects: len(projectNames) > 0, - Milestones: milestoneTitle != "", - } + if tb.HasMetadata() { + if tb.MetadataResult == nil { + metadataInput := api.RepoMetadataInput{ + Reviewers: len(tb.Reviewers) > 0, + Assignees: len(tb.Assignees) > 0, + Labels: len(tb.Labels) > 0, + Projects: len(tb.Projects) > 0, + Milestones: tb.Milestone != "", + } - metadata, err := api.RepoMetadata(client, baseRepo, metadataInput) - if err != nil { - return err + tb.MetadataResult, err = api.RepoMetadata(client, baseRepo, metadataInput) + if err != nil { + return err + } } - err = addMetadataToIssueParams(params, metadata, assignees, labelNames, projectNames, milestoneTitle) + + err = addMetadataToIssueParams(params, tb.MetadataResult, tb.Assignees, tb.Labels, tb.Projects, tb.Milestone) if err != nil { return err } var userReviewers []string var teamReviewers []string - for _, r := range reviewers { + for _, r := range tb.Reviewers { if strings.ContainsRune(r, '/') { teamReviewers = append(teamReviewers, r) } else { @@ -344,13 +353,13 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - userReviewerIDs, err := metadata.MembersToIDs(userReviewers) + userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) if err != nil { return fmt.Errorf("could not request reviewer: %w", err) } params["userReviewerIds"] = userReviewerIDs - teamReviewerIDs, err := metadata.TeamsToIDs(teamReviewers) + teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) if err != nil { return fmt.Errorf("could not request reviewer: %w", err) } diff --git a/command/pr_create_test.go b/command/pr_create_test.go index f3a0954c876..a13b6e7363b 100644 --- a/command/pr_create_test.go +++ b/command/pr_create_test.go @@ -373,7 +373,7 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) { as.Stub([]*QuestionStub{ { Name: "confirmation", - Value: 1, + Value: 0, }, }) @@ -450,7 +450,7 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) { as.Stub([]*QuestionStub{ { Name: "confirmation", - Value: 1, + Value: 0, }, }) @@ -617,7 +617,7 @@ func TestPRCreate_defaults_error_interactive(t *testing.T) { as.Stub([]*QuestionStub{ { Name: "confirmation", - Value: 0, + Value: 1, }, }) diff --git a/command/title_body_survey.go b/command/title_body_survey.go index d74d070ee39..9199d80153b 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -5,6 +5,8 @@ import ( "os" "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/githubtemplate" "github.com/cli/cli/pkg/surveyext" "github.com/spf13/cobra" @@ -16,24 +18,52 @@ type titleBody struct { Body string Title string Action Action + + Metadata []string + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string + + MetadataResult *api.RepoMetadataResult +} + +func (tb *titleBody) HasMetadata() bool { + return len(tb.Reviewers) > 0 || + len(tb.Assignees) > 0 || + len(tb.Labels) > 0 || + len(tb.Projects) > 0 || + tb.Milestone != "" } const ( - PreviewAction Action = iota - SubmitAction + SubmitAction Action = iota + PreviewAction CancelAction + MetadataAction ) var SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error { return survey.Ask(qs, response, opts...) } -func confirmSubmission(allowPreview bool) (Action, error) { - options := []string{} +func confirmSubmission(allowPreview bool, allowMetadata bool) (Action, error) { + const ( + submitLabel = "Submit" + previewLabel = "Continue in browser" + metadataLabel = "Add metadata" + cancelLabel = "Cancel" + ) + + options := []string{submitLabel} if allowPreview { - options = append(options, "Preview in browser") + options = append(options, previewLabel) + } + if allowMetadata { + options = append(options, metadataLabel) } - options = append(options, "Submit", "Cancel") + options = append(options, cancelLabel) confirmAnswers := struct { Confirmation int @@ -53,11 +83,18 @@ func confirmSubmission(allowPreview bool) (Action, error) { return -1, fmt.Errorf("could not prompt: %w", err) } - choice := confirmAnswers.Confirmation - if !allowPreview { - choice++ + switch options[confirmAnswers.Confirmation] { + case submitLabel: + return SubmitAction, nil + case previewLabel: + return PreviewAction, nil + case metadataLabel: + return MetadataAction, nil + case cancelLabel: + return CancelAction, nil + default: + return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation) } - return Action(choice), nil } func selectTemplate(templatePaths []string) (string, error) { @@ -88,19 +125,18 @@ func selectTemplate(templatePaths []string) (string, error) { return string(templateContents), nil } -func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, defs defaults, templatePaths []string, allowPreview bool) (*titleBody, error) { +func titleBodySurvey(cmd *cobra.Command, data *titleBody, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers bool) error { editorCommand := os.Getenv("GH_EDITOR") if editorCommand == "" { ctx := contextForCommand(cmd) cfg, err := ctx.Config() if err != nil { - return nil, fmt.Errorf("could not read config: %w", err) + return fmt.Errorf("could not read config: %w", err) } editorCommand, _ = cfg.Get(defaultHostname, "editor") } - var inProgress titleBody - inProgress.Title = defs.Title + data.Title = defs.Title templateContents := "" if providedBody == "" { @@ -108,11 +144,11 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def var err error templateContents, err = selectTemplate(templatePaths) if err != nil { - return nil, err + return err } - inProgress.Body = templateContents + data.Body = templateContents } else { - inProgress.Body = defs.Body + data.Body = defs.Body } } @@ -120,7 +156,7 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def Name: "title", Prompt: &survey.Input{ Message: "Title", - Default: inProgress.Title, + Default: data.Title, }, } bodyQuestion := &survey.Question{ @@ -130,7 +166,7 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def Editor: &survey.Editor{ Message: "Body", FileName: "*.md", - Default: inProgress.Body, + Default: data.Body, HideDefault: true, AppendDefault: true, }, @@ -145,21 +181,150 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def qs = append(qs, bodyQuestion) } - err := SurveyAsk(qs, &inProgress) + err := SurveyAsk(qs, data) if err != nil { - return nil, fmt.Errorf("could not prompt: %w", err) + return fmt.Errorf("could not prompt: %w", err) } - if inProgress.Body == "" { - inProgress.Body = templateContents + if data.Body == "" { + data.Body = templateContents } - confirmA, err := confirmSubmission(allowPreview) + confirmA, err := confirmSubmission(!data.HasMetadata(), true) if err != nil { - return nil, fmt.Errorf("unable to confirm: %w", err) + return fmt.Errorf("unable to confirm: %w", err) } - inProgress.Action = confirmA + if confirmA == MetadataAction { + isChosen := func(m string) bool { + for _, c := range data.Metadata { + if m == c { + return true + } + } + return false + } + + extraFieldsOptions := []string{} + if allowReviewers { + extraFieldsOptions = append(extraFieldsOptions, "Reviewers") + } + extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone") + + err = SurveyAsk([]*survey.Question{ + { + Name: "metadata", + Prompt: &survey.MultiSelect{ + Message: "What would you like to add?", + Options: extraFieldsOptions, + }, + }, + }, data) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + // TODO: show spinner while preloading repo metadata + metadataInput := api.RepoMetadataInput{ + Reviewers: isChosen("Reviewers"), + Assignees: isChosen("Assignees"), + Labels: isChosen("Labels"), + Projects: isChosen("Projects"), + Milestones: isChosen("Milestone"), + } + data.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput) + if err != nil { + return fmt.Errorf("error fetching metadata options: %w", err) + } + + var users []string + for _, u := range data.MetadataResult.AssignableUsers { + users = append(users, u.Login) + } + var teams []string + for _, t := range data.MetadataResult.Teams { + teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug)) + } + var labels []string + for _, l := range data.MetadataResult.Labels { + labels = append(labels, l.Name) + } + var projects []string + for _, l := range data.MetadataResult.Projects { + projects = append(projects, l.Name) + } + milestones := []string{"(none)"} + for _, m := range data.MetadataResult.Milestones { + milestones = append(milestones, m.Title) + } + + var mqs []*survey.Question + if isChosen("Reviewers") { + mqs = append(mqs, &survey.Question{ + Name: "reviewers", + Prompt: &survey.MultiSelect{ + Message: "Reviewers", + Options: append(users, teams...), + Default: data.Reviewers, + }, + }) + } + if isChosen("Assignees") { + mqs = append(mqs, &survey.Question{ + Name: "assignees", + Prompt: &survey.MultiSelect{ + Message: "Assignees", + Options: users, + Default: data.Assignees, + }, + }) + } + if isChosen("Labels") { + mqs = append(mqs, &survey.Question{ + Name: "labels", + Prompt: &survey.MultiSelect{ + Message: "Labels", + Options: labels, + Default: data.Labels, + }, + }) + } + if isChosen("Projects") { + mqs = append(mqs, &survey.Question{ + Name: "projects", + Prompt: &survey.MultiSelect{ + Message: "Projects", + Options: projects, + Default: data.Projects, + }, + }) + } + if isChosen("Milestone") { + mqs = append(mqs, &survey.Question{ + Name: "milestone", + Prompt: &survey.Select{ + Message: "Milestone", + Options: milestones, + Default: data.Milestone, + }, + }) + } + + err = SurveyAsk(mqs, data, survey.WithKeepFilter(true)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if data.Milestone == "(none)" { + data.Milestone = "" + } + + confirmA, err = confirmSubmission(!data.HasMetadata(), false) + if err != nil { + return fmt.Errorf("unable to confirm: %w", err) + } + } - return &inProgress, nil + data.Action = confirmA + return nil } From 2089b15e6a4199a41823f18413e69c2f37dbf0a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Mon, 4 May 2020 17:09:11 +0200 Subject: [PATCH 09/18] Fix pagination when fetching metadata --- api/queries_org.go | 4 ++-- api/queries_repo.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/api/queries_org.go b/api/queries_org.go index 07c83b514ed..bc492a88a52 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -60,7 +60,7 @@ func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) { if !query.Organization.Projects.PageInfo.HasNextPage { break } - variables["endCursor"] = query.Organization.Projects.PageInfo.EndCursor + variables["endCursor"] = githubv4.String(query.Organization.Projects.PageInfo.EndCursor) } return projects, nil @@ -103,7 +103,7 @@ func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) { if !query.Organization.Teams.PageInfo.HasNextPage { break } - variables["endCursor"] = query.Organization.Teams.PageInfo.EndCursor + variables["endCursor"] = githubv4.String(query.Organization.Teams.PageInfo.EndCursor) } return teams, nil diff --git a/api/queries_repo.go b/api/queries_repo.go index b10f8cd2ed5..e92357cf0e5 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -607,7 +607,7 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) if !query.Repository.Projects.PageInfo.HasNextPage { break } - variables["endCursor"] = query.Repository.Projects.PageInfo.EndCursor + variables["endCursor"] = githubv4.String(query.Repository.Projects.PageInfo.EndCursor) } return projects, nil @@ -651,7 +651,7 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, if !query.Repository.AssignableUsers.PageInfo.HasNextPage { break } - variables["endCursor"] = query.Repository.AssignableUsers.PageInfo.EndCursor + variables["endCursor"] = githubv4.String(query.Repository.AssignableUsers.PageInfo.EndCursor) } return users, nil @@ -695,7 +695,7 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) { if !query.Repository.Labels.PageInfo.HasNextPage { break } - variables["endCursor"] = query.Repository.Labels.PageInfo.EndCursor + variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor) } return labels, nil @@ -739,7 +739,7 @@ func RepoMilestones(client *Client, repo ghrepo.Interface) ([]RepoMilestone, err if !query.Repository.Milestones.PageInfo.HasNextPage { break } - variables["endCursor"] = query.Repository.Milestones.PageInfo.EndCursor + variables["endCursor"] = githubv4.String(query.Repository.Milestones.PageInfo.EndCursor) } return milestones, nil From 716036161bdc1304f50317455eaf4658f76bacef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 17:55:46 +0200 Subject: [PATCH 10/18] Rename `titleBody` (now a misnomer) to something more descriptive --- command/issue.go | 2 +- command/pr_create.go | 2 +- command/title_body_survey.go | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/command/issue.go b/command/issue.go index cd0c2dcee29..8b5c4d6b488 100644 --- a/command/issue.go +++ b/command/issue.go @@ -418,7 +418,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { } action := SubmitAction - tb := titleBody{ + tb := issueMetadataState{ Assignees: assignees, Labels: labelNames, Projects: projectNames, diff --git a/command/pr_create.go b/command/pr_create.go index d24a0527755..29762de0b70 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -200,7 +200,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - tb := titleBody{ + tb := issueMetadataState{ Reviewers: reviewers, Assignees: assignees, Labels: labelNames, diff --git a/command/title_body_survey.go b/command/title_body_survey.go index 9199d80153b..f47e6a64344 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -14,7 +14,7 @@ import ( type Action int -type titleBody struct { +type issueMetadataState struct { Body string Title string Action Action @@ -29,7 +29,7 @@ type titleBody struct { MetadataResult *api.RepoMetadataResult } -func (tb *titleBody) HasMetadata() bool { +func (tb *issueMetadataState) HasMetadata() bool { return len(tb.Reviewers) > 0 || len(tb.Assignees) > 0 || len(tb.Labels) > 0 || @@ -125,7 +125,7 @@ func selectTemplate(templatePaths []string) (string, error) { return string(templateContents), nil } -func titleBodySurvey(cmd *cobra.Command, data *titleBody, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers bool) error { +func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers bool) error { editorCommand := os.Getenv("GH_EDITOR") if editorCommand == "" { ctx := contextForCommand(cmd) From df144926b8ac5027a19d8b1e8b5b64685416d098 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 17:56:47 +0200 Subject: [PATCH 11/18] Rename `data` variable to something more descriptive --- command/title_body_survey.go | 56 ++++++++++++++++++------------------ 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/command/title_body_survey.go b/command/title_body_survey.go index f47e6a64344..94ac0a1fc10 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -125,7 +125,7 @@ func selectTemplate(templatePaths []string) (string, error) { return string(templateContents), nil } -func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers bool) error { +func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers bool) error { editorCommand := os.Getenv("GH_EDITOR") if editorCommand == "" { ctx := contextForCommand(cmd) @@ -136,7 +136,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap editorCommand, _ = cfg.Get(defaultHostname, "editor") } - data.Title = defs.Title + issueState.Title = defs.Title templateContents := "" if providedBody == "" { @@ -146,9 +146,9 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap if err != nil { return err } - data.Body = templateContents + issueState.Body = templateContents } else { - data.Body = defs.Body + issueState.Body = defs.Body } } @@ -156,7 +156,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Name: "title", Prompt: &survey.Input{ Message: "Title", - Default: data.Title, + Default: issueState.Title, }, } bodyQuestion := &survey.Question{ @@ -166,7 +166,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Editor: &survey.Editor{ Message: "Body", FileName: "*.md", - Default: data.Body, + Default: issueState.Body, HideDefault: true, AppendDefault: true, }, @@ -181,23 +181,23 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap qs = append(qs, bodyQuestion) } - err := SurveyAsk(qs, data) + err := SurveyAsk(qs, issueState) if err != nil { return fmt.Errorf("could not prompt: %w", err) } - if data.Body == "" { - data.Body = templateContents + if issueState.Body == "" { + issueState.Body = templateContents } - confirmA, err := confirmSubmission(!data.HasMetadata(), true) + confirmA, err := confirmSubmission(!issueState.HasMetadata(), true) if err != nil { return fmt.Errorf("unable to confirm: %w", err) } if confirmA == MetadataAction { isChosen := func(m string) bool { - for _, c := range data.Metadata { + for _, c := range issueState.Metadata { if m == c { return true } @@ -219,7 +219,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Options: extraFieldsOptions, }, }, - }, data) + }, issueState) if err != nil { return fmt.Errorf("could not prompt: %w", err) } @@ -232,29 +232,29 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } - data.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput) + issueState.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput) if err != nil { return fmt.Errorf("error fetching metadata options: %w", err) } var users []string - for _, u := range data.MetadataResult.AssignableUsers { + for _, u := range issueState.MetadataResult.AssignableUsers { users = append(users, u.Login) } var teams []string - for _, t := range data.MetadataResult.Teams { + for _, t := range issueState.MetadataResult.Teams { teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug)) } var labels []string - for _, l := range data.MetadataResult.Labels { + for _, l := range issueState.MetadataResult.Labels { labels = append(labels, l.Name) } var projects []string - for _, l := range data.MetadataResult.Projects { + for _, l := range issueState.MetadataResult.Projects { projects = append(projects, l.Name) } milestones := []string{"(none)"} - for _, m := range data.MetadataResult.Milestones { + for _, m := range issueState.MetadataResult.Milestones { milestones = append(milestones, m.Title) } @@ -265,7 +265,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Prompt: &survey.MultiSelect{ Message: "Reviewers", Options: append(users, teams...), - Default: data.Reviewers, + Default: issueState.Reviewers, }, }) } @@ -275,7 +275,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Prompt: &survey.MultiSelect{ Message: "Assignees", Options: users, - Default: data.Assignees, + Default: issueState.Assignees, }, }) } @@ -285,7 +285,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Prompt: &survey.MultiSelect{ Message: "Labels", Options: labels, - Default: data.Labels, + Default: issueState.Labels, }, }) } @@ -295,7 +295,7 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Prompt: &survey.MultiSelect{ Message: "Projects", Options: projects, - Default: data.Projects, + Default: issueState.Projects, }, }) } @@ -305,26 +305,26 @@ func titleBodySurvey(cmd *cobra.Command, data *issueMetadataState, apiClient *ap Prompt: &survey.Select{ Message: "Milestone", Options: milestones, - Default: data.Milestone, + Default: issueState.Milestone, }, }) } - err = SurveyAsk(mqs, data, survey.WithKeepFilter(true)) + err = SurveyAsk(mqs, issueState, survey.WithKeepFilter(true)) if err != nil { return fmt.Errorf("could not prompt: %w", err) } - if data.Milestone == "(none)" { - data.Milestone = "" + if issueState.Milestone == "(none)" { + issueState.Milestone = "" } - confirmA, err = confirmSubmission(!data.HasMetadata(), false) + confirmA, err = confirmSubmission(!issueState.HasMetadata(), false) if err != nil { return fmt.Errorf("unable to confirm: %w", err) } } - data.Action = confirmA + issueState.Action = confirmA return nil } From cedf94f45057c1f68bc37a539d7d7a655e5297f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 17:58:33 +0200 Subject: [PATCH 12/18] Name some values for readability --- command/title_body_survey.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/command/title_body_survey.go b/command/title_body_survey.go index 94ac0a1fc10..0eb84b590bd 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -190,7 +190,9 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie issueState.Body = templateContents } - confirmA, err := confirmSubmission(!issueState.HasMetadata(), true) + allowPreview := !issueState.HasMetadata() + allowMetadata := true + confirmA, err := confirmSubmission(allowPreview, allowMetadata) if err != nil { return fmt.Errorf("unable to confirm: %w", err) } @@ -319,7 +321,9 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie issueState.Milestone = "" } - confirmA, err = confirmSubmission(!issueState.HasMetadata(), false) + allowPreview := !issueState.HasMetadata() + allowMetadata := false + confirmA, err = confirmSubmission(allowPreview, allowMetadata) if err != nil { return fmt.Errorf("unable to confirm: %w", err) } From 34fc3457d681b3f69f531bb5001051a5e050b106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 18:06:05 +0200 Subject: [PATCH 13/18] Skip interactive mode in `pr create` if title & body were passed This matches `issue create` behavior 4b32e3f2159bec8e7e1d1e9c514062d8fd3f5c64 --- command/pr_create.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/command/pr_create.go b/command/pr_create.go index 29762de0b70..9a6e165eef0 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -208,8 +208,9 @@ func prCreate(cmd *cobra.Command, _ []string) error { Milestone: milestoneTitle, } - // TODO: only drop into interactive mode if stdin & stdout are a tty - if !isWeb && !autofill && (title == "" || body == "") { + interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) + + if !isWeb && !autofill && interactive { var templateFiles []string if rootDir, err := git.ToplevelDir(); err == nil { // TODO: figure out how to stub this in tests From 1f774b4158c73e97c43ffa978fbf86f5a571353d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 18:11:57 +0200 Subject: [PATCH 14/18] Add spinner while loading metadata --- command/title_body_survey.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/command/title_body_survey.go b/command/title_body_survey.go index 0eb84b590bd..bfc68e91e64 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -9,6 +9,7 @@ import ( "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/githubtemplate" "github.com/cli/cli/pkg/surveyext" + "github.com/cli/cli/utils" "github.com/spf13/cobra" ) @@ -226,7 +227,6 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie return fmt.Errorf("could not prompt: %w", err) } - // TODO: show spinner while preloading repo metadata metadataInput := api.RepoMetadataInput{ Reviewers: isChosen("Reviewers"), Assignees: isChosen("Assignees"), @@ -234,7 +234,10 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie Projects: isChosen("Projects"), Milestones: isChosen("Milestone"), } + s := utils.Spinner(cmd.OutOrStderr()) + utils.StartSpinner(s) issueState.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput) + utils.StopSpinner(s) if err != nil { return fmt.Errorf("error fetching metadata options: %w", err) } From d7e6d21766125823e6598c86d78fb549768e769f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 18:22:14 +0200 Subject: [PATCH 15/18] Order projects, teams, and labels by name --- api/queries_org.go | 4 ++-- api/queries_repo.go | 4 ++-- command/issue.go | 1 + command/pr_create.go | 1 + 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/api/queries_org.go b/api/queries_org.go index bc492a88a52..eb7ec078925 100644 --- a/api/queries_org.go +++ b/api/queries_org.go @@ -38,7 +38,7 @@ func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) { HasNextPage bool EndCursor string } - } `graphql:"projects(states: [OPEN], first: 100, after: $endCursor)"` + } `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"` } `graphql:"organization(login: $owner)"` } @@ -81,7 +81,7 @@ func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) { HasNextPage bool EndCursor string } - } `graphql:"teams(first: 100, after: $endCursor)"` + } `graphql:"teams(first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"` } `graphql:"organization(login: $owner)"` } diff --git a/api/queries_repo.go b/api/queries_repo.go index e92357cf0e5..a8e3ff0032b 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -584,7 +584,7 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) HasNextPage bool EndCursor string } - } `graphql:"projects(states: [OPEN], first: 100, after: $endCursor)"` + } `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"` } `graphql:"repository(owner: $owner, name: $name)"` } @@ -672,7 +672,7 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) { HasNextPage bool EndCursor string } - } `graphql:"labels(first: 100, after: $endCursor)"` + } `graphql:"labels(first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"` } `graphql:"repository(owner: $owner, name: $name)"` } diff --git a/command/issue.go b/command/issue.go index 8b5c4d6b488..480ed4f020b 100644 --- a/command/issue.go +++ b/command/issue.go @@ -478,6 +478,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { Milestones: tb.Milestone != "", } + // TODO: for non-interactive mode, only translate given objects to GraphQL IDs tb.MetadataResult, err = api.RepoMetadata(apiClient, baseRepo, metadataInput) if err != nil { return err diff --git a/command/pr_create.go b/command/pr_create.go index 9a6e165eef0..982bc5e9a12 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -333,6 +333,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { Milestones: tb.Milestone != "", } + // TODO: for non-interactive mode, only translate given objects to GraphQL IDs tb.MetadataResult, err = api.RepoMetadata(client, baseRepo, metadataInput) if err != nil { return err From 1128439cd1c7b3a093522fa4aad4311d93f9aa3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 18:37:08 +0200 Subject: [PATCH 16/18] `issue/pr create`: hide "Add metadata" if viewer does not have triage permission --- api/queries_repo.go | 11 +++++++++++ command/issue.go | 2 +- command/pr_create.go | 2 +- command/title_body_survey.go | 7 +++---- 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/api/queries_repo.go b/api/queries_repo.go index a8e3ff0032b..af578413d03 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -66,6 +66,16 @@ func (r Repository) ViewerCanPush() bool { } } +// ViewerCanTriage is true when the requesting user can triage issues and pull requests +func (r Repository) ViewerCanTriage() bool { + switch r.ViewerPermission { + case "ADMIN", "MAINTAIN", "WRITE", "TRIAGE": + return true + default: + return false + } +} + func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { query := ` query($owner: String!, $name: String!) { @@ -73,6 +83,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { id hasIssuesEnabled description + viewerPermission } }` variables := map[string]interface{}{ diff --git a/command/issue.go b/command/issue.go index 480ed4f020b..e5cf2402613 100644 --- a/command/issue.go +++ b/command/issue.go @@ -428,7 +428,7 @@ func issueCreate(cmd *cobra.Command, args []string) error { interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) if interactive { - err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, templateFiles, false) + err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, templateFiles, false, repo.ViewerCanTriage()) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } diff --git a/command/pr_create.go b/command/pr_create.go index 982bc5e9a12..4ce58a6812c 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -217,7 +217,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE") } - err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, templateFiles, true) + err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, templateFiles, true, baseRepo.ViewerCanTriage()) if err != nil { return fmt.Errorf("could not collect title and/or body: %w", err) } diff --git a/command/title_body_survey.go b/command/title_body_survey.go index bfc68e91e64..ecc6f839f7d 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -126,7 +126,7 @@ func selectTemplate(templatePaths []string) (string, error) { return string(templateContents), nil } -func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers bool) error { +func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers, allowMetadata bool) error { editorCommand := os.Getenv("GH_EDITOR") if editorCommand == "" { ctx := contextForCommand(cmd) @@ -192,7 +192,6 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie } allowPreview := !issueState.HasMetadata() - allowMetadata := true confirmA, err := confirmSubmission(allowPreview, allowMetadata) if err != nil { return fmt.Errorf("unable to confirm: %w", err) @@ -324,8 +323,8 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie issueState.Milestone = "" } - allowPreview := !issueState.HasMetadata() - allowMetadata := false + allowPreview = !issueState.HasMetadata() + allowMetadata = false confirmA, err = confirmSubmission(allowPreview, allowMetadata) if err != nil { return fmt.Errorf("unable to confirm: %w", err) From 8c84d6881ea280d9a8879d6f054c7aaa40aa5d6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 18:48:50 +0200 Subject: [PATCH 17/18] Avoid aborting survey if reviewers/assignees/labels/projects/milestones are empty --- command/title_body_survey.go | 100 +++++++++++++++++++++-------------- 1 file changed, 60 insertions(+), 40 deletions(-) diff --git a/command/title_body_survey.go b/command/title_body_survey.go index ecc6f839f7d..06ab14a096d 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -264,54 +264,74 @@ func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClie var mqs []*survey.Question if isChosen("Reviewers") { - mqs = append(mqs, &survey.Question{ - Name: "reviewers", - Prompt: &survey.MultiSelect{ - Message: "Reviewers", - Options: append(users, teams...), - Default: issueState.Reviewers, - }, - }) + if len(users) > 0 || len(teams) > 0 { + mqs = append(mqs, &survey.Question{ + Name: "reviewers", + Prompt: &survey.MultiSelect{ + Message: "Reviewers", + Options: append(users, teams...), + Default: issueState.Reviewers, + }, + }) + } else { + cmd.PrintErrln("warning: no available reviewers") + } } if isChosen("Assignees") { - mqs = append(mqs, &survey.Question{ - Name: "assignees", - Prompt: &survey.MultiSelect{ - Message: "Assignees", - Options: users, - Default: issueState.Assignees, - }, - }) + if len(users) > 0 { + mqs = append(mqs, &survey.Question{ + Name: "assignees", + Prompt: &survey.MultiSelect{ + Message: "Assignees", + Options: users, + Default: issueState.Assignees, + }, + }) + } else { + cmd.PrintErrln("warning: no assignable users") + } } if isChosen("Labels") { - mqs = append(mqs, &survey.Question{ - Name: "labels", - Prompt: &survey.MultiSelect{ - Message: "Labels", - Options: labels, - Default: issueState.Labels, - }, - }) + if len(labels) > 0 { + mqs = append(mqs, &survey.Question{ + Name: "labels", + Prompt: &survey.MultiSelect{ + Message: "Labels", + Options: labels, + Default: issueState.Labels, + }, + }) + } else { + cmd.PrintErrln("warning: no labels in the repository") + } } if isChosen("Projects") { - mqs = append(mqs, &survey.Question{ - Name: "projects", - Prompt: &survey.MultiSelect{ - Message: "Projects", - Options: projects, - Default: issueState.Projects, - }, - }) + if len(projects) > 0 { + mqs = append(mqs, &survey.Question{ + Name: "projects", + Prompt: &survey.MultiSelect{ + Message: "Projects", + Options: projects, + Default: issueState.Projects, + }, + }) + } else { + cmd.PrintErrln("warning: no projects to choose from") + } } if isChosen("Milestone") { - mqs = append(mqs, &survey.Question{ - Name: "milestone", - Prompt: &survey.Select{ - Message: "Milestone", - Options: milestones, - Default: issueState.Milestone, - }, - }) + if len(milestones) > 1 { + mqs = append(mqs, &survey.Question{ + Name: "milestone", + Prompt: &survey.Select{ + Message: "Milestone", + Options: milestones, + Default: issueState.Milestone, + }, + }) + } else { + cmd.PrintErrln("warning: no milestones in the repository") + } } err = SurveyAsk(mqs, issueState, survey.WithKeepFilter(true)) From c682d90466bd96f1be41c1ed1428ba880756a6ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mislav=20Marohni=C4=87?= Date: Fri, 8 May 2020 20:32:46 +0200 Subject: [PATCH 18/18] Add tests for `issue/pr create` with metadata --- command/issue_test.go | 97 +++++++++++++++++++++++++++ command/pr_create_test.go | 135 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 232 insertions(+) diff --git a/command/issue_test.go b/command/issue_test.go index f2985d4286c..5a9d4c23943 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/test" "github.com/google/go-cmp/cmp" ) @@ -481,6 +482,102 @@ func TestIssueCreate(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } +func TestIssueCreate_metadata(t *testing.T) { + initBlankContext("", "OWNER/REPO", "master") + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`\bviewerPermission\b`), + httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) + http.Register( + httpmock.GraphQL(`\bhasIssuesEnabled\b`), + httpmock.StringResponse(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true, + "viewerPermission": "WRITE" + } } } + `)) + http.Register( + httpmock.GraphQL(`\bassignableUsers\(`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\blabels\(`), + httpmock.StringResponse(` + { "data": { "repository": { "labels": { + "nodes": [ + { "name": "feature", "id": "FEATUREID" }, + { "name": "TODO", "id": "TODOID" }, + { "name": "bug", "id": "BUGID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\bmilestones\(`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\brepository\(.+\bprojects\(`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [ + { "name": "Cleanup", "id": "CLEANUPID" }, + { "name": "Roadmap", "id": "ROADMAPID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\borganization\(.+\bprojects\(`), + httpmock.StringResponse(` + { "data": { "organization": null }, + "errors": [{ + "type": "NOT_FOUND", + "path": [ "organization" ], + "message": "Could not resolve to an Organization with the login of 'OWNER'." + }] + } + `)) + http.Register( + httpmock.GraphQL(`\bcreateIssue\(`), + httpmock.GraphQLMutation(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["title"], "TITLE") + eq(t, inputs["body"], "BODY") + eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) + eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) + eq(t, inputs["milestoneId"], "BIGONEID") + })) + + output, err := RunCommand(`issue create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`) + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") +} + func TestIssueCreate_disabledIssues(t *testing.T) { initBlankContext("", "OWNER/REPO", "master") http := initFakeHTTP() diff --git a/command/pr_create_test.go b/command/pr_create_test.go index c9ed4c2a9a2..85966aa7258 100644 --- a/command/pr_create_test.go +++ b/command/pr_create_test.go @@ -66,6 +66,141 @@ func TestPRCreate(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } +func TestPRCreate_metadata(t *testing.T) { + initBlankContext("", "OWNER/REPO", "feature") + http := initFakeHTTP() + defer http.Verify(t) + + http.Register( + httpmock.GraphQL(`\bviewerPermission\b`), + httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) + http.Register( + httpmock.GraphQL(`\bforks\(`), + httpmock.StringResponse(` + { "data": { "repository": { "forks": { "nodes": [ + ] } } } } + `)) + http.Register( + httpmock.GraphQL(`\bpullRequests\(`), + httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes": [ + ] } } } } + `)) + http.Register( + httpmock.GraphQL(`\bassignableUsers\(`), + httpmock.StringResponse(` + { "data": { "repository": { "assignableUsers": { + "nodes": [ + { "login": "hubot", "id": "HUBOTID" }, + { "login": "MonaLisa", "id": "MONAID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\blabels\(`), + httpmock.StringResponse(` + { "data": { "repository": { "labels": { + "nodes": [ + { "name": "feature", "id": "FEATUREID" }, + { "name": "TODO", "id": "TODOID" }, + { "name": "bug", "id": "BUGID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\bmilestones\(`), + httpmock.StringResponse(` + { "data": { "repository": { "milestones": { + "nodes": [ + { "title": "GA", "id": "GAID" }, + { "title": "Big One.oh", "id": "BIGONEID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\brepository\(.+\bprojects\(`), + httpmock.StringResponse(` + { "data": { "repository": { "projects": { + "nodes": [ + { "name": "Cleanup", "id": "CLEANUPID" }, + { "name": "Roadmap", "id": "ROADMAPID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\borganization\(.+\bprojects\(`), + httpmock.StringResponse(` + { "data": { "organization": { "projects": { + "nodes": [], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\borganization\(.+\bteams\(`), + httpmock.StringResponse(` + { "data": { "organization": { "teams": { + "nodes": [ + { "slug": "owners", "id": "OWNERSID" }, + { "slug": "Core", "id": "COREID" } + ], + "pageInfo": { "hasNextPage": false } + } } } } + `)) + http.Register( + httpmock.GraphQL(`\bcreatePullRequest\(`), + httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "id": "NEWPULLID", + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["title"], "TITLE") + eq(t, inputs["body"], "BODY") + })) + http.Register( + httpmock.GraphQL(`\bupdatePullRequest\(`), + httpmock.GraphQLMutation(` + { "data": { "updatePullRequest": { + "clientMutationId": "" + } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["pullRequestId"], "NEWPULLID") + eq(t, inputs["assigneeIds"], []interface{}{"MONAID"}) + eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"}) + eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"}) + eq(t, inputs["milestoneId"], "BIGONEID") + })) + http.Register( + httpmock.GraphQL(`\brequestReviews\(`), + httpmock.GraphQLMutation(` + { "data": { "requestReviews": { + "clientMutationId": "" + } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["pullRequestId"], "NEWPULLID") + eq(t, inputs["userIds"], []interface{}{"HUBOTID"}) + eq(t, inputs["teamIds"], []interface{}{"COREID"}) + })) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp (determineTrackingBranch) + cs.Stub("") // git show-ref --verify (determineTrackingBranch) + cs.Stub("") // git status + cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + cs.Stub("") // git push + + output, err := RunCommand(`pr create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r /core`) + eq(t, err, nil) + + eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") +} + func TestPRCreate_withForking(t *testing.T) { initBlankContext("", "OWNER/REPO", "feature") http := initFakeHTTP()