Updated test mocks and logic to consistently use lowercase 'monalisa' for login names and display names for user assignees. Improved handling of dynamic assignee fetching in interactive flows by relying on searchFunc and metadata population, and clarified logic in FetchOptions to fetch assignees only when necessary. These changes ensure more accurate simulation of interactive assignment and better test coverage for actor assignee features.
533 lines
14 KiB
Go
533 lines
14 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 EditableSlice
|
|
ReviewerSearchFunc func(string) ([]string, []string, error)
|
|
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
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
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 (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 {
|
|
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.
|
|
// 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.
|
|
teamReviewers := 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.
|
|
if len(editable.Reviewers.Add) == 0 && len(editable.Reviewers.Remove) == 0 {
|
|
teamReviewers = true
|
|
}
|
|
}
|
|
|
|
fetchAssignees := false
|
|
if editable.Assignees.Edited {
|
|
// Similar as above, this is likely an interactive flow if no Add/Remove slices are set.
|
|
// The addition here is that we also check for an assignee search func.
|
|
// 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.
|
|
if len(editable.Assignees.Add) > 0 || len(editable.Assignees.Remove) > 0 {
|
|
fetchAssignees = true
|
|
}
|
|
}
|
|
|
|
input := api.RepoMetadataInput{
|
|
Reviewers: editable.Reviewers.Edited,
|
|
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
|
|
}
|