From 3c00ffdade319d47505479edf215909762d46bac Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:04:41 -0600 Subject: [PATCH 01/10] refactor(pr shared): consolidate ActorAssignees and ActorReviewers into ApiActorsSupported The CLI had two per-entity flags (ActorAssignees on EditableAssignees and IssueMetadataState, ActorReviewers on IssueMetadataState) threaded through different layers of the stack to distinguish github.com from GHES. Both flags were always set from the same source (issueFeatures.ActorIsAssignable) and never had different values, but they were carried independently on different structs. This led to a confusing asymmetry where: - EditableAssignees had ActorAssignees but EditableReviewers had nothing - The PR edit flow piggybacked on editable.Assignees.ActorAssignees to make reviewer mutation decisions, which was misleading - RepoMetadataInput only had ActorAssignees with no reviewer equivalent This commit replaces all per-entity flags with a single ApiActorsSupported bool hoisted to the shared level on Editable, IssueMetadataState, and RepoMetadataInput. Both assignees and reviewers now key off the same signal. Every branch site is marked with // TODO ApiActorsSupported so we can grep for cleanup sites when GHES eventually supports the actor-based mutations (replaceActorsForAssignable, requestReviewsByLogin). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_issue.go | 2 +- api/queries_pr.go | 2 +- api/queries_repo.go | 20 +++++++------ pkg/cmd/issue/create/create.go | 16 +++++----- pkg/cmd/issue/edit/edit.go | 7 +++-- pkg/cmd/issue/edit/edit_test.go | 10 +++---- pkg/cmd/pr/create/create.go | 4 +-- pkg/cmd/pr/create/create_test.go | 2 +- pkg/cmd/pr/edit/edit.go | 10 ++++--- pkg/cmd/pr/edit/edit_test.go | 22 +++++++------- pkg/cmd/pr/shared/editable.go | 47 ++++++++++++++++++------------ pkg/cmd/pr/shared/editable_http.go | 6 ++-- pkg/cmd/pr/shared/params.go | 21 +++++++------ pkg/cmd/pr/shared/state.go | 11 +++++-- pkg/cmd/pr/shared/survey.go | 31 +++++++++++--------- 15 files changed, 119 insertions(+), 92 deletions(-) diff --git a/api/queries_issue.go b/api/queries_issue.go index f719e52f9ae..bff84029dc0 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -311,7 +311,7 @@ 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). + // Assign users using login-based mutation when ApiActorsSupported is true (github.com). if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 { err := ReplaceActorsForAssignableByLogin(client, repo, issue.ID, assigneeLogins) if err != nil { diff --git a/api/queries_pr.go b/api/queries_pr.go index 01846a9e6d0..1b044a439bf 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -524,7 +524,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } } - // Assign users using login-based mutation when ActorAssignees is true (github.com). + // Assign users using login-based mutation when ApiActorsSupported is true (github.com). if assigneeLogins, ok := params["assigneeLogins"].([]string); ok && len(assigneeLogins) > 0 { err := ReplaceActorsForAssignableByLogin(client, repo, pr.ID, assigneeLogins) if err != nil { diff --git a/api/queries_repo.go b/api/queries_repo.go index e5806b91e90..c3a0729616f 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -922,14 +922,15 @@ func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) { } type RepoMetadataInput struct { - Assignees bool - ActorAssignees bool - Reviewers bool - TeamReviewers bool - Labels bool - ProjectsV1 bool - ProjectsV2 bool - Milestones bool + Assignees bool + Reviewers bool + TeamReviewers bool + // TODO ApiActorsSupported + ApiActorsSupported bool + Labels bool + ProjectsV1 bool + ProjectsV2 bool + Milestones bool } // RepoMetadata pre-fetches the metadata for attaching to issues and pull requests @@ -938,7 +939,8 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput var g errgroup.Group if input.Assignees || input.Reviewers { - if input.ActorAssignees { + // TODO ApiActorsSupported + if input.ApiActorsSupported { g.Go(func() error { actors, err := RepoAssignableActors(client, repo) if err != nil { diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index f9fab11a1c7..83149589122 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -188,14 +188,14 @@ func createRun(opts *CreateOptions) (err error) { assigneeSet.AddValues(assignees) tb := prShared.IssueMetadataState{ - Type: prShared.IssueMetadata, - ActorAssignees: issueFeatures.ActorIsAssignable, - Assignees: assigneeSet.ToSlice(), - Labels: opts.Labels, - ProjectTitles: opts.Projects, - Milestones: milestones, - Title: opts.Title, - Body: opts.Body, + Type: prShared.IssueMetadata, + ApiActorsSupported: issueFeatures.ActorIsAssignable, // TODO ApiActorsSupported + Assignees: assigneeSet.ToSlice(), + Labels: opts.Labels, + ProjectTitles: opts.Projects, + Milestones: milestones, + Title: opts.Title, + Body: opts.Body, } if opts.RecoverFile != "" { diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 2fd632b5ef7..965fc0b4957 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -215,9 +215,9 @@ func editRun(opts *EditOptions) error { lookupFields := []string{"id", "number", "title", "body", "url"} if editable.Assignees.Edited { - // TODO actorIsAssignableCleanup + // TODO ApiActorsSupported if issueFeatures.ActorIsAssignable { - editable.Assignees.ActorAssignees = true + editable.ApiActorsSupported = true lookupFields = append(lookupFields, "assignedActors") } else { lookupFields = append(lookupFields, "assignees") @@ -280,7 +280,8 @@ func editRun(opts *EditOptions) error { editable.Body.Default = issue.Body // We use Actors as the default assignees if Actors are assignable // on this GitHub host. - if editable.Assignees.ActorAssignees { + // TODO ApiActorsSupported + if editable.ApiActorsSupported { editable.Assignees.Default = issue.AssignedActors.DisplayNames() editable.Assignees.DefaultLogins = issue.AssignedActors.Logins() } else { diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index ef496d1ded8..41af97fef96 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -395,7 +395,7 @@ func Test_editRun(t *testing.T) { mockIssueProjectItemsGet(t, reg) mockRepoMetadata(t, reg) mockIssueUpdate(t, reg) - mockIssueUpdateActorAssignees(t, reg) + mockIssueUpdateApiActors(t, reg) mockIssueUpdateLabels(t, reg) mockProjectV2ItemUpdate(t, reg) }, @@ -444,8 +444,8 @@ func Test_editRun(t *testing.T) { mockIssueProjectItemsGet(t, reg) mockIssueUpdate(t, reg) mockIssueUpdate(t, reg) - mockIssueUpdateActorAssignees(t, reg) - mockIssueUpdateActorAssignees(t, reg) + mockIssueUpdateApiActors(t, reg) + mockIssueUpdateApiActors(t, reg) mockIssueUpdateLabels(t, reg) mockIssueUpdateLabels(t, reg) mockProjectV2ItemUpdate(t, reg) @@ -608,7 +608,7 @@ func Test_editRun(t *testing.T) { mockIssueProjectItemsGet(t, reg) mockRepoMetadata(t, reg) mockIssueUpdate(t, reg) - mockIssueUpdateActorAssignees(t, reg) + mockIssueUpdateApiActors(t, reg) mockIssueUpdateLabels(t, reg) mockProjectV2ItemUpdate(t, reg) }, @@ -902,7 +902,7 @@ func mockIssueUpdate(t *testing.T, reg *httpmock.Registry) { ) } -func mockIssueUpdateActorAssignees(t *testing.T, reg *httpmock.Registry) { +func mockIssueUpdateApiActors(t *testing.T, reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), httpmock.GraphQLMutation(` diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index d8230dc3c68..b62e04d736c 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -429,9 +429,9 @@ func createRun(opts *CreateOptions) error { return err } + // TODO ApiActorsSupported if issueFeatures.ActorIsAssignable { - state.ActorReviewers = true - state.ActorAssignees = true + state.ApiActorsSupported = true } var openURL string diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index fd53a92fcc7..5bad889b675 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -965,7 +965,7 @@ func Test_createRun(t *testing.T) { `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWPULLID", inputs["pullRequestId"]) if _, ok := inputs["assigneeIds"]; ok { - t.Error("did not expect assigneeIds in updatePullRequest when ActorAssignees is true") + t.Error("did not expect assigneeIds in updatePullRequest when ApiActorsSupported is true") } assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"]) assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"]) diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 12c197b5af6..c2535019b7a 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -289,9 +289,9 @@ func editRun(opts *EditOptions) error { editable.Base.Default = pr.BaseRefName editable.Reviewers.Default = pr.ReviewRequests.DisplayNames() editable.Reviewers.DefaultLogins = pr.ReviewRequests.Logins() - // TODO actorIsAssignableCleanup + // TODO ApiActorsSupported if issueFeatures.ActorIsAssignable { - editable.Assignees.ActorAssignees = true + editable.ApiActorsSupported = true editable.Assignees.Default = pr.AssignedActors.DisplayNames() editable.Assignees.DefaultLogins = pr.AssignedActors.Logins() } else { @@ -438,7 +438,8 @@ func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, pr // Replace @copilot with the Copilot reviewer login (only on github.com). // Also use DefaultLogins (not Default display names) for computing the set. var defaultLogins []string - if editable.Assignees.ActorAssignees { + // TODO ApiActorsSupported + if editable.ApiActorsSupported { copilotReplacer := shared.NewCopilotReviewerReplacer() add = copilotReplacer.ReplaceSlice(add) remove = copilotReplacer.ReplaceSlice(remove) @@ -457,7 +458,8 @@ func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, pr // On github.com, use the new GraphQL mutation which supports bots. // On GHES, fall back to REST API. - if editable.Assignees.ActorAssignees { + // TODO ApiActorsSupported + if editable.ApiActorsSupported { return updatePullRequestReviewsGraphQL(client, repo, prID, editable) } return updatePullRequestReviewsREST(client, repo, number, editable) diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 01acb7e0fba..abe76415883 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -417,7 +417,7 @@ func Test_editRun(t *testing.T) { // REST API accepts logins and team slugs directly mockRepoMetadata(reg, mockRepoMetadataOptions{reviewers: false, teamReviewers: false, assignees: false, labels: true, projects: true, milestones: true}) mockPullRequestUpdate(reg) - mockPullRequestUpdateActorAssignees(reg) + mockPullRequestUpdateApiActors(reg) mockRequestReviewsByLogin(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) @@ -475,7 +475,7 @@ func Test_editRun(t *testing.T) { httpStubs: func(t *testing.T, reg *httpmock.Registry) { mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: false, labels: true, projects: true, milestones: true}) mockPullRequestUpdate(reg) - mockPullRequestUpdateActorAssignees(reg) + mockPullRequestUpdateApiActors(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) }, @@ -551,7 +551,7 @@ func Test_editRun(t *testing.T) { mockPullRequestUpdate(reg) mockRequestReviewsByLogin(reg) mockPullRequestUpdateLabels(reg) - mockPullRequestUpdateActorAssignees(reg) + mockPullRequestUpdateApiActors(reg) mockProjectV2ItemUpdate(reg) }, stdout: "https://github.com/OWNER/REPO/pull/123\n", @@ -756,7 +756,7 @@ func Test_editRun(t *testing.T) { // (searchFunc handles dynamic fetching, metadata populated in test mock) mockRepoMetadata(reg, mockRepoMetadataOptions{reviewers: false, teamReviewers: false, assignees: false, labels: true, projects: true, milestones: true}) mockPullRequestUpdate(reg) - mockPullRequestUpdateActorAssignees(reg) + mockPullRequestUpdateApiActors(reg) mockRequestReviewsByLogin(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) @@ -785,7 +785,7 @@ func Test_editRun(t *testing.T) { editFields: func(e *shared.Editable, _ string) error { e.Title.Value = "new title" e.Body.Value = "new body" - // When ActorAssignees is enabled, the interactive flow returns display names (or logins for non-users) + // When ApiActorsSupported is enabled, the interactive flow returns display names (or logins for non-users) e.Assignees.Value = []string{"monalisa (Mona Display Name)", "hubot"} // Populate metadata to simulate what searchFunc would do during prompting e.Metadata.AssignableActors = []api.AssignableActor{ @@ -808,7 +808,7 @@ func Test_editRun(t *testing.T) { // assignees: false because searchFunc handles dynamic fetching (metadata populated in test mock) mockRepoMetadata(reg, mockRepoMetadataOptions{assignees: false, labels: true, projects: true, milestones: true}) mockPullRequestUpdate(reg) - mockPullRequestUpdateActorAssignees(reg) + mockPullRequestUpdateApiActors(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) }, @@ -876,7 +876,7 @@ func Test_editRun(t *testing.T) { mockRepoMetadata(reg, mockRepoMetadataOptions{reviewers: false, teamReviewers: false, assignees: false, labels: true, projects: true, milestones: true}) mockPullRequestUpdate(reg) mockRequestReviewsByLogin(reg) - mockPullRequestUpdateActorAssignees(reg) + mockPullRequestUpdateApiActors(reg) mockPullRequestUpdateLabels(reg) mockProjectV2ItemUpdate(reg) }, @@ -990,7 +990,7 @@ func Test_editRun(t *testing.T) { return nil }, editFields: func(e *shared.Editable, _ string) error { - require.False(t, e.Assignees.ActorAssignees) + require.False(t, e.ApiActorsSupported) require.Nil(t, e.AssigneeSearchFunc) require.Contains(t, e.Assignees.Options, "monalisa") require.Contains(t, e.Assignees.Options, "hubot") @@ -1190,7 +1190,7 @@ type mockRepoMetadataOptions struct { } func mockRepoMetadata(reg *httpmock.Registry, opt mockRepoMetadataOptions) { - // Assignable actors (users/bots) are fetched when reviewers OR assignees edited with ActorAssignees enabled. + // Assignable actors (users/bots) are fetched when reviewers OR assignees edited with ApiActorsSupported enabled. if opt.reviewers || opt.assignees { reg.Register( httpmock.GraphQL(`query RepositoryAssignableActors\b`), @@ -1314,7 +1314,7 @@ func mockPullRequestUpdate(reg *httpmock.Registry) { httpmock.StringResponse(`{}`)) } -func mockPullRequestUpdateActorAssignees(reg *httpmock.Registry) { +func mockPullRequestUpdateApiActors(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation ReplaceActorsForAssignable\b`), httpmock.GraphQLMutation(` @@ -1336,7 +1336,7 @@ func mockPullRequestRemoveReviewers(reg *httpmock.Registry) { } // mockRequestReviewsByLogin mocks the RequestReviewsByLogin GraphQL mutation -// used on github.com when ActorAssignees is enabled. +// used on github.com when ApiActorsSupported is enabled. func mockRequestReviewsByLogin(reg *httpmock.Registry) { reg.Register( httpmock.GraphQL(`mutation RequestReviewsByLogin\b`), diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 02fe5d33c50..d29b6d4c490 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -22,6 +22,13 @@ type Editable struct { Projects EditableProjects Milestone EditableString Metadata api.RepoMetadataResult + + // TODO ApiActorsSupported + // ApiActorsSupported indicates the host supports actor-based APIs (github.com, ghe.com). + // When true, mutations use logins directly instead of resolving node IDs. + // Remove this flag (and collapse to actor-only paths) once GHES supports + // replaceActorsForAssignable and requestReviewsByLogin mutations. + ApiActorsSupported bool } type EditableString struct { @@ -42,11 +49,9 @@ type EditableSlice struct { } // EditableAssignees is a special case of EditableSlice. -// It contains a flag to indicate whether the assignees are actors or not. type EditableAssignees struct { EditableSlice - ActorAssignees bool - DefaultLogins []string // For disambiguating actors from display names + DefaultLogins []string // For disambiguating actors from display names } // EditableReviewers is a special case of EditableSlice. @@ -95,7 +100,8 @@ 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 { - replacer := NewSpecialAssigneeReplacer(client, repo.RepoHost(), e.Assignees.ActorAssignees, true) + // TODO ApiActorsSupported + replacer := NewSpecialAssigneeReplacer(client, repo.RepoHost(), e.ApiActorsSupported, true) assigneeSet := set.NewStringSet() @@ -107,7 +113,8 @@ func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]str // So, we need to add the default logins here instead of the DisplayNames. // 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 { + // TODO ApiActorsSupported + if e.ApiActorsSupported { assigneeSet.AddValues(e.Assignees.DefaultLogins) } else { assigneeSet.AddValues(e.Assignees.Default) @@ -283,6 +290,7 @@ func (e *Editable) Clone() Editable { Labels: e.Labels.clone(), Projects: e.Projects.clone(), Milestone: e.Milestone.clone(), + ApiActorsSupported: e.ApiActorsSupported, // Shallow copy since no mutation. Metadata: e.Metadata, } @@ -319,9 +327,8 @@ func (es *EditableSlice) clone() EditableSlice { func (ea *EditableAssignees) clone() EditableAssignees { return EditableAssignees{ - EditableSlice: ea.EditableSlice.clone(), - ActorAssignees: ea.ActorAssignees, - DefaultLogins: ea.DefaultLogins, + EditableSlice: ea.EditableSlice.clone(), + DefaultLogins: ea.DefaultLogins, } } @@ -522,22 +529,23 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, fetchAssignees = true } // For non-interactive Add/Remove operations, we only need to fetch assignees - // on GHES where ID resolution is required. On github.com (ActorAssignees), + // on GHES where ID resolution is required. On github.com (ApiActorsSupported), // logins are passed directly to the mutation. - if (len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0) && !editable.Assignees.ActorAssignees { + // TODO ApiActorsSupported + if (len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0) && !editable.ApiActorsSupported { fetchAssignees = true } } input := api.RepoMetadataInput{ - Reviewers: fetchReviewers, - TeamReviewers: teamReviewers, - Assignees: fetchAssignees, - ActorAssignees: editable.Assignees.ActorAssignees, - Labels: editable.Labels.Edited, - ProjectsV1: editable.Projects.Edited && projectV1Support == gh.ProjectsV1Supported, - ProjectsV2: editable.Projects.Edited, - Milestones: editable.Milestone.Edited, + Reviewers: fetchReviewers, + TeamReviewers: teamReviewers, + Assignees: fetchAssignees, + ApiActorsSupported: editable.ApiActorsSupported, + Labels: editable.Labels.Edited, + ProjectsV1: editable.Projects.Edited && projectV1Support == gh.ProjectsV1Supported, + ProjectsV2: editable.Projects.Edited, + Milestones: editable.Milestone.Edited, } metadata, err := api.RepoMetadata(client, repo, input) if err != nil { @@ -574,7 +582,8 @@ func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, editable.Metadata = *metadata editable.Reviewers.Options = append(users, teams...) - if editable.Assignees.ActorAssignees { + // TODO ApiActorsSupported + if editable.ApiActorsSupported { editable.Assignees.Options = actors } else { editable.Assignees.Options = users diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index bb63ceb0f62..39140eefd61 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -66,7 +66,8 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR // other issue fields to ensure consistency with how legacy // user assignees are handled. // https://github.com/cli/cli/pull/10960#discussion_r2086725348 - if options.Assignees.Edited && options.Assignees.ActorAssignees { + // TODO ApiActorsSupported + if options.Assignees.Edited && options.ApiActorsSupported { apiClient := api.NewClientFromHTTP(httpClient) logins, err := options.AssigneeLogins(apiClient, repo) if err != nil { @@ -99,7 +100,8 @@ func replaceIssueFields(httpClient *http.Client, repo ghrepo.Interface, id strin } var assigneeIds *[]string - if !options.Assignees.ActorAssignees { + // TODO ApiActorsSupported + if !options.ApiActorsSupported { assigneeIds, err = options.AssigneeIds(apiClient, repo) if err != nil { return err diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go index 17d40d6395c..76f096efd7e 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -61,11 +61,13 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par return nil } - // When ActorReviewers is true, we use login-based mutation and don't need to resolve reviewer IDs. - needReviewerIDs := len(tb.Reviewers) > 0 && !tb.ActorReviewers + // TODO ApiActorsSupported + // When ApiActorsSupported is true, we use login-based mutation and don't need to resolve reviewer IDs. + needReviewerIDs := len(tb.Reviewers) > 0 && !tb.ApiActorsSupported - // When ActorAssignees is true, we use login-based mutation and don't need to resolve assignee IDs. - needAssigneeIDs := len(tb.Assignees) > 0 && !tb.ActorAssignees + // TODO ApiActorsSupported + // When ApiActorsSupported is true, we use login-based mutation and don't need to resolve assignee IDs. + needAssigneeIDs := len(tb.Assignees) > 0 && !tb.ApiActorsSupported // Retrieve minimal information needed to resolve metadata if this was not previously cached from additional metadata survey. if tb.MetadataResult == nil { @@ -88,9 +90,10 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par tb.MetadataResult = metadataResult } - // When ActorAssignees is true (github.com), pass logins directly for use with + // TODO ApiActorsSupported + // When ApiActorsSupported is true (github.com), pass logins directly for use with // ReplaceActorsForAssignable mutation. The ID-based else branch is for GHES compatibility. - if tb.ActorAssignees { + if tb.ApiActorsSupported { params["assigneeLogins"] = tb.Assignees } else { assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees) @@ -138,11 +141,11 @@ func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, par } } - // TODO requestReviewsByLoginCleanup - // When ActorReviewers is true (github.com), pass logins directly for use with + // TODO ApiActorsSupported + // When ApiActorsSupported is true (github.com), pass logins directly for use with // RequestReviewsByLogin mutation. The ID-based else branch can be removed once // GHES supports requestReviewsByLogin. - if tb.ActorReviewers { + if tb.ApiActorsSupported { params["userReviewerLogins"] = userReviewers if len(botReviewers) > 0 { params["botReviewerLogins"] = botReviewers diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go index 0e5c31cdd0a..32807ae4d37 100644 --- a/pkg/cmd/pr/shared/state.go +++ b/pkg/cmd/pr/shared/state.go @@ -18,9 +18,14 @@ const ( type IssueMetadataState struct { Type metadataStateType - Draft bool - ActorAssignees bool - ActorReviewers bool + Draft bool + + // TODO ApiActorsSupported + // ApiActorsSupported indicates the host supports actor-based APIs (github.com, ghe.com). + // When true, mutations use logins directly instead of resolving node IDs. + // Remove this flag (and collapse to actor-only paths) once GHES supports + // replaceActorsForAssignable and requestReviewsByLogin mutations. + ApiActorsSupported bool Body string Title string diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 71cfcd06343..544c04bc81f 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -181,19 +181,20 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } // Retrieve and process data for survey prompts based on the extra fields selected. - // 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 + // When search-based selection is available, skip the expensive assignable-users + // and teams fetch since they are found dynamically via the search function. + // TODO ApiActorsSupported + useReviewerSearch := state.ApiActorsSupported && reviewerSearchFunc != nil + useAssigneeSearch := state.ApiActorsSupported && assigneeSearchFunc != nil metadataInput := api.RepoMetadataInput{ - Reviewers: isChosen("Reviewers") && !useReviewerSearch, - TeamReviewers: isChosen("Reviewers") && !useReviewerSearch, - Assignees: isChosen("Assignees") && !useAssigneeSearch, - ActorAssignees: isChosen("Assignees") && !useAssigneeSearch && state.ActorAssignees, - Labels: isChosen("Labels"), - ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported, - ProjectsV2: isChosen("Projects"), - Milestones: isChosen("Milestone"), + Reviewers: isChosen("Reviewers") && !useReviewerSearch, + TeamReviewers: isChosen("Reviewers") && !useReviewerSearch, + Assignees: isChosen("Assignees") && !useAssigneeSearch, + ApiActorsSupported: state.ApiActorsSupported && ((isChosen("Assignees") && !useAssigneeSearch) || (isChosen("Reviewers") && !useReviewerSearch)), + Labels: isChosen("Labels"), + ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported, + ProjectsV2: isChosen("Projects"), + Milestones: isChosen("Milestone"), } metadataResult, err := fetcher.RepoMetadataFetch(metadataInput) if err != nil { @@ -217,7 +218,8 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface var assignees []string var assigneesDefault []string if !useAssigneeSearch { - if state.ActorAssignees { + // TODO ApiActorsSupported + if state.ApiActorsSupported { for _, u := range metadataResult.AssignableActors { assignees = append(assignees, u.DisplayName()) @@ -305,7 +307,8 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface return err } for _, i := range selected { - if state.ActorAssignees { + // TODO ApiActorsSupported + if state.ApiActorsSupported { values.Assignees = append(values.Assignees, metadataResult.AssignableActors[i].Login()) } else { values.Assignees = append(values.Assignees, metadataResult.AssignableUsers[i].Login()) From ae5e857c2e4c23be065667b4515de9c82588035f Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:09:04 -0600 Subject: [PATCH 02/10] refactor(featuredetection): rename ActorIsAssignable to ApiActorsSupported Aligns the feature detector field name with the downstream ApiActorsSupported flag introduced in the previous commit, so the signal has one consistent name from detection through to consumption. Also consolidates leftover TODO tags (actorIsAssignableCleanup, requestReviewsByLoginCleanup) under the single // TODO ApiActorsSupported tag so there's exactly one thing to grep for. Pure rename with no logic changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr.go | 2 +- internal/featuredetection/feature_detection.go | 12 +++++++++--- internal/featuredetection/feature_detection_test.go | 6 +++--- pkg/cmd/issue/create/create.go | 6 +++--- pkg/cmd/issue/edit/edit.go | 6 +++--- pkg/cmd/issue/edit/edit_test.go | 2 +- pkg/cmd/pr/create/create.go | 6 +++--- pkg/cmd/pr/edit/edit.go | 10 +++++----- pkg/cmd/pr/shared/survey.go | 2 +- 9 files changed, 29 insertions(+), 23 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 1b044a439bf..073ef6886cb 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -532,7 +532,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter } } - // TODO requestReviewsByLoginCleanup + // TODO ApiActorsSupported // Request reviewers using either login-based (github.com) or ID-based (GHES) mutation. // The ID-based path can be removed once GHES supports requestReviewsByLogin. userLogins, hasUserLogins := params["userReviewerLogins"].([]string) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index 9af4c5aeca5..f0759bd3ea4 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -23,11 +23,17 @@ type Detector interface { } type IssueFeatures struct { - ActorIsAssignable bool + // TODO ApiActorsSupported + // ApiActorsSupported indicates the host supports actor-based APIs + // (replaceActorsForAssignable, requestReviewsByLogin, suggestedAssignableActors, etc.). + // True for github.com and ghe.com. False for GHES. + // Remove this flag once GHES supports these mutations, then collapse all + // // TODO ApiActorsSupported sites to the actor-only path. + ApiActorsSupported bool } var allIssueFeatures = IssueFeatures{ - ActorIsAssignable: true, + ApiActorsSupported: true, } type PullRequestFeatures struct { @@ -136,7 +142,7 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) { } return IssueFeatures{ - ActorIsAssignable: false, // replaceActorsForAssignable GraphQL mutation unavailable on GHES + ApiActorsSupported: false, // TODO ApiActorsSupported — actor-based mutations unavailable on GHES }, nil } diff --git a/internal/featuredetection/feature_detection_test.go b/internal/featuredetection/feature_detection_test.go index 82132ab83fd..f24e31f4c73 100644 --- a/internal/featuredetection/feature_detection_test.go +++ b/internal/featuredetection/feature_detection_test.go @@ -23,7 +23,7 @@ func TestIssueFeatures(t *testing.T) { name: "github.com", hostname: "github.com", wantFeatures: IssueFeatures{ - ActorIsAssignable: true, + ApiActorsSupported: true, }, wantErr: false, }, @@ -31,7 +31,7 @@ func TestIssueFeatures(t *testing.T) { name: "ghec data residency (ghe.com)", hostname: "stampname.ghe.com", wantFeatures: IssueFeatures{ - ActorIsAssignable: true, + ApiActorsSupported: true, }, wantErr: false, }, @@ -39,7 +39,7 @@ func TestIssueFeatures(t *testing.T) { name: "GHE", hostname: "git.my.org", wantFeatures: IssueFeatures{ - ActorIsAssignable: false, + ApiActorsSupported: false, }, wantErr: false, }, diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 83149589122..2712bc89d4d 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -179,7 +179,7 @@ func createRun(opts *CreateOptions) (err error) { // Replace special values in assignees // For web mode, @copilot should be replaced by name; otherwise, login. - assigneeReplacer := prShared.NewSpecialAssigneeReplacer(apiClient, baseRepo.RepoHost(), issueFeatures.ActorIsAssignable, !opts.WebMode) + assigneeReplacer := prShared.NewSpecialAssigneeReplacer(apiClient, baseRepo.RepoHost(), issueFeatures.ApiActorsSupported, !opts.WebMode) assignees, err := assigneeReplacer.ReplaceSlice(opts.Assignees) if err != nil { return err @@ -189,7 +189,7 @@ func createRun(opts *CreateOptions) (err error) { tb := prShared.IssueMetadataState{ Type: prShared.IssueMetadata, - ApiActorsSupported: issueFeatures.ActorIsAssignable, // TODO ApiActorsSupported + ApiActorsSupported: issueFeatures.ApiActorsSupported, // TODO ApiActorsSupported Assignees: assigneeSet.ToSlice(), Labels: opts.Labels, ProjectTitles: opts.Projects, @@ -309,7 +309,7 @@ func createRun(opts *CreateOptions) (err error) { State: &tb, } var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult - if issueFeatures.ActorIsAssignable { + if issueFeatures.ApiActorsSupported { assigneeSearchFunc = prShared.RepoAssigneeSearchFunc(apiClient, baseRepo) } err = prShared.MetadataSurvey(opts.Prompter, opts.IO, baseRepo, fetcher, &tb, projectsV1Support, nil, assigneeSearchFunc) diff --git a/pkg/cmd/issue/edit/edit.go b/pkg/cmd/issue/edit/edit.go index 965fc0b4957..1d5455504e2 100644 --- a/pkg/cmd/issue/edit/edit.go +++ b/pkg/cmd/issue/edit/edit.go @@ -216,7 +216,7 @@ func editRun(opts *EditOptions) error { lookupFields := []string{"id", "number", "title", "body", "url"} if editable.Assignees.Edited { // TODO ApiActorsSupported - if issueFeatures.ActorIsAssignable { + if issueFeatures.ApiActorsSupported { editable.ApiActorsSupported = true lookupFields = append(lookupFields, "assignedActors") } else { @@ -249,9 +249,9 @@ 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. + // Wire up search function for assignees when ApiActorsSupported 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 { + if issueFeatures.ApiActorsSupported && opts.Interactive && len(issues) == 1 { editable.AssigneeSearchFunc = prShared.AssigneeSearchFunc(apiClient, baseRepo, issues[0].ID) } diff --git a/pkg/cmd/issue/edit/edit_test.go b/pkg/cmd/issue/edit/edit_test.go index 41af97fef96..626c28162da 100644 --- a/pkg/cmd/issue/edit/edit_test.go +++ b/pkg/cmd/issue/edit/edit_test.go @@ -935,7 +935,7 @@ func mockProjectV2ItemUpdate(t *testing.T, reg *httpmock.Registry) { ) } -func TestActorIsAssignable(t *testing.T) { +func TestApiActorsSupported(t *testing.T) { t.Run("when actors are assignable, query includes assignedActors", func(t *testing.T) { ios, _, _, _ := iostreams.Test() diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index b62e04d736c..37f65837918 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -399,7 +399,7 @@ func createRun(opts *CreateOptions) error { client := ctx.Client - // Detect ActorIsAssignable feature to determine if we can use search-based + // Detect ApiActorsSupported feature to determine if we can use search-based // reviewer selection (github.com) or need to use legacy ID-based selection (GHES) issueFeatures, err := opts.Detector.IssueFeatures() if err != nil { @@ -407,7 +407,7 @@ func createRun(opts *CreateOptions) error { } var reviewerSearchFunc func(string) prompter.MultiSelectSearchResult var assigneeSearchFunc func(string) prompter.MultiSelectSearchResult - if issueFeatures.ActorIsAssignable { + if issueFeatures.ApiActorsSupported { reviewerSearchFunc = func(query string) prompter.MultiSelectSearchResult { candidates, moreResults, err := api.SuggestedReviewerActorsForRepo(client, ctx.PRRefs.BaseRepo(), query) if err != nil { @@ -430,7 +430,7 @@ func createRun(opts *CreateOptions) error { } // TODO ApiActorsSupported - if issueFeatures.ActorIsAssignable { + if issueFeatures.ApiActorsSupported { state.ApiActorsSupported = true } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index c2535019b7a..33a71154ad2 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -270,8 +270,8 @@ func editRun(opts *EditOptions) error { return err } - // TODO actorIsAssignableCleanup - if issueFeatures.ActorIsAssignable { + // TODO ApiActorsSupported + if issueFeatures.ApiActorsSupported { findOptions.Fields = append(findOptions.Fields, "assignedActors") } else { findOptions.Fields = append(findOptions.Fields, "assignees") @@ -290,7 +290,7 @@ func editRun(opts *EditOptions) error { editable.Reviewers.Default = pr.ReviewRequests.DisplayNames() editable.Reviewers.DefaultLogins = pr.ReviewRequests.Logins() // TODO ApiActorsSupported - if issueFeatures.ActorIsAssignable { + if issueFeatures.ApiActorsSupported { editable.ApiActorsSupported = true editable.Assignees.Default = pr.AssignedActors.DisplayNames() editable.Assignees.DefaultLogins = pr.AssignedActors.Logins() @@ -320,8 +320,8 @@ func editRun(opts *EditOptions) error { // Wire up search functions for assignees and reviewers. // When these aren't wired up, it triggers a downstream fallback // to legacy reviewer/assignee fetching. - // TODO actorIsAssignableCleanup - if issueFeatures.ActorIsAssignable { + // TODO ApiActorsSupported + if issueFeatures.ApiActorsSupported { editable.AssigneeSearchFunc = shared.AssigneeSearchFunc(apiClient, repo, pr.ID) editable.ReviewerSearchFunc = reviewerSearchFunc(apiClient, repo, &editable, pr.ID) } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 544c04bc81f..0936d941441 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -275,7 +275,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface } values.Reviewers = selectedReviewers } else if len(reviewers) > 0 { - // TODO requestReviewsByLoginCleanup + // TODO ApiActorsSupported // The static MultiSelect path can be removed once GHES supports // requestReviewsByLogin and search-based selection is always used. selected, err := p.MultiSelect("Reviewers", state.Reviewers, reviewers) From 92f205e54bca14d70d428fd7e5fd2f4fe596d57c Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:15:39 -0600 Subject: [PATCH 03/10] docs(featuredetection): document GHES removal criteria for ApiActorsSupported Expand the ApiActorsSupported doc comment to explain the two API generations (legacy AssignableUser vs actor-based AssignableActor), what each returns, and the specific GraphQL schema additions to check for when evaluating GHES support. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../featuredetection/feature_detection.go | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/internal/featuredetection/feature_detection.go b/internal/featuredetection/feature_detection.go index f0759bd3ea4..d4ef62070a8 100644 --- a/internal/featuredetection/feature_detection.go +++ b/internal/featuredetection/feature_detection.go @@ -24,11 +24,29 @@ type Detector interface { type IssueFeatures struct { // TODO ApiActorsSupported - // ApiActorsSupported indicates the host supports actor-based APIs - // (replaceActorsForAssignable, requestReviewsByLogin, suggestedAssignableActors, etc.). - // True for github.com and ghe.com. False for GHES. - // Remove this flag once GHES supports these mutations, then collapse all - // // TODO ApiActorsSupported sites to the actor-only path. + // ApiActorsSupported indicates the host supports actor-based APIs. True for + // github.com and ghe.com, false for GHES. + // + // The GitHub API has two generations of assignee/reviewer types: + // + // Legacy (GHES): Uses AssignableUser (users only) and node-ID-based mutations. + // - assignableUsers query returns []AssignableUser + // - Mutations take node IDs (assigneeIds, userReviewerIds, teamReviewerIds) + // + // Actor-based (github.com): Uses AssignableActor (User + Bot union) and + // login-based mutations, enabling assignment of non-user actors like Copilot. + // - suggestedActors query returns []AssignableActor (User | Bot) + // - suggestedReviewerActors returns []ReviewerCandidate (User | Bot | Team) + // - Mutations take logins (replaceActorsForAssignable, requestReviewsByLogin) + // + // When GHES adds support for the actor-based types and mutations, this flag + // can be removed and all // TODO ApiActorsSupported sites collapsed to the + // actor-only path. To verify GHES support, check whether the GHES GraphQL + // schema includes: + // - The suggestedActors field on Repository (assignee search) + // - The suggestedReviewerActors field on PullRequest (reviewer search) + // - The replaceActorsForAssignable mutation + // - The requestReviewsByLogin mutation ApiActorsSupported bool } From 6a68ebc1c9e49cfb0760be7fd7d987fb582c455d Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 24 Mar 2026 21:37:58 -0600 Subject: [PATCH 04/10] refactor(survey): simplify ApiActorsSupported in RepoMetadataInput The expression was redundantly re-checking whether assignees or reviewers were chosen. RepoMetadata already gates on input.Assignees || input.Reviewers before consulting ApiActorsSupported, so passing state.ApiActorsSupported directly is sufficient. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/survey.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 0936d941441..c219a13cfbc 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -190,7 +190,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface Reviewers: isChosen("Reviewers") && !useReviewerSearch, TeamReviewers: isChosen("Reviewers") && !useReviewerSearch, Assignees: isChosen("Assignees") && !useAssigneeSearch, - ApiActorsSupported: state.ApiActorsSupported && ((isChosen("Assignees") && !useAssigneeSearch) || (isChosen("Reviewers") && !useReviewerSearch)), + ApiActorsSupported: state.ApiActorsSupported, Labels: isChosen("Labels"), ProjectsV1: isChosen("Projects") && projectsV1Support == gh.ProjectsV1Supported, ProjectsV2: isChosen("Projects"), From bff468bafe1d7144d23aaf7ff25191361f16f9e2 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:25:32 -0600 Subject: [PATCH 05/10] fix(pr create): wire up @copilot assignee replacement and [bot] suffix Two bugs introduced in #13009 found during acceptance testing: 1. `pr create --assignee @copilot` sent the literal `@copilot` to the API because NewIssueState only ran MeReplacer on assignees, not CopilotReplacer. Fixed by switching to SpecialAssigneeReplacer (which handles both @me and @copilot) like issue create already does. 2. The replaceActorsForAssignable mutation requires the [bot] suffix on bot actor logins (e.g. `copilot-swe-agent[bot]`), unlike requestReviewsByLogin which has a separate botLogins field. Added the suffix in ReplaceActorsForAssignableByLogin for CopilotAssigneeLogin. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/queries_pr.go | 6 ++++++ pkg/cmd/issue/create/create_test.go | 4 ++-- pkg/cmd/pr/create/create.go | 8 ++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/api/queries_pr.go b/api/queries_pr.go index 073ef6886cb..29342521f8c 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -599,6 +599,12 @@ func ReplaceActorsForAssignableByLogin(client *Client, repo ghrepo.Interface, as actorLogins := make([]githubv4.String, len(logins)) for i, l := range logins { + // The replaceActorsForAssignable mutation requires the [bot] suffix + // for bot actor logins (e.g. "copilot-swe-agent[bot]"), unlike + // requestReviewsByLogin which has a separate botLogins field. + if l == CopilotAssigneeLogin { + l = l + "[bot]" + } actorLogins[i] = githubv4.String(l) } diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index 57de4c9b579..bf8315a9a0b 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -548,7 +548,7 @@ func Test_createRun(t *testing.T) { { "data": { "replaceActorsForAssignable": { "__typename": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "ISSUEID", inputs["assignableId"]) - assert.Equal(t, []interface{}{"copilot-swe-agent", "MonaLisa"}, inputs["actorLogins"]) + assert.Equal(t, []interface{}{"copilot-swe-agent[bot]", "MonaLisa"}, inputs["actorLogins"]) })) }, wantsStdout: "https://github.com/OWNER/REPO/issues/12\n", @@ -1161,7 +1161,7 @@ func TestIssueCreate_AtCopilotAssignee(t *testing.T) { { "data": { "replaceActorsForAssignable": { "__typename": "" } } } `, func(inputs map[string]interface{}) { assert.Equal(t, "NEWISSUEID", inputs["assignableId"]) - assert.Equal(t, []interface{}{"copilot-swe-agent"}, inputs["actorLogins"]) + assert.Equal(t, []interface{}{"copilot-swe-agent[bot]"}, inputs["actorLogins"]) })) output, err := runCommand(http, true, `-a @copilot -t hello -b "cash rules everything around me"`, nil) diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 37f65837918..1a3eba7d882 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -424,7 +424,7 @@ func createRun(opts *CreateOptions) error { assigneeSearchFunc = shared.RepoAssigneeSearchFunc(client, ctx.PRRefs.BaseRepo()) } - state, err := NewIssueState(*ctx, *opts) + state, err := NewIssueState(*ctx, *opts, issueFeatures.ApiActorsSupported) if err != nil { return err } @@ -672,14 +672,14 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState, u return nil } -func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) { +func NewIssueState(ctx CreateContext, opts CreateOptions, apiActorsSupported bool) (*shared.IssueMetadataState, error) { var milestoneTitles []string if opts.Milestone != "" { milestoneTitles = []string{opts.Milestone} } - meReplacer := shared.NewMeReplacer(ctx.Client, ctx.PRRefs.BaseRepo().RepoHost()) - assignees, err := meReplacer.ReplaceSlice(opts.Assignees) + assigneeReplacer := shared.NewSpecialAssigneeReplacer(ctx.Client, ctx.PRRefs.BaseRepo().RepoHost(), apiActorsSupported, !opts.WebMode) + assignees, err := assigneeReplacer.ReplaceSlice(opts.Assignees) if err != nil { return nil, err } From 391e6616d5abb75cc4d148bb671b15c2f4f28c70 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Tue, 24 Mar 2026 23:28:26 -0600 Subject: [PATCH 06/10] fix(survey): use useReviewerSearch consistently in prompt path The reviewer prompt branch checked reviewerSearchFunc != nil directly instead of useReviewerSearch, making the fetch and prompt decisions inconsistent. This mirrors how the assignee path already uses useAssigneeSearch at both the fetch and prompt gates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/cmd/pr/shared/survey.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index c219a13cfbc..05b41d79bac 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -263,7 +263,7 @@ func MetadataSurvey(p Prompt, io *iostreams.IOStreams, baseRepo ghrepo.Interface }{} if isChosen("Reviewers") { - if reviewerSearchFunc != nil { + if useReviewerSearch { selectedReviewers, err := p.MultiSelectWithSearch( "Reviewers", "Search reviewers", From c82177c7020e2cca73ed19d2832ac11ed2745385 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:45:59 -0400 Subject: [PATCH 07/10] Align triage.md with unified triage process --- docs/triage.md | 147 ++++++++++++++++++++++--------------------------- 1 file changed, 66 insertions(+), 81 deletions(-) diff --git a/docs/triage.md b/docs/triage.md index 54443345fd3..9cf44f8d77f 100644 --- a/docs/triage.md +++ b/docs/triage.md @@ -1,117 +1,102 @@ # Triage role -As we get more issues and pull requests opened on the GitHub CLI, we've decided on a weekly rotation triage role as defined by our First Responder (FR) rotation. The primary responsibility of the FR during that week is to triage incoming issues from the Open Source community, as defined below. An issue is considered "triaged" when the `needs-triage` label is removed. +The primary responsibility of the First Responder (FR) during their weekly rotation is to triage incoming issues and pull requests from the open source community. An issue is considered "triaged" when the `needs-triage` label is removed. -## Expectations for triaging incoming issues +## Quick Guide -Review and label [open issues missing either the `enhancement`, `bug`, or `docs` label](https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue+-label%3Abug%2Cenhancement%2Cdocs+) and the label(s) corresponding to the command space prefixed with `gh-`, such as `gh-pr` for the `gh pr` command set, or `gh-extension` for the `gh extension` command set. +Pick an issue from the triage queue. -The heuristics for triaging the different issue types are as follows: +**Your goal:** Do what is needed to remove the `needs-triage` label. -### Bugs +1. **Can we close it?** + - Duplicate → Comment and close as duplicate, linking the original + - Spam → Add `invalid` or `suspected-spam` (auto-closes) + - Abuse → Add `invalid`, remove content, report, block (see [Spam and abuse](#spam-and-abuse)) + - Off-topic → Add `off-topic` (auto-closes with comment) -For bugs, the FR should engage with the issue and community with the goal to remove the `needs-triage` label from the issue. +2. **Is it a bug?** + - Reproducible → Add `bug` and a priority label (`priority-1`, `priority-2`, or `priority-3`) + - Not reproducible → Add `unable-to-reproduce` (auto-requests info, 14-day timer) -To be considered triaged, `bug` issues require the following: +3. **Is it an enhancement?** + - Clear value → Add `enhancement` (auto-posts backlog comment) + - Unclear → Comment for clarification and add `more-info-needed` (14-day timer) -- A severity label `priority-1`, `priority-2`, and `priority-3` -- Clearly defined Acceptance Criteria, added to the Issue as a standalone comment (see [example](https://github.com/cli/cli/issues/9469#issuecomment-2292315743)) +4. **Is it a pull request?** (see [Community pull requests](#community-pull-requests)) + - Spam or AI sludge → Add `invalid` (auto-closes) + - Tiny fix (e.g., typo) → Review, test, and merge directly + - Not linked to a help-wanted issue → Add `no-help-wanted-issue` (auto-closes with comment) + - Valid → Add `ready-for-review` and run CI (auto-removes `needs-triage`, auto-posts acknowledging comment) -#### Bug severities +The `needs-triage` label is automatically removed when end-state labels (`enhancement`, `bug`, `ready-for-review`) are applied or the issue is closed. -| Severity | Description | -| - | - | -| `priority-1` | Affects a large population and inhibits work | -| `priority-2` | Affects more than a few users but doesn't prevent core functions | -| `priority-3` | Affects a small number of users or is largely cosmetic | - -### Enhancements and Docs - -For `enhancement` issues, the FR's role is to prepare the issue for team review and triage. - -When a new issue is opened, the FR **should**: +## Bug Triage -- Acknowledge the issue -- Assign themselves to the issue -- Ensure there is enough information to understand the enhancement's scope and value -- Ask the user for more information about value and use-case, if necessary -- Leave the `needs-triage` label on the issue -- Add the `more-info-needed` and `needs-investigation` labels as needed +1. Try to reproduce the issue +2. If reproducible (or strongly suspect an intermittent bug) → add `bug` and a priority label +3. If not reproducible → add `unable-to-reproduce` (auto-requests info, 14-day timer) or request clarification with `more-info-needed` -When the FR has enough information to be triaged, they should: -- Remove the `more-info-needed` and `needs-investigation` labels -- Remove their assignment from the issue +### Bug Priorities -The FR should **avoid**: - -- Thoroughly investigating the enhancement's technical feasibility -- Prematurely accepting the enhancement request -- Removing the `needs-triage` label -- Labeling issues as `help wanted` +| Priority | Description | +|----------|-------------| +| `priority-1` | Affects a large population and inhibits work. **Escalate in Slack; may require a hotfix.** | +| `priority-2` | Affects more than a few users but does not prevent core functions | +| `priority-3` | Affects a small number of users or is largely cosmetic | -## Additional triaging labels +## Enhancement Triage -The FR can consider adding any of the following labels below. +**Do:** +- Ensure the value is clear (ask if needed) and apply `more-info-needed` while waiting for clarification +- Apply the `enhancement` label once value is clear (auto-posts backlog comment) -| Label | Description | -| - | - | -| `discuss` | Some issues require discussion with the internal team. Adding this label will automatically open up an internal discussion with the team to facilitate this discussion. | -| `core` | Defines what we would like to do internally. We tend to lean towards `help wanted` by default, and adding `core` should be reserved for trickier issues or implementations we have strong opinions/preferences about. | -| `more-info-needed` | After asking any contributors for more information, add this label so it is clear that the issue has been responded to and we are waiting on the user. | -| `needs-investigation` | Used when the issue requires further investigation before it can be reviewed and triaged. This is often used for issues that are not clearly bugs or enhancements, or when the FR needs to gather more information before proceeding. | -| `invalid` | Added to spam and abusive issues. | +**Don't:** +- Deep-dive technical feasibility +- Prematurely accept or suggest the feature will be added -### Labels for team enhancement triaging +## Community Pull Requests -The FR should **avoid** adding these labels outside of team enhancement triage. +Community pull requests receive `needs-triage` (as well as `external`) just like issues do, but **are not meant to be reviewed as part of triage.** -| Label | Description | -| - | - | -| `good first issue` | Used to denote when an issue may be a good candidate for a first-time contributor to the CLI. These are usually small and well defined issues. | -| `help wanted` | These issues are ready for community contribution. | -| `help wanted candidate` | Used to denote when an issue may be a good candidate for community contribution. Issues labelled this way are discussed internally and may be promoted to `help wanted`. | +The triager's responsibility is to do a quick pass: -## Expectations for community pull requests +1. **Spam or AI sludge** → Add `invalid` label (auto-closes). Block user if necessary. +2. **Tiny mergeable fix** (e.g., typo) → Review, test, and merge. +3. **Not related to a help-wanted issue** → Add `no-help-wanted-issue` (auto-closes with comment). +4. **Valid for review** → Add `ready-for-review` and run CI (auto-removes `needs-triage`, auto-posts acknowledging comment). -All incoming pull requests are assigned to one of the engineers for review on a load-balanced basis. -The person in a triage role for a week could take a glance at these pull requests, mostly to see whether -the changeset is feasible and to allow the associated CI run for new contributors. +The pull request will be auto-assigned to an engineer on the team; that engineer will wait to review until `needs-triage` is removed. -## Spam and abuse +## Spam and Abuse The primary goal of triaging spam and abuse is to remove distracting and offensive content from our community. -We get a lot of spam. Whenever you determine an issue as spam, add the `invalid` label and close it as "won't do". For spammy comments, simply mark them as spam using GitHub's built-in spam feature. - -Abusive contributions are defined by our [Code of Conduct](../.github/CODE-OF-CONDUCT.md). Any contribution you determine abusive should be removed. Repeat offenses or particularly offensive abuse should be reported using GitHub's reporting features and the user blocked. If an entire issue is abusive, label it as `invalid` and close as "won't do". +- **Spam issues:** Add the `invalid` label and close as "won't do". +- **Spam comments:** Mark as spam using GitHub's built-in feature. +- **Abusive content:** Defined by our [Code of Conduct](../.github/CODE-OF-CONDUCT.md). Remove the content. Repeat offenses or particularly offensive abuse should be reported and the user blocked. -## Weekly PR audit +## Automated Workflows -In the interest of not letting our open PR list get out of hand (20+ total PRs _or_ multiple PRs -over a few months old), try to audit open PRs each week with the goal of getting them merged and/or -closed. It's likely too much work to deal with every PR, but even getting a few closer to done is -helpful. - -For each PR, ask: - -- is this too stale (more than two months old or too many conflicts)? close with comment -- is this really close but author is absent? push commits to finish, request review -- is this waiting on triage? go through the PR triage flow - -## Useful aliases - -This gist has some useful aliases for first responders: - -https://gist.github.com/vilmibm/ee6ed8a783e4fef5b69b2ed42d743b1a +| Label | Automation | +|-------|------------| +| `needs-triage` | Auto-added on open; removed when classified or closed | +| `more-info-needed` | Auto-closes after 14 days without response | +| `unable-to-reproduce` | Auto-adds `more-info-needed` + posts comment | +| `enhancement` | Auto-posts backlog comment | +| `invalid` | Auto-closes immediately | +| `suspected-spam` | Auto-closes immediately | +| `off-topic` | Auto-posts explanation comment + closes | +| `no-help-wanted-issue` | Auto-posts explanation comment + closes | +| `ready-for-review` | Auto-removes `needs-triage` + posts acknowledging comment | ## Examples -We want our project to be a safe and encouraging open-source environment. Below are some examples -of how to empathetically respond to or close an issue/PR: +We want our project to be a safe and encouraging open-source environment. Below are some examples of how to empathetically respond to or close an issue/PR: -- [Closing a quality PR its scope is too large](https://github.com/cli/cli/pull/1161) +- [Closing a quality PR when its scope is too large](https://github.com/cli/cli/pull/1161) - [Closing a stale PR](https://github.com/cli/cli/pull/557#issuecomment-639077269) - [Closing a PR that doesn't follow our CONTRIBUTING policy](https://github.com/cli/cli/pull/864) - [Responding to a bug report](https://github.com/desktop/desktop/issues/9195#issuecomment-592243129) -- [Closing an issue that out of scope](https://github.com/cli/cli/issues/777#issuecomment-612926229) +- [Closing an issue that is out of scope](https://github.com/cli/cli/issues/777#issuecomment-612926229) - [Closing an issue with a feature request](https://github.com/desktop/desktop/issues/9722#issuecomment-625461766) + From e6680adaaacbf97951c30d3763a80da1a5b08a6f Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:56:19 -0700 Subject: [PATCH 08/10] Update docs/triage.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/triage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/triage.md b/docs/triage.md index 9cf44f8d77f..ad492aaffc9 100644 --- a/docs/triage.md +++ b/docs/triage.md @@ -40,7 +40,7 @@ The `needs-triage` label is automatically removed when end-state labels (`enhanc | Priority | Description | |----------|-------------| -| `priority-1` | Affects a large population and inhibits work. **Escalate in Slack; may require a hotfix.** | +| `priority-1` | Affects a large population and inhibits work. **Escalate internally via the appropriate incident channel; may require a hotfix.** | | `priority-2` | Affects more than a few users but does not prevent core functions | | `priority-3` | Affects a small number of users or is largely cosmetic | From 5477387fa5846ff27e89292135ede68ef528bd01 Mon Sep 17 00:00:00 2001 From: tidy-dev <75402236+tidy-dev@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:57:10 -0700 Subject: [PATCH 09/10] Update docs/triage.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/triage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/triage.md b/docs/triage.md index ad492aaffc9..e34bea475f2 100644 --- a/docs/triage.md +++ b/docs/triage.md @@ -71,7 +71,7 @@ The pull request will be auto-assigned to an engineer on the team; that engineer The primary goal of triaging spam and abuse is to remove distracting and offensive content from our community. -- **Spam issues:** Add the `invalid` label and close as "won't do". +- **Spam issues:** Add the `invalid` label (auto-closes as "won't do"). - **Spam comments:** Mark as spam using GitHub's built-in feature. - **Abusive content:** Defined by our [Code of Conduct](../.github/CODE-OF-CONDUCT.md). Remove the content. Repeat offenses or particularly offensive abuse should be reported and the user blocked. From e53e360d5bf4a9fd0193fc75066bcbb70ced248c Mon Sep 17 00:00:00 2001 From: Qingwei Li <332664203@qq.com> Date: Thu, 26 Mar 2026 00:31:56 +0800 Subject: [PATCH 10/10] internal/codespaces/portforwarder: define err in go func instead of use err defined in outer scope Fixes #13001 --- internal/codespaces/portforwarder/port_forwarder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/codespaces/portforwarder/port_forwarder.go b/internal/codespaces/portforwarder/port_forwarder.go index b24f8f637f5..951ebdd8ea6 100644 --- a/internal/codespaces/portforwarder/port_forwarder.go +++ b/internal/codespaces/portforwarder/port_forwarder.go @@ -276,7 +276,7 @@ func (fwd *CodespacesPortForwarder) UpdatePortVisibility(ctx context.Context, re done := make(chan error) go func() { // Connect to the tunnel - err = fwd.connection.Connect(ctx) + err := fwd.connection.Connect(ctx) if err != nil { done <- fmt.Errorf("connect failed: %v", err) return