cli/pkg/cmd/pr/shared/editable.go
Kynan Ware e6d9019bc9 fix(pr create): use login-based assignee mutation on github.com
When ActorAssignees is true (github.com), pass assignee logins directly
to the ReplaceActorsForAssignable mutation instead of resolving logins
to node IDs. This eliminates the need to bulk fetch all assignable
users/actors and fixes a bug where providing assignees via CLI flag
and then interactively adding metadata would fail with 'not found'
because the cached MetadataResult had no assignee data.

Changes:
- Set state.ActorAssignees = true in pr create (was missing)
- AddMetadataToIssueParams: pass assigneeLogins when ActorAssignees
  is true, skip fetch and ID resolution entirely
- CreatePullRequest/IssueCreate: call ReplaceActorsForAssignableByLogin
  after creation to assign via logins
- Consolidate replaceActorsForAssignable mutation into api/ package
  (ReplaceActorsForAssignableByLogin + ReplaceActorsForAssignableByID)
- Remove duplicate replaceActorAssigneesForEditable from editable_http.go
- Add TODO replaceActorsByLoginCleanup markers on edit paths

Fixes cli/cli#13000

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-23 15:21:20 -06:00

574 lines
16 KiB
Go

package shared
import (
"fmt"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/gh"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/set"
)
type Editable struct {
Title EditableString
Body EditableString
Base EditableString
Reviewers EditableReviewers
ReviewerSearchFunc func(string) prompter.MultiSelectSearchResult
Assignees EditableAssignees
AssigneeSearchFunc func(string) prompter.MultiSelectSearchResult
Labels EditableSlice
Projects EditableProjects
Milestone EditableString
Metadata api.RepoMetadataResult
}
type EditableString struct {
Value string
Default string
Options []string
Edited bool
}
type EditableSlice struct {
Value []string
Add []string
Remove []string
Default []string
Options []string
Edited bool
Allowed bool
}
// 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
}
// EditableReviewers is a special case of EditableSlice.
type EditableReviewers struct {
EditableSlice
DefaultLogins []string // For disambiguating actors from display names
}
// ProjectsV2 mutations require a mapping of an item ID to a project ID.
// Keep that map along with standard EditableSlice data.
type EditableProjects struct {
EditableSlice
ProjectItems map[string]string
}
func (e Editable) Dirty() bool {
return e.Title.Edited ||
e.Body.Edited ||
e.Base.Edited ||
e.Reviewers.Edited ||
e.Assignees.Edited ||
e.Labels.Edited ||
e.Projects.Edited ||
e.Milestone.Edited
}
func (e Editable) TitleValue() *string {
if !e.Title.Edited {
return nil
}
return &e.Title.Value
}
func (e Editable) BodyValue() *string {
if !e.Body.Edited {
return nil
}
return &e.Body.Value
}
func (e Editable) AssigneeIds(client *api.Client, repo ghrepo.Interface) (*[]string, error) {
if !e.Assignees.Edited {
return nil, nil
}
// If assignees came in from command line flags, we need to
// curate the final list of assignees from the default list.
if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 {
meReplacer := NewMeReplacer(client, repo.RepoHost())
copilotReplacer := NewCopilotReplacer(true)
replaceSpecialAssigneeNames := func(value []string) ([]string, error) {
replaced, err := meReplacer.ReplaceSlice(value)
if err != nil {
return nil, err
}
// Only suppported for actor assignees.
if e.Assignees.ActorAssignees {
replaced = copilotReplacer.ReplaceSlice(replaced)
}
return replaced, nil
}
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
// DisplayNames and not logins (this is to accommodate special actor
// display names in the interactive flow).
// 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 {
assigneeSet.AddValues(e.Assignees.DefaultLogins)
} else {
assigneeSet.AddValues(e.Assignees.Default)
}
add, err := replaceSpecialAssigneeNames(e.Assignees.Add)
if err != nil {
return nil, err
}
assigneeSet.AddValues(add)
remove, err := replaceSpecialAssigneeNames(e.Assignees.Remove)
if err != nil {
return nil, err
}
assigneeSet.RemoveValues(remove)
e.Assignees.Value = assigneeSet.ToSlice()
}
// TODO replaceActorsByLoginCleanup
// When ActorAssignees is true (github.com), this should compute the final
// assignee logins and return them directly without resolving to IDs, for use
// with ReplaceActorsForAssignableByLogin.
a, err := e.Metadata.MembersToIDs(e.Assignees.Value)
return &a, err
}
// ProjectIds returns a slice containing IDs of projects v1 that the issue or a PR has to be linked to.
func (e Editable) ProjectIds() (*[]string, error) {
if !e.Projects.Edited {
return nil, nil
}
if len(e.Projects.Add) != 0 || len(e.Projects.Remove) != 0 {
s := set.NewStringSet()
s.AddValues(e.Projects.Default)
s.AddValues(e.Projects.Add)
s.RemoveValues(e.Projects.Remove)
e.Projects.Value = s.ToSlice()
}
p, _, err := e.Metadata.ProjectsTitlesToIDs(e.Projects.Value)
return &p, err
}
// ProjectV2Ids returns a pair of slices.
// The first is the projects the item should be added to.
// The second is the projects the items should be removed from.
func (e Editable) ProjectV2Ids() (*[]string, *[]string, error) {
if !e.Projects.Edited {
return nil, nil, nil
}
// titles of projects to add
addTitles := set.NewStringSet()
// titles of projects to remove
removeTitles := set.NewStringSet()
if len(e.Projects.Add) != 0 || len(e.Projects.Remove) != 0 {
// Projects were selected using flags.
addTitles.AddValues(e.Projects.Add)
removeTitles.AddValues(e.Projects.Remove)
} else {
// Projects were selected interactively.
addTitles.AddValues(e.Projects.Value)
addTitles.RemoveValues(e.Projects.Default)
removeTitles.AddValues(e.Projects.Default)
removeTitles.RemoveValues(e.Projects.Value)
}
var addIds []string
var removeIds []string
var err error
if addTitles.Len() > 0 {
_, addIds, err = e.Metadata.ProjectsTitlesToIDs(addTitles.ToSlice())
if err != nil {
return nil, nil, err
}
}
if removeTitles.Len() > 0 {
_, removeIds, err = e.Metadata.ProjectsTitlesToIDs(removeTitles.ToSlice())
if err != nil {
return nil, nil, err
}
}
return &addIds, &removeIds, nil
}
func (e Editable) MilestoneId() (*string, error) {
if !e.Milestone.Edited {
return nil, nil
}
if e.Milestone.Value == noMilestone || e.Milestone.Value == "" {
s := ""
return &s, nil
}
m, err := e.Metadata.MilestoneToID(e.Milestone.Value)
return &m, err
}
// Clone creates a mostly-shallow copy of Editable suitable for use in parallel
// go routines. Fields that would be mutated will be copied.
func (e *Editable) Clone() Editable {
return Editable{
Title: e.Title.clone(),
Body: e.Body.clone(),
Base: e.Base.clone(),
Reviewers: e.Reviewers.clone(),
Assignees: e.Assignees.clone(),
Labels: e.Labels.clone(),
Projects: e.Projects.clone(),
Milestone: e.Milestone.clone(),
// Shallow copy since no mutation.
Metadata: e.Metadata,
}
}
func (es *EditableString) clone() EditableString {
return EditableString{
Value: es.Value,
Default: es.Default,
Edited: es.Edited,
// Shallow copies since no mutation.
Options: es.Options,
}
}
func (es *EditableSlice) clone() EditableSlice {
cpy := EditableSlice{
Edited: es.Edited,
Allowed: es.Allowed,
// Shallow copies since no mutation.
Options: es.Options,
// Copy mutable string slices.
Add: make([]string, len(es.Add)),
Remove: make([]string, len(es.Remove)),
Value: make([]string, len(es.Value)),
Default: make([]string, len(es.Default)),
}
copy(cpy.Add, es.Add)
copy(cpy.Remove, es.Remove)
copy(cpy.Value, es.Value)
copy(cpy.Default, es.Default)
return cpy
}
func (ea *EditableAssignees) clone() EditableAssignees {
return EditableAssignees{
EditableSlice: ea.EditableSlice.clone(),
ActorAssignees: ea.ActorAssignees,
DefaultLogins: ea.DefaultLogins,
}
}
func (er *EditableReviewers) clone() EditableReviewers {
return EditableReviewers{
EditableSlice: er.EditableSlice.clone(),
DefaultLogins: er.DefaultLogins,
}
}
func (ep *EditableProjects) clone() EditableProjects {
return EditableProjects{
EditableSlice: ep.EditableSlice.clone(),
ProjectItems: ep.ProjectItems,
}
}
type EditPrompter interface {
Select(string, string, []string) (int, error)
Input(string, string) (string, error)
MarkdownEditor(string, string, bool) (string, error)
MultiSelect(string, []string, []string) ([]int, error)
MultiSelectWithSearch(prompt, searchPrompt string, defaults []string, persistentOptions []string, searchFunc func(string) prompter.MultiSelectSearchResult) ([]string, error)
Confirm(string, bool) (bool, error)
}
func EditFieldsSurvey(p EditPrompter, editable *Editable, editorCommand string) error {
var err error
if editable.Title.Edited {
editable.Title.Value, err = p.Input("Title", editable.Title.Default)
if err != nil {
return err
}
}
if editable.Body.Edited {
editable.Body.Value, err = p.MarkdownEditor("Body", editable.Body.Default, false)
if err != nil {
return err
}
}
if editable.Reviewers.Edited {
if editable.ReviewerSearchFunc != nil {
editable.Reviewers.Options = []string{}
editable.Reviewers.Value, err = p.MultiSelectWithSearch(
"Reviewers",
"Search reviewers",
editable.Reviewers.DefaultLogins,
// No persistent options - teams are included in search results
[]string{},
editable.ReviewerSearchFunc)
if err != nil {
return err
}
} else {
editable.Reviewers.Value, err = multiSelectSurvey(
p, "Reviewers", editable.Reviewers.Default, editable.Reviewers.Options)
if err != nil {
return err
}
}
}
if editable.Assignees.Edited {
if editable.AssigneeSearchFunc != nil {
editable.Assignees.Options = []string{}
editable.Assignees.Value, err = p.MultiSelectWithSearch(
"Assignees",
"Search assignees",
editable.Assignees.DefaultLogins,
// No persistent options required here as teams cannot be assignees.
[]string{},
editable.AssigneeSearchFunc)
if err != nil {
return err
}
} else {
editable.Assignees.Value, err = multiSelectSurvey(
p, "Assignees", editable.Assignees.Default, editable.Assignees.Options)
if err != nil {
return err
}
}
}
if editable.Labels.Edited {
editable.Labels.Add, err = multiSelectSurvey(
p, "Labels", editable.Labels.Default, editable.Labels.Options)
if err != nil {
return err
}
for _, prev := range editable.Labels.Default {
var found bool
for _, selected := range editable.Labels.Add {
if prev == selected {
found = true
break
}
}
if !found {
editable.Labels.Remove = append(editable.Labels.Remove, prev)
}
}
}
if editable.Projects.Edited {
editable.Projects.Value, err = multiSelectSurvey(
p, "Projects", editable.Projects.Default, editable.Projects.Options)
if err != nil {
return err
}
}
if editable.Milestone.Edited {
editable.Milestone.Value, err = milestoneSurvey(p, editable.Milestone.Default, editable.Milestone.Options)
if err != nil {
return err
}
}
confirm, err := p.Confirm("Submit?", true)
if err != nil {
return err
}
if !confirm {
return fmt.Errorf("Discarding...")
}
return nil
}
func FieldsToEditSurvey(p EditPrompter, editable *Editable) error {
contains := func(s []string, str string) bool {
for _, v := range s {
if v == str {
return true
}
}
return false
}
opts := []string{"Title", "Body"}
if editable.Reviewers.Allowed {
opts = append(opts, "Reviewers")
}
opts = append(opts, "Assignees", "Labels", "Projects", "Milestone")
results, err := multiSelectSurvey(p, "What would you like to edit?", []string{}, opts)
if err != nil {
return err
}
if contains(results, "Title") {
editable.Title.Edited = true
}
if contains(results, "Body") {
editable.Body.Edited = true
}
if contains(results, "Reviewers") {
editable.Reviewers.Edited = true
}
if contains(results, "Assignees") {
editable.Assignees.Edited = true
}
if contains(results, "Labels") {
editable.Labels.Edited = true
}
if contains(results, "Projects") {
editable.Projects.Edited = true
}
if contains(results, "Milestone") {
editable.Milestone.Edited = true
}
return nil
}
func FetchOptions(client *api.Client, repo ghrepo.Interface, editable *Editable, projectV1Support gh.ProjectsV1Support) error {
// Determine whether to fetch organization teams and reviewers.
// Interactive reviewer editing (Edited true, but no Add/Remove slices) still needs
// team data for selection UI. For non-interactive flows, we never need to fetch teams
// as the REST API accepts team slugs directly.
// If we have a search func, we don't need to fetch teams/reviewers since we
// assume that will be done dynamically in the prompting flow.
teamReviewers := false
fetchReviewers := false
if editable.Reviewers.Edited {
// This is likely an interactive flow since edited is set but no mutations to
// Add/Remove slices, so we need to load the teams and reviewers.
// However, if we have a search func, skip fetching as it will be done dynamically.
if len(editable.Reviewers.Add) == 0 && len(editable.Reviewers.Remove) == 0 && editable.ReviewerSearchFunc == nil {
teamReviewers = true
fetchReviewers = true
}
// Note: Non-interactive flows (with Add/Remove) don't need to fetch reviewers/teams
// because the APIs in use for both GHES and GitHub.com accept user logins and team slugs directly.
}
fetchAssignees := false
if editable.Assignees.Edited {
// Similar as above, this is likely an interactive flow if no Add/Remove slices are set.
// If we have a search func, we don't need to fetch assignees since we
// assume that will be done dynamically in the prompting flow.
if len(editable.Assignees.Add) == 0 && len(editable.Assignees.Remove) == 0 && editable.AssigneeSearchFunc == nil {
fetchAssignees = true
}
// However, if we have Add/Remove operations (non-interactive flow),
// we do need to fetch the assignees.
// TODO replaceActorsByLoginCleanup
// When ActorAssignees is true, noninteractive assignees should use
// ReplaceActorsForAssignableByLogin to skip this fetch entirely.
if len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0 {
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,
}
metadata, err := api.RepoMetadata(client, repo, input)
if err != nil {
return err
}
var users []string
for _, u := range metadata.AssignableUsers {
users = append(users, u.Login())
}
var actors []string
for _, a := range metadata.AssignableActors {
actors = append(actors, a.DisplayName())
}
var teams []string
for _, t := range metadata.Teams {
teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug))
}
var labels []string
for _, l := range metadata.Labels {
labels = append(labels, l.Name)
}
var projects []string
for _, p := range metadata.Projects {
projects = append(projects, p.Name)
}
for _, p := range metadata.ProjectsV2 {
projects = append(projects, p.Title)
}
milestones := []string{noMilestone}
for _, m := range metadata.Milestones {
milestones = append(milestones, m.Title)
}
editable.Metadata = *metadata
editable.Reviewers.Options = append(users, teams...)
if editable.Assignees.ActorAssignees {
editable.Assignees.Options = actors
} else {
editable.Assignees.Options = users
}
editable.Labels.Options = labels
editable.Projects.Options = projects
editable.Milestone.Options = milestones
return nil
}
func multiSelectSurvey(p EditPrompter, message string, defaults, options []string) (results []string, err error) {
if len(options) == 0 {
return nil, nil
}
var selected []int
selected, err = p.MultiSelect(message, defaults, options)
if err != nil {
return
}
for _, i := range selected {
results = append(results, options[i])
}
return results, err
}
func milestoneSurvey(p EditPrompter, title string, opts []string) (result string, err error) {
if len(opts) == 0 {
return "", nil
}
var selected int
selected, err = p.Select("Milestone", title, opts)
if err != nil {
return
}
result = opts[selected]
return
}