diff --git a/api/queries_pr.go b/api/queries_pr.go index de98c661f..d994d3aae 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -701,6 +701,114 @@ func RemovePullRequestReviews(client *Client, repo ghrepo.Interface, prNumber in return client.REST(repo.RepoHost(), "DELETE", path, buf, nil) } +// SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable +// (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR. +// If query is empty, the query variable is passed as null to omit filtering. +func SuggestedAssignableActors(client *Client, repo ghrepo.Interface, assignableID string, query string) ([]AssignableActor, error) { + type responseData struct { + Viewer struct { + ID string + Login string + Name string + } `graphql:"viewer"` + Node struct { + Issue struct { + SuggestedActors struct { + Nodes []struct { + User struct { + ID string + Login string + Name string + TypeName string `graphql:"__typename"` + } `graphql:"... on User"` + Bot struct { + ID string + Login string + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + } `graphql:"suggestedActors(first: 10, query: $query)"` + } `graphql:"... on Issue"` + PullRequest struct { + SuggestedActors struct { + Nodes []struct { + User struct { + ID string + Login string + Name string + TypeName string `graphql:"__typename"` + } `graphql:"... on User"` + Bot struct { + ID string + Login string + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + } `graphql:"suggestedActors(first: 10, query: $query)"` + } `graphql:"... on PullRequest"` + } `graphql:"node(id: $id)"` + } + + variables := map[string]interface{}{ + "id": githubv4.ID(assignableID), + } + if query != "" { + variables["query"] = githubv4.String(query) + } else { + variables["query"] = (*githubv4.String)(nil) + } + + var result responseData + if err := client.Query(repo.RepoHost(), "SuggestedAssignableActors", &result, variables); err != nil { + return nil, err + } + + var nodes []struct { + User struct { + ID string + Login string + Name string + TypeName string `graphql:"__typename"` + } `graphql:"... on User"` + Bot struct { + ID string + Login string + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + + if result.Node.PullRequest.SuggestedActors.Nodes != nil { + nodes = result.Node.PullRequest.SuggestedActors.Nodes + } else if result.Node.Issue.SuggestedActors.Nodes != nil { + nodes = result.Node.Issue.SuggestedActors.Nodes + } + + actors := make([]AssignableActor, 0, len(nodes)+1) // +1 in case we add viewer + viewer := result.Viewer + viewerLogin := viewer.Login + viewerIncluded := false + + for _, n := range nodes { + if n.User.TypeName == "User" && n.User.Login != "" { + actors = append(actors, AssignableUser{id: n.User.ID, login: n.User.Login, name: n.User.Name}) + if query == "" && viewerLogin != "" && n.User.Login == viewerLogin { + viewerIncluded = true + } + } else if n.Bot.TypeName == "Bot" && n.Bot.Login != "" { + actors = append(actors, AssignableBot{id: n.Bot.ID, login: n.Bot.Login}) + if query == "" && viewerLogin != "" && n.Bot.Login == viewerLogin { + viewerIncluded = true + } + } + } + + // When query is blank, append viewer if not already present. + if query == "" && viewerLogin != "" && !viewerIncluded { + actors = append(actors, AssignableUser{id: viewer.ID, login: viewer.Login, name: viewer.Name}) + } + return actors, nil +} + func UpdatePullRequestBranch(client *Client, repo ghrepo.Interface, params githubv4.UpdatePullRequestBranchInput) error { var mutation struct { UpdatePullRequestBranch struct { diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 11cb74b1f..f62ca096b 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -292,6 +292,10 @@ func editRun(opts *EditOptions) error { apiClient := api.NewClientFromHTTP(httpClient) + // Wire up search functions for assignees and reviewers. + // TODO: Wire up reviewer search func. + editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, pr.ID) + opts.IO.StartProgressIndicator() err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, opts.Detector.ProjectsV1()) opts.IO.StopProgressIndicator() @@ -331,6 +335,38 @@ func editRun(opts *EditOptions) error { return nil } +func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, assignableID string) func(string) ([]string, []string, int, error) { + searchFunc := func(input string) ([]string, []string, int, error) { + actors, err := api.SuggestedAssignableActors( + apiClient, + repo, + assignableID, + input) + if err != nil { + return nil, nil, 0, err + } + + var logins []string + var displayNames []string + + for _, a := range actors { + if a.Login() != "" { + logins = append(logins, a.Login()) + } else { + continue + } + + if a.DisplayName() != "" { + displayNames = append(displayNames, a.DisplayName()) + } else { + displayNames = append(displayNames, a.Login()) + } + } + return logins, displayNames, 0, nil + } + return searchFunc +} + func updatePullRequest(httpClient *http.Client, repo ghrepo.Interface, id string, number int, editable shared.Editable) error { var wg errgroup.Group wg.Go(func() error { diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 5cf55ad37..c69e5a0b5 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -10,15 +10,17 @@ import ( ) type Editable struct { - Title EditableString - Body EditableString - Base EditableString - Reviewers EditableSlice - Assignees EditableAssignees - Labels EditableSlice - Projects EditableProjects - Milestone EditableString - Metadata api.RepoMetadataResult + Title EditableString + Body EditableString + Base EditableString + Reviewers EditableSlice + ReviewerSearchFunc func(string) ([]string, []string, error) + Assignees EditableAssignees + AssigneeSearchFunc func(string) ([]string, []string, int, error) + Labels EditableSlice + Projects EditableProjects + Milestone EditableString + Metadata api.RepoMetadataResult } type EditableString struct { @@ -277,6 +279,7 @@ type EditPrompter interface { Input(string, string) (string, error) MarkdownEditor(string, string, bool) (string, error) MultiSelect(string, []string, []string) ([]int, error) + MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) ([]string, []string, int, error)) ([]string, error) Confirm(string, bool) (bool, error) } @@ -302,10 +305,24 @@ func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) } } if editable.Assignees.Edited { - editable.Assignees.Value, err = multiSelectSurvey( - p, "Assignees", editable.Assignees.Default, editable.Assignees.Options) - if err != nil { - return err + if editable.AssigneeSearchFunc != nil { + editable.Assignees.Options = []string{} + editable.Assignees.Value, err = p.MultiSelectWithSearch( + "Assignees", + "Search assignees", + editable.Assignees.DefaultLogins, + // No persistent options required here as teams cannot be assignees. + []string{}, + editable.AssigneeSearchFunc) + if err != nil { + return err + } + } else { + editable.Assignees.Value, err = multiSelectSurvey( + p, "Assignees", editable.Assignees.Default, editable.Assignees.Options) + if err != nil { + return err + } } } if editable.Labels.Edited { @@ -408,10 +425,21 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, teamReviewers = true } } + + fetchAssignees := false + if editable.Assignees.Edited { + // Similar as above, this is likely an interactive flow if no Add/Remove slices are set. + // The addition here is that we also check for an assignee search func because + // if that is set, the prompter will handle dynamic fetching of assignees. + if len(editable.Assignees.Add) == 0 && len(editable.Assignees.Remove) == 0 && editable.AssigneeSearchFunc == nil { + fetchAssignees = true + } + } + input := api.RepoMetadataInput{ Reviewers: editable.Reviewers.Edited, TeamReviewers: teamReviewers, - Assignees: editable.Assignees.Edited, + Assignees: fetchAssignees, ActorAssignees: editable.Assignees.ActorAssignees, Labels: editable.Labels.Edited, ProjectsV1: editable.Projects.Edited && projectV1Support == gh.ProjectsV1Supported,