cli/pkg/cmd/pr/shared/params.go
Kynan Ware e6d9019bc9 fix(pr create): use login-based assignee mutation on github.com
When ActorAssignees is true (github.com), pass assignee logins directly
to the ReplaceActorsForAssignable mutation instead of resolving logins
to node IDs. This eliminates the need to bulk fetch all assignable
users/actors and fixes a bug where providing assignees via CLI flag
and then interactively adding metadata would fail with 'not found'
because the cached MetadataResult had no assignee data.

Changes:
- Set state.ActorAssignees = true in pr create (was missing)
- AddMetadataToIssueParams: pass assigneeLogins when ActorAssignees
  is true, skip fetch and ID resolution entirely
- CreatePullRequest/IssueCreate: call ReplaceActorsForAssignableByLogin
  after creation to assign via logins
- Consolidate replaceActorsForAssignable mutation into api/ package
  (ReplaceActorsForAssignableByLogin + ReplaceActorsForAssignableByID)
- Remove duplicate replaceActorAssigneesForEditable from editable_http.go
- Add TODO replaceActorsByLoginCleanup markers on edit paths

Fixes cli/cli#13000

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-23 15:21:20 -06:00

362 lines
9.7 KiB
Go

package shared
import (
"fmt"
"net/url"
"slices"
"strings"
"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/pkg/search"
"github.com/google/shlex"
)
func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState, projectsV1Support gh.ProjectsV1Support) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", err
}
q := u.Query()
if state.Title != "" {
q.Set("title", state.Title)
}
// We always want to send the body parameter, even if it's empty, to prevent the web interface from
// applying the default template. Since the user has the option to select a template in the terminal,
// assume that empty body here means that the user either skipped it or erased its contents.
q.Set("body", state.Body)
if len(state.Assignees) > 0 {
q.Set("assignees", strings.Join(state.Assignees, ","))
}
// Set a template parameter if no body parameter is provided e.g. Web Mode
if len(state.Template) > 0 && len(state.Body) == 0 {
q.Set("template", state.Template)
}
if len(state.Labels) > 0 {
q.Set("labels", strings.Join(state.Labels, ","))
}
if len(state.ProjectTitles) > 0 {
projectPaths, err := api.ProjectTitlesToPaths(client, baseRepo, state.ProjectTitles, projectsV1Support)
if err != nil {
return "", fmt.Errorf("could not add to project: %w", err)
}
q.Set("projects", strings.Join(projectPaths, ","))
}
if len(state.Milestones) > 0 {
q.Set("milestone", state.Milestones[0])
}
u.RawQuery = q.Encode()
return u.String(), nil
}
// Maximum length of a URL: 8192 bytes
func ValidURL(urlStr string) bool {
return len(urlStr) < 8192
}
func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState, projectV1Support gh.ProjectsV1Support) error {
if !tb.HasMetadata() {
return nil
}
// When ActorReviewers is true, we use login-based mutation and don't need to resolve reviewer IDs.
needReviewerIDs := len(tb.Reviewers) > 0 && !tb.ActorReviewers
// When ActorAssignees is true, we use login-based mutation and don't need to resolve assignee IDs.
needAssigneeIDs := len(tb.Assignees) > 0 && !tb.ActorAssignees
// Retrieve minimal information needed to resolve metadata if this was not previously cached from additional metadata survey.
if tb.MetadataResult == nil {
input := api.RepoMetadataInput{
Reviewers: needReviewerIDs,
TeamReviewers: needReviewerIDs && slices.ContainsFunc(tb.Reviewers, func(r string) bool {
return strings.ContainsRune(r, '/')
}),
Assignees: needAssigneeIDs,
Labels: len(tb.Labels) > 0,
ProjectsV1: len(tb.ProjectTitles) > 0 && projectV1Support == gh.ProjectsV1Supported,
ProjectsV2: len(tb.ProjectTitles) > 0,
Milestones: len(tb.Milestones) > 0,
}
metadataResult, err := api.RepoMetadata(client, baseRepo, input)
if err != nil {
return err
}
tb.MetadataResult = metadataResult
}
// When ActorAssignees is true (github.com), pass logins directly for use with
// ReplaceActorsForAssignable mutation. The ID-based else branch is for GHES compatibility.
if tb.ActorAssignees {
params["assigneeLogins"] = tb.Assignees
} else {
assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees)
if err != nil {
return fmt.Errorf("could not assign user: %w", err)
}
params["assigneeIds"] = assigneeIDs
}
labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels)
if err != nil {
return fmt.Errorf("could not add label: %w", err)
}
params["labelIds"] = labelIDs
projectIDs, projectV2IDs, err := tb.MetadataResult.ProjectsTitlesToIDs(tb.ProjectTitles)
if err != nil {
return fmt.Errorf("could not add to project: %w", err)
}
params["projectIds"] = projectIDs
params["projectV2Ids"] = projectV2IDs
if len(tb.Milestones) > 0 {
milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0])
if err != nil {
return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err)
}
params["milestoneId"] = milestoneID
}
if len(tb.Reviewers) == 0 {
return nil
}
var userReviewers []string
var botReviewers []string
var teamReviewers []string
for _, r := range tb.Reviewers {
if strings.ContainsRune(r, '/') {
teamReviewers = append(teamReviewers, r)
} else if r == api.CopilotReviewerLogin {
botReviewers = append(botReviewers, r)
} else {
userReviewers = append(userReviewers, r)
}
}
// TODO requestReviewsByLoginCleanup
// When ActorReviewers is true (github.com), pass logins directly for use with
// RequestReviewsByLogin mutation. The ID-based else branch can be removed once
// GHES supports requestReviewsByLogin.
if tb.ActorReviewers {
params["userReviewerLogins"] = userReviewers
if len(botReviewers) > 0 {
params["botReviewerLogins"] = botReviewers
}
params["teamReviewerSlugs"] = teamReviewers
} else {
userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers)
if err != nil {
return fmt.Errorf("could not request reviewer: %w", err)
}
params["userReviewerIds"] = userReviewerIDs
teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers)
if err != nil {
return fmt.Errorf("could not request reviewer: %w", err)
}
params["teamReviewerIds"] = teamReviewerIDs
}
return nil
}
type FilterOptions struct {
Assignee string
Author string
BaseBranch string
Draft *bool
Entity string
Fields []string
HeadBranch string
Labels []string
Mention string
Milestone string
Repo string
Search string
State string
}
func (opts *FilterOptions) IsDefault() bool {
if opts.State != "open" {
return false
}
if len(opts.Labels) > 0 {
return false
}
if opts.Assignee != "" {
return false
}
if opts.Author != "" {
return false
}
if opts.BaseBranch != "" {
return false
}
if opts.HeadBranch != "" {
return false
}
if opts.Mention != "" {
return false
}
if opts.Milestone != "" {
return false
}
if opts.Search != "" {
return false
}
return true
}
func ListURLWithQuery(listURL string, options FilterOptions, advancedIssueSearchSyntax bool) (string, error) {
u, err := url.Parse(listURL)
if err != nil {
return "", err
}
params := u.Query()
params.Set("q", SearchQueryBuild(options, advancedIssueSearchSyntax))
u.RawQuery = params.Encode()
return u.String(), nil
}
func SearchQueryBuild(options FilterOptions, advancedIssueSearchSyntax bool) string {
var is, state string
switch options.State {
case "open", "closed":
state = options.State
case "merged":
is = "merged"
}
query := search.Query{
Qualifiers: search.Qualifiers{
Assignee: options.Assignee,
Author: options.Author,
Base: options.BaseBranch,
Draft: options.Draft,
Head: options.HeadBranch,
Label: options.Labels,
Mentions: options.Mention,
Milestone: options.Milestone,
Repo: []string{options.Repo},
State: state,
Is: []string{is},
Type: options.Entity,
},
ImmutableKeywords: options.Search,
}
if !advancedIssueSearchSyntax {
return query.StandardSearchString()
}
return query.AdvancedIssueSearchString()
}
func QueryHasStateClause(searchQuery string) bool {
argv, err := shlex.Split(searchQuery)
if err != nil {
return false
}
for _, arg := range argv {
if arg == "is:closed" || arg == "is:merged" || arg == "state:closed" || arg == "state:merged" || strings.HasPrefix(arg, "merged:") || strings.HasPrefix(arg, "closed:") {
return true
}
}
return false
}
// MeReplacer resolves usages of `@me` to the handle of the currently logged in user.
type MeReplacer struct {
apiClient *api.Client
hostname string
login string
}
func NewMeReplacer(apiClient *api.Client, hostname string) *MeReplacer {
return &MeReplacer{
apiClient: apiClient,
hostname: hostname,
}
}
func (r *MeReplacer) currentLogin() (string, error) {
if r.login != "" {
return r.login, nil
}
login, err := api.CurrentLoginName(r.apiClient, r.hostname)
if err != nil {
return "", fmt.Errorf("failed resolving `@me` to your user handle: %w", err)
}
r.login = login
return login, nil
}
func (r *MeReplacer) Replace(handle string) (string, error) {
if handle == "@me" {
return r.currentLogin()
}
return handle, nil
}
func (r *MeReplacer) ReplaceSlice(handles []string) ([]string, error) {
res := make([]string, len(handles))
for i, h := range handles {
var err error
res[i], err = r.Replace(h)
if err != nil {
return nil, err
}
}
return res, nil
}
// CopilotReplacer resolves usages of `@copilot` to either Copilot's login or name.
// Login is generally needed for API calls; name is used when launching web browser.
type CopilotReplacer struct {
returnLogin bool
// copilotLogin is the login to use when replacing @copilot.
// Different Copilot features use different bot logins.
copilotLogin string
}
// NewCopilotReplacer creates a replacer for assignee @copilot references.
func NewCopilotReplacer(returnLogin bool) *CopilotReplacer {
return &CopilotReplacer{
returnLogin: returnLogin,
copilotLogin: api.CopilotAssigneeLogin,
}
}
// NewCopilotReviewerReplacer creates a replacer for reviewer @copilot references.
func NewCopilotReviewerReplacer() *CopilotReplacer {
return &CopilotReplacer{
returnLogin: true,
copilotLogin: api.CopilotReviewerLogin,
}
}
func (r *CopilotReplacer) replace(handle string) string {
if !strings.EqualFold(handle, "@copilot") {
return handle
}
if r.returnLogin {
return r.copilotLogin
}
return api.CopilotActorName
}
// ReplaceSlice replaces usages of `@copilot` in a slice with Copilot's login.
func (r *CopilotReplacer) ReplaceSlice(handles []string) []string {
res := make([]string, len(handles))
for i, h := range handles {
res[i] = r.replace(h)
}
return res
}