From adb2382aa57c57ab52ccccc1bf8bc7a62c9b2ae6 Mon Sep 17 00:00:00 2001 From: Norwin Date: Wed, 9 Dec 2020 05:41:50 +0800 Subject: [PATCH] Add interactive mode for `tea pr create` (#279) refactor pull create into task & interact module avoid creation of invalid PRs refactor task.CreatePull to make functionality reusable in interact module implement interactive.CreatePull Co-authored-by: Norwin Roosen Reviewed-on: https://gitea.com/gitea/tea/pulls/279 Reviewed-by: 6543 <6543@obermui.de> Reviewed-by: techknowlogick Co-Authored-By: Norwin Co-Committed-By: Norwin --- cmd/pulls/create.go | 117 ++++--------------------- modules/interact/pull_create.go | 133 ++++++++++++++++++++++++++++ modules/task/pull_create.go | 151 ++++++++++++++++++++++++++++++++ 3 files changed, 299 insertions(+), 102 deletions(-) create mode 100644 modules/interact/pull_create.go create mode 100644 modules/task/pull_create.go diff --git a/cmd/pulls/create.go b/cmd/pulls/create.go index 8547d37..5ca8a3c 100644 --- a/cmd/pulls/create.go +++ b/cmd/pulls/create.go @@ -5,18 +5,11 @@ package pulls import ( - "fmt" - "log" - "strings" - "code.gitea.io/tea/cmd/flags" "code.gitea.io/tea/modules/config" - local_git "code.gitea.io/tea/modules/git" - "code.gitea.io/tea/modules/print" - "code.gitea.io/tea/modules/utils" + "code.gitea.io/tea/modules/interact" + "code.gitea.io/tea/modules/task" - "code.gitea.io/sdk/gitea" - "github.com/go-git/go-git/v5" "github.com/urfave/cli/v2" ) @@ -51,100 +44,20 @@ var CmdPullsCreate = cli.Command{ func runPullsCreate(ctx *cli.Context) error { login, ownerArg, repoArg := config.InitCommand(flags.GlobalRepoValue, flags.GlobalLoginValue, flags.GlobalRemoteValue) - client := login.Client() - repo, _, err := client.GetRepo(ownerArg, repoArg) - if err != nil { - log.Fatal("could not fetch repo meta: ", err) + // no args -> interactive mode + if ctx.NumFlags() == 0 { + return interact.CreatePull(login, ownerArg, repoArg) } - // open local git repo - localRepo, err := local_git.RepoForWorkdir() - if err != nil { - log.Fatal("could not open local repo: ", err) - } - - // push if possible - log.Println("git push") - err = localRepo.Push(&git.PushOptions{}) - if err != nil && err != git.NoErrAlreadyUpToDate { - log.Printf("Error occurred during 'git push':\n%s\n", err.Error()) - } - - base := ctx.String("base") - // default is default branch - if len(base) == 0 { - base = repo.DefaultBranch - } - - head := ctx.String("head") - // default is current one - if len(head) == 0 { - headBranch, err := localRepo.Head() - if err != nil { - log.Fatal(err) - } - sha := headBranch.Hash().String() - - remote, err := localRepo.TeaFindBranchRemote("", sha) - if err != nil { - log.Fatal("could not determine remote for current branch: ", err) - } - - if remote == nil { - // if no remote branch is found for the local hash, we abort: - // user has probably not configured a remote for the local branch, - // or local branch does not represent remote state. - log.Fatal("no matching remote found for this branch. try git push -u ") - } - - branchName, err := localRepo.TeaGetCurrentBranchName() - if err != nil { - log.Fatal(err) - } - - url, err := local_git.ParseURL(remote.Config().URLs[0]) - if err != nil { - log.Fatal(err) - } - owner, _ := utils.GetOwnerAndRepo(strings.TrimLeft(url.Path, "/"), "") - if owner != repo.Owner.UserName { - head = fmt.Sprintf("%s:%s", owner, branchName) - } else { - head = branchName - } - } - - title := ctx.String("title") - // default is head branch name - if len(title) == 0 { - title = head - if strings.Contains(title, ":") { - title = strings.SplitN(title, ":", 2)[1] - } - title = strings.Replace(title, "-", " ", -1) - title = strings.Replace(title, "_", " ", -1) - title = strings.Title(strings.ToLower(title)) - } - // title is required - if len(title) == 0 { - fmt.Printf("Title is required") - return nil - } - - pr, _, err := client.CreatePullRequest(ownerArg, repoArg, gitea.CreatePullRequestOption{ - Head: head, - Base: base, - Title: title, - Body: ctx.String("description"), - }) - - if err != nil { - log.Fatalf("could not create PR from %s to %s:%s: %s", head, ownerArg, base, err) - } - - print.PullDetails(pr, nil) - - fmt.Println(pr.HTMLURL) - return err + // else use args to create PR + return task.CreatePull( + login, + ownerArg, + repoArg, + ctx.String("base"), + ctx.String("head"), + ctx.String("title"), + ctx.String("description"), + ) } diff --git a/modules/interact/pull_create.go b/modules/interact/pull_create.go new file mode 100644 index 0000000..a1ec947 --- /dev/null +++ b/modules/interact/pull_create.go @@ -0,0 +1,133 @@ +// 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 interact + +import ( + "fmt" + "strings" + + "code.gitea.io/tea/modules/config" + "code.gitea.io/tea/modules/git" + "code.gitea.io/tea/modules/task" + + "github.com/AlecAivazis/survey/v2" +) + +// CreatePull interactively creates a PR +func CreatePull(login *config.Login, owner, repo string) error { + var base, head, title, description string + + // owner, repo + owner, repo, err := promptRepoSlug(owner, repo) + if err != nil { + return err + } + + // base + baseBranch, err := task.GetDefaultPRBase(login, owner, repo) + if err != nil { + return err + } + promptI := &survey.Input{Message: "Target branch [" + baseBranch + "]:"} + if err := survey.AskOne(promptI, &base); err != nil { + return err + } + if len(base) == 0 { + base = baseBranch + } + + // head + localRepo, err := git.RepoForWorkdir() + if err != nil { + return err + } + promptOpts := survey.WithValidator(survey.Required) + headOwner, headBranch, err := task.GetDefaultPRHead(localRepo) + 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 { + return err + } + if len(headOwnerInput) != 0 { + headOwner = headOwnerInput + } + promptI = &survey.Input{Message: "Source branch [" + headBranch + "]:"} + if err := survey.AskOne(promptI, &headBranchInput, 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 { + return err + } + + return task.CreatePull( + login, + owner, + repo, + base, + head, + title, + description) +} + +func promptRepoSlug(defaultOwner, defaultRepo string) (owner, repo string, err error) { + prompt := "Target repo:" + required := true + if len(defaultOwner) != 0 && len(defaultRepo) != 0 { + prompt = fmt.Sprintf("Target repo [%s/%s]:", defaultOwner, defaultRepo) + required = false + } + var repoSlug string + + owner = defaultOwner + repo = defaultRepo + + err = survey.AskOne( + &survey.Input{Message: prompt}, + &repoSlug, + survey.WithValidator(func(input interface{}) error { + if str, ok := input.(string); ok { + if !required && len(str) == 0 { + return nil + } + split := strings.Split(str, "/") + if len(split) != 2 || len(split[0]) == 0 || len(split[1]) == 0 { + return fmt.Errorf("must follow the / syntax") + } + } else { + return fmt.Errorf("invalid result type") + } + return nil + }), + ) + + if err == nil && len(repoSlug) != 0 { + repoSlugSplit := strings.Split(repoSlug, "/") + owner = repoSlugSplit[0] + repo = repoSlugSplit[1] + } + return +} diff --git a/modules/task/pull_create.go b/modules/task/pull_create.go new file mode 100644 index 0000000..c588068 --- /dev/null +++ b/modules/task/pull_create.go @@ -0,0 +1,151 @@ +// 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 ( + "fmt" + "log" + "strings" + + "code.gitea.io/sdk/gitea" + "code.gitea.io/tea/modules/config" + local_git "code.gitea.io/tea/modules/git" + "code.gitea.io/tea/modules/print" + "code.gitea.io/tea/modules/utils" + + "github.com/go-git/go-git/v5" +) + +// 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 { + + // open local git repo + localRepo, err := local_git.RepoForWorkdir() + if err != nil { + log.Fatal("could not open local repo: ", err) + } + + // push if possible + log.Println("git push") + err = localRepo.Push(&git.PushOptions{}) + if err != nil && err != git.NoErrAlreadyUpToDate { + log.Printf("Error occurred during 'git push':\n%s\n", err.Error()) + } + + // default is default branch + if len(base) == 0 { + base, err = GetDefaultPRBase(login, repoOwner, repoName) + if err != nil { + return err + } + } + + // default is current one + if len(head) == 0 { + headOwner, headBranch, err := GetDefaultPRHead(localRepo) + if err != nil { + return err + } + + head = GetHeadSpec(headOwner, headBranch, repoOwner) + } + + // head & base may not be the same + if head == base { + return fmt.Errorf("can't create PR from %s to %s", head, base) + } + + // default is head branch name + if len(title) == 0 { + title = GetDefaultPRTitle(head) + } + // title is required + if len(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, + }) + + if err != nil { + log.Fatalf("could not create PR from %s to %s:%s: %s", head, repoOwner, base, err) + } + + print.PullDetails(pr, nil) + + fmt.Println(pr.HTMLURL) + + return err +} + +// GetDefaultPRBase retrieves the default base branch for the given repo +func GetDefaultPRBase(login *config.Login, owner, repo string) (string, error) { + meta, _, err := login.Client().GetRepo(owner, repo) + if err != nil { + return "", fmt.Errorf("could not fetch repo meta: %s", err) + } + return meta.DefaultBranch, nil +} + +// GetDefaultPRHead uses the currently checked out branch, checks if +// a remote currently holds the commit it points to, extracts the owner +// from its URL, and assembles the result to a valid head spec for gitea. +func GetDefaultPRHead(localRepo *local_git.TeaRepo) (owner, branch string, err error) { + headBranch, err := localRepo.Head() + if err != nil { + return + } + sha := headBranch.Hash().String() + + remote, err := localRepo.TeaFindBranchRemote("", sha) + if err != nil { + err = fmt.Errorf("could not determine remote for current branch: %s", err) + return + } + + if remote == nil { + // if no remote branch is found for the local hash, we abort: + // user has probably not configured a remote for the local branch, + // or local branch does not represent remote state. + err = fmt.Errorf("no matching remote found for this branch. try git push -u ") + return + } + + branch, err = localRepo.TeaGetCurrentBranchName() + if err != nil { + return + } + + url, err := local_git.ParseURL(remote.Config().URLs[0]) + if err != nil { + return + } + owner, _ = utils.GetOwnerAndRepo(strings.TrimLeft(url.Path, "/"), "") + return +} + +// GetHeadSpec creates a head string as expected by gitea API +func GetHeadSpec(owner, branch, baseOwner string) string { + if len(owner) != 0 && owner != baseOwner { + return fmt.Sprintf("%s:%s", owner, branch) + } + return branch +} + +// GetDefaultPRTitle transforms a string like a branchname to a readable text +func GetDefaultPRTitle(head string) string { + title := head + if strings.Contains(title, ":") { + title = strings.SplitN(title, ":", 2)[1] + } + title = strings.Replace(title, "-", " ", -1) + title = strings.Replace(title, "_", " ", -1) + title = strings.Title(strings.ToLower(title)) + return title +}