From 6f738df4a5e842f062b60832b9fac58160d2ed94 Mon Sep 17 00:00:00 2001 From: Norwin Date: Mon, 8 Mar 2021 19:48:03 +0800 Subject: [PATCH] Add more issue / pr creation params (#331) adds assignees, labels, deadline, milestone params - [x] add flags to `tea issue create` (this is BREAKING, `-b` moved to `-d` for consistency with pr create) - [x] add interactive mode to `tea issue create` - [x] add flags to `tea pr create` - [x] add interactive mode to `tea pr create` fixes #171, fixes #303 Co-authored-by: Norwin Roosen Co-authored-by: 6543 <6543@obermui.de> Reviewed-on: https://gitea.com/gitea/tea/pulls/331 Reviewed-by: 6543 <6543@obermui.de> Reviewed-by: Lunny Xiao Co-authored-by: Norwin Co-committed-by: Norwin --- cmd/flags/flags.go | 80 +++++++++++++++ cmd/issues/create.go | 21 ++-- cmd/pulls/create.go | 20 ++-- modules/interact/issue_create.go | 145 ++++++++++++++++++++++++--- modules/interact/milestone_create.go | 27 +---- modules/interact/prompts.go | 104 ++++++++++++++++++- modules/interact/pull_create.go | 44 ++------ modules/task/issue_create.go | 17 +--- modules/task/labels.go | 25 +++++ modules/task/pull_create.go | 21 ++-- modules/utils/utils.go | 12 ++- 11 files changed, 389 insertions(+), 127 deletions(-) create mode 100644 modules/task/labels.go diff --git a/cmd/flags/flags.go b/cmd/flags/flags.go index 57b8ae8..04ed315 100644 --- a/cmd/flags/flags.go +++ b/cmd/flags/flags.go @@ -8,8 +8,12 @@ import ( "fmt" "strings" + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/context" + "code.gitea.io/tea/modules/task" "code.gitea.io/tea/modules/utils" + "github.com/araddon/dateparse" "github.com/urfave/cli/v2" ) @@ -97,6 +101,82 @@ var IssuePRFlags = append([]cli.Flag{ &PaginationLimitFlag, }, AllDefaultFlags...) +// IssuePREditFlags defines flags for properties of issues and PRs +var IssuePREditFlags = append([]cli.Flag{ + &cli.StringFlag{ + Name: "title", + Aliases: []string{"t"}, + }, + &cli.StringFlag{ + Name: "description", + Aliases: []string{"d"}, + }, + &cli.StringFlag{ + Name: "assignees", + Aliases: []string{"a"}, + Usage: "Comma-separated list of usernames to assign", + }, + &cli.StringFlag{ + Name: "labels", + Aliases: []string{"L"}, + Usage: "Comma-separated list of labels to assign", + }, + &cli.StringFlag{ + Name: "deadline", + Aliases: []string{"D"}, + Usage: "Deadline timestamp to assign", + }, + &cli.StringFlag{ + Name: "milestone", + Aliases: []string{"m"}, + Usage: "Milestone to assign", + }, +}, LoginRepoFlags...) + +// GetIssuePREditFlags parses all IssuePREditFlags +func GetIssuePREditFlags(ctx *context.TeaContext) (*gitea.CreateIssueOption, error) { + opts := gitea.CreateIssueOption{ + Title: ctx.String("title"), + Body: ctx.String("body"), + Assignees: strings.Split(ctx.String("assignees"), ","), + } + var err error + + date := ctx.String("deadline") + if date != "" { + t, err := dateparse.ParseAny(date) + if err != nil { + return nil, err + } + opts.Deadline = &t + } + + client := ctx.Login.Client() + + labelNames := strings.Split(ctx.String("labels"), ",") + if len(labelNames) != 0 { + if client == nil { + client = ctx.Login.Client() + } + if opts.Labels, err = task.ResolveLabelNames(client, ctx.Owner, ctx.Repo, labelNames); err != nil { + return nil, err + } + } + + if milestoneName := ctx.String("milestone"); len(milestoneName) != 0 { + if client == nil { + client = ctx.Login.Client() + } + ms, _, err := client.GetMilestoneByName(ctx.Owner, ctx.Repo, milestoneName) + if err != nil { + return nil, fmt.Errorf("Milestone '%s' not found", milestoneName) + } + opts.Milestone = ms.ID + } + + return &opts, nil +} + // FieldsFlag generates a flag selecting printable fields. // To retrieve the value, use GetFields() func FieldsFlag(availableFields, defaultFields []string) *cli.StringFlag { diff --git a/cmd/issues/create.go b/cmd/issues/create.go index f46031a..73b162b 100644 --- a/cmd/issues/create.go +++ b/cmd/issues/create.go @@ -20,18 +20,7 @@ var CmdIssuesCreate = cli.Command{ Usage: "Create an issue on repository", Description: `Create an issue on repository`, Action: runIssuesCreate, - Flags: append([]cli.Flag{ - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - Usage: "issue title to create", - }, - &cli.StringFlag{ - Name: "body", - Aliases: []string{"b"}, - Usage: "issue body to create", - }, - }, flags.LoginRepoFlags...), + Flags: flags.IssuePREditFlags, } func runIssuesCreate(cmd *cli.Context) error { @@ -42,11 +31,15 @@ func runIssuesCreate(cmd *cli.Context) error { return interact.CreateIssue(ctx.Login, ctx.Owner, ctx.Repo) } + opts, err := flags.GetIssuePREditFlags(ctx) + if err != nil { + return err + } + return task.CreateIssue( ctx.Login, ctx.Owner, ctx.Repo, - ctx.String("title"), - ctx.String("body"), + *opts, ) } diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index dd27cf8..6ef3ec2 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -30,17 +30,7 @@ var CmdPullsCreate = cli.Command{ Aliases: []string{"b"}, Usage: "Set base branch (default is default branch)", }, - &cli.StringFlag{ - Name: "title", - Aliases: []string{"t"}, - Usage: "Set title of pull (default is head branch name)", - }, - &cli.StringFlag{ - Name: "description", - Aliases: []string{"d"}, - Usage: "Set body of new pull", - }, - }, flags.AllDefaultFlags...), + }, flags.IssuePREditFlags...), } func runPullsCreate(cmd *cli.Context) error { @@ -53,13 +43,17 @@ func runPullsCreate(cmd *cli.Context) error { } // else use args to create PR + opts, err := flags.GetIssuePREditFlags(ctx) + if err != nil { + return err + } + return task.CreatePull( ctx.Login, ctx.Owner, ctx.Repo, ctx.String("base"), ctx.String("head"), - ctx.String("title"), - ctx.String("description"), + opts, ) } diff --git a/modules/interact/issue_create.go b/modules/interact/issue_create.go index 55b93d5..e9b79a8 100644 --- a/modules/interact/issue_create.go +++ b/modules/interact/issue_create.go @@ -5,6 +5,7 @@ package interact import ( + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/task" @@ -13,31 +14,149 @@ import ( // CreateIssue interactively creates an issue func CreateIssue(login *config.Login, owner, repo string) error { - var title, description string - - // owner, repo owner, repo, err := promptRepoSlug(owner, repo) if err != nil { return err } + var opts gitea.CreateIssueOption + if err := promptIssueProperties(login, owner, repo, &opts); err != nil { + return err + } + + return task.CreateIssue(login, owner, repo, opts) +} + +func promptIssueProperties(login *config.Login, owner, repo string, o *gitea.CreateIssueOption) error { + var milestoneName string + var labels []string + var err error + + selectableChan := make(chan (issueSelectables), 1) + go fetchIssueSelectables(login, owner, repo, selectableChan) + // title promptOpts := survey.WithValidator(survey.Required) - promptI := &survey.Input{Message: "Issue title:"} - if err := survey.AskOne(promptI, &title, promptOpts); err != nil { + promptI := &survey.Input{Message: "Issue title:", Default: o.Title} + if err = survey.AskOne(promptI, &o.Title, promptOpts); err != nil { return err } // description - promptM := &survey.Multiline{Message: "Issue description:"} - if err := survey.AskOne(promptM, &description); err != nil { + promptD := &survey.Multiline{Message: "Issue description:", Default: o.Body} + if err = survey.AskOne(promptD, &o.Body); err != nil { return err } - return task.CreateIssue( - login, - owner, - repo, - title, - description) + // wait until selectables are fetched + selectables := <-selectableChan + if selectables.Err != nil { + return selectables.Err + } + + // skip remaining props if we don't have permission to set them + if !selectables.Repo.Permissions.Push { + return nil + } + + // assignees + if o.Assignees, err = promptMultiSelect("Assignees:", selectables.Collaborators, "[other]"); err != nil { + return err + } + + // milestone + if len(selectables.MilestoneList) != 0 { + if milestoneName, err = promptSelect("Milestone:", selectables.MilestoneList, "", "[none]"); err != nil { + return err + } + o.Milestone = selectables.MilestoneMap[milestoneName] + } + + // labels + if len(selectables.LabelList) != 0 { + promptL := &survey.MultiSelect{Message: "Labels:", Options: selectables.LabelList, VimMode: true, Default: o.Labels} + if err := survey.AskOne(promptL, &labels); err != nil { + return err + } + o.Labels = make([]int64, len(labels)) + for i, l := range labels { + o.Labels[i] = selectables.LabelMap[l] + } + } + + // deadline + if o.Deadline, err = promptDatetime("Due date:"); err != nil { + return err + } + + return nil +} + +type issueSelectables struct { + Repo *gitea.Repository + Collaborators []string + MilestoneList []string + MilestoneMap map[string]int64 + LabelList []string + LabelMap map[string]int64 + Err error +} + +func fetchIssueSelectables(login *config.Login, owner, repo string, done chan issueSelectables) { + // TODO PERF make these calls concurrent + r := issueSelectables{} + c := login.Client() + + r.Repo, _, r.Err = c.GetRepo(owner, repo) + if r.Err != nil { + done <- r + return + } + // we can set the following properties only if we have write access to the repo + // so we fastpath this if not. + if !r.Repo.Permissions.Push { + done <- r + return + } + + // FIXME: this should ideally be ListAssignees(), https://github.com/go-gitea/gitea/issues/14856 + colabs, _, err := c.ListCollaborators(owner, repo, gitea.ListCollaboratorsOptions{}) + if err != nil { + r.Err = err + done <- r + return + } + r.Collaborators = make([]string, len(colabs)+1) + r.Collaborators[0] = login.User + for i, u := range colabs { + r.Collaborators[i+1] = u.UserName + } + + milestones, _, err := c.ListRepoMilestones(owner, repo, gitea.ListMilestoneOption{}) + if err != nil { + r.Err = err + done <- r + return + } + r.MilestoneMap = make(map[string]int64) + r.MilestoneList = make([]string, len(milestones)) + for i, m := range milestones { + r.MilestoneMap[m.Title] = m.ID + r.MilestoneList[i] = m.Title + } + + labels, _, err := c.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{}) + if err != nil { + r.Err = err + done <- r + return + } + r.LabelMap = make(map[string]int64) + r.LabelList = make([]string, len(labels)) + for i, l := range labels { + r.LabelMap[l.Name] = l.ID + r.LabelList[i] = l.Name + } + + done <- r } diff --git a/modules/interact/milestone_create.go b/modules/interact/milestone_create.go index 984fedb..8e8b2b1 100644 --- a/modules/interact/milestone_create.go +++ b/modules/interact/milestone_create.go @@ -5,7 +5,6 @@ package interact import ( - "fmt" "time" "code.gitea.io/tea/modules/config" @@ -13,12 +12,11 @@ import ( "code.gitea.io/sdk/gitea" "github.com/AlecAivazis/survey/v2" - "github.com/araddon/dateparse" ) // CreateMilestone interactively creates a milestone func CreateMilestone(login *config.Login, owner, repo string) error { - var title, description, dueDate string + var title, description string var deadline *time.Time // owner, repo @@ -41,28 +39,7 @@ func CreateMilestone(login *config.Login, owner, repo string) error { } // deadline - promptI = &survey.Input{Message: "Milestone deadline [no due date]:"} - err = survey.AskOne( - promptI, - &dueDate, - survey.WithValidator(func(input interface{}) error { - if str, ok := input.(string); ok { - if len(str) == 0 { - return nil - } - t, err := dateparse.ParseAny(str) - if err != nil { - return err - } - deadline = &t - } else { - return fmt.Errorf("invalid result type") - } - return nil - }), - ) - - if err != nil { + if deadline, err = promptDatetime("Milestone deadline:"); err != nil { return err } diff --git a/modules/interact/prompts.go b/modules/interact/prompts.go index 32566d4..debe944 100644 --- a/modules/interact/prompts.go +++ b/modules/interact/prompts.go @@ -7,8 +7,11 @@ package interact import ( "fmt" "strings" + "time" + "code.gitea.io/tea/modules/utils" "github.com/AlecAivazis/survey/v2" + "github.com/araddon/dateparse" ) // PromptMultiline runs a textfield-style prompt and blocks until input was made. @@ -27,9 +30,10 @@ func PromptPassword(name string) (pass string, err error) { // promptRepoSlug interactively prompts for a Gitea repository or returns the current one func promptRepoSlug(defaultOwner, defaultRepo string) (owner, repo string, err error) { prompt := "Target repo:" + defaultVal := "" required := true if len(defaultOwner) != 0 && len(defaultRepo) != 0 { - prompt = fmt.Sprintf("Target repo [%s/%s]:", defaultOwner, defaultRepo) + defaultVal = fmt.Sprintf("%s/%s", defaultOwner, defaultRepo) required = false } var repoSlug string @@ -38,7 +42,10 @@ func promptRepoSlug(defaultOwner, defaultRepo string) (owner, repo string, err e repo = defaultRepo err = survey.AskOne( - &survey.Input{Message: prompt}, + &survey.Input{ + Message: prompt, + Default: defaultVal, + }, &repoSlug, survey.WithValidator(func(input interface{}) error { if str, ok := input.(string); ok { @@ -63,3 +70,96 @@ func promptRepoSlug(defaultOwner, defaultRepo string) (owner, repo string, err e } return } + +// promptDatetime prompts for a date or datetime string. +// Supports all formats understood by araddon/dateparse. +func promptDatetime(prompt string) (val *time.Time, err error) { + var input string + err = survey.AskOne( + &survey.Input{Message: prompt}, + &input, + survey.WithValidator(func(input interface{}) error { + if str, ok := input.(string); ok { + if len(str) == 0 { + return nil + } + t, err := dateparse.ParseAny(str) + if err != nil { + return err + } + val = &t + } else { + return fmt.Errorf("invalid result type") + } + return nil + }), + ) + return +} + +// promptSelect creates a generic multiselect prompt, with processing of custom values. +func promptMultiSelect(prompt string, options []string, customVal string) ([]string, error) { + var selection []string + promptA := &survey.MultiSelect{ + Message: prompt, + Options: makeSelectOpts(options, customVal, ""), + VimMode: true, + } + if err := survey.AskOne(promptA, &selection); err != nil { + return nil, err + } + return promptCustomVal(prompt, customVal, selection) +} + +// promptSelect creates a generic select prompt, with processing of custom values or none-option. +func promptSelect(prompt string, options []string, customVal, noneVal string) (string, error) { + var selection string + promptA := &survey.Select{ + Message: prompt, + Options: makeSelectOpts(options, customVal, noneVal), + VimMode: true, + Default: noneVal, + } + if err := survey.AskOne(promptA, &selection); err != nil { + return "", err + } + if noneVal != "" && selection == noneVal { + return "", nil + } + if customVal != "" { + sel, err := promptCustomVal(prompt, customVal, []string{selection}) + if err != nil { + return "", err + } + selection = sel[0] + } + return selection, nil +} + +// makeSelectOpts adds cusotmVal & noneVal to opts if set. +func makeSelectOpts(opts []string, customVal, noneVal string) []string { + if customVal != "" { + opts = append(opts, customVal) + } + if noneVal != "" { + opts = append(opts, noneVal) + } + return opts +} + +// promptCustomVal checks if customVal is present in selection, and prompts +// for custom input to add to the selection instead. +func promptCustomVal(prompt, customVal string, selection []string) ([]string, error) { + // check for custom value & prompt again with text input + // HACK until https://github.com/AlecAivazis/survey/issues/339 is implemented + if otherIndex := utils.IndexOf(selection, customVal); otherIndex != -1 { + var customAssignees string + promptA := &survey.Input{Message: prompt, Help: "comma separated list"} + if err := survey.AskOne(promptA, &customAssignees); err != nil { + return nil, err + } + selection = append(selection[:otherIndex], selection[otherIndex+1:]...) + selection = append(selection, strings.Split(customAssignees, ",")...) + } + return selection, nil +} diff --git a/modules/interact/pull_create.go b/modules/interact/pull_create.go index ff14e99..5d8e594 100644 --- a/modules/interact/pull_create.go +++ b/modules/interact/pull_create.go @@ -5,6 +5,7 @@ package interact import ( + "code.gitea.io/sdk/gitea" "code.gitea.io/tea/modules/config" "code.gitea.io/tea/modules/git" "code.gitea.io/tea/modules/task" @@ -14,7 +15,7 @@ import ( // CreatePull interactively creates a PR func CreatePull(login *config.Login, owner, repo string) error { - var base, head, title, description string + var base, head string // owner, repo owner, repo, err := promptRepoSlug(owner, repo) @@ -23,17 +24,14 @@ func CreatePull(login *config.Login, owner, repo string) error { } // base - baseBranch, err := task.GetDefaultPRBase(login, owner, repo) + base, err = task.GetDefaultPRBase(login, owner, repo) if err != nil { return err } - promptI := &survey.Input{Message: "Target branch [" + baseBranch + "]:"} + promptI := &survey.Input{Message: "Target branch:", Default: base} if err := survey.AskOne(promptI, &base); err != nil { return err } - if len(base) == 0 { - base = baseBranch - } // head localRepo, err := git.RepoForWorkdir() @@ -45,38 +43,19 @@ func CreatePull(login *config.Login, owner, repo string) error { if err == nil { promptOpts = nil } - var headOwnerInput, headBranchInput string - promptI = &survey.Input{Message: "Source repo owner [" + headOwner + "]:"} - if err := survey.AskOne(promptI, &headOwnerInput); err != nil { + promptI = &survey.Input{Message: "Source repo owner:", Default: headOwner} + if err := survey.AskOne(promptI, &headOwner); err != nil { return err } - if len(headOwnerInput) != 0 { - headOwner = headOwnerInput - } - promptI = &survey.Input{Message: "Source branch [" + headBranch + "]:"} - if err := survey.AskOne(promptI, &headBranchInput, promptOpts); err != nil { + promptI = &survey.Input{Message: "Source branch:", Default: headBranch} + if err := survey.AskOne(promptI, &headBranch, promptOpts); err != nil { return err } - if len(headBranchInput) != 0 { - headBranch = headBranchInput - } head = task.GetHeadSpec(headOwner, headBranch, owner) - // title - title = task.GetDefaultPRTitle(head) - promptOpts = survey.WithValidator(survey.Required) - if len(title) != 0 { - promptOpts = nil - } - promptI = &survey.Input{Message: "PR title [" + title + "]:"} - if err := survey.AskOne(promptI, &title, promptOpts); err != nil { - return err - } - - // description - promptM := &survey.Multiline{Message: "PR description:"} - if err := survey.AskOne(promptM, &description); err != nil { + opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)} + if err = promptIssueProperties(login, owner, repo, &opts); err != nil { return err } @@ -86,6 +65,5 @@ func CreatePull(login *config.Login, owner, repo string) error { repo, base, head, - title, - description) + &opts) } diff --git a/modules/task/issue_create.go b/modules/task/issue_create.go index 792dd69..60a42cd 100644 --- a/modules/task/issue_create.go +++ b/modules/task/issue_create.go @@ -13,25 +13,14 @@ import ( ) // CreateIssue creates an issue in the given repo and prints the result -func CreateIssue(login *config.Login, repoOwner, repoName, title, description string) error { +func CreateIssue(login *config.Login, repoOwner, repoName string, opts gitea.CreateIssueOption) error { // title is required - if len(title) == 0 { + if len(opts.Title) == 0 { return fmt.Errorf("Title is required") } - issue, _, err := login.Client().CreateIssue(repoOwner, repoName, gitea.CreateIssueOption{ - Title: title, - Body: description, - // TODO: - //Assignee string `json:"assignee"` - //Assignees []string `json:"assignees"` - //Deadline *time.Time `json:"due_date"` - //Milestone int64 `json:"milestone"` - //Labels []int64 `json:"labels"` - //Closed bool `json:"closed"` - }) - + issue, _, err := login.Client().CreateIssue(repoOwner, repoName, opts) if err != nil { return fmt.Errorf("could not create issue: %s", err) } diff --git a/modules/task/labels.go b/modules/task/labels.go new file mode 100644 index 0000000..608dca1 --- /dev/null +++ b/modules/task/labels.go @@ -0,0 +1,25 @@ +// Copyright 2020 The Gitea Authors. All rights reserved. +// Use of this source code is governed by a MIT-style +// license that can be found in the LICENSE file. + +package task + +import ( + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/utils" +) + +// ResolveLabelNames returns a list of label IDs for a given list of label names +func ResolveLabelNames(client *gitea.Client, owner, repo string, labelNames []string) ([]int64, error) { + labelIDs := make([]int64, len(labelNames)) + labels, _, err := client.ListRepoLabels(owner, repo, gitea.ListLabelsOptions{}) + if err != nil { + return nil, err + } + for _, l := range labels { + if utils.Contains(labelNames, l.Name) { + labelIDs = append(labelIDs, l.ID) + } + } + return labelIDs, nil +} diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go index bedf019..2b2d729 100644 --- a/modules/task/pull_create.go +++ b/modules/task/pull_create.go @@ -16,8 +16,7 @@ import ( ) // CreatePull creates a PR in the given repo and prints the result -func CreatePull(login *config.Login, repoOwner, repoName, base, head, title, description string) error { - +func CreatePull(login *config.Login, repoOwner, repoName, base, head string, opts *gitea.CreateIssueOption) error { // open local git repo localRepo, err := local_git.RepoForWorkdir() if err != nil { @@ -48,19 +47,23 @@ func CreatePull(login *config.Login, repoOwner, repoName, base, head, title, des } // default is head branch name - if len(title) == 0 { - title = GetDefaultPRTitle(head) + if len(opts.Title) == 0 { + opts.Title = GetDefaultPRTitle(head) } // title is required - if len(title) == 0 { + if len(opts.Title) == 0 { return fmt.Errorf("Title is required") } pr, _, err := login.Client().CreatePullRequest(repoOwner, repoName, gitea.CreatePullRequestOption{ - Head: head, - Base: base, - Title: title, - Body: description, + Head: head, + Base: base, + Title: opts.Title, + Body: opts.Body, + Assignees: opts.Assignees, + Labels: opts.Labels, + Milestone: opts.Milestone, + Deadline: opts.Deadline, }) if err != nil { diff --git a/modules/utils/utils.go b/modules/utils/utils.go index 88c4514..631c345 100644 --- a/modules/utils/utils.go +++ b/modules/utils/utils.go @@ -6,11 +6,15 @@ package utils // Contains checks containment func Contains(haystack []string, needle string) bool { - for _, s := range haystack { + return IndexOf(haystack, needle) != -1 +} + +// IndexOf returns the index of first occurrence of needle in haystack +func IndexOf(haystack []string, needle string) int { + for i, s := range haystack { if s == needle { - return true + return i } } - - return false + return -1 }