Refactored the MultiSelectWithSearch function and related interfaces to use a MultiSelectSearchResult struct instead of multiple return values. This change improves clarity and extensibility of the search function signature, and updates all usages, mocks, and tests accordingly.
487 lines
15 KiB
Go
487 lines
15 KiB
Go
package edit
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/MakeNowJust/heredoc"
|
|
"github.com/cli/cli/v2/api"
|
|
fd "github.com/cli/cli/v2/internal/featuredetection"
|
|
"github.com/cli/cli/v2/internal/gh"
|
|
"github.com/cli/cli/v2/internal/ghrepo"
|
|
"github.com/cli/cli/v2/internal/prompter"
|
|
shared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
|
"github.com/cli/cli/v2/pkg/cmdutil"
|
|
"github.com/cli/cli/v2/pkg/iostreams"
|
|
"github.com/cli/cli/v2/pkg/set"
|
|
"github.com/spf13/cobra"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
type EditOptions struct {
|
|
HttpClient func() (*http.Client, error)
|
|
IO *iostreams.IOStreams
|
|
|
|
Finder shared.PRFinder
|
|
Surveyor Surveyor
|
|
Fetcher EditableOptionsFetcher
|
|
EditorRetriever EditorRetriever
|
|
Prompter shared.EditPrompter
|
|
Detector fd.Detector
|
|
BaseRepo func() (ghrepo.Interface, error)
|
|
|
|
SelectorArg string
|
|
Interactive bool
|
|
|
|
shared.Editable
|
|
}
|
|
|
|
func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command {
|
|
opts := &EditOptions{
|
|
IO: f.IOStreams,
|
|
HttpClient: f.HttpClient,
|
|
Surveyor: surveyor{P: f.Prompter},
|
|
Fetcher: fetcher{},
|
|
EditorRetriever: editorRetriever{config: f.Config},
|
|
Prompter: f.Prompter,
|
|
}
|
|
|
|
var bodyFile string
|
|
var removeMilestone bool
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "edit [<number> | <url> | <branch>]",
|
|
Short: "Edit a pull request",
|
|
Long: heredoc.Docf(`
|
|
Edit a pull request.
|
|
|
|
Without an argument, the pull request that belongs to the current branch
|
|
is selected.
|
|
|
|
Editing a pull request's projects requires authorization with the %[1]sproject%[1]s scope.
|
|
To authorize, run %[1]sgh auth refresh -s project%[1]s.
|
|
|
|
The %[1]s--add-assignee%[1]s and %[1]s--remove-assignee%[1]s flags both support
|
|
the following special values:
|
|
- %[1]s@me%[1]s: assign or unassign yourself
|
|
- %[1]s@copilot%[1]s: assign or unassign Copilot (not supported on GitHub Enterprise Server)
|
|
|
|
The %[1]s--add-reviewer%[1]s and %[1]s--remove-reviewer%[1]s flags do not support
|
|
these special values.
|
|
`, "`"),
|
|
Example: heredoc.Doc(`
|
|
$ gh pr edit 23 --title "I found a bug" --body "Nothing works"
|
|
$ gh pr edit 23 --add-label "bug,help wanted" --remove-label "core"
|
|
$ gh pr edit 23 --add-reviewer monalisa,hubot --remove-reviewer myorg/team-name
|
|
$ gh pr edit 23 --add-assignee "@me" --remove-assignee monalisa,hubot
|
|
$ gh pr edit 23 --add-assignee "@copilot"
|
|
$ gh pr edit 23 --add-project "Roadmap" --remove-project v1,v2
|
|
$ gh pr edit 23 --milestone "Version 1"
|
|
$ gh pr edit 23 --remove-milestone
|
|
`),
|
|
Args: cobra.MaximumNArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts.Finder = shared.NewFinder(f)
|
|
|
|
// support `-R, --repo` override
|
|
opts.BaseRepo = f.BaseRepo
|
|
|
|
if len(args) > 0 {
|
|
opts.SelectorArg = args[0]
|
|
}
|
|
|
|
if opts.SelectorArg != "" {
|
|
// If a URL is provided, we need to parse it to override the
|
|
// base repository, especially the hostname part. That's because
|
|
// we need a feature detector down in this command, and that
|
|
// needs to know the API host. If the command is run outside of
|
|
// a git repo, we cannot instantiate the detector unless we have
|
|
// already parsed the URL.
|
|
if baseRepo, _, _, err := shared.ParseURL(opts.SelectorArg); err == nil {
|
|
opts.BaseRepo = func() (ghrepo.Interface, error) {
|
|
return baseRepo, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
flags := cmd.Flags()
|
|
|
|
bodyProvided := flags.Changed("body")
|
|
bodyFileProvided := bodyFile != ""
|
|
|
|
if err := cmdutil.MutuallyExclusive(
|
|
"specify only one of `--body` or `--body-file`",
|
|
bodyProvided,
|
|
bodyFileProvided,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
if bodyProvided || bodyFileProvided {
|
|
opts.Editable.Body.Edited = true
|
|
if bodyFileProvided {
|
|
b, err := cmdutil.ReadFile(bodyFile, opts.IO.In)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
opts.Editable.Body.Value = string(b)
|
|
}
|
|
}
|
|
|
|
if err := cmdutil.MutuallyExclusive(
|
|
"specify only one of `--milestone` or `--remove-milestone`",
|
|
flags.Changed("milestone"),
|
|
removeMilestone,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
|
|
if flags.Changed("title") {
|
|
opts.Editable.Title.Edited = true
|
|
}
|
|
if flags.Changed("body") {
|
|
opts.Editable.Body.Edited = true
|
|
}
|
|
if flags.Changed("base") {
|
|
opts.Editable.Base.Edited = true
|
|
}
|
|
if flags.Changed("add-reviewer") || flags.Changed("remove-reviewer") {
|
|
opts.Editable.Reviewers.Edited = true
|
|
}
|
|
if flags.Changed("add-assignee") || flags.Changed("remove-assignee") {
|
|
opts.Editable.Assignees.Edited = true
|
|
}
|
|
if flags.Changed("add-label") || flags.Changed("remove-label") {
|
|
opts.Editable.Labels.Edited = true
|
|
}
|
|
if flags.Changed("add-project") || flags.Changed("remove-project") {
|
|
opts.Editable.Projects.Edited = true
|
|
}
|
|
if flags.Changed("milestone") || removeMilestone {
|
|
opts.Editable.Milestone.Edited = true
|
|
|
|
// Note that when `--remove-milestone` is provided, the value of
|
|
// `opts.Editable.Milestone.Value` will automatically be empty,
|
|
// which results in milestone association removal. For reference,
|
|
// see the `Editable.MilestoneId` method.
|
|
}
|
|
|
|
if !opts.Editable.Dirty() {
|
|
opts.Interactive = true
|
|
}
|
|
|
|
if opts.Interactive && !opts.IO.CanPrompt() {
|
|
return cmdutil.FlagErrorf("--title, --body, --reviewer, --assignee, --label, --project, or --milestone required when not running interactively")
|
|
}
|
|
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
|
|
return editRun(opts)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().StringVarP(&opts.Editable.Title.Value, "title", "t", "", "Set the new title.")
|
|
cmd.Flags().StringVarP(&opts.Editable.Body.Value, "body", "b", "", "Set the new body.")
|
|
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
|
|
cmd.Flags().StringVarP(&opts.Editable.Base.Value, "base", "B", "", "Change the base `branch` for this pull request")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Add, "add-reviewer", nil, "Add reviewers by their `login`.")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Remove, "remove-reviewer", nil, "Remove reviewers by their `login`.")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Add, "add-assignee", nil, "Add assigned users by their `login`. Use \"@me\" to assign yourself, or \"@copilot\" to assign Copilot.")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Assignees.Remove, "remove-assignee", nil, "Remove assigned users by their `login`. Use \"@me\" to unassign yourself, or \"@copilot\" to unassign Copilot.")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Add, "add-label", nil, "Add labels by `name`")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Labels.Remove, "remove-label", nil, "Remove labels by `name`")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Add, "add-project", nil, "Add the pull request to projects by `title`")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `title`")
|
|
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`")
|
|
cmd.Flags().BoolVar(&removeMilestone, "remove-milestone", false, "Remove the milestone association from the pull request")
|
|
|
|
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base")
|
|
|
|
for _, flagName := range []string{"add-reviewer", "remove-reviewer"} {
|
|
_ = cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
|
baseRepo, err := f.BaseRepo()
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveError
|
|
}
|
|
httpClient, err := f.HttpClient()
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveError
|
|
}
|
|
results, err := shared.RequestableReviewersForCompletion(httpClient, baseRepo)
|
|
if err != nil {
|
|
return nil, cobra.ShellCompDirectiveError
|
|
}
|
|
return results, cobra.ShellCompDirectiveNoFileComp
|
|
})
|
|
}
|
|
|
|
return cmd
|
|
}
|
|
|
|
func editRun(opts *EditOptions) error {
|
|
httpClient, err := opts.HttpClient()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.Detector == nil {
|
|
baseRepo, err := opts.BaseRepo()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cachedClient := api.NewCachedHTTPClient(httpClient, time.Hour*24)
|
|
opts.Detector = fd.NewDetector(cachedClient, baseRepo.RepoHost())
|
|
}
|
|
|
|
findOptions := shared.FindOptions{
|
|
Selector: opts.SelectorArg,
|
|
Fields: []string{"id", "author", "url", "title", "body", "baseRefName", "reviewRequests", "labels", "projectCards", "projectItems", "milestone"},
|
|
Detector: opts.Detector,
|
|
}
|
|
|
|
issueFeatures, err := opts.Detector.IssueFeatures()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if issueFeatures.ActorIsAssignable {
|
|
findOptions.Fields = append(findOptions.Fields, "assignedActors")
|
|
} else {
|
|
findOptions.Fields = append(findOptions.Fields, "assignees")
|
|
}
|
|
|
|
pr, repo, err := opts.Finder.Find(findOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
editable := opts.Editable
|
|
editable.Reviewers.Allowed = true
|
|
editable.Title.Default = pr.Title
|
|
editable.Body.Default = pr.Body
|
|
editable.Base.Default = pr.BaseRefName
|
|
editable.Reviewers.Default = pr.ReviewRequests.Logins()
|
|
if issueFeatures.ActorIsAssignable {
|
|
editable.Assignees.ActorAssignees = true
|
|
editable.Assignees.Default = pr.AssignedActors.DisplayNames()
|
|
editable.Assignees.DefaultLogins = pr.AssignedActors.Logins()
|
|
} else {
|
|
editable.Assignees.Default = pr.Assignees.Logins()
|
|
}
|
|
editable.Labels.Default = pr.Labels.Names()
|
|
editable.Projects.Default = append(pr.ProjectCards.ProjectNames(), pr.ProjectItems.ProjectTitles()...)
|
|
projectItems := map[string]string{}
|
|
for _, n := range pr.ProjectItems.Nodes {
|
|
projectItems[n.Project.ID] = n.ID
|
|
}
|
|
editable.Projects.ProjectItems = projectItems
|
|
if pr.Milestone != nil {
|
|
editable.Milestone.Default = pr.Milestone.Title
|
|
}
|
|
|
|
if opts.Interactive {
|
|
err = opts.Surveyor.FieldsToEdit(&editable)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
apiClient := api.NewClientFromHTTP(httpClient)
|
|
|
|
// Wire up search functions for assignees and reviewers.
|
|
// TODO: Wire up reviewer search func.
|
|
editable.AssigneeSearchFunc = assigneeSearchFunc(apiClient, repo, &editable, pr.ID)
|
|
|
|
opts.IO.StartProgressIndicator()
|
|
err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable, opts.Detector.ProjectsV1())
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opts.Interactive {
|
|
// Remove PR author from reviewer options;
|
|
// REST API errors if author is included (GraphQL silently ignores).
|
|
if editable.Reviewers.Edited {
|
|
s := set.NewStringSet()
|
|
s.AddValues(editable.Reviewers.Options)
|
|
s.Remove(pr.Author.Login)
|
|
editable.Reviewers.Options = s.ToSlice()
|
|
}
|
|
|
|
editorCommand, err := opts.EditorRetriever.Retrieve()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = opts.Surveyor.EditFields(&editable, editorCommand)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
opts.IO.StartProgressIndicator()
|
|
err = updatePullRequest(httpClient, repo, pr.ID, pr.Number, editable)
|
|
opts.IO.StopProgressIndicator()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
fmt.Fprintln(opts.IO.Out, pr.URL)
|
|
|
|
return nil
|
|
}
|
|
|
|
func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) prompter.MultiSelectSearchResult {
|
|
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
|
actors, err := api.SuggestedAssignableActors(
|
|
apiClient,
|
|
repo,
|
|
assignableID,
|
|
input)
|
|
if err != nil {
|
|
return prompter.MultiSelectSearchResult{
|
|
Keys: nil,
|
|
Labels: nil,
|
|
MoreResults: 0,
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
var logins []string
|
|
var displayNames []string
|
|
|
|
for _, a := range actors {
|
|
if a.Login() != "" {
|
|
logins = append(logins, a.Login())
|
|
} else {
|
|
continue
|
|
}
|
|
|
|
if a.DisplayName() != "" {
|
|
displayNames = append(displayNames, a.DisplayName())
|
|
} else {
|
|
displayNames = append(displayNames, a.Login())
|
|
}
|
|
|
|
// Update the assignable actors metadata in the editable struct
|
|
// so that updating the PR later can resolve the actor ID.
|
|
editable.Metadata.AssignableActors = append(editable.Metadata.AssignableActors, a)
|
|
}
|
|
return prompter.MultiSelectSearchResult{
|
|
Keys: logins,
|
|
Labels: displayNames,
|
|
MoreResults: 0,
|
|
Err: nil,
|
|
}
|
|
}
|
|
return searchFunc
|
|
}
|
|
|
|
func updatePullRequest(httpClient *http.Client, repo ghrepo.Interface, id string, number int, editable shared.Editable) error {
|
|
var wg errgroup.Group
|
|
wg.Go(func() error {
|
|
return shared.UpdateIssue(httpClient, repo, id, true, editable)
|
|
})
|
|
if editable.Reviewers.Edited {
|
|
wg.Go(func() error {
|
|
return updatePullRequestReviews(httpClient, repo, number, editable)
|
|
})
|
|
}
|
|
return wg.Wait()
|
|
}
|
|
|
|
func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, number int, editable shared.Editable) error {
|
|
if !editable.Reviewers.Edited {
|
|
return nil
|
|
}
|
|
|
|
// Rebuild the Value slice from non-interactive flag input.
|
|
if len(editable.Reviewers.Add) != 0 || len(editable.Reviewers.Remove) != 0 {
|
|
s := set.NewStringSet()
|
|
s.AddValues(editable.Reviewers.Add)
|
|
s.AddValues(editable.Reviewers.Default)
|
|
s.RemoveValues(editable.Reviewers.Remove)
|
|
editable.Reviewers.Value = s.ToSlice()
|
|
}
|
|
|
|
addUsers, addTeams := partitionUsersAndTeams(editable.Reviewers.Value)
|
|
|
|
// Reviewers in Default but not in the Value have been removed interactively.
|
|
var toRemove []string
|
|
for _, r := range editable.Reviewers.Default {
|
|
if !slices.Contains(editable.Reviewers.Value, r) {
|
|
toRemove = append(toRemove, r)
|
|
}
|
|
}
|
|
removeUsers, removeTeams := partitionUsersAndTeams(toRemove)
|
|
|
|
client := api.NewClientFromHTTP(httpClient)
|
|
wg := errgroup.Group{}
|
|
wg.Go(func() error {
|
|
return api.AddPullRequestReviews(client, repo, number, addUsers, addTeams)
|
|
})
|
|
wg.Go(func() error {
|
|
return api.RemovePullRequestReviews(client, repo, number, removeUsers, removeTeams)
|
|
})
|
|
return wg.Wait()
|
|
}
|
|
|
|
type Surveyor interface {
|
|
FieldsToEdit(*shared.Editable) error
|
|
EditFields(*shared.Editable, string) error
|
|
}
|
|
|
|
type surveyor struct {
|
|
P shared.EditPrompter
|
|
}
|
|
|
|
func (s surveyor) FieldsToEdit(editable *shared.Editable) error {
|
|
return shared.FieldsToEditSurvey(s.P, editable)
|
|
}
|
|
|
|
func (s surveyor) EditFields(editable *shared.Editable, editorCmd string) error {
|
|
return shared.EditFieldsSurvey(s.P, editable, editorCmd)
|
|
}
|
|
|
|
type EditableOptionsFetcher interface {
|
|
EditableOptionsFetch(*api.Client, ghrepo.Interface, *shared.Editable, gh.ProjectsV1Support) error
|
|
}
|
|
|
|
type fetcher struct{}
|
|
|
|
func (f fetcher) EditableOptionsFetch(client *api.Client, repo ghrepo.Interface, opts *shared.Editable, projectsV1Support gh.ProjectsV1Support) error {
|
|
return shared.FetchOptions(client, repo, opts, projectsV1Support)
|
|
}
|
|
|
|
type EditorRetriever interface {
|
|
Retrieve() (string, error)
|
|
}
|
|
|
|
type editorRetriever struct {
|
|
config func() (gh.Config, error)
|
|
}
|
|
|
|
func (e editorRetriever) Retrieve() (string, error) {
|
|
return cmdutil.DetermineEditor(e.config)
|
|
}
|
|
|
|
// partitionUsersAndTeams splits reviewer identifiers into user logins and team slugs.
|
|
// Team identifiers are in the form "org/slug"; only the slug portion is returned for teams.
|
|
func partitionUsersAndTeams(values []string) (users []string, teams []string) {
|
|
for _, v := range values {
|
|
if strings.ContainsRune(v, '/') {
|
|
parts := strings.SplitN(v, "/", 2)
|
|
if len(parts) == 2 && parts[1] != "" {
|
|
teams = append(teams, parts[1])
|
|
}
|
|
} else if v != "" {
|
|
users = append(users, v)
|
|
}
|
|
}
|
|
return
|
|
}
|