Skip to content

Commit 1df6f84

Browse files
authored
Merge pull request cli#13009 from cli/fix/pr-create-assignee-metadata-13000
Use login-based assignee mutation on github.com
2 parents bdc0413 + 4e6bc78 commit 1df6f84

File tree

16 files changed

+390
-271
lines changed

16 files changed

+390
-271
lines changed

api/queries_issue.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,8 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
289289
switch key {
290290
case "assigneeIds", "body", "issueTemplate", "labelIds", "milestoneId", "projectIds", "repositoryId", "title":
291291
inputParams[key] = val
292-
case "projectV2Ids":
292+
case "projectV2Ids", "assigneeLogins":
293+
// handled after issue creation
293294
default:
294295
return nil, fmt.Errorf("invalid IssueCreate mutation parameter %s", key)
295296
}
@@ -310,6 +311,14 @@ func IssueCreate(client *Client, repo *Repository, params map[string]interface{}
310311
}
311312
issue := &result.CreateIssue.Issue
312313

314+
// Assign users using login-based mutation when ActorAssignees is true (github.com).
315+
if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 {
316+
err := ReplaceActorsForAssignableByLogin(client, repo, issue.ID, assigneeLogins)
317+
if err != nil {
318+
return issue, err
319+
}
320+
}
321+
313322
// projectV2 parameters aren't supported in the `createIssue` mutation,
314323
// so add them after the issue has been created.
315324
projectV2Ids, ok := params["projectV2Ids"].([]string)

api/queries_pr.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,14 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
524524
}
525525
}
526526

527+
// Assign users using login-based mutation when ActorAssignees is true (github.com).
528+
if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 {
529+
err := ReplaceActorsForAssignableByLogin(client, repo, pr.ID, assigneeLogins)
530+
if err != nil {
531+
return pr, err
532+
}
533+
}
534+
527535
// TODO requestReviewsByLoginCleanup
528536
// Request reviewers using either login-based (github.com) or ID-based (GHES) mutation.
529537
// 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
581589
return pr, nil
582590
}
583591

592+
// ReplaceActorsForAssignableByLogin calls the replaceActorsForAssignable mutation
593+
// using actor logins. This avoids the need to resolve logins to node IDs.
594+
func ReplaceActorsForAssignableByLogin(client *Client, repo ghrepo.Interface, assignableID string, logins []string) error {
595+
type ReplaceActorsForAssignableInput struct {
596+
AssignableID githubv4.ID `json:"assignableId"`
597+
ActorLogins []githubv4.String `json:"actorLogins"`
598+
}
599+
600+
actorLogins := make([]githubv4.String, len(logins))
601+
for i, l := range logins {
602+
actorLogins[i] = githubv4.String(l)
603+
}
604+
605+
var mutation struct {
606+
ReplaceActorsForAssignable struct {
607+
TypeName string `graphql:"__typename"`
608+
} `graphql:"replaceActorsForAssignable(input: $input)"`
609+
}
610+
611+
variables := map[string]interface{}{
612+
"input": ReplaceActorsForAssignableInput{
613+
AssignableID: githubv4.ID(assignableID),
614+
ActorLogins: actorLogins,
615+
},
616+
}
617+
618+
return client.Mutate(repo.RepoHost(), "ReplaceActorsForAssignable", &mutation, variables)
619+
}
620+
584621
// SuggestedAssignableActors fetches up to 10 suggested actors for a specific assignable
585622
// (Issue or PullRequest) node ID. `assignableID` is the GraphQL node ID for the Issue/PR.
586623
// Returns the actors, the total count of available assignees in the repo, and an error.

api/queries_repo.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1298,6 +1298,69 @@ func RepoAssignableActors(client *Client, repo ghrepo.Interface) ([]AssignableAc
12981298
return actors, nil
12991299
}
13001300

1301+
// SearchRepoAssignableActors searches assignable actors for a repository with an optional
1302+
// query string. Unlike RepoAssignableActors which fetches all actors with pagination, this
1303+
// returns up to 10 results matching the query, suitable for search-based selection.
1304+
func SearchRepoAssignableActors(client *Client, repo ghrepo.Interface, query string) ([]AssignableActor, int, error) {
1305+
type responseData struct {
1306+
Repository struct {
1307+
AssignableUsers struct {
1308+
TotalCount int
1309+
}
1310+
SuggestedActors struct {
1311+
Nodes []struct {
1312+
User struct {
1313+
ID string
1314+
Login string
1315+
Name string
1316+
TypeName string `graphql:"__typename"`
1317+
} `graphql:"... on User"`
1318+
Bot struct {
1319+
ID string
1320+
Login string
1321+
TypeName string `graphql:"__typename"`
1322+
} `graphql:"... on Bot"`
1323+
}
1324+
} `graphql:"suggestedActors(first: 10, query: $query, capabilities: CAN_BE_ASSIGNED)"`
1325+
} `graphql:"repository(owner: $owner, name: $name)"`
1326+
}
1327+
1328+
var q *githubv4.String
1329+
if query != "" {
1330+
v := githubv4.String(query)
1331+
q = &v
1332+
}
1333+
1334+
variables := map[string]interface{}{
1335+
"owner": githubv4.String(repo.RepoOwner()),
1336+
"name": githubv4.String(repo.RepoName()),
1337+
"query": q,
1338+
}
1339+
1340+
var result responseData
1341+
if err := client.Query(repo.RepoHost(), "SearchRepoAssignableActors", &result, variables); err != nil {
1342+
return nil, 0, err
1343+
}
1344+
1345+
var actors []AssignableActor
1346+
for _, node := range result.Repository.SuggestedActors.Nodes {
1347+
if node.User.TypeName == "User" {
1348+
actors = append(actors, AssignableUser{
1349+
id: node.User.ID,
1350+
login: node.User.Login,
1351+
name: node.User.Name,
1352+
})
1353+
} else if node.Bot.TypeName == "Bot" {
1354+
actors = append(actors, AssignableBot{
1355+
id: node.Bot.ID,
1356+
login: node.Bot.Login,
1357+
})
1358+
}
1359+
}
1360+
1361+
return actors, result.Repository.AssignableUsers.TotalCount, nil
1362+
}
1363+
13011364
type RepoLabel struct {
13021365
ID string
13031366
Name string

pkg/cmd/issue/create/create.go

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
fd "github.com/cli/cli/v2/internal/featuredetection"
1313
"github.com/cli/cli/v2/internal/gh"
1414
"github.com/cli/cli/v2/internal/ghrepo"
15+
"github.com/cli/cli/v2/internal/prompter"
1516
"github.com/cli/cli/v2/internal/text"
1617
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
1718
"github.com/cli/cli/v2/pkg/cmdutil"
@@ -178,18 +179,12 @@ func createRun(opts *CreateOptions) (err error) {
178179

179180
// Replace special values in assignees
180181
// For web mode, @copilot should be replaced by name; otherwise, login.
181-
assigneeSet := set.NewStringSet()
182-
meReplacer := prShared.NewMeReplacer(apiClient, baseRepo.RepoHost())
183-
copilotReplacer := prShared.NewCopilotReplacer(!opts.WebMode)
184-
assignees, err := meReplacer.ReplaceSlice(opts.Assignees)
182+
assigneeReplacer := prShared.NewSpecialAssigneeReplacer(apiClient, baseRepo.RepoHost(), issueFeatures.ActorIsAssignable, !opts.WebMode)
183+
assignees, err := assigneeReplacer.ReplaceSlice(opts.Assignees)
185184
if err != nil {
186185
return err
187186
}
188-
189-
// TODO actorIsAssignableCleanup
190-
if issueFeatures.ActorIsAssignable {
191-
assignees = copilotReplacer.ReplaceSlice(assignees)
192-
}
187+
assigneeSet := set.NewStringSet()
193188
assigneeSet.AddValues(assignees)
194189

195190
tb := prShared.IssueMetadataState{
@@ -313,7 +308,11 @@ func createRun(opts *CreateOptions) (err error) {
313308
Repo: baseRepo,
314309
State: &tb,
315310
}
316-
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil)
311+
var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult
312+
if issueFeatures.ActorIsAssignable {
313+
assigneeSearchFunc = prShared.RepoAssigneeSearchFunc(apiClient, baseRepo)
314+
}
315+
err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil, assigneeSearchFunc)
317316
if err != nil {
318317
return
319318
}

pkg/cmd/issue/create/create_test.go

Lines changed: 56 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -495,12 +495,18 @@ func Test_createRun(t *testing.T) {
495495
switch message {
496496
case "What would you like to add?":
497497
return prompter.IndexesFor(options, "Assignees")
498-
case "Assignees":
499-
return prompter.IndexesFor(options, "Copilot (AI)", "MonaLisa (Mona Display Name)")
500498
default:
501499
return nil, fmt.Errorf("unexpected multi-select prompt: %s", message)
502500
}
503501
}
502+
pm.MultiSelectWithSearchFunc = func(message, searchPrompt string, defaults, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error) {
503+
switch message {
504+
case "Assignees":
505+
return []string{"copilot-swe-agent", "MonaLisa"}, nil
506+
default:
507+
return nil, fmt.Errorf("unexpected multi-select-with-search prompt: %s", message)
508+
}
509+
}
504510
pm.SelectFunc = func(message, defaultValue string, options []string) (int, error) {
505511
switch message {
506512
case "What's next?":
@@ -524,25 +530,25 @@ func Test_createRun(t *testing.T) {
524530
"viewerPermission": "WRITE"
525531
} } }
526532
`))
527-
r.Register(
528-
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
529-
httpmock.StringResponse(`
530-
{ "data": { "repository": { "suggestedActors": {
531-
"nodes": [
532-
{ "login": "copilot-swe-agent", "id": "COPILOTID", "name": "Copilot (AI)", "__typename": "Bot" },
533-
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
534-
],
535-
"pageInfo": { "hasNextPage": false }
536-
} } } }
537-
`))
538533
r.Register(
539534
httpmock.GraphQL(`mutation IssueCreate\b`),
540535
httpmock.GraphQLMutation(`
541536
{ "data": { "createIssue": { "issue": {
537+
"id": "ISSUEID",
542538
"URL": "https://github.com/OWNER/REPO/issues/12"
543539
} } } }
544540
`, func(inputs map[string]interface{}) {
545-
assert.Equal(t, []interface{}{"COPILOTID", "MONAID"}, inputs["assigneeIds"])
541+
if v, ok := inputs["assigneeIds"]; ok {
542+
t.Errorf("did not expect assigneeIds: %v", v)
543+
}
544+
}))
545+
r.Register(
546+
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
547+
httpmock.GraphQLMutation(`
548+
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
549+
`, func(inputs map[string]interface{}) {
550+
assert.Equal(t, "ISSUEID", inputs["assignableId"])
551+
assert.Equal(t, []interface{}{"copilot-swe-agent", "MonaLisa"}, inputs["actorLogins"])
546552
}))
547553
},
548554
wantsStdout: "https://github.com/OWNER/REPO/issues/12\n",
@@ -948,16 +954,6 @@ func TestIssueCreate_metadata(t *testing.T) {
948954
defer http.Verify(t)
949955

950956
http.StubRepoInfoResponse("OWNER", "REPO", "main")
951-
http.Register(
952-
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
953-
httpmock.StringResponse(`
954-
{ "data": { "repository": { "suggestedActors": {
955-
"nodes": [
956-
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
957-
],
958-
"pageInfo": { "hasNextPage": false }
959-
} } } }
960-
`))
961957
http.Register(
962958
httpmock.GraphQL(`query RepositoryLabelList\b`),
963959
httpmock.StringResponse(`
@@ -1030,19 +1026,30 @@ func TestIssueCreate_metadata(t *testing.T) {
10301026
httpmock.GraphQL(`mutation IssueCreate\b`),
10311027
httpmock.GraphQLMutation(`
10321028
{ "data": { "createIssue": { "issue": {
1029+
"id": "NEWISSUEID",
10331030
"URL": "https://github.com/OWNER/REPO/issues/12"
10341031
} } } }
10351032
`, func(inputs map[string]interface{}) {
10361033
assert.Equal(t, "TITLE", inputs["title"])
10371034
assert.Equal(t, "BODY", inputs["body"])
1038-
assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"])
1035+
if v, ok := inputs["assigneeIds"]; ok {
1036+
t.Errorf("did not expect assigneeIds: %v", v)
1037+
}
10391038
assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
10401039
assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
10411040
assert.Equal(t, "BIGONEID", inputs["milestoneId"])
10421041
assert.NotContains(t, inputs, "userIds")
10431042
assert.NotContains(t, inputs, "teamIds")
10441043
assert.NotContains(t, inputs, "projectV2Ids")
10451044
}))
1045+
http.Register(
1046+
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
1047+
httpmock.GraphQLMutation(`
1048+
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
1049+
`, func(inputs map[string]interface{}) {
1050+
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
1051+
assert.Equal(t, []interface{}{"monalisa"}, inputs["actorLogins"])
1052+
}))
10461053

10471054
output, err := runCommand(http, true, `-t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`, nil)
10481055
if err != nil {
@@ -1091,27 +1098,27 @@ func TestIssueCreate_AtMeAssignee(t *testing.T) {
10911098
"hasIssuesEnabled": true
10921099
} } }
10931100
`))
1094-
http.Register(
1095-
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
1096-
httpmock.StringResponse(`
1097-
{ "data": { "repository": { "suggestedActors": {
1098-
"nodes": [
1099-
{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" },
1100-
{ "login": "SomeOneElse", "id": "SOMEID", "name": "Someone else", "__typename": "User" }
1101-
],
1102-
"pageInfo": { "hasNextPage": false }
1103-
} } } }
1104-
`))
11051101
http.Register(
11061102
httpmock.GraphQL(`mutation IssueCreate\b`),
11071103
httpmock.GraphQLMutation(`
11081104
{ "data": { "createIssue": { "issue": {
1105+
"id": "NEWISSUEID",
11091106
"URL": "https://github.com/OWNER/REPO/issues/12"
11101107
} } } }
11111108
`, func(inputs map[string]interface{}) {
11121109
assert.Equal(t, "hello", inputs["title"])
11131110
assert.Equal(t, "cash rules everything around me", inputs["body"])
1114-
assert.Equal(t, []interface{}{"MONAID", "SOMEID"}, inputs["assigneeIds"])
1111+
if v, ok := inputs["assigneeIds"]; ok {
1112+
t.Errorf("did not expect assigneeIds: %v", v)
1113+
}
1114+
}))
1115+
http.Register(
1116+
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
1117+
httpmock.GraphQLMutation(`
1118+
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
1119+
`, func(inputs map[string]interface{}) {
1120+
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
1121+
assert.Equal(t, []interface{}{"MonaLisa", "someoneelse"}, inputs["actorLogins"])
11151122
}))
11161123

11171124
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) {
11341141
"hasIssuesEnabled": true
11351142
} } }
11361143
`))
1137-
http.Register(
1138-
httpmock.GraphQL(`query RepositoryAssignableActors\b`),
1139-
httpmock.StringResponse(`
1140-
{ "data": { "repository": { "suggestedActors": {
1141-
"nodes": [
1142-
{ "login": "copilot-swe-agent", "id": "COPILOTID", "name": "Copilot (AI)", "__typename": "Bot" }
1143-
],
1144-
"pageInfo": { "hasNextPage": false }
1145-
} } } }
1146-
`))
11471144
http.Register(
11481145
httpmock.GraphQL(`mutation IssueCreate\b`),
11491146
httpmock.GraphQLMutation(`
11501147
{ "data": { "createIssue": { "issue": {
1148+
"id": "NEWISSUEID",
11511149
"URL": "https://github.com/OWNER/REPO/issues/12"
11521150
} } } }
11531151
`, func(inputs map[string]interface{}) {
11541152
assert.Equal(t, "hello", inputs["title"])
11551153
assert.Equal(t, "cash rules everything around me", inputs["body"])
1156-
assert.Equal(t, []interface{}{"COPILOTID"}, inputs["assigneeIds"])
1154+
if v, ok := inputs["assigneeIds"]; ok {
1155+
t.Errorf("did not expect assigneeIds: %v", v)
1156+
}
1157+
}))
1158+
http.Register(
1159+
httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`),
1160+
httpmock.GraphQLMutation(`
1161+
{ "data": { "replaceActorsForAssignable": { "__typename": "" } } }
1162+
`, func(inputs map[string]interface{}) {
1163+
assert.Equal(t, "NEWISSUEID", inputs["assignableId"])
1164+
assert.Equal(t, []interface{}{"copilot-swe-agent"}, inputs["actorLogins"])
11571165
}))
11581166

11591167
output, err := runCommand(http, true, `-a @copilot -t hello -b "cash rules everything around me"`, nil)

pkg/cmd/issue/edit/edit.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,13 @@ func editRun(opts *EditOptions) error {
248248

249249
// Fetch editable shared fields once for all issues.
250250
apiClient := api.NewClientFromHTTP(httpClient)
251+
252+
// Wire up search function for assignees when ActorIsAssignable is available.
253+
// Interactive mode only supports a single issue, so we use its ID for the search query.
254+
if issueFeatures.ActorIsAssignable && opts.Interactive && len(issues) == 1 {
255+
editable.AssigneeSearchFunc = prShared.AssigneeSearchFunc(apiClient, baseRepo, issues[0].ID)
256+
}
257+
251258
opts.IO.StartProgressIndicatorWithLabel("Fetching repository information")
252259
err = opts.FetchOptions(apiClient, baseRepo, &editable, opts.Detector.ProjectsV1())
253260
opts.IO.StopProgressIndicator()

0 commit comments

Comments
 (0)