cli/pkg/cmd/pr/edit/edit.go
Kynan Ware 28e07666f8 Return total assignee count in SuggestedAssignableActors
Updated SuggestedAssignableActors to return the total count of available assignees in the repository. Modified assigneeSearchFunc to use this count to calculate and display the number of additional assignees beyond the current results.
2026-01-26 14:33:45 -07:00

494 lines
16 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 KW: Wire up reviewer search func if/when it exists.
if issueFeatures.ActorIsAssignable {
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
}
// assigneeSearchFunc is intended to be an arg for MultiSelectWithSearch
// to return potential assignee actors.
// It also contains an important enclosure to update the editable's
// assignable actors metadata for later ID resolution - this is required
// while we continue to use IDs for mutating assignees with the GQL API.
func assigneeSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, assignableID string) func(string) prompter.MultiSelectSearchResult {
searchFunc := func(input string) prompter.MultiSelectSearchResult {
actors, availableAssigneesCount, err := api.SuggestedAssignableActors(
apiClient,
repo,
assignableID,
input)
if err != nil {
return prompter.MultiSelectSearchResult{
Keys: nil,
Labels: nil,
MoreResults: 0,
Err: err,
}
}
logins := make([]string, 0, len(actors))
displayNames := make([]string, 0, len(actors))
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: availableAssigneesCount,
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
}