From 375c6cd28f7ded11fe8b53974735ec10404ea865 Mon Sep 17 00:00:00 2001 From: Kynan Ware <47394200+BagToad@users.noreply.github.com> Date: Thu, 15 May 2025 12:50:03 -0600 Subject: [PATCH] feat(issue/pr edit): support @copilot in assignee flags - Introduced CopilotReplacer to handle `@copilot` mentions in assignee lists. --- pkg/cmd/pr/shared/editable.go | 40 +++++++++++++++++++----- pkg/cmd/pr/shared/params.go | 27 ++++++++++++++++ pkg/cmd/pr/shared/params_test.go | 53 ++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/pkg/cmd/pr/shared/editable.go b/pkg/cmd/pr/shared/editable.go index 77bab8440..1bbcd3113 100644 --- a/pkg/cmd/pr/shared/editable.go +++ b/pkg/cmd/pr/shared/editable.go @@ -118,7 +118,28 @@ 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() + + // A closure to replace special assignee names with the actual logins. + 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, err = copilotReplacer.ReplaceSlice(replaced) + if err != nil { + return nil, err + } + } + + 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 +149,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..7ea364707 100644 --- a/pkg/cmd/pr/shared/params.go +++ b/pkg/cmd/pr/shared/params.go @@ -312,3 +312,30 @@ 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, error) { + if strings.EqualFold(handle, "@copilot") { + return "copilot-swe-agent", nil + } + return handle, nil +} + +// Replace replaces usages of `@copilot` in a slice with Copilot's login. +func (r *CopilotReplacer) ReplaceSlice(handles []string) ([]string, error) { + res := make([]string, len(handles)) + for i, h := range handles { + var err error + res[i], err = r.replace(h) + if err != nil { + return nil, err + } + } + return res, nil +} diff --git a/pkg/cmd/pr/shared/params_test.go b/pkg/cmd/pr/shared/params_test.go index 15f00ca4f..b1e3d32d6 100644 --- a/pkg/cmd/pr/shared/params_test.go +++ b/pkg/cmd/pr/shared/params_test.go @@ -187,6 +187,59 @@ 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 + wantErr bool + }{ + { + name: "replaces @copilot with copilot-swe-agent", + args: args{ + handles: []string{"monalisa", "@copilot", "hubot"}, + }, + want: []string{"monalisa", "copilot-swe-agent", "hubot"}, + wantErr: false, + }, + { + name: "handles no @copilot mentions", + args: args{ + handles: []string{"monalisa", "user", "hubot"}, + }, + want: []string{"monalisa", "user", "hubot"}, + wantErr: false, + }, + { + name: "replaces multiple @copilot mentions", + args: args{ + handles: []string{"@copilot", "user", "@copilot"}, + }, + want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, + wantErr: false, + }, + { + name: "handles @copilot case-insensitively", + args: args{ + handles: []string{"@Copilot", "user", "@CoPiLoT"}, + }, + want: []string{"copilot-swe-agent", "user", "copilot-swe-agent"}, + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := NewCopilotReplacer() + got, err := r.ReplaceSlice(tt.args.handles) + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + func Test_QueryHasStateClause(t *testing.T) { tests := []struct { searchQuery string