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 } // TODO ApiActorsSupported // When ApiActorsSupported is true, we use login-based mutation and don't need to resolve reviewer IDs. needReviewerIDs := len(tb.Reviewers) > 0 && !tb.ApiActorsSupported // TODO ApiActorsSupported // When ApiActorsSupported is true, we use login-based mutation and don't need to resolve assignee IDs. needAssigneeIDs := len(tb.Assignees) > 0 && !tb.ApiActorsSupported // 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 } // TODO ApiActorsSupported // When ApiActorsSupported is true (github.com), pass logins directly for use with // ReplaceActorsForAssignable mutation. The ID-based else branch is for GHES compatibility. if tb.ApiActorsSupported { 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 ApiActorsSupported // When ApiActorsSupported 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.ApiActorsSupported { 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 }