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:
parent
0beb74bf72
commit
d04317c273
3 changed files with 186 additions and 14 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue