diff --git a/api/queries_org.go b/api/queries_org.go index 21d1f528c..eb7ec0789 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,84 @@ 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 } + +// OrganizationProjects fetches all open projects for an organization +func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) { + var query struct { + Organization struct { + Projects struct { + Nodes []RepoProject + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, 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 []RepoProject + 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"] = githubv4.String(query.Organization.Projects.PageInfo.EndCursor) + } + + return projects, nil +} + +type OrgTeam struct { + ID string + Slug 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, orderBy: {field: NAME, direction: ASC}, 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"] = githubv4.String(query.Organization.Teams.PageInfo.EndCursor) + } + + return teams, nil +} diff --git a/api/queries_pr.go b/api/queries_pr.go index e62b735e5..5dc6c7396 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -586,6 +586,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter mutation CreatePullRequest($input: CreatePullRequestInput!) { createPullRequest(input: $input) { pullRequest { + id url } } @@ -595,7 +596,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, @@ -611,8 +615,70 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter if err != nil { return nil, err } + pr := &result.CreatePullRequest.PullRequest - return &result.CreatePullRequest.PullRequest, nil + // 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: UpdatePullRequestInput!) { + updatePullRequest(input: $input) { clientMutationId } + }` + updateParams["pullRequestId"] = pr.ID + variables := map[string]interface{}{ + "input": updateParams, + } + err := client.GraphQL(updateQuery, variables, &result) + if err != nil { + return nil, err + } + } + + // reviewers are requested in yet another additional mutation + 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: RequestReviewsInput!) { + requestReviews(input: $input) { clientMutationId } + }` + reviewParams["pullRequestId"] = pr.ID + 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 len(vv) == 0 + default: + return true + } } func AddReview(client *Client, pr *PullRequest, input *PullRequestReviewInput) error { diff --git a/api/queries_repo.go b/api/queries_repo.go index b8ad760d0..af578413d 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{}{ @@ -388,6 +399,363 @@ func RepositoryReadme(client *Client, fullName string) (string, error) { } +type RepoMetadataResult struct { + AssignableUsers []RepoAssignee + Labels []RepoLabel + Projects []RepoProject + Milestones []RepoMilestone + Teams []OrgTeam +} + +func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { + var ids []string + for _, assigneeLogin := range names { + found := false + for _, u := range m.AssignableUsers { + 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) 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 { + found := false + for _, l := range m.Labels { + 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 { + 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 { + if strings.EqualFold(title, m.Title) { + return m.ID, nil + } + } + 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, input RepoMetadataInput) (*RepoMetadataResult, error) { + result := RepoMetadataResult{} + errc := make(chan error) + count := 0 + + if input.Assignees || input.Reviewers { + 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 { + 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++ + 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 { + count++ + go func() { + projects, err := RepoProjects(client, repo) + if err != nil { + errc <- fmt.Errorf("error fetching projects: %w", err) + return + } + result.Projects = projects + + 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 { + count++ + go func() { + milestones, err := RepoMilestones(client, repo) + if err != nil { + err = fmt.Errorf("error fetching milestones: %w", err) + } + result.Milestones = milestones + errc <- err + }() + } + + var err error + for i := 0; i < count; i++ { + if e := <-errc; e != nil { + err = e + } + } + + return &result, err +} + +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 struct { + Projects struct { + Nodes []RepoProject + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, 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 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"] = githubv4.String(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"] = githubv4.String(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, orderBy: {field: NAME, direction: ASC}, 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"] = githubv4.String(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"] = githubv4.String(query.Repository.Milestones.PageInfo.EndCursor) + } + + return milestones, 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 65b477910..e5cf24026 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") @@ -365,6 +369,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)) @@ -397,11 +418,17 @@ func issueCreate(cmd *cobra.Command, args []string) error { } action := SubmitAction + tb := issueMetadataState{ + Assignees: assignees, + Labels: labelNames, + Projects: projectNames, + Milestone: milestoneTitle, + } interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body")) if interactive { - tb, err := titleBodySurvey(cmd, title, body, defaults{}, templateFiles) + 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) } @@ -442,6 +469,28 @@ func issueCreate(cmd *cobra.Command, args []string) error { "body": body, } + 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 != "", + } + + // 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 + } + } + + err = addMetadataToIssueParams(params, tb.MetadataResult, tb.Assignees, tb.Labels, tb.Projects, tb.Milestone) + if err != nil { + return err + } + } + newIssue, err := api.IssueCreate(apiClient, repo, params) if err != nil { return err @@ -455,6 +504,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.MembersToIDs(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/issue_test.go b/command/issue_test.go index f2985d428..5a9d4c239 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.go b/command/pr_create.go index 2d029677a..4ce58a681 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -124,6 +124,27 @@ 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) + } + baseTrackingBranch := baseBranch if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil { baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch) @@ -179,15 +200,24 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } - // TODO: only drop into interactive mode if stdin & stdout are a tty - if !isWeb && !autofill && (title == "" || body == "") { + tb := issueMetadataState{ + Reviewers: reviewers, + Assignees: assignees, + Labels: labelNames, + Projects: projectNames, + Milestone: milestoneTitle, + } + + 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 templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE") } - tb, err := titleBodySurvey(cmd, title, body, defs, templateFiles) + 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) } @@ -293,6 +323,51 @@ func prCreate(cmd *cobra.Command, _ []string) error { "headRefName": headBranchLabel, } + 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 != "", + } + + // 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 + } + } + + 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 tb.Reviewers { + if strings.ContainsRune(r, '/') { + teamReviewers = append(teamReviewers, r) + } else { + userReviewers = append(teamReviewers, r) + } + } + + userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["userReviewerIds"] = userReviewerIDs + + teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers) + if err != nil { + return fmt.Errorf("could not request reviewer: %w", err) + } + params["teamReviewerIds"] = teamReviewerIDs + } + pr, err := api.CreatePullRequest(client, baseRepo, params) if err != nil { return fmt.Errorf("failed to create pull request: %w", err) @@ -385,4 +460,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/pr_create_test.go b/command/pr_create_test.go index f69c5c0c6..85966aa72 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() @@ -374,7 +509,7 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) { as.Stub([]*QuestionStub{ { Name: "confirmation", - Value: 1, + Value: 0, }, }) @@ -458,7 +593,7 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) { as.Stub([]*QuestionStub{ { Name: "confirmation", - Value: 1, + Value: 0, }, }) @@ -602,7 +737,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 397c27bef..06ab14a09 100644 --- a/command/title_body_survey.go +++ b/command/title_body_survey.go @@ -5,30 +5,67 @@ 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/cli/cli/utils" "github.com/spf13/cobra" ) type Action int -type titleBody struct { +type issueMetadataState struct { Body string Title string Action Action + + Metadata []string + Reviewers []string + Assignees []string + Labels []string + Projects []string + Milestone string + + MetadataResult *api.RepoMetadataResult +} + +func (tb *issueMetadataState) 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() (Action, error) { +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, previewLabel) + } + if allowMetadata { + options = append(options, metadataLabel) + } + options = append(options, cancelLabel) + confirmAnswers := struct { Confirmation int }{} @@ -37,11 +74,7 @@ func confirmSubmission() (Action, error) { Name: "confirmation", Prompt: &survey.Select{ Message: "What's next?", - Options: []string{ - "Preview in browser", - "Submit", - "Cancel", - }, + Options: options, }, }, } @@ -51,7 +84,18 @@ func confirmSubmission() (Action, error) { return -1, fmt.Errorf("could not prompt: %w", err) } - return Action(confirmAnswers.Confirmation), nil + 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) + } } func selectTemplate(templatePaths []string) (string, error) { @@ -82,19 +126,18 @@ 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, 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) 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 + issueState.Title = defs.Title templateContents := "" if providedBody == "" { @@ -102,11 +145,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 + issueState.Body = templateContents } else { - inProgress.Body = defs.Body + issueState.Body = defs.Body } } @@ -114,7 +157,7 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def Name: "title", Prompt: &survey.Input{ Message: "Title", - Default: inProgress.Title, + Default: issueState.Title, }, } bodyQuestion := &survey.Question{ @@ -124,7 +167,7 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def Editor: &survey.Editor{ Message: "Body", FileName: "*.md", - Default: inProgress.Body, + Default: issueState.Body, HideDefault: true, AppendDefault: true, }, @@ -139,21 +182,175 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle, providedBody string, def qs = append(qs, bodyQuestion) } - err := SurveyAsk(qs, &inProgress) + err := SurveyAsk(qs, issueState) 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 issueState.Body == "" { + issueState.Body = templateContents } - confirmA, err := confirmSubmission() + allowPreview := !issueState.HasMetadata() + confirmA, err := confirmSubmission(allowPreview, allowMetadata) 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 issueState.Metadata { + if m == c { + return true + } + } + return false + } - return &inProgress, nil + 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, + }, + }, + }, issueState) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + metadataInput := api.RepoMetadataInput{ + Reviewers: isChosen("Reviewers"), + Assignees: isChosen("Assignees"), + Labels: isChosen("Labels"), + 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) + } + + var users []string + for _, u := range issueState.MetadataResult.AssignableUsers { + users = append(users, u.Login) + } + var teams []string + for _, t := range issueState.MetadataResult.Teams { + teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug)) + } + var labels []string + for _, l := range issueState.MetadataResult.Labels { + labels = append(labels, l.Name) + } + var projects []string + for _, l := range issueState.MetadataResult.Projects { + projects = append(projects, l.Name) + } + milestones := []string{"(none)"} + for _, m := range issueState.MetadataResult.Milestones { + milestones = append(milestones, m.Title) + } + + var mqs []*survey.Question + if isChosen("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") { + 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") { + 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") { + 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") { + 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)) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + if issueState.Milestone == "(none)" { + issueState.Milestone = "" + } + + allowPreview = !issueState.HasMetadata() + allowMetadata = false + confirmA, err = confirmSubmission(allowPreview, allowMetadata) + if err != nil { + return fmt.Errorf("unable to confirm: %w", err) + } + } + + issueState.Action = confirmA + return nil }