Merge pull request #13009 from cli/fix/pr-create-assignee-metadata-13000
Use login-based assignee mutation on github.com
This commit is contained in:
commit
1df6f84d70
16 changed files with 390 additions and 271 deletions
|
|
@ -289,7 +289,8 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
|||
switch key {
|
||||
case "assigneeIds", "body", "issueTemplate", "labelIds", "milestoneId", "projectIds", "repositoryId", "title":
|
||||
inputParams[key] = val
|
||||
case "projectV2Ids":
|
||||
case "projectV2Ids", "assigneeLogins":
|
||||
// handled after issue creation
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid IssueCreate mutation parameter %s", key)
|
||||
}
|
||||
|
|
@ -310,6 +311,14 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
|
|||
}
|
||||
issue := &result.CreateIssue.Issue
|
||||
|
||||
// Assign users using login-based mutation when ActorAssignees is true (github.com).
|
||||
if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 {
|
||||
err := ReplaceActorsForAssignableByLogin(client, repo, issue.ID, assigneeLogins)
|
||||
if err != nil {
|
||||
return issue, err
|
||||
}
|
||||
}
|
||||
|
||||
// projectV2 parameters aren't supported in the `createIssue` mutation,
|
||||
// so add them after the issue has been created.
|
||||
projectV2Ids, ok := params["projectV2Ids"].([]string)
|
||||
|
|
|
|||
|
|
@ -524,6 +524,14 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
|||
}
|
||||
}
|
||||
|
||||
// Assign users using login-based mutation when ActorAssignees is true (github.com).
|
||||
if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 {
|
||||
err := ReplaceActorsForAssignableByLogin(client, repo, pr.ID, assigneeLogins)
|
||||
if err != nil {
|
||||
return pr, err
|
||||
}
|
||||
}
|
||||
|
||||
// TODO requestReviewsByLoginCleanup
|
||||
// Request reviewers using either login-based (github.com) or ID-based (GHES) mutation.
|
||||
// The ID-based path can be removed once GHES supports requestReviewsByLogin.
|
||||
|
|
@ -581,6 +589,35 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
|||
return pr, nil
|
||||
}
|
||||
|
||||
// ReplaceActorsForAssignableByLogin calls the replaceActorsForAssignable mutation
|
||||
// using actor logins. This avoids the need to resolve logins to node IDs.
|
||||
func ReplaceActorsForAssignableByLogin(client *Client, repo ghrepo.Interface, assignableID string, logins []string) error {
|
||||
type ReplaceActorsForAssignableInput struct {
|
||||
AssignableID githubv4.ID `json:"assignableId"`
|
||||
ActorLogins []githubv4.String `json:"actorLogins"`
|
||||
}
|
||||
|
||||
actorLogins := make([]githubv4.String, len(logins))
|
||||
for i, l := range logins {
|
||||
actorLogins[i] = githubv4.String(l)
|
||||
}
|
||||
|
||||
var mutation struct {
|
||||
ReplaceActorsForAssignable struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
} `graphql:"replaceActorsForAssignable(input: $input)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"input": ReplaceActorsForAssignableInput{
|
||||
AssignableID: githubv4.ID(assignableID),
|
||||
ActorLogins: actorLogins,
|
||||
},
|
||||
}
|
||||
|
||||
return client.Mutate(repo.RepoHost(), "ReplaceActorsForAssignable", &mutation, variables)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Returns the actors, the total count of available assignees in the repo, and an error.
|
||||
|
|
|
|||
|
|
@ -1298,6 +1298,69 @@ func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableAc
|
|||
return actors, nil
|
||||
}
|
||||
|
||||
// SearchRepoAssignableActors searches assignable actors for a repository with an optional
|
||||
// query string. Unlike RepoAssignableActors which fetches all actors with pagination, this
|
||||
// returns up to 10 results matching the query, suitable for search-based selection.
|
||||
func SearchRepoAssignableActors(client *Client, repo ghrepo.Interface, query string) ([]AssignableActor, int, error) {
|
||||
type responseData struct {
|
||||
Repository struct {
|
||||
AssignableUsers struct {
|
||||
TotalCount int
|
||||
}
|
||||
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, capabilities: CAN_BE_ASSIGNED)"`
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
}
|
||||
|
||||
var q *githubv4.String
|
||||
if query != "" {
|
||||
v := githubv4.String(query)
|
||||
q = &v
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"name": githubv4.String(repo.RepoName()),
|
||||
"query": q,
|
||||
}
|
||||
|
||||
var result responseData
|
||||
if err := client.Query(repo.RepoHost(), "SearchRepoAssignableActors", &result, variables); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var actors []AssignableActor
|
||||
for _, node := range result.Repository.SuggestedActors.Nodes {
|
||||
if node.User.TypeName == "User" {
|
||||
actors = append(actors, AssignableUser{
|
||||
id: node.User.ID,
|
||||
login: node.User.Login,
|
||||
name: node.User.Name,
|
||||
})
|
||||
} else if node.Bot.TypeName == "Bot" {
|
||||
actors = append(actors, AssignableBot{
|
||||
id: node.Bot.ID,
|
||||
login: node.Bot.Login,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return actors, result.Repository.AssignableUsers.TotalCount, nil
|
||||
}
|
||||
|
||||
type RepoLabel struct {
|
||||
ID string
|
||||
Name string
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import (
|
|||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
"github.com/cli/cli/v2/internal/gh"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -178,18 +179,12 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
|
||||
// Replace special values in assignees
|
||||
// For web mode, @copilot should be replaced by name; otherwise, login.
|
||||
assigneeSet := set.NewStringSet()
|
||||
meReplacer := prShared.NewMeReplacer(apiClient, baseRepo.RepoHost())
|
||||
copilotReplacer := prShared.NewCopilotReplacer(!opts.WebMode)
|
||||
assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
|
||||
assigneeReplacer := prShared.NewSpecialAssigneeReplacer(apiClient, baseRepo.RepoHost(), issueFeatures.ActorIsAssignable, !opts.WebMode)
|
||||
assignees, err := assigneeReplacer.ReplaceSlice(opts.Assignees)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO actorIsAssignableCleanup
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
assignees = copilotReplacer.ReplaceSlice(assignees)
|
||||
}
|
||||
assigneeSet := set.NewStringSet()
|
||||
assigneeSet.AddValues(assignees)
|
||||
|
||||
tb := prShared.IssueMetadataState{
|
||||
|
|
@ -313,7 +308,11 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
Repo: baseRepo,
|
||||
State: &tb,
|
||||
}
|
||||
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil)
|
||||
var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
assigneeSearchFunc = prShared.RepoAssigneeSearchFunc(apiClient, baseRepo)
|
||||
}
|
||||
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil, assigneeSearchFunc)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -495,12 +495,18 @@ func Test_createRun(t *testing.T) {
|
|||
switch message {
|
||||
case "What would you like to add?":
|
||||
return prompter.IndexesFor(options, "Assignees")
|
||||
case "Assignees":
|
||||
return prompter.IndexesFor(options, "Copilot (AI)", "MonaLisa (Mona Display Name)")
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected multi-select prompt: %s", message)
|
||||
}
|
||||
}
|
||||
pm.MultiSelectWithSearchFunc = func(message, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) {
|
||||
switch message {
|
||||
case "Assignees":
|
||||
return []string{"copilot-swe-agent", "MonaLisa"}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unexpected multi-select-with-search prompt: %s", message)
|
||||
}
|
||||
}
|
||||
pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
|
||||
switch message {
|
||||
case "What's next?":
|
||||
|
|
@ -524,25 +530,25 @@ func Test_createRun(t *testing.T) {
|
|||
"viewerPermission": "WRITE"
|
||||
} } }
|
||||
`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "copilot-swe-agent", "id": "COPILOTID", "name": "Copilot (AI)", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createIssue": { "issue": {
|
||||
"id": "ISSUEID",
|
||||
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||
} } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, []interface{}{"COPILOTID", "MONAID"}, inputs["assigneeIds"])
|
||||
if v, ok := inputs["assigneeIds"]; ok {
|
||||
t.Errorf("did not expect assigneeIds: %v", v)
|
||||
}
|
||||
}))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "ISSUEID", inputs["assignableId"])
|
||||
assert.Equal(t, []interface{}{"copilot-swe-agent", "MonaLisa"}, inputs["actorLogins"])
|
||||
}))
|
||||
},
|
||||
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
|
||||
|
|
@ -948,16 +954,6 @@ func TestIssueCreate_metadata(t *testing.T) {
|
|||
defer http.Verify(t)
|
||||
|
||||
http.StubRepoInfoResponse("OWNER", "REPO", "main")
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryLabelList\b`),
|
||||
httpmock.StringResponse(`
|
||||
|
|
@ -1030,12 +1026,15 @@ func TestIssueCreate_metadata(t *testing.T) {
|
|||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createIssue": { "issue": {
|
||||
"id": "NEWISSUEID",
|
||||
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||
} } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "TITLE", inputs["title"])
|
||||
assert.Equal(t, "BODY", inputs["body"])
|
||||
assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"])
|
||||
if v, ok := inputs["assigneeIds"]; ok {
|
||||
t.Errorf("did not expect assigneeIds: %v", v)
|
||||
}
|
||||
assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
|
||||
assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
|
||||
assert.Equal(t, "BIGONEID", inputs["milestoneId"])
|
||||
|
|
@ -1043,6 +1042,14 @@ func TestIssueCreate_metadata(t *testing.T) {
|
|||
assert.NotContains(t, inputs, "teamIds")
|
||||
assert.NotContains(t, inputs, "projectV2Ids")
|
||||
}))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
|
||||
assert.Equal(t, []interface{}{"monalisa"}, inputs["actorLogins"])
|
||||
}))
|
||||
|
||||
output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`, nil)
|
||||
if err != nil {
|
||||
|
|
@ -1091,27 +1098,27 @@ func TestIssueCreate_AtMeAssignee(t *testing.T) {
|
|||
"hasIssuesEnabled": true
|
||||
} } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" },
|
||||
{ "login": "SomeOneElse", "id": "SOMEID", "name": "Someone else", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createIssue": { "issue": {
|
||||
"id": "NEWISSUEID",
|
||||
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||
} } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "hello", inputs["title"])
|
||||
assert.Equal(t, "cash rules everything around me", inputs["body"])
|
||||
assert.Equal(t, []interface{}{"MONAID", "SOMEID"}, inputs["assigneeIds"])
|
||||
if v, ok := inputs["assigneeIds"]; ok {
|
||||
t.Errorf("did not expect assigneeIds: %v", v)
|
||||
}
|
||||
}))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
|
||||
assert.Equal(t, []interface{}{"MonaLisa", "someoneelse"}, inputs["actorLogins"])
|
||||
}))
|
||||
|
||||
output, err := runCommand(http, true, `-a @me -a someoneelse -t hello -b "cash rules everything around me"`, nil)
|
||||
|
|
@ -1134,26 +1141,27 @@ func TestIssueCreate_AtCopilotAssignee(t *testing.T) {
|
|||
"hasIssuesEnabled": true
|
||||
} } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "copilot-swe-agent", "id": "COPILOTID", "name": "Copilot (AI)", "__typename": "Bot" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation IssueCreate\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "createIssue": { "issue": {
|
||||
"id": "NEWISSUEID",
|
||||
"URL": "https://github.com/OWNER/REPO/issues/12"
|
||||
} } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "hello", inputs["title"])
|
||||
assert.Equal(t, "cash rules everything around me", inputs["body"])
|
||||
assert.Equal(t, []interface{}{"COPILOTID"}, inputs["assigneeIds"])
|
||||
if v, ok := inputs["assigneeIds"]; ok {
|
||||
t.Errorf("did not expect assigneeIds: %v", v)
|
||||
}
|
||||
}))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
|
||||
assert.Equal(t, []interface{}{"copilot-swe-agent"}, inputs["actorLogins"])
|
||||
}))
|
||||
|
||||
output, err := runCommand(http, true, `-a @copilot -t hello -b "cash rules everything around me"`, nil)
|
||||
|
|
|
|||
|
|
@ -248,6 +248,13 @@ func editRun(opts *EditOptions) error {
|
|||
|
||||
// Fetch editable shared fields once for all issues.
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
// Wire up search function for assignees when ActorIsAssignable is available.
|
||||
// Interactive mode only supports a single issue, so we use its ID for the search query.
|
||||
if issueFeatures.ActorIsAssignable && opts.Interactive && len(issues) == 1 {
|
||||
editable.AssigneeSearchFunc = prShared.AssigneeSearchFunc(apiClient, baseRepo, issues[0].ID)
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicatorWithLabel("Fetching repository information")
|
||||
err = opts.FetchOptions(apiClient, baseRepo, &editable, opts.Detector.ProjectsV1())
|
||||
opts.IO.StopProgressIndicator()
|
||||
|
|
|
|||
|
|
@ -527,17 +527,6 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// Should only be one fetch of metadata.
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false, "endCursor": "Mg" }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
|
||||
httpmock.StringResponse(`
|
||||
|
|
@ -640,8 +629,9 @@ func Test_editRun(t *testing.T) {
|
|||
require.Equal(t, []string{"hubot"}, eo.Assignees.Default)
|
||||
require.Equal(t, []string{"hubot"}, eo.Assignees.DefaultLogins)
|
||||
|
||||
// Adding MonaLisa as PR assignee, should preserve hubot.
|
||||
eo.Assignees.Value = []string{"hubot", "MonaLisa (Mona Display Name)"}
|
||||
// Adding MonaLisa as issue assignee, should preserve hubot.
|
||||
// MultiSelectWithSearch returns Keys (logins), not display names.
|
||||
eo.Assignees.Value = []string{"hubot", "MonaLisa"}
|
||||
return nil
|
||||
},
|
||||
FetchOptions: prShared.FetchOptions,
|
||||
|
|
@ -649,27 +639,13 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
mockIsssueNumberGetWithAssignedActors(t, reg, 123)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
mockIssueUpdate(t, reg)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }`,
|
||||
func(inputs map[string]interface{}) {
|
||||
// Checking that despite the display name being returned
|
||||
// from the EditFieldsSurvey, the ID is still
|
||||
// used in the mutation.
|
||||
require.Subset(t, inputs["actorIds"], []string{"MONAID", "HUBOTID"})
|
||||
require.Subset(t, inputs["actorLogins"], []interface{}{"hubot", "MonaLisa"})
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
@ -839,18 +815,6 @@ func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) {
|
|||
}
|
||||
|
||||
func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "suggestedActors": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
|
||||
{ "login": "monalisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryLabelList\b`),
|
||||
httpmock.StringResponse(`
|
||||
|
|
|
|||
|
|
@ -406,6 +406,7 @@ func createRun(opts *CreateOptions) error {
|
|||
return err
|
||||
}
|
||||
var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult
|
||||
var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
reviewerSearchFunc = func(query string) prompter.MultiSelectSearchResult {
|
||||
candidates, moreResults, err := api.SuggestedReviewerActorsForRepo(client, ctx.PRRefs.BaseRepo(), query)
|
||||
|
|
@ -420,6 +421,7 @@ func createRun(opts *CreateOptions) error {
|
|||
}
|
||||
return prompter.MultiSelectSearchResult{Keys: keys, Labels: labels, MoreResults: moreResults}
|
||||
}
|
||||
assigneeSearchFunc = shared.RepoAssigneeSearchFunc(client, ctx.PRRefs.BaseRepo())
|
||||
}
|
||||
|
||||
state, err := NewIssueState(*ctx, *opts)
|
||||
|
|
@ -429,6 +431,7 @@ func createRun(opts *CreateOptions) error {
|
|||
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
state.ActorReviewers = true
|
||||
state.ActorAssignees = true
|
||||
}
|
||||
|
||||
var openURL string
|
||||
|
|
@ -597,7 +600,7 @@ func createRun(opts *CreateOptions) error {
|
|||
Repo: ctx.PRRefs.BaseRepo(),
|
||||
State: state,
|
||||
}
|
||||
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support, reviewerSearchFunc)
|
||||
err = shared.MetadataSurvey(opts.Prompter, opts.IO, ctx.PRRefs.BaseRepo(), fetcher, state, projectsV1Support, reviewerSearchFunc, assigneeSearchFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -916,17 +916,6 @@ func Test_createRun(t *testing.T) {
|
|||
return func() {}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry, t *testing.T) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "assignableUsers": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID", "name": "" },
|
||||
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryLabelList\b`),
|
||||
httpmock.StringResponse(`
|
||||
|
|
@ -975,11 +964,21 @@ func Test_createRun(t *testing.T) {
|
|||
} } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
|
||||
assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"])
|
||||
if _, ok := inputs["assigneeIds"]; ok {
|
||||
t.Error("did not expect assigneeIds in updatePullRequest when ActorAssignees is true")
|
||||
}
|
||||
assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
|
||||
assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
|
||||
assert.Equal(t, "BIGONEID", inputs["milestoneId"])
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
|
||||
`, func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, "NEWPULLID", inputs["assignableId"])
|
||||
assert.Equal(t, []interface{}{"monalisa"}, inputs["actorLogins"])
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation RequestReviewsByLogin\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
|
|
|
|||
|
|
@ -322,7 +322,7 @@ func editRun(opts *EditOptions) error {
|
|||
// to legacy reviewer/assignee fetching.
|
||||
// TODO actorIsAssignableCleanup
|
||||
if issueFeatures.ActorIsAssignable {
|
||||
editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID)
|
||||
editable.AssigneeSearchFunc = shared.AssigneeSearchFunc(apiClient, repo, pr.ID)
|
||||
editable.ReviewerSearchFunc = reviewerSearchFunc(apiClient, repo, &editable, pr.ID)
|
||||
}
|
||||
|
||||
|
|
@ -365,57 +365,6 @@ func editRun(opts *EditOptions) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// assigneeSearchFunc is intended to be an arg for MultiSelectWithSearch
|
||||
// to return potential assignee actors.
|
||||
// It also contains an important enclosure to update the editable's
|
||||
// assignable actors metadata for later ID resolution - this is required
|
||||
// while we continue to use IDs for mutating assignees with the GQL API.
|
||||
func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) prompter.MultiSelectSearchResult {
|
||||
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
||||
actors, availableAssigneesCount, err := api.SuggestedAssignableActors(
|
||||
apiClient,
|
||||
repo,
|
||||
assignableID,
|
||||
input)
|
||||
if err != nil {
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: nil,
|
||||
Labels: nil,
|
||||
MoreResults: 0,
|
||||
Err: err,
|
||||
}
|
||||
}
|
||||
|
||||
logins := make([]string, 0, len(actors))
|
||||
displayNames := make([]string, 0, len(actors))
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
// Update the assignable actors metadata in the editable struct
|
||||
// so that updating the PR later can resolve the actor ID.
|
||||
editable.Metadata.AssignableActors = append(editable.Metadata.AssignableActors, a)
|
||||
}
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: logins,
|
||||
Labels: displayNames,
|
||||
MoreResults: availableAssigneesCount,
|
||||
Err: nil,
|
||||
}
|
||||
}
|
||||
return searchFunc
|
||||
}
|
||||
|
||||
// reviewerSearchFunc is intended to be an arg for MultiSelectWithSearch
|
||||
// to return potential reviewer candidates (users, bots, and teams).
|
||||
// It also updates the editable's metadata for later ID resolution.
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ func Test_editRun(t *testing.T) {
|
|||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// Non-interactive with Add/Remove doesn't need reviewers/assignees metadata
|
||||
// REST API accepts logins and team slugs directly
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{reviewers: false, teamReviewers: false, assignees: true, labels: true, projects: true, milestones: true})
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{reviewers: false, teamReviewers: false, assignees: false, labels: true, projects: true, milestones: true})
|
||||
mockPullRequestUpdate(reg)
|
||||
mockPullRequestUpdateActorAssignees(reg)
|
||||
mockRequestReviewsByLogin(reg)
|
||||
|
|
@ -473,7 +473,7 @@ func Test_editRun(t *testing.T) {
|
|||
Fetcher: testFetcher{},
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: true, labels: true, projects: true, milestones: true})
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: false, labels: true, projects: true, milestones: true})
|
||||
mockPullRequestUpdate(reg)
|
||||
mockPullRequestUpdateActorAssignees(reg)
|
||||
mockPullRequestUpdateLabels(reg)
|
||||
|
|
@ -547,7 +547,7 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// Non-interactive with Remove doesn't need reviewers metadata
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{reviewers: false, teamReviewers: false, assignees: true, labels: true, projects: true, milestones: true})
|
||||
mockRepoMetadata(reg, mockRepoMetadataOptions{reviewers: false, teamReviewers: false, assignees: false, labels: true, projects: true, milestones: true})
|
||||
mockPullRequestUpdate(reg)
|
||||
mockRequestReviewsByLogin(reg)
|
||||
mockPullRequestUpdateLabels(reg)
|
||||
|
|
@ -912,12 +912,8 @@ func Test_editRun(t *testing.T) {
|
|||
require.Equal(t, []string{"hubot"}, e.Assignees.DefaultLogins)
|
||||
|
||||
// Adding monalisa as PR assignee, should preserve hubot.
|
||||
e.Assignees.Value = []string{"hubot", "monalisa (Mona Display Name)"}
|
||||
// Populate metadata to simulate what searchFunc would do during prompting
|
||||
e.Metadata.AssignableActors = []api.AssignableActor{
|
||||
api.NewAssignableBot("HUBOTID", "hubot"),
|
||||
api.NewAssignableUser("MONAID", "monalisa", "Mona Display Name"),
|
||||
}
|
||||
// MultiSelectWithSearch returns Keys (logins), not display names.
|
||||
e.Assignees.Value = []string{"hubot", "monalisa"}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
|
@ -931,10 +927,7 @@ func Test_editRun(t *testing.T) {
|
|||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }`,
|
||||
func(inputs map[string]interface{}) {
|
||||
// Checking that despite the display name being returned
|
||||
// from the EditFieldsSurvey, the ID is still
|
||||
// used in the mutation.
|
||||
require.Subset(t, inputs["actorIds"], []string{"MONAID", "HUBOTID"})
|
||||
require.Subset(t, inputs["actorLogins"], []interface{}{"hubot", "monalisa"})
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -95,22 +95,7 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str
|
|||
// If assignees came in from command line flags, we need to
|
||||
// curate the final list of assignees from the default list.
|
||||
if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 {
|
||||
meReplacer := NewMeReplacer(client, repo.RepoHost())
|
||||
copilotReplacer := NewCopilotReplacer(true)
|
||||
|
||||
replaceSpecialAssigneeNames := func(value []string) ([]string, error) {
|
||||
replaced, err := meReplacer.ReplaceSlice(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only suppported for actor assignees.
|
||||
if e.Assignees.ActorAssignees {
|
||||
replaced = copilotReplacer.ReplaceSlice(replaced)
|
||||
}
|
||||
|
||||
return replaced, nil
|
||||
}
|
||||
replacer := NewSpecialAssigneeReplacer(client, repo.RepoHost(), e.Assignees.ActorAssignees, true)
|
||||
|
||||
assigneeSet := set.NewStringSet()
|
||||
|
||||
|
|
@ -128,13 +113,13 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str
|
|||
assigneeSet.AddValues(e.Assignees.Default)
|
||||
}
|
||||
|
||||
add, err := replaceSpecialAssigneeNames(e.Assignees.Add)
|
||||
add, err := replacer.ReplaceSlice(e.Assignees.Add)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assigneeSet.AddValues(add)
|
||||
|
||||
remove, err := replaceSpecialAssigneeNames(e.Assignees.Remove)
|
||||
remove, err := replacer.ReplaceSlice(e.Assignees.Remove)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -146,6 +131,70 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str
|
|||
return &a, err
|
||||
}
|
||||
|
||||
// AssigneeLogins computes the final list of assignee logins from the current
|
||||
// defaults plus any Add/Remove operations. Unlike AssigneeIds, this does not
|
||||
// resolve logins to node IDs, and is used on github.com where the
|
||||
// ReplaceActorsForAssignable mutation accepts logins directly.
|
||||
func (e Editable) AssigneeLogins(client *api.Client, repo ghrepo.Interface) ([]string, error) {
|
||||
if !e.Assignees.Edited {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 {
|
||||
replacer := NewSpecialAssigneeReplacer(client, repo.RepoHost(), true, true)
|
||||
|
||||
assigneeSet := set.NewStringSet()
|
||||
assigneeSet.AddValues(e.Assignees.DefaultLogins)
|
||||
|
||||
add, err := replacer.ReplaceSlice(e.Assignees.Add)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assigneeSet.AddValues(add)
|
||||
|
||||
remove, err := replacer.ReplaceSlice(e.Assignees.Remove)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
assigneeSet.RemoveValues(remove)
|
||||
|
||||
e.Assignees.Value = assigneeSet.ToSlice()
|
||||
}
|
||||
|
||||
return e.Assignees.Value, nil
|
||||
}
|
||||
|
||||
// SpecialAssigneeReplacer expands special assignee names (@me, Copilot actors)
|
||||
// in login slices. Use NewSpecialAssigneeReplacer to create one.
|
||||
type SpecialAssigneeReplacer struct {
|
||||
meReplacer *MeReplacer
|
||||
copilotReplacer *CopilotReplacer
|
||||
actorAssignees bool
|
||||
}
|
||||
|
||||
// NewSpecialAssigneeReplacer creates a replacer that expands @me and (when
|
||||
// actorAssignees is true) Copilot actor names in assignee slices.
|
||||
// copilotUseLogin controls whether Copilot actors are replaced with their
|
||||
// login (true) or display name (false, used for web mode).
|
||||
func NewSpecialAssigneeReplacer(client *api.Client, host string, actorAssignees bool, copilotUseLogin bool) *SpecialAssigneeReplacer {
|
||||
return &SpecialAssigneeReplacer{
|
||||
meReplacer: NewMeReplacer(client, host),
|
||||
copilotReplacer: NewCopilotReplacer(copilotUseLogin),
|
||||
actorAssignees: actorAssignees,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *SpecialAssigneeReplacer) ReplaceSlice(logins []string) ([]string, error) {
|
||||
replaced, err := r.meReplacer.ReplaceSlice(logins)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.actorAssignees {
|
||||
replaced = r.copilotReplacer.ReplaceSlice(replaced)
|
||||
}
|
||||
return replaced, nil
|
||||
}
|
||||
|
||||
// ProjectIds returns a slice containing IDs of projects v1 that the issue or a PR has to be linked to.
|
||||
func (e Editable) ProjectIds() (*[]string, error) {
|
||||
if !e.Projects.Edited {
|
||||
|
|
@ -224,14 +273,16 @@ func (e Editable) MilestoneId() (*string, error) {
|
|||
// go routines. Fields that would be mutated will be copied.
|
||||
func (e *Editable) Clone() Editable {
|
||||
return Editable{
|
||||
Title: e.Title.clone(),
|
||||
Body: e.Body.clone(),
|
||||
Base: e.Base.clone(),
|
||||
Reviewers: e.Reviewers.clone(),
|
||||
Assignees: e.Assignees.clone(),
|
||||
Labels: e.Labels.clone(),
|
||||
Projects: e.Projects.clone(),
|
||||
Milestone: e.Milestone.clone(),
|
||||
Title: e.Title.clone(),
|
||||
Body: e.Body.clone(),
|
||||
Base: e.Base.clone(),
|
||||
Reviewers: e.Reviewers.clone(),
|
||||
ReviewerSearchFunc: e.ReviewerSearchFunc,
|
||||
Assignees: e.Assignees.clone(),
|
||||
AssigneeSearchFunc: e.AssigneeSearchFunc,
|
||||
Labels: e.Labels.clone(),
|
||||
Projects: e.Projects.clone(),
|
||||
Milestone: e.Milestone.clone(),
|
||||
// Shallow copy since no mutation.
|
||||
Metadata: e.Metadata,
|
||||
}
|
||||
|
|
@ -470,11 +521,10 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable,
|
|||
if len(editable.Assignees.Add) == 0 && len(editable.Assignees.Remove) == 0 && editable.AssigneeSearchFunc == nil {
|
||||
fetchAssignees = true
|
||||
}
|
||||
// However, if we have Add/Remove operations (non-interactive flow),
|
||||
// we do need to fetch the assignees.
|
||||
// TODO: KW noninteractive assignees need to migrate to directly use
|
||||
// new logins input with ReplaceActorsForAssignable to prevent fetching.
|
||||
if len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0 {
|
||||
// For non-interactive Add/Remove operations, we only need to fetch assignees
|
||||
// on GHES where ID resolution is required. On github.com (ActorAssignees),
|
||||
// logins are passed directly to the mutation.
|
||||
if (len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0) && !editable.Assignees.ActorAssignees {
|
||||
fetchAssignees = true
|
||||
}
|
||||
}
|
||||
|
|
@ -567,3 +617,50 @@ func milestoneSurvey(p EditPrompter, title string, opts []string) (result string
|
|||
result = opts[selected]
|
||||
return
|
||||
}
|
||||
|
||||
// AssigneeSearchFunc returns a search function for MultiSelectWithSearch that
|
||||
// dynamically fetches assignable actors for the given assignable (Issue/PR) node ID.
|
||||
func AssigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, assignableID string) func(string) prompter.MultiSelectSearchResult {
|
||||
return func(input string) prompter.MultiSelectSearchResult {
|
||||
actors, count, err := api.SuggestedAssignableActors(apiClient, repo, assignableID, input)
|
||||
if err != nil {
|
||||
return prompter.MultiSelectSearchResult{Err: err}
|
||||
}
|
||||
return actorsToSearchResult(actors, count)
|
||||
}
|
||||
}
|
||||
|
||||
// RepoAssigneeSearchFunc returns a search function for MultiSelectWithSearch that
|
||||
// dynamically fetches assignable actors at the repository level. Used during create
|
||||
// flows where no issue/PR node ID exists yet.
|
||||
func RepoAssigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface) func(string) prompter.MultiSelectSearchResult {
|
||||
return func(input string) prompter.MultiSelectSearchResult {
|
||||
actors, count, err := api.SearchRepoAssignableActors(apiClient, repo, input)
|
||||
if err != nil {
|
||||
return prompter.MultiSelectSearchResult{Err: err}
|
||||
}
|
||||
return actorsToSearchResult(actors, count)
|
||||
}
|
||||
}
|
||||
|
||||
func actorsToSearchResult(actors []api.AssignableActor, totalCount int) prompter.MultiSelectSearchResult {
|
||||
logins := make([]string, 0, len(actors))
|
||||
displayNames := make([]string, 0, len(actors))
|
||||
|
||||
for _, a := range actors {
|
||||
if a.Login() == "" {
|
||||
continue
|
||||
}
|
||||
logins = append(logins, a.Login())
|
||||
if a.DisplayName() != "" {
|
||||
displayNames = append(displayNames, a.DisplayName())
|
||||
} else {
|
||||
displayNames = append(displayNames, a.Login())
|
||||
}
|
||||
}
|
||||
return prompter.MultiSelectSearchResult{
|
||||
Keys: logins,
|
||||
Labels: displayNames,
|
||||
MoreResults: totalCount,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,12 +68,12 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR
|
|||
// https://github.com/cli/cli/pull/10960#discussion_r2086725348
|
||||
if options.Assignees.Edited && options.Assignees.ActorAssignees {
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
assigneeIds, err := options.AssigneeIds(apiClient, repo)
|
||||
logins, err := options.AssigneeLogins(apiClient, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = replaceActorAssigneesForEditable(apiClient, repo, id, assigneeIds)
|
||||
err = api.ReplaceActorsForAssignableByLogin(apiClient, repo, id, logins)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -90,32 +90,6 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR
|
|||
return wg.Wait()
|
||||
}
|
||||
|
||||
func replaceActorAssigneesForEditable(apiClient *api.Client, repo ghrepo.Interface, id string, assigneeIds *[]string) error {
|
||||
type ReplaceActorsForAssignableInput struct {
|
||||
AssignableID githubv4.ID `json:"assignableId"`
|
||||
ActorIDs []githubv4.ID `json:"actorIds"`
|
||||
}
|
||||
|
||||
params := ReplaceActorsForAssignableInput{
|
||||
AssignableID: githubv4.ID(id),
|
||||
ActorIDs: *ghIds(assigneeIds),
|
||||
}
|
||||
|
||||
var mutation struct {
|
||||
ReplaceActorsForAssignable struct {
|
||||
TypeName string `graphql:"__typename"`
|
||||
} `graphql:"replaceActorsForAssignable(input: $input)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{"input": params}
|
||||
err := apiClient.Mutate(repo.RepoHost(), "ReplaceActorsForAssignable", &mutation, variables)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func replaceIssueFields(httpClient *http.Client, repo ghrepo.Interface, id string, isPR bool, options Editable) error {
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,9 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par
|
|||
// When ActorReviewers is true, we use login-based mutation and don't need to resolve reviewer IDs.
|
||||
needReviewerIDs := len(tb.Reviewers) > 0 && !tb.ActorReviewers
|
||||
|
||||
// When ActorAssignees is true, we use login-based mutation and don't need to resolve assignee IDs.
|
||||
needAssigneeIDs := len(tb.Assignees) > 0 && !tb.ActorAssignees
|
||||
|
||||
// Retrieve minimal information needed to resolve metadata if this was not previously cached from additional metadata survey.
|
||||
if tb.MetadataResult == nil {
|
||||
input := api.RepoMetadataInput{
|
||||
|
|
@ -71,12 +74,11 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par
|
|||
TeamReviewers: needReviewerIDs && slices.ContainsFunc(tb.Reviewers, func(r string) bool {
|
||||
return strings.ContainsRune(r, '/')
|
||||
}),
|
||||
Assignees: len(tb.Assignees) > 0,
|
||||
ActorAssignees: tb.ActorAssignees,
|
||||
Labels: len(tb.Labels) > 0,
|
||||
ProjectsV1: len(tb.ProjectTitles) > 0 && projectV1Support == gh.ProjectsV1Supported,
|
||||
ProjectsV2: len(tb.ProjectTitles) > 0,
|
||||
Milestones: len(tb.Milestones) > 0,
|
||||
Assignees: needAssigneeIDs,
|
||||
Labels: len(tb.Labels) > 0,
|
||||
ProjectsV1: len(tb.ProjectTitles) > 0 && projectV1Support == gh.ProjectsV1Supported,
|
||||
ProjectsV2: len(tb.ProjectTitles) > 0,
|
||||
Milestones: len(tb.Milestones) > 0,
|
||||
}
|
||||
|
||||
metadataResult, err := api.RepoMetadata(client, baseRepo, input)
|
||||
|
|
@ -86,11 +88,17 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par
|
|||
tb.MetadataResult = metadataResult
|
||||
}
|
||||
|
||||
assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not assign user: %w", err)
|
||||
// When ActorAssignees is true (github.com), pass logins directly for use with
|
||||
// ReplaceActorsForAssignable mutation. The ID-based else branch is for GHES compatibility.
|
||||
if tb.ActorAssignees {
|
||||
params["assigneeLogins"] = tb.Assignees
|
||||
} else {
|
||||
assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not assign user: %w", err)
|
||||
}
|
||||
params["assigneeIds"] = assigneeIDs
|
||||
}
|
||||
params["assigneeIds"] = assigneeIDs
|
||||
|
||||
labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ type RepoMetadataFetcher interface {
|
|||
RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error)
|
||||
}
|
||||
|
||||
func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support, reviewerSearchFunc func(string) prompter.MultiSelectSearchResult) error {
|
||||
func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState, projectsV1Support gh.ProjectsV1Support, reviewerSearchFunc func(string) prompter.MultiSelectSearchResult, assigneeSearchFunc func(string) prompter.MultiSelectSearchResult) error {
|
||||
isChosen := func(m string) bool {
|
||||
for _, c := range state.Metadata {
|
||||
if m == c {
|
||||
|
|
@ -184,11 +184,12 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
// When search-based reviewer selection is available, skip the expensive assignable-users
|
||||
// and teams fetch since reviewers are found dynamically via the search function.
|
||||
useReviewerSearch := state.ActorReviewers && reviewerSearchFunc != nil
|
||||
useAssigneeSearch := state.ActorAssignees && assigneeSearchFunc != nil
|
||||
metadataInput := api.RepoMetadataInput{
|
||||
Reviewers: isChosen("Reviewers") && !useReviewerSearch,
|
||||
TeamReviewers: isChosen("Reviewers") && !useReviewerSearch,
|
||||
Assignees: isChosen("Assignees"),
|
||||
ActorAssignees: isChosen("Assignees") && state.ActorAssignees,
|
||||
Assignees: isChosen("Assignees") && !useAssigneeSearch,
|
||||
ActorAssignees: isChosen("Assignees") && !useAssigneeSearch && state.ActorAssignees,
|
||||
Labels: isChosen("Labels"),
|
||||
ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported,
|
||||
ProjectsV2: isChosen("Projects"),
|
||||
|
|
@ -212,24 +213,25 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
}
|
||||
|
||||
// Populate the list of selectable assignees and their default selections.
|
||||
// This logic maps the default assignees from `state` to the corresponding actors or users
|
||||
// so that the correct display names are preselected in the prompt.
|
||||
// When search-based selection is available, skip building the static list.
|
||||
var assignees []string
|
||||
var assigneesDefault []string
|
||||
if state.ActorAssignees {
|
||||
for _, u := range metadataResult.AssignableActors {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
if !useAssigneeSearch {
|
||||
if state.ActorAssignees {
|
||||
for _, u := range metadataResult.AssignableActors {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, u := range metadataResult.AssignableUsers {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
} else {
|
||||
for _, u := range metadataResult.AssignableUsers {
|
||||
assignees = append(assignees, u.DisplayName())
|
||||
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
if slices.Contains(state.Assignees, u.Login()) {
|
||||
assigneesDefault = append(assigneesDefault, u.DisplayName())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -286,16 +288,23 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface
|
|||
}
|
||||
}
|
||||
if isChosen("Assignees") {
|
||||
if len(assignees) > 0 {
|
||||
if useAssigneeSearch {
|
||||
selectedAssignees, err := p.MultiSelectWithSearch(
|
||||
"Assignees",
|
||||
"Search assignees",
|
||||
state.Assignees,
|
||||
[]string{},
|
||||
assigneeSearchFunc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
values.Assignees = selectedAssignees
|
||||
} else if len(assignees) > 0 {
|
||||
selected, err := p.MultiSelect("Assignees", assigneesDefault, assignees)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, i := range selected {
|
||||
// Previously, this logic relied upon `assignees` being in `<login>` or `<login> (<name>)` form,
|
||||
// however the inclusion of actors breaks this convention.
|
||||
// Instead, we map the selected indexes to the source that populated `assignees` rather than
|
||||
// relying on parsing the information out.
|
||||
if state.ActorAssignees {
|
||||
values.Assignees = append(values.Assignees, metadataResult.AssignableActors[i].Login())
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ func TestMetadataSurvey_selectAll(t *testing.T) {
|
|||
Assignees: []string{"hubot"},
|
||||
Type: PRMetadata,
|
||||
}
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "", stdout.String())
|
||||
|
|
@ -117,7 +117,7 @@ func TestMetadataSurvey_keepExisting(t *testing.T) {
|
|||
Assignees: []string{"hubot"},
|
||||
}
|
||||
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, state, gh.ProjectsV1Supported, nil, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "", stdout.String())
|
||||
|
|
@ -146,7 +146,7 @@ func TestMetadataSurveyProjectV1Deprecation(t *testing.T) {
|
|||
return []int{0}, nil
|
||||
})
|
||||
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Supported, nil, nil)
|
||||
require.ErrorContains(t, err, "expected test error")
|
||||
|
||||
require.True(t, fetcher.projectsV1Requested, "expected projectsV1 to be requested")
|
||||
|
|
@ -167,7 +167,7 @@ func TestMetadataSurveyProjectV1Deprecation(t *testing.T) {
|
|||
return []int{0}, nil
|
||||
})
|
||||
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported, nil)
|
||||
err := MetadataSurvey(pm, ios, repo, fetcher, &IssueMetadataState{}, gh.ProjectsV1Unsupported, nil, nil)
|
||||
require.ErrorContains(t, err, "expected test error")
|
||||
|
||||
require.False(t, fetcher.projectsV1Requested, "expected projectsV1 not to be requested")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue