mirror of https://gitlab.com/gitlab-org/cli.git
562 lines
16 KiB
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)
|
|
}
|
|
}
|