Switches pull request reviewer add/remove operations from GraphQL to the REST API, enabling separate add and remove calls for reviewers and teams. Refactors reviewer editing logic to avoid fetching organization teams unless required for interactive editing, improving performance for non-interactive flows. Updates tests and supporting code to reflect the new reviewer management and metadata fetching behavior.
497 lines
13 KiB
Go
497 lines
13 KiB
Go
package shared
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"github.com/cli/cli/v2/api"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/pkg/set"
|
|
)
|
|
|
|
type Editable struct {
|
|
Title EditableString
|
|
Body EditableString
|
|
Base EditableString
|
|
Reviewers EditableSlice
|
|
Assignees EditableAssignees
|
|
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)
|
|
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 {
|
|
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) 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
|
|
}
|
|
}
|
|
input := api.RepoMetadataInput{
|
|
Reviewers: editable.Reviewers.Edited,
|
|
TeamReviewers: teamReviewers,
|
|
Assignees: editable.Assignees.Edited,
|
|
ActorAssignees: editable.Assignees.ActorAssignees,
|
|
Labels: editable.Labels.Edited,
|
|
ProjectsV1: editable.Projects.Edited,
|
|
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
|
|
}
|