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>
559 lines
18 KiB
Go
559 lines
18 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 support
|
|
the following special value:
|
|
- %[1]s@copilot%[1]s: request or remove review from Copilot (not supported on GitHub Enterprise Server)
|
|
`, "`"),
|
|
Example: heredoc.Doc(`
|
|
# Edit the title and body of a pull request
|
|
$ gh pr edit 23 --title "I found a bug" --body "Nothing works"
|
|
|
|
# Use a file as the body
|
|
$ gh pr edit 23 --body-file body.txt
|
|
|
|
# Manage labels
|
|
$ gh pr edit 23 --add-label "bug,help wanted" --remove-label "core"
|
|
|
|
# Manage reviewers
|
|
$ gh pr edit 23 --add-reviewer monalisa,hubot --remove-reviewer myorg/team-name
|
|
|
|
# Re-request review
|
|
$ gh pr edit 23 --add-reviewer monalisa
|
|
|
|
# Request a review from GitHub Copilot
|
|
$ gh pr edit 23 --add-reviewer "@copilot"
|
|
|
|
# Manage assignees
|
|
$ gh pr edit 23 --add-assignee "@me" --remove-assignee monalisa,hubot
|
|
|
|
# Assign GitHub Copilot
|
|
$ gh pr edit 23 --add-assignee "@copilot"
|
|
|
|
# Manage projects and milestones
|
|
$ 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 or re-request reviewers by their `login`. Use \"@copilot\" to request review from Copilot.")
|
|
cmd.Flags().StringSliceVar(&opts.Editable.Reviewers.Remove, "remove-reviewer", nil, "Remove reviewers by their `login`. Use \"@copilot\" to remove review request from Copilot.")
|
|
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
|
|
}
|
|
|
|
// TODO actorIsAssignableCleanup
|
|
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.DisplayNames()
|
|
editable.Reviewers.DefaultLogins = pr.ReviewRequests.Logins()
|
|
// TODO ApiActorsSupported
|
|
if issueFeatures.ActorIsAssignable {
|
|
editable.ApiActorsSupported = 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.
|
|
// When these aren't wired up, it triggers a downstream fallback
|
|
// to legacy reviewer/assignee fetching.
|
|
// TODO actorIsAssignableCleanup
|
|
if issueFeatures.ActorIsAssignable {
|
|
editable.AssigneeSearchFunc = shared.AssigneeSearchFunc(apiClient, repo, pr.ID)
|
|
editable.ReviewerSearchFunc = reviewerSearchFunc(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
|
|
}
|
|
|
|
// reviewerSearchFunc is intended to be an arg for MultiSelectWithSearch
|
|
// to return potential reviewer candidates (users, bots, and teams).
|
|
// It also updates the editable's metadata for later ID resolution.
|
|
func reviewerSearchFunc(apiClient *api.Client, repo ghrepo.Interface, editable *shared.Editable, prID string) func(string) prompter.MultiSelectSearchResult {
|
|
searchFunc := func(input string) prompter.MultiSelectSearchResult {
|
|
candidates, moreResults, err := api.SuggestedReviewerActors(
|
|
apiClient,
|
|
repo,
|
|
prID,
|
|
input)
|
|
if err != nil {
|
|
return prompter.MultiSelectSearchResult{
|
|
Keys: nil,
|
|
Labels: nil,
|
|
MoreResults: 0,
|
|
Err: err,
|
|
}
|
|
}
|
|
|
|
keys := make([]string, 0, len(candidates))
|
|
labels := make([]string, 0, len(candidates))
|
|
|
|
for _, c := range candidates {
|
|
keys = append(keys, c.Login())
|
|
labels = append(labels, c.DisplayName())
|
|
|
|
// Update the teams metadata in the editable struct
|
|
// so that updating the PR later can resolve the team ID.
|
|
if team, ok := c.(api.ReviewerTeam); ok {
|
|
editable.Metadata.Teams = append(editable.Metadata.Teams, api.OrgTeam{
|
|
ID: "", // ID not needed for REST API reviewer mutations
|
|
Slug: team.Slug(),
|
|
})
|
|
}
|
|
}
|
|
return prompter.MultiSelectSearchResult{
|
|
Keys: keys,
|
|
Labels: labels,
|
|
MoreResults: moreResults,
|
|
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, id, number, editable)
|
|
})
|
|
}
|
|
return wg.Wait()
|
|
}
|
|
|
|
func updatePullRequestReviews(httpClient *http.Client, repo ghrepo.Interface, prID string, number int, editable shared.Editable) error {
|
|
if !editable.Reviewers.Edited {
|
|
return nil
|
|
}
|
|
|
|
client := api.NewClientFromHTTP(httpClient)
|
|
|
|
// Rebuild the Value slice from non-interactive flag input.
|
|
if len(editable.Reviewers.Add) != 0 || len(editable.Reviewers.Remove) != 0 {
|
|
add := editable.Reviewers.Add
|
|
remove := editable.Reviewers.Remove
|
|
|
|
// Replace @copilot with the Copilot reviewer login (only on github.com).
|
|
// Also use DefaultLogins (not Default display names) for computing the set.
|
|
var defaultLogins []string
|
|
// TODO ApiActorsSupported
|
|
if editable.ApiActorsSupported {
|
|
copilotReplacer := shared.NewCopilotReviewerReplacer()
|
|
add = copilotReplacer.ReplaceSlice(add)
|
|
remove = copilotReplacer.ReplaceSlice(remove)
|
|
defaultLogins = editable.Reviewers.DefaultLogins
|
|
} else {
|
|
// On GHES, Default already contains logins (no display name distinction)
|
|
defaultLogins = editable.Reviewers.Default
|
|
}
|
|
|
|
s := set.NewStringSet()
|
|
s.AddValues(add)
|
|
s.AddValues(defaultLogins)
|
|
s.RemoveValues(remove)
|
|
editable.Reviewers.Value = s.ToSlice()
|
|
}
|
|
|
|
// On github.com, use the new GraphQL mutation which supports bots.
|
|
// On GHES, fall back to REST API.
|
|
// TODO ApiActorsSupported
|
|
if editable.ApiActorsSupported {
|
|
return updatePullRequestReviewsGraphQL(client, repo, prID, editable)
|
|
}
|
|
return updatePullRequestReviewsREST(client, repo, number, editable)
|
|
}
|
|
|
|
// updatePullRequestReviewsGraphQL uses the RequestReviewsByLogin mutation.
|
|
// This mutation replaces the entire reviewer set (union: false).
|
|
func updatePullRequestReviewsGraphQL(client *api.Client, repo ghrepo.Interface, prID string, editable shared.Editable) error {
|
|
users, bots, teams := partitionReviewersByType(editable.Reviewers.Value)
|
|
return api.RequestReviewsByLogin(client, repo, prID, users, bots, teams, false)
|
|
}
|
|
|
|
// updatePullRequestReviewsREST uses the REST API to add/remove reviewers.
|
|
// This is the legacy path for GHES compatibility.
|
|
func updatePullRequestReviewsREST(client *api.Client, repo ghrepo.Interface, number int, editable shared.Editable) error {
|
|
addUsers, addBots, addTeams := partitionReviewersByType(editable.Reviewers.Value)
|
|
// REST API doesn't distinguish bots from users, so we need to combine them.
|
|
allAddUsers := append(addUsers, addBots...)
|
|
|
|
// Reviewers in Default but not in 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, removeBots, removeTeams := partitionReviewersByType(toRemove)
|
|
allRemoveUsers := append(removeUsers, removeBots...)
|
|
|
|
wg := errgroup.Group{}
|
|
wg.Go(func() error {
|
|
return api.AddPullRequestReviews(client, repo, number, allAddUsers, addTeams)
|
|
})
|
|
wg.Go(func() error {
|
|
return api.RemovePullRequestReviews(client, repo, number, allRemoveUsers, 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)
|
|
}
|
|
|
|
// partitionReviewersByType splits reviewer identifiers into users, bots, and teams.
|
|
// Team identifiers are in the form "org/slug" and are returned as-is.
|
|
// Bot logins (currently only Copilot) are identified and returned separately.
|
|
func partitionReviewersByType(values []string) (users []string, bots []string, teams []string) {
|
|
for _, v := range values {
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if strings.ContainsRune(v, '/') {
|
|
// Team: org/slug format, pass as-is
|
|
teams = append(teams, v)
|
|
} else if v == api.CopilotReviewerLogin {
|
|
bots = append(bots, v)
|
|
} else {
|
|
users = append(users, v)
|
|
}
|
|
}
|
|
return
|
|
}
|