Add dynamic assignee search to PR edit flow

Introduces SuggestedAssignableActors API query and wires up a dynamic assignee search function in the PR edit command. Updates Editable and EditPrompter interfaces to support search-based multi-select for assignees, improving the user experience when assigning users to pull requests.
This commit is contained in:
Kynan Ware 2025-11-24 10:43:27 -07:00
parent 0beb74bf72
commit d04317c273
3 changed files with 186 additions and 14 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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,