Repository: cli/cli
Pull request #10991 (2025-05-15 19:14:08)

`issue edit`, `pr edit`: Support special non-interactive (flags) assignee name `@copilot`
This introduces functionality to handle `@copilot` mentions in assignee flag lists by replacing them with the appropriate login.


Patch:
diff --git a/api/queries_repo.go b/api/queries_repo.go
index f72277fb2..7edf26f85 100644
--- a/api/queries_repo.go
+++ b/api/queries_repo.go
@@ -932,12 +932,16 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
 			g.Go(func() error {
 				actors, err := RepoAssignableActors(client, repo)
 				if err != nil {
-					return fmt.Errorf("error fetching assignable actors: %w", err)
+					return fmt.Errorf("error fetching assignees: %w", err)
 				}
 				result.AssignableActors = actors
 
-				// Filter actors for users to use for pull request reviewers,
-				// skip retrieving the same info through RepoAssignableUsers().
+				// Processing the bots out from the actors
+				// because requesting a reviewer leverages
+				// result.AssignableUsers.
+				// Note: this prevents us from needing to make another
+				// request to fetch assignable users when the user has
+				// selected to modify both reviewers and assignees.
 				var users []AssignableUser
 				for _, a := range actors {
 					if _, ok := a.(AssignableUser); !ok {
@@ -953,7 +957,7 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
 			g.Go(func() error {
 				users, err := RepoAssignableUsers(client, repo)
 				if err != nil {
-					err = fmt.Errorf("error fetching assignable users: %w", err)
+					err = fmt.Errorf("error fetching assignees: %w", err)
 				}
 				result.AssignableUsers = users
 				return err
diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go
index 8fc8a2e41..e959cde2b 100644
--- a/pkg/cmd/issue/edit/edit.go
+++ b/pkg/cmd/issue/edit/edit.go
@@ -60,11 +60,17 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
 
 			Editing issues' projects requires authorization with the %[1]sproject%[1]s scope.
 			To authorize, run %[1]sgh auth refresh -s project%[1]s.
+
+			The %[1]s--add-assignee%[1]s and %[1]s--remove-assignee%[1]s flags both support
+			the following special values:
+			- %[1]s@me%[1]s: assign or unassign yourself
+			- %[1]s@copilot%[1]s: assign or unassign Copilot (not supported on GitHub Enterprise Server)
 		`, "`"),
 		Example: heredoc.Doc(`
 			$ gh issue edit 23 --title "I found a bug" --body "Nothing works"
 			$ gh issue edit 23 --add-label "bug,help wanted" --remove-label "core"
 			$ gh issue edit 23 --add-assignee "@me" --remove-assignee monalisa,hubot
+			$ gh issue edit 23 --add-assignee "@copilot"
 			$ gh issue edit 23 --add-project "Roadmap" --remove-project v1,v2
 			$ gh issue edit 23 --milestone "Version 1"
 			$ gh issue edit 23 --remove-milestone
diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go
index 903cb81b3..c0dc61199 100644
--- a/pkg/cmd/pr/edit/edit.go
+++ b/pkg/cmd/pr/edit/edit.go
@@ -60,12 +60,21 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
 
 			Editing a pull request's projects requires authorization with the %[1]sproject%[1]s scope.
 			To authorize, run %[1]sgh auth refresh -s project%[1]s.
+
+			The %[1]s--add-assignee%[1]s and %[1]s--remove-assignee%[1]s flags both support
+			the following special values:
+			- %[1]s@me%[1]s: assign or unassign yourself
+			- %[1]s@copilot%[1]s: assign or unassign Copilot (not supported on GitHub Enterprise Server)
+
+			The %[1]s--add-reviewer%[1]s and %[1]s--remove-reviewer%[1]s flags do not support
+			these special values.
 		`, "`"),
 		Example: heredoc.Doc(`
 			$ gh pr edit 23 --title "I found a bug" --body "Nothing works"
 			$ gh pr edit 23 --add-label "bug,help wanted" --remove-label "core"
 			$ gh pr edit 23 --add-reviewer monalisa,hubot  --remove-reviewer myorg/team-name
 			$ gh pr edit 23 --add-assignee "@me" --remove-assignee monalisa,hubot
+			$ gh pr edit 23 --add-assignee "@copilot"
 			$ gh pr edit 23 --add-project "Roadmap" --remove-project v1,v2
 			$ gh pr edit 23 --milestone "Version 1"
 			$ gh pr edit 23 --remove-milestone
@@ -201,12 +210,12 @@ func editRun(opts *EditOptions) error {
 		Detector: opts.Detector,
 	}
 
-	httpClient, err := opts.HttpClient()
-	if err != nil {
-		return err
-	}
-
 	if opts.Detector == nil {
+		httpClient, err := opts.HttpClient()
+		if err != nil {
+			return err
+		}
+
 		baseRepo, err := opts.BaseRepo()
 		if err != nil {
 			return err
@@ -262,6 +271,10 @@ func editRun(opts *EditOptions) error {
 		}
 	}
 
+	httpClient, err := opts.HttpClient()
+	if err != nil {
+		return err
+	}
 	apiClient := api.NewClientFromHTTP(httpClient)
 
 	opts.IO.StartProgressIndicator()
diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go
index 77bab8440..c71f89b52 100644
--- a/pkg/cmd/pr/shared/editable.go
+++ b/pkg/cmd/pr/shared/editable.go
@@ -118,7 +118,24 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str
 	// 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())
-		s := set.NewStringSet()
+		copilotReplacer := NewCopilotReplacer()
+
+		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
+		}
+
+		assigneeSet := set.NewStringSet()
+
 		// This check below is required because in a non-interactive flow,
 		// the user gives us a login and not the DisplayName, and when
 		// we have actor assignees e.Assignees.Default will contain
@@ -128,21 +145,24 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str
 		// Otherwise, the value the user provided won't be found in the
 		// set to be added or removed, causing unexpected behavior.
 		if e.Assignees.ActorAssignees {
-			s.AddValues(e.Assignees.DefaultLogins)
+			assigneeSet.AddValues(e.Assignees.DefaultLogins)
 		} else {
-			s.AddValues(e.Assignees.Default)
+			assigneeSet.AddValues(e.Assignees.Default)
 		}
-		add, err := meReplacer.ReplaceSlice(e.Assignees.Add)
+
+		add, err := replaceSpecialAssigneeNames(e.Assignees.Add)
 		if err != nil {
 			return nil, err
 		}
-		s.AddValues(add)
-		remove, err := meReplacer.ReplaceSlice(e.Assignees.Remove)
+		assigneeSet.AddValues(add)
+
+		remove, err := replaceSpecialAssigneeNames(e.Assignees.Remove)
 		if err != nil {
 			return nil, err
 		}
-		s.RemoveValues(remove)
-		e.Assignees.Value = s.ToSlice()
+		assigneeSet.RemoveValues(remove)
+
+		e.Assignees.Value = assigneeSet.ToSlice()
 	}
 	a, err := e.Metadata.MembersToIDs(e.Assignees.Value)
 	return &a, err
diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go
index 08968939d..e53ad7a06 100644
--- a/pkg/cmd/pr/shared/params.go
+++ b/pkg/cmd/pr/shared/params.go
@@ -312,3 +312,26 @@ func (r *MeReplacer) ReplaceSlice(handles []string) ([]string, error) {
 	}
 	return res, nil
 }
+
+// CopilotReplacer resolves usages of `@copilot` to Copilot's login.
+type CopilotReplacer struct{}
+
+func NewCopilotReplacer() *CopilotReplacer {
+	return &CopilotReplacer{}
+}
+
+func (r *CopilotReplacer) replace(handle string) string {
+	if strings.EqualFold(handle, "@copilot") {
+		return "copilot-swe-agent"
+	}
+	return handle
+}
+
+// Replace replaces usages of `@copilot` in a slice with Copilot's login.
+func (r *CopilotReplacer) ReplaceSlice(handles []string) []string {
+	res := make([]string, len(handles))
+	for i, h := range handles {
+		res[i] = r.replace(h)
+	}
+	return res
+}


Test patch:
diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go
index 4840cbf7a..0df6d5e9f 100644
--- a/pkg/cmd/issue/edit/edit_test.go
+++ b/pkg/cmd/issue/edit/edit_test.go
@@ -620,123 +620,6 @@ func Test_editRun(t *testing.T) {
 			},
 			stdout: "https://github.com/OWNER/REPO/issue/123\n",
 		},
-		{
-			name: "interactive prompts with actor assignee display names when actors available",
-			input: &EditOptions{
-				IssueNumbers: []int{123},
-				Interactive:  true,
-				FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
-					eo.Assignees.Edited = true
-					return nil
-				},
-				EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error {
-					// Checking that the display name is being used in the prompt.
-					require.Equal(t, eo.Assignees.Default, []string{"hubot", "MonaLisa (Mona Display Name)"})
-
-					// Mocking a selection of only MonaLisa in the prompt.
-					eo.Assignees.Value = []string{"MonaLisa (Mona Display Name)"}
-					return nil
-				},
-				FetchOptions:    prShared.FetchOptions,
-				DetermineEditor: func() (string, error) { return "vim", nil },
-			},
-			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.Contains(t, inputs["actorIds"], "MONAID")
-						}),
-				)
-			},
-			stdout: "https://github.com/OWNER/REPO/issue/123\n",
-		},
-		{
-			name: "interactive prompts with user assignee logins when actors unavailable",
-			input: &EditOptions{
-				IssueNumbers: []int{123},
-				Interactive:  true,
-				FieldsToEditSurvey: func(p prShared.EditPrompter, eo *prShared.Editable) error {
-					eo.Assignees.Edited = true
-					return nil
-				},
-				EditFieldsSurvey: func(p prShared.EditPrompter, eo *prShared.Editable, _ string) error {
-					// Checking that only the login is used in the prompt (no display name)
-					require.Equal(t, eo.Assignees.Default, []string{"hubot", "MonaLisa"})
-
-					// Mocking a selection of only MonaLisa in the prompt.
-					eo.Assignees.Value = []string{"MonaLisa"}
-					return nil
-				},
-				FetchOptions:    prShared.FetchOptions,
-				DetermineEditor: func() (string, error) { return "vim", nil },
-				Detector:        &fd.DisabledDetectorMock{},
-			},
-			httpStubs: func(t *testing.T, reg *httpmock.Registry) {
-				reg.Register(
-					httpmock.GraphQL(`query IssueByNumber\b`),
-					httpmock.StringResponse(fmt.Sprintf(`
-                        { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
-							"id": "%[1]d",
-							"number": %[1]d,
-                            "url": "https://github.com/OWNER/REPO/issue/123",
-                            "assignees": {
-                                "nodes": [
-                                    {
-                                        "id": "HUBOTID",
-                                        "login": "hubot",
-										"name": ""
-                                    },
-                                    {
-                                        "id": "MONAID",
-                                        "login": "MonaLisa",
-										"name": "Mona Display Name"
-                                    }
-                                ],
-                                "totalCount": 2
-                            }
-                        } } } }`, 123)),
-				)
-				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(`mutation IssueUpdate\b`),
-					httpmock.GraphQLMutation(`
-								{ "data": { "updateIssue": { "__typename": "" } } }`,
-						func(inputs map[string]interface{}) {
-							// Checking that we still assigned the expected ID.
-							require.Contains(t, inputs["assigneeIds"], "MONAID")
-						}),
-				)
-			},
-			stdout: "https://github.com/OWNER/REPO/issue/123\n",
-		},
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
@@ -795,34 +678,6 @@ func mockIssueNumberGet(_ *testing.T, reg *httpmock.Registry, number int) {
 	)
 }
 
-func mockIsssueNumberGetWithAssignedActors(_ *testing.T, reg *httpmock.Registry, number int) {
-	reg.Register(
-		httpmock.GraphQL(`query IssueByNumber\b`),
-		httpmock.StringResponse(fmt.Sprintf(`
-			{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
-				"id": "%[1]d",
-				"number": %[1]d,
-				"url": "https://github.com/OWNER/REPO/issue/%[1]d",
-				"assignedActors": {
-					"nodes": [
-						{
-							"id": "HUBOTID",
-							"login": "hubot",
-							"__typename": "Bot"
-						},
-						{
-							"id": "MONAID",
-							"login": "MonaLisa",
-							"name": "Mona Display Name",
-							"__typename": "User"
-						}
-					],
-					"totalCount": 2
-				}
-			} } } }`, number)),
-	)
-}
-
 func mockIssueProjectItemsGet(_ *testing.T, reg *httpmock.Registry) {
 	reg.Register(
 		httpmock.GraphQL(`query IssueProjectItems\b`),
@@ -844,7 +699,7 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry) {
 		{ "data": { "repository": { "suggestedActors": {
 			"nodes": [
 				{ "login": "hubot", "id": "HUBOTID", "__typename": "Bot" },
-				{ "login": "MonaLisa", "id": "MONAID", "name": "Mona Display Name", "__typename": "User" }
+				{ "login": "MonaLisa", "id": "MONAID", "__typename": "User" }
 			],
 			"pageInfo": { "hasNextPage": false }
 		} } } }
diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go
index 76fb40f88..0f15d6bd1 100644
--- a/pkg/cmd/pr/edit/edit_test.go
+++ b/pkg/cmd/pr/edit/edit_test.go
@@ -500,7 +500,7 @@ func Test_editRun(t *testing.T) {
 			stdout: "https://github.com/OWNER/REPO/pull/123\n",
 		},
 		{
-			name: "Legacy assignee users are fetched and updated on unsupported GitHub Hosts",
+			name: "Legacy assignee user are fetched and updated on unsupported GitHub Hosts",
 			input: &EditOptions{
 				Detector:    &fd.DisabledDetectorMock{},
 				SelectorArg: "123",
diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go
index 15f00ca4f..53eb6328f 100644
--- a/pkg/cmd/pr/shared/params_test.go
+++ b/pkg/cmd/pr/shared/params_test.go
@@ -187,6 +187,67 @@ func TestMeReplacer_Replace(t *testing.T) {
 	}
 }
 
+func TestCopilotReplacer_ReplaceSlice(t *testing.T) {
+	type args struct {
+		handles []string
+	}
+	tests := []struct {
+		name string
+		args args
+		want []string
+	}{
+		{
+			name: "replaces @copilot with copilot-swe-agent",
+			args: args{
+				handles: []string{"monalisa", "@copilot", "hubot"},
+			},
+			want: []string{"monalisa", "copilot-swe-agent", "hubot"},
+		},
+		{
+			name: "handles no @copilot mentions",
+			args: args{
+				handles: []string{"monalisa", "user", "hubot"},
+			},
+			want: []string{"monalisa", "user", "hubot"},
+		},
+		{
+			name: "replaces multiple @copilot mentions",
+			args: args{
+				handles: []string{"@copilot", "user", "@copilot"},
+			},
+			want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"},
+		},
+		{
+			name: "handles @copilot case-insensitively",
+			args: args{
+				handles: []string{"@Copilot", "user", "@CoPiLoT"},
+			},
+			want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"},
+		},
+		{
+			name: "handles nil slice",
+			args: args{
+				handles: nil,
+			},
+			want: []string{},
+		},
+		{
+			name: "handles empty slice",
+			args: args{
+				handles: []string{},
+			},
+			want: []string{},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			r := NewCopilotReplacer()
+			got := r.ReplaceSlice(tt.args.handles)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}
+
 func Test_QueryHasStateClause(t *testing.T) {
 	tests := []struct {
 		searchQuery string