cli/commands/cmdutils/cmdutils.go

562 lines
16 KiB
Go

package cmdutils
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"github.com/xanzy/go-gitlab"
"gitlab.com/gitlab-org/cli/api"
"gitlab.com/gitlab-org/cli/internal/glrepo"
"gitlab.com/gitlab-org/cli/pkg/utils"
"github.com/AlecAivazis/survey/v2"
"gitlab.com/gitlab-org/cli/pkg/prompt"
"gitlab.com/gitlab-org/cli/pkg/surveyext"
"gitlab.com/gitlab-org/cli/internal/config"
"gitlab.com/gitlab-org/cli/pkg/git"
)
const (
IssueTemplate = "issue_templates"
MergeRequestTemplate = "merge_request_templates"
)
// LoadGitLabTemplate finds and loads the GitLab template from the working git directory
// Follows the format officially supported by GitLab
// https://docs.gitlab.com/ee/user/project/description_templates.html#setting-a-default-template-for-issues-and-merge-requests.
//
// TODO: load from remote repository if repo is overridden by -R flag
func LoadGitLabTemplate(tmplType, tmplName string) (string, error) {
wdir, err := git.ToplevelDir()
if err != nil {
return "", err
}
if !strings.HasSuffix(tmplName, ".md") {
tmplName = tmplName + ".md"
}
tmplFile := filepath.Join(wdir, ".gitlab", tmplType, tmplName)
f, err := os.Open(tmplFile)
if os.IsNotExist(err) {
return "", nil
} else if err != nil {
return "", err
}
tmpl, err := ioutil.ReadAll(f)
if err != nil {
return "", err
}
return strings.TrimSpace(string(tmpl)), nil
}
// TODO: properly handle errors in this function.
//
// For now, it returns nil and empty slice if there's an error
func ListGitLabTemplates(tmplType string) ([]string, error) {
wdir, err := git.ToplevelDir()
if err != nil {
return []string{}, nil
}
tmplFolder := filepath.Join(wdir, ".gitlab", tmplType)
var files []string
f, err := os.Open(tmplFolder)
// if error return an empty slice since it only returns PathError
if err != nil {
return files, nil
}
fileNames, err := f.Readdirnames(-1)
defer f.Close()
if err != nil {
// return empty slice if error
return files, nil
}
for _, file := range fileNames {
if strings.HasPrefix(file, ".") || !strings.HasSuffix(file, ".md") {
continue
}
files = append(files, strings.TrimSuffix(file, ".md"))
}
sort.Slice(files, func(i, j int) bool { return files[i] < files[j] })
return files, nil
}
func GetEditor(cf func() (config.Config, error)) (string, error) {
cfg, err := cf()
if err != nil {
return "", fmt.Errorf("could not read config: %w", err)
}
// will search in the order glab_editor, visual, editor first from the env before the config file
editorCommand, _ := cfg.Get("", "editor")
return editorCommand, nil
}
func EditorPrompt(response *string, question, templateContent, editorCommand string) error {
defaultBody := *response
if templateContent != "" {
if defaultBody != "" {
// prevent excessive newlines between default body and template
defaultBody = strings.TrimRight(defaultBody, "\n")
defaultBody += "\n\n"
}
defaultBody += templateContent
}
qs := []*survey.Question{
{
Name: question,
Prompt: &surveyext.GLabEditor{
BlankAllowed: true,
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Description",
FileName: "*.md",
Default: defaultBody,
HideDefault: true,
AppendDefault: true,
},
},
},
}
err := prompt.Ask(qs, response)
if err != nil {
return err
}
if *response == "" {
*response = defaultBody
}
return nil
}
func LabelsPrompt(response *[]string, apiClient *gitlab.Client, repoRemote *glrepo.Remote) (err error) {
lOpts := &gitlab.ListLabelsOptions{}
lOpts.PerPage = 100
labels, err := api.ListLabels(apiClient, repoRemote.FullName(), lOpts)
if err != nil {
return err
}
if len(labels) != 0 {
var labelOptions []string
for i := range labels {
labelOptions = append(labelOptions, labels[i].Name)
}
var selectedLabels []string
err = prompt.MultiSelect(&selectedLabels, "labels", "Select Labels", labelOptions)
if err != nil {
return err
}
*response = append(*response, selectedLabels...)
return nil
}
var responseString string
err = prompt.AskQuestionWithInput(&responseString, "labels", "Label(s) [Comma Separated]", "", false)
if err != nil {
return err
}
if responseString != "" {
*response = append(*response, strings.Split(responseString, ",")...)
}
return nil
}
func MilestonesPrompt(response *int, apiClient *gitlab.Client, repoRemote *glrepo.Remote, io *iostreams.IOStreams) (err error) {
var milestoneOptions []string
milestoneMap := map[string]int{}
lOpts := &api.ListMilestonesOptions{
IncludeParentMilestones: gitlab.Bool(true),
State: gitlab.String("active"),
PerPage: 100,
}
milestones, err := api.ListAllMilestones(apiClient, repoRemote.FullName(), lOpts)
if err != nil {
return err
}
if len(milestones) == 0 {
fmt.Fprintln(io.StdErr, "There are no active milestones in this project")
return nil
}
for i := range milestones {
milestoneOptions = append(milestoneOptions, milestones[i].Title)
milestoneMap[milestones[i].Title] = milestones[i].ID
}
var selectedMilestone string
err = prompt.Select(&selectedMilestone, "milestone", "Select Milestone", milestoneOptions)
if err != nil {
return err
}
*response = milestoneMap[selectedMilestone]
return nil
}
// GroupMemberLevel maps a number representing the access level to a string shown to the
// user.
// API docs:
// https://docs.gitlab.com/ce/api/members.html#valid-access-levels
var GroupMemberLevel = map[int]string{
0: "no access",
5: "minimal access",
10: "guest",
20: "reporter",
30: "developer",
40: "maintainer",
50: "owner",
}
// AssigneesPrompt creates a multi-selection prompt of all the users below the given access level
// for the remote referenced by the `*glrepo.Remote`
func AssigneesPrompt(response *[]string, apiClient *gitlab.Client, repoRemote *glrepo.Remote, io *iostreams.IOStreams, minimumAccessLevel int) (err error) {
var assigneeOptions []string
assigneeMap := map[string]string{}
lOpts := &gitlab.ListProjectMembersOptions{}
lOpts.PerPage = 100
members, err := api.ListProjectMembers(apiClient, repoRemote.FullName(), lOpts)
if err != nil {
return err
}
for i := range members {
if members[i].AccessLevel >= gitlab.AccessLevelValue(minimumAccessLevel) {
assigneeOptions = append(assigneeOptions, fmt.Sprintf("%s (%s)",
members[i].Username,
GroupMemberLevel[int(members[i].AccessLevel)],
))
assigneeMap[fmt.Sprintf("%s (%s)", members[i].Username, GroupMemberLevel[int(members[i].AccessLevel)])] = members[i].Username
}
}
if len(assigneeOptions) == 0 {
fmt.Fprintf(io.StdErr, "Couldn't fetch any members with minimum permission level %d\n", minimumAccessLevel)
return nil
}
var selectedAssignees []string
err = prompt.MultiSelect(&selectedAssignees, "assignees", "Select assignees", assigneeOptions)
if err != nil {
return err
}
for _, x := range selectedAssignees {
*response = append(*response, assigneeMap[x])
}
return nil
}
type Action int
const (
NoAction Action = iota
SubmitAction
PreviewAction
AddMetadataAction
CancelAction
EditCommitMessageAction
)
func ConfirmSubmission(allowPreview bool, allowAddMetadata bool) (Action, error) {
const (
submitLabel = "Submit"
previewLabel = "Continue in browser"
addMetadataLabel = "Add metadata"
cancelLabel = "Cancel"
)
options := []string{submitLabel}
if allowPreview {
options = append(options, previewLabel)
}
if allowAddMetadata {
options = append(options, addMetadataLabel)
}
options = append(options, cancelLabel)
var confirmAnswer string
err := prompt.Select(&confirmAnswer, "confirmation", "What's next?", options)
if err != nil {
return -1, fmt.Errorf("could not prompt: %w", err)
}
switch confirmAnswer {
case submitLabel:
return SubmitAction, nil
case previewLabel:
return PreviewAction, nil
case addMetadataLabel:
return AddMetadataAction, nil
case cancelLabel:
return CancelAction, nil
default:
return -1, fmt.Errorf("invalid value: %s", confirmAnswer)
}
}
const (
AddLabelAction Action = iota
AddAssigneeAction
AddMilestoneAction
)
func PickMetadata() ([]Action, error) {
const (
labelsLabel = "labels"
assigneeLabel = "assignees"
milestoneLabel = "milestones"
)
options := []string{
labelsLabel,
assigneeLabel,
milestoneLabel,
}
var confirmAnswers []string
err := prompt.MultiSelect(&confirmAnswers, "metadata", "Which metadata types to add?", options)
if err != nil {
return nil, fmt.Errorf("could not prompt: %w", err)
}
var pickedActions []Action
for _, x := range confirmAnswers {
switch x {
case labelsLabel:
pickedActions = append(pickedActions, AddLabelAction)
case assigneeLabel:
pickedActions = append(pickedActions, AddAssigneeAction)
case milestoneLabel:
pickedActions = append(pickedActions, AddMilestoneAction)
}
}
return pickedActions, nil
}
// IDsFromUsers collects all user IDs from a slice of users
func IDsFromUsers(users []*gitlab.User) *[]int {
ids := make([]int, len(users))
for i, user := range users {
ids[i] = user.ID
}
return &ids
}
func ParseMilestone(apiClient *gitlab.Client, repo glrepo.Interface, milestoneTitle string) (int, error) {
if milestoneID, err := strconv.Atoi(milestoneTitle); err == nil {
return milestoneID, nil
}
milestone, err := api.ProjectMilestoneByTitle(apiClient, repo.FullName(), milestoneTitle)
if err != nil {
return 0, err
}
return milestone.ID, nil
}
// UserAssignments holds 3 slice strings that represent which assignees should be added, removed, and replaced
// helper functions are also provided
type UserAssignments struct {
ToAdd []string
ToRemove []string
ToReplace []string
AssignmentType UserAssignmentType
}
type UserAssignmentType int
const (
AssigneeAssignment UserAssignmentType = iota
ReviewerAssignment
)
// ParseAssignees takes a String Slice and splits them into 3 Slice Strings based on
// the first character of a string.
//
// '+' is put in the first slice, '!' and '-' in the second slice and all other cases
// in the third slice.
//
// The 3 String slices are returned regardless if anything was put it in or not the user
// is responsible for checking the length to see if anything is in it
func ParseAssignees(assignees []string) *UserAssignments {
ua := UserAssignments{
AssignmentType: AssigneeAssignment,
}
for _, assignee := range assignees {
switch string([]rune(assignee)[0]) {
case "+":
ua.ToAdd = append(ua.ToAdd, string([]rune(assignee)[1:]))
case "!", "-":
ua.ToRemove = append(ua.ToRemove, string([]rune(assignee)[1:]))
default:
ua.ToReplace = append(ua.ToReplace, assignee)
}
}
return &ua
}
// VerifyAssignees is a method for UserAssignments that checks them for validity
func (ua *UserAssignments) VerifyAssignees() error {
// Fail if relative and absolute assignees were given, there is no reason to mix them.
if len(ua.ToReplace) != 0 && (len(ua.ToAdd) != 0 || len(ua.ToRemove) != 0) {
return errors.New("mixing relative (+,!,-) and absolute assignments is forbidden")
}
if m := utils.CommonElementsInStringSlice(ua.ToAdd, ua.ToRemove); len(m) != 0 {
return fmt.Errorf("%s %q present in both add and remove which is forbidden",
utils.Pluralize(len(m), "element"),
strings.Join(m, " "))
}
return nil
}
// UsersFromReplaces converts all users from the `ToReplace` member of the struct into
// an Slice of String representing the Users' IDs, it also takes a Slice of Strings and
// writes a proper action message to it
func (ua *UserAssignments) UsersFromReplaces(apiClient *gitlab.Client, actions []string) (*[]int, []string, error) {
users, err := api.UsersByNames(apiClient, ua.ToReplace)
if err != nil {
return &[]int{}, actions, err
}
var usernames []string
for i := range users {
usernames = append(usernames, fmt.Sprintf("@%s", users[i].Username))
}
if len(usernames) != 0 {
if ua.AssignmentType == ReviewerAssignment {
actions = append(actions, fmt.Sprintf("requested review from %q", strings.Join(usernames, " ")))
} else {
actions = append(actions, fmt.Sprintf("assigned to %q", strings.Join(usernames, " ")))
}
}
return IDsFromUsers(users), actions, nil
}
// UsersFromAddRemove works with both `ToAdd` and `ToRemove` members to produce a Slice of Ints that
// represents the final collection of IDs to assigned.
//
// It starts by getting all IDs already assigned, but ignoring ones present in `ToRemove`, it then
// converts all `usernames` in `ToAdd` into IDs by using the `api` package and adds them to the
// IDs to be assigned
func (ua *UserAssignments) UsersFromAddRemove(
issueAssignees []*gitlab.IssueAssignee,
mergeRequestAssignees []*gitlab.BasicUser,
apiClient *gitlab.Client,
actions []string,
) (*[]int, []string, error) {
var assignedIDs []int
var usernames []string
// Only one of those is required
if mergeRequestAssignees != nil && issueAssignees != nil {
return &[]int{}, actions, fmt.Errorf("issueAssignes and mergeRequestAssignes can't both not be nil")
}
// Path for Issues
for i := range issueAssignees {
// Only store them in assigneedIDs if they are not marked for removal
if !utils.PresentInStringSlice(ua.ToRemove, issueAssignees[i].Username) {
assignedIDs = append(assignedIDs, issueAssignees[i].ID)
}
}
// Path for Merge Requests
for i := range mergeRequestAssignees {
// Only store them in assigneedIDs if they are not marked for removal
if !utils.PresentInStringSlice(ua.ToRemove, mergeRequestAssignees[i].Username) {
assignedIDs = append(assignedIDs, mergeRequestAssignees[i].ID)
}
}
// Add action string
if len(ua.ToRemove) != 0 {
for _, x := range ua.ToRemove {
usernames = append(usernames, fmt.Sprintf("@%s", x))
}
if ua.AssignmentType == ReviewerAssignment {
actions = append(actions, fmt.Sprintf("removed review request for %q", strings.Join(usernames, " ")))
} else {
actions = append(actions, fmt.Sprintf("unassigned %q", strings.Join(usernames, " ")))
}
}
if len(ua.ToAdd) != 0 {
users, err := api.UsersByNames(apiClient, ua.ToAdd)
if err != nil {
return nil, nil, err
}
// Work-around GitLab (the company's own instance, not all instances have this) bug
// which causes a 500 Internal Error if duplicate `IDs` are used. Filter out any
// IDs that is already present
for i := range users {
if !utils.PresentInIntSlice(assignedIDs, users[i].ID) {
assignedIDs = append(assignedIDs, users[i].ID)
}
}
// Reset the usernames array because it might have been used by `unassignedUsers`
usernames = []string{}
for _, x := range ua.ToAdd {
usernames = append(usernames, fmt.Sprintf("@%s", x))
}
if ua.AssignmentType == ReviewerAssignment {
actions = append(actions, fmt.Sprintf("requested review from %q", strings.Join(usernames, " ")))
} else {
actions = append(actions, fmt.Sprintf("assigned %q", strings.Join(usernames, " ")))
}
}
// That means that all assignees were removed but we can't pass an empty Slice of Ints so
// pass the documented value of 0
if len(assignedIDs) == 0 {
assignedIDs = []int{0}
}
return &assignedIDs, actions, nil
}
func ConfirmTransfer() error {
const (
performTransferLabel = "Confirm repository transfer"
abortTransferLabel = "Abort repository transfer"
)
options := []string{abortTransferLabel, performTransferLabel}
var confirmTransfer string
err := prompt.Select(&confirmTransfer, "confirmation", "Do you wish to proceed with the repository transfer?", options)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
switch confirmTransfer {
case performTransferLabel:
return nil
case abortTransferLabel:
return fmt.Errorf("user aborted operation")
default:
return fmt.Errorf("invalid value: %s", confirmTransfer)
}
}