add tea pulls [checkout | clean] commands (#93 #97 #107) (#105)

Merge branch 'master' into issue-97/pulls-clean

vendor terminal dependency

pull/push: provide authentication method

automatically select an AuthMethod according to the
remote url type. If required, credentials are prompted for

login: store username & optional keyfile

refactor

refactor GetRemote

Merge branch 'master' into issue-97/pulls-clean

adress code review

add --ignore-sha flag

When set, the local branch is not matched against the remote sha,
but the remote branch name. This makes the command more flexible
with diverging branches.

add missing error check

fix branch-not-found case

Merge branch 'master' into issue-97/pulls-clean

use directory namespaces for branches & remotes

fix TeaCreateBranch()

improve method of TeaFindBranch()

now only checking .git/refs instead of looking up .git/config which may
not list the branch

add `tea pulls clean`

fixes #97

add copyright to new files

make linter happy

refactor: use new git functions for old code

add `tea pulls checkout`

Co-authored-by: Norwin Roosen <git@nroo.de>
Co-authored-by: Norwin <git@nroo.de>
Reviewed-on: https://gitea.com/gitea/tea/pulls/105
Reviewed-by: 6543 <6543@noreply.gitea.io>
Reviewed-by: Lunny Xiao <xiaolunwen@gmail.com>
This commit is contained in:
Norwin
2020-04-19 03:09:03 +00:00
committed by Lunny Xiao
parent 81fa4e78ff
commit 4cda7e0299
23 changed files with 2050 additions and 92 deletions

View File

@ -18,21 +18,24 @@ import (
"strings"
"code.gitea.io/sdk/gitea"
local_git "code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/git"
"code.gitea.io/tea/modules/utils"
go_git "gopkg.in/src-d/go-git.v4"
"github.com/go-gitea/yaml"
)
// Login represents a login to a gitea server, you even could add multiple logins for one gitea server
type Login struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token"`
Active bool `yaml:"active"`
SSHHost string `yaml:"ssh_host"`
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token"`
Active bool `yaml:"active"`
SSHHost string `yaml:"ssh_host"`
// optional path to the private key
SSHKey string `yaml:"ssh_key"`
Insecure bool `yaml:"insecure"`
// optional gitea username
User string `yaml:"user"`
}
// Client returns a client to operate Gitea API
@ -187,11 +190,11 @@ func saveConfig(ymlPath string) error {
}
func curGitRepoPath() (*Login, string, error) {
gitPath, err := go_git.PlainOpenWithOptions("./", &go_git.PlainOpenOptions{DetectDotGit: true})
repo, err := git.RepoForWorkdir()
if err != nil {
return nil, "", errors.New("No Gitea login found")
}
gitConfig, err := gitPath.Config()
gitConfig, err := repo.Config()
if err != nil {
return nil, "", err
}
@ -224,7 +227,7 @@ func curGitRepoPath() (*Login, string, error) {
for _, l := range config.Logins {
for _, u := range remoteConfig.URLs {
p, err := local_git.ParseURL(strings.TrimSpace(u))
p, err := git.ParseURL(strings.TrimSpace(u))
if err != nil {
return nil, "", fmt.Errorf("Git remote URL parse failed: %s", err.Error())
}

View File

@ -81,24 +81,9 @@ var AllDefaultFlags = append([]cli.Flag{
// initCommand returns repository and *Login based on flags
func initCommand() (*Login, string, string) {
err := loadConfig(yamlConfigPath)
if err != nil {
log.Fatal("Unable to load config file " + yamlConfigPath)
}
var login *Login
if loginValue == "" {
login, err = getActiveLogin()
if err != nil {
log.Fatal(err)
}
} else {
login = getLoginByName(loginValue)
if login == nil {
log.Fatal("Login name " + loginValue + " does not exist")
}
}
login := initCommandLoginOnly()
var err error
repoPath := repoValue
if repoPath == "" {
login, repoPath, err = curGitRepoPath()
@ -119,10 +104,16 @@ func initCommandLoginOnly() *Login {
}
var login *Login
login = getLoginByName(loginValue)
if login == nil {
log.Fatal("indicated login name ", loginValue, " does not exist")
if loginValue == "" {
login, err = getActiveLogin()
if err != nil {
log.Fatal(err)
}
} else {
login = getLoginByName(loginValue)
if login == nil {
log.Fatal("Login name " + loginValue + " does not exist")
}
}
return login

View File

@ -8,7 +8,6 @@ import (
"fmt"
"log"
"strconv"
"strings"
"code.gitea.io/sdk/gitea"
@ -53,15 +52,10 @@ func runIssues(ctx *cli.Context) error {
func runIssueDetail(ctx *cli.Context, index string) error {
login, owner, repo := initCommand()
if strings.HasPrefix(index, "#") {
index = index[1:]
}
idx, err := strconv.ParseInt(index, 10, 64)
idx, err := argToIndex(index)
if err != nil {
return err
}
issue, err := login.Client().GetIssue(owner, repo, idx)
if err != nil {
return err

View File

@ -34,23 +34,31 @@ var cmdLoginAdd = cli.Command{
Description: `Add a Gitea login`,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Login name",
Name: "name",
Aliases: []string{"n"},
Usage: "Login name",
Required: true,
},
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Value: "https://try.gitea.io",
EnvVars: []string{"GITEA_SERVER_URL"},
Usage: "Server URL",
Name: "url",
Aliases: []string{"u"},
Value: "https://try.gitea.io",
EnvVars: []string{"GITEA_SERVER_URL"},
Usage: "Server URL",
Required: true,
},
&cli.StringFlag{
Name: "token",
Aliases: []string{"t"},
Value: "",
EnvVars: []string{"GITEA_SERVER_TOKEN"},
Usage: "Access token. Can be obtained from Settings > Applications",
Name: "token",
Aliases: []string{"t"},
Value: "",
EnvVars: []string{"GITEA_SERVER_TOKEN"},
Usage: "Access token. Can be obtained from Settings > Applications",
Required: true,
},
&cli.BoolFlag{
Name: "ssh-key",
Aliases: []string{"s"},
Usage: "Path to a SSH key to use for pull/push operations",
},
&cli.BoolFlag{
Name: "insecure",
@ -100,6 +108,8 @@ func runLoginAdd(ctx *cli.Context) error {
URL: ctx.String("url"),
Token: ctx.String("token"),
Insecure: ctx.Bool("insecure"),
SSHKey: ctx.String("ssh-key"),
User: u.UserName,
})
if err != nil {
log.Fatal(err)

View File

@ -37,7 +37,11 @@ func runOpen(ctx *cli.Context) error {
case strings.EqualFold(number, "releases"):
suffix = "releases"
case strings.EqualFold(number, "commits"):
b, err := local_git.GetRepoReference("./")
repo, err := local_git.RepoForWorkdir()
if err != nil {
log.Fatal(err)
}
b, err := repo.Head()
if err != nil {
log.Fatal(err)
return nil

View File

@ -5,17 +5,23 @@
package cmd
import (
"fmt"
"log"
"strconv"
"strings"
local_git "code.gitea.io/tea/modules/git"
"code.gitea.io/sdk/gitea"
"github.com/urfave/cli/v2"
"gopkg.in/src-d/go-git.v4"
git_config "gopkg.in/src-d/go-git.v4/config"
)
// CmdPulls represents to login a gitea server.
// CmdPulls is the main command to operate on PRs
var CmdPulls = cli.Command{
Name: "pulls",
Aliases: []string{"pull", "pr"},
Usage: "List open pull requests",
Description: `List open pull requests`,
Action: runPulls,
@ -26,6 +32,10 @@ var CmdPulls = cli.Command{
DefaultText: "open",
},
}, AllDefaultFlags...),
Subcommands: []*cli.Command{
&CmdPullsCheckout,
&CmdPullsClean,
},
}
func runPulls(ctx *cli.Context) error {
@ -88,3 +98,176 @@ func runPulls(ctx *cli.Context) error {
return nil
}
// CmdPullsCheckout is a command to locally checkout the given PR
var CmdPullsCheckout = cli.Command{
Name: "checkout",
Usage: "Locally check out the given PR",
Description: `Locally check out the given PR`,
Action: runPullsCheckout,
ArgsUsage: "<pull index>",
Flags: AllDefaultFlags,
}
func runPullsCheckout(ctx *cli.Context) error {
login, owner, repo := initCommand()
if ctx.Args().Len() != 1 {
log.Fatal("Must specify a PR index")
}
// fetch PR source-repo & -branch from gitea
idx, err := argToIndex(ctx.Args().First())
if err != nil {
return err
}
pr, err := login.Client().GetPullRequest(owner, repo, idx)
if err != nil {
return err
}
remoteURL := pr.Head.Repository.CloneURL
remoteBranchName := pr.Head.Ref
// open local git repo
localRepo, err := local_git.RepoForWorkdir()
if err != nil {
return nil
}
// verify related remote is in local repo, otherwise add it
newRemoteName := fmt.Sprintf("pulls/%v", pr.Head.Repository.Owner.UserName)
localRemote, err := localRepo.GetOrCreateRemote(remoteURL, newRemoteName)
if err != nil {
return err
}
localRemoteName := localRemote.Config().Name
localBranchName := fmt.Sprintf("pulls/%v-%v", idx, remoteBranchName)
// fetch remote
fmt.Printf("Fetching PR %v (head %s:%s) from remote '%s'\n",
idx, remoteURL, remoteBranchName, localRemoteName)
url, err := local_git.ParseURL(localRemote.Config().URLs[0])
if err != nil {
return err
}
auth, err := local_git.GetAuthForURL(url, login.User, login.SSHKey)
if err != nil {
return err
}
err = localRemote.Fetch(&git.FetchOptions{Auth: auth})
if err == git.NoErrAlreadyUpToDate {
fmt.Println(err)
} else if err != nil {
return err
}
// checkout local branch
fmt.Printf("Creating branch '%s'\n", localBranchName)
err = localRepo.TeaCreateBranch(localBranchName, remoteBranchName, localRemoteName)
if err == git.ErrBranchExists {
fmt.Println(err)
} else if err != nil {
return err
}
fmt.Printf("Checking out PR %v\n", idx)
err = localRepo.TeaCheckout(localBranchName)
return err
}
// CmdPullsClean removes the remote and local feature branches, if a PR is merged.
var CmdPullsClean = cli.Command{
Name: "clean",
Usage: "Deletes local & remote feature-branches for a closed pull request",
Description: `Deletes local & remote feature-branches for a closed pull request`,
ArgsUsage: "<pull index>",
Action: runPullsClean,
Flags: append([]cli.Flag{
&cli.BoolFlag{
Name: "ignore-sha",
Usage: "Find the local branch by name instead of commit hash (less precise)",
},
}, AllDefaultFlags...),
}
func runPullsClean(ctx *cli.Context) error {
login, owner, repo := initCommand()
if ctx.Args().Len() != 1 {
return fmt.Errorf("Must specify a PR index")
}
// fetch PR source-repo & -branch from gitea
idx, err := argToIndex(ctx.Args().First())
if err != nil {
return err
}
pr, err := login.Client().GetPullRequest(owner, repo, idx)
if err != nil {
return err
}
if pr.State == gitea.StateOpen {
return fmt.Errorf("PR is still open, won't delete branches")
}
// IDEA: abort if PR.Head.Repository.CloneURL does not match login.URL?
r, err := local_git.RepoForWorkdir()
if err != nil {
return err
}
// find a branch with matching sha or name, that has a remote matching the repo url
var branch *git_config.Branch
if ctx.Bool("ignore-sha") {
branch, err = r.TeaFindBranchByName(pr.Head.Ref, pr.Head.Repository.CloneURL)
} else {
branch, err = r.TeaFindBranchBySha(pr.Head.Sha, pr.Head.Repository.CloneURL)
}
if err != nil {
return err
}
if branch == nil {
if ctx.Bool("ignore-sha") {
return fmt.Errorf("Remote branch %s not found in local repo", pr.Head.Ref)
}
return fmt.Errorf(`Remote branch %s not found in local repo.
Either you don't track this PR, or the local branch has diverged from the remote.
If you still want to continue & are sure you don't loose any important commits,
call me again with the --ignore-sha flag`, pr.Head.Ref)
}
// prepare deletion of local branch:
headRef, err := r.Head()
if err != nil {
return err
}
if headRef.Name().Short() == branch.Name {
fmt.Printf("Checking out 'master' to delete local branch '%s'\n", branch.Name)
err = r.TeaCheckout("master")
if err != nil {
return err
}
}
// remove local & remote branch
fmt.Printf("Deleting local branch %s and remote branch %s\n", branch.Name, pr.Head.Ref)
url, err := r.TeaRemoteURL(branch.Remote)
if err != nil {
return err
}
auth, err := local_git.GetAuthForURL(url, login.User, login.SSHKey)
if err != nil {
return err
}
return r.TeaDeleteBranch(branch, pr.Head.Ref, auth)
}
func argToIndex(arg string) (int64, error) {
if strings.HasPrefix(arg, "#") {
arg = arg[1:]
}
return strconv.ParseInt(arg, 10, 64)
}

View File

@ -69,9 +69,9 @@ func runTrackedTimes(ctx *cli.Context) error {
times, err = client.GetRepoTrackedTimes(owner, repo)
} else if strings.HasPrefix(user, "#") {
// get all tracked times on the specified issue
issue, err2 := strconv.ParseInt(user[1:], 10, 64)
if err2 != nil {
return err2
issue, err := argToIndex(user)
if err != nil {
return err
}
times, err = client.ListTrackedTimes(owner, repo, issue)
} else {
@ -166,11 +166,7 @@ func runTrackedTimesAdd(ctx *cli.Context) error {
return fmt.Errorf("No issue or duration specified.\nUsage:\t%s", ctx.Command.UsageText)
}
issueStr := ctx.Args().First()
if strings.HasPrefix(issueStr, "#") {
issueStr = issueStr[1:]
}
issue, err := strconv.ParseInt(issueStr, 10, 64)
issue, err := argToIndex(ctx.Args().First())
if err != nil {
log.Fatal(err)
}
@ -212,11 +208,7 @@ func runTrackedTimesDelete(ctx *cli.Context) error {
return fmt.Errorf("No issue or time ID specified.\nUsage:\t%s", ctx.Command.UsageText)
}
issueStr := ctx.Args().First()
if strings.HasPrefix(issueStr, "#") {
issueStr = issueStr[1:]
}
issue, err := strconv.ParseInt(issueStr, 10, 64)
issue, err := argToIndex(ctx.Args().First())
if err != nil {
log.Fatal(err)
}
@ -256,11 +248,7 @@ func runTrackedTimesReset(ctx *cli.Context) error {
return fmt.Errorf("No issue specified.\nUsage:\t%s", ctx.Command.UsageText)
}
issueStr := ctx.Args().First()
if strings.HasPrefix(issueStr, "#") {
issueStr = issueStr[1:]
}
issue, err := strconv.ParseInt(issueStr, 10, 64)
issue, err := argToIndex(ctx.Args().First())
if err != nil {
log.Fatal(err)
}