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>
675 lines
20 KiB
Go
675 lines
20 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
|
|
|
|
// 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 {
|
|
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.
|
|
type EditableAssignees struct {
|
|
EditableSlice
|
|
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 {
|
|
// TODO ApiActorsSupported
|
|
replacer := NewSpecialAssigneeReplacer(client, repo.RepoHost(), e.ApiActorsSupported, true)
|
|
|
|
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.
|
|
// TODO ApiActorsSupported
|
|
if e.ApiActorsSupported {
|
|
assigneeSet.AddValues(e.Assignees.DefaultLogins)
|
|
} else {
|
|
assigneeSet.AddValues(e.Assignees.Default)
|
|
}
|
|
|
|
add, err := replacer.ReplaceSlice(e.Assignees.Add)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
assigneeSet.AddValues(add)
|
|
|
|
remove, err := replacer.ReplaceSlice(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
|
|
}
|
|
|
|
// AssigneeLogins computes the final list of assignee logins from the current
|
|
// defaults plus any Add/Remove operations. Unlike AssigneeIds, this does not
|
|
// resolve logins to node IDs, and is used on github.com where the
|
|
// ReplaceActorsForAssignable mutation accepts logins directly.
|
|
func (e Editable) AssigneeLogins(client *api.Client, repo ghrepo.Interface) ([]string, error) {
|
|
if !e.Assignees.Edited {
|
|
return nil, nil
|
|
}
|
|
|
|
if len(e.Assignees.Add) != 0 || len(e.Assignees.Remove) != 0 {
|
|
replacer := NewSpecialAssigneeReplacer(client, repo.RepoHost(), true, true)
|
|
|
|
assigneeSet := set.NewStringSet()
|
|
assigneeSet.AddValues(e.Assignees.DefaultLogins)
|
|
|
|
add, err := replacer.ReplaceSlice(e.Assignees.Add)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
assigneeSet.AddValues(add)
|
|
|
|
remove, err := replacer.ReplaceSlice(e.Assignees.Remove)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
assigneeSet.RemoveValues(remove)
|
|
|
|
e.Assignees.Value = assigneeSet.ToSlice()
|
|
}
|
|
|
|
return e.Assignees.Value, nil
|
|
}
|
|
|
|
// SpecialAssigneeReplacer expands special assignee names (@me, Copilot actors)
|
|
// in login slices. Use NewSpecialAssigneeReplacer to create one.
|
|
type SpecialAssigneeReplacer struct {
|
|
meReplacer *MeReplacer
|
|
copilotReplacer *CopilotReplacer
|
|
actorAssignees bool
|
|
}
|
|
|
|
// NewSpecialAssigneeReplacer creates a replacer that expands @me and (when
|
|
// actorAssignees is true) Copilot actor names in assignee slices.
|
|
// copilotUseLogin controls whether Copilot actors are replaced with their
|
|
// login (true) or display name (false, used for web mode).
|
|
func NewSpecialAssigneeReplacer(client *api.Client, host string, actorAssignees bool, copilotUseLogin bool) *SpecialAssigneeReplacer {
|
|
return &SpecialAssigneeReplacer{
|
|
meReplacer: NewMeReplacer(client, host),
|
|
copilotReplacer: NewCopilotReplacer(copilotUseLogin),
|
|
actorAssignees: actorAssignees,
|
|
}
|
|
}
|
|
|
|
func (r *SpecialAssigneeReplacer) ReplaceSlice(logins []string) ([]string, error) {
|
|
replaced, err := r.meReplacer.ReplaceSlice(logins)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if r.actorAssignees {
|
|
replaced = r.copilotReplacer.ReplaceSlice(replaced)
|
|
}
|
|
return replaced, nil
|
|
}
|
|
|
|
// 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(),
|
|
ReviewerSearchFunc: e.ReviewerSearchFunc,
|
|
Assignees: e.Assignees.clone(),
|
|
AssigneeSearchFunc: e.AssigneeSearchFunc,
|
|
Labels: e.Labels.clone(),
|
|
Projects: e.Projects.clone(),
|
|
Milestone: e.Milestone.clone(),
|
|
ApiActorsSupported: e.ApiActorsSupported,
|
|
// 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(),
|
|
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
|
|
}
|
|
// For non-interactive Add/Remove operations, we only need to fetch assignees
|
|
// on GHES where ID resolution is required. On github.com (ApiActorsSupported),
|
|
// logins are passed directly to the mutation.
|
|
// 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,
|
|
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 {
|
|
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...)
|
|
// TODO ApiActorsSupported
|
|
if editable.ApiActorsSupported {
|
|
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
|
|
}
|
|
|
|
// AssigneeSearchFunc returns a search function for MultiSelectWithSearch that
|
|
// dynamically fetches assignable actors for the given assignable (Issue/PR) node ID.
|
|
func AssigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, assignableID string) func(string) prompter.MultiSelectSearchResult {
|
|
return func(input string) prompter.MultiSelectSearchResult {
|
|
actors, count, err := api.SuggestedAssignableActors(apiClient, repo, assignableID, input)
|
|
if err != nil {
|
|
return prompter.MultiSelectSearchResult{Err: err}
|
|
}
|
|
return actorsToSearchResult(actors, count)
|
|
}
|
|
}
|
|
|
|
// RepoAssigneeSearchFunc returns a search function for MultiSelectWithSearch that
|
|
// dynamically fetches assignable actors at the repository level. Used during create
|
|
// flows where no issue/PR node ID exists yet.
|
|
func RepoAssigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface) func(string) prompter.MultiSelectSearchResult {
|
|
return func(input string) prompter.MultiSelectSearchResult {
|
|
actors, count, err := api.SearchRepoAssignableActors(apiClient, repo, input)
|
|
if err != nil {
|
|
return prompter.MultiSelectSearchResult{Err: err}
|
|
}
|
|
return actorsToSearchResult(actors, count)
|
|
}
|
|
}
|
|
|
|
func actorsToSearchResult(actors []api.AssignableActor, totalCount int) prompter.MultiSelectSearchResult {
|
|
logins := make([]string, 0, len(actors))
|
|
displayNames := make([]string, 0, len(actors))
|
|
|
|
for _, a := range actors {
|
|
if a.Login() == "" {
|
|
continue
|
|
}
|
|
logins = append(logins, a.Login())
|
|
if a.DisplayName() != "" {
|
|
displayNames = append(displayNames, a.DisplayName())
|
|
} else {
|
|
displayNames = append(displayNames, a.Login())
|
|
}
|
|
}
|
|
return prompter.MultiSelectSearchResult{
|
|
Keys: logins,
|
|
Labels: displayNames,
|
|
MoreResults: totalCount,
|
|
}
|
|
}
|