add tea pulls checkout command (#93) #103

Closed
noerw wants to merge 4 commits from noerw:issue-93/pulls-checkout into main
8 changed files with 225 additions and 34 deletions

View File

@ -18,9 +18,8 @@ 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"
)
@ -187,11 +186,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 +223,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

@ -9,7 +9,6 @@ import (
"log"
"os"
"strconv"
"strings"
"code.gitea.io/sdk/gitea"
@ -48,15 +47,7 @@ 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)
if err != nil {
return err
}
idx, err := argToIndex(index)
issue, err := login.Client().GetIssue(owner, repo, idx)
if err != nil {
return err

View File

@ -5,21 +5,29 @@
package cmd
import (
"fmt"
"log"
"strconv"
"strings"
"code.gitea.io/sdk/gitea"
local_git "code.gitea.io/tea/modules/git"
"gopkg.in/src-d/go-git.v4"
"github.com/urfave/cli/v2"
)
// CmdPulls represents to login a gitea server.
// CmdPulls is the main command to operate on PRs
var CmdPulls = cli.Command{
Name: "pulls",
Usage: "List open pull requests",
Description: `List open pull requests`,
Action: runPulls,
Flags: AllDefaultFlags,
Subcommands: []*cli.Command{
&CmdPullsCheckout,
},
}
func runPulls(ctx *cli.Context) error {
@ -70,3 +78,79 @@ 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 := pr.Head.Repository.Owner.UserName
localRemote, err := localRepo.GetOrCreateRemote(remoteURL, newRemoteName)
if err != nil {
return err
}
localRemoteName := localRemote.Config().Name
localBranchName := fmt.Sprintf("pull-%v-%v", idx, remoteBranchName)
// fetch remote
fmt.Printf("Fetching PR %v (head %s:%s) from remote '%s'\n",
idx, remoteURL, remoteBranchName, localRemoteName)
err = localRemote.Fetch(&git.FetchOptions{})
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
}
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)
}

46
modules/git/branch.go Normal file
View File

@ -0,0 +1,46 @@
// 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 git
import (
"gopkg.in/src-d/go-git.v4"
git_config "gopkg.in/src-d/go-git.v4/config"
git_plumbing "gopkg.in/src-d/go-git.v4/plumbing"
)
// TeaCreateBranch creates a new branch in the repo, tracking from another branch.
// If remoteName is not-null, a remote branch is tracked.
func (r TeaRepo) TeaCreateBranch(localBranchName, remoteBranchName, remoteName string) error {
remoteBranchRefName := git_plumbing.NewBranchReferenceName(remoteBranchName)
err := r.CreateBranch(&git_config.Branch{
Name: localBranchName,
Merge: remoteBranchRefName,
Remote: remoteName,
})
if err != nil {
return err
}
// serialize the branch to .git/refs/heads (otherwise branch is only defined
// in .git/.config)
localBranchRefName := git_plumbing.NewBranchReferenceName(localBranchName)
remoteBranchRef, err := r.Storer.Reference(remoteBranchRefName)
if err != nil {
return nil
}
localHashRef := git_plumbing.NewHashReference(localBranchRefName, remoteBranchRef.Hash())
r.Storer.SetReference(localHashRef)
return nil
}
// TeaCheckout checks out the given branch in the worktree.
func (r TeaRepo) TeaCheckout(branchName string) error {
tree, err := r.Worktree()
if err != nil {
return nil
}
localBranchRefName := git_plumbing.NewBranchReferenceName(branchName)
return tree.Checkout(&git.CheckoutOptions{Branch: localBranchRefName})
}

51
modules/git/remote.go Normal file
View File

@ -0,0 +1,51 @@
// 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 git
import (
"gopkg.in/src-d/go-git.v4"
git_config "gopkg.in/src-d/go-git.v4/config"
)
// GetOrCreateRemote tries to match a Remote of the repo via the given URL.
// If no match is found, a new Remote with `newRemoteName` is created.
// Matching is based on the normalized URL, accepting different protocols.
func (r TeaRepo) GetOrCreateRemote(remoteURL, newRemoteName string) (*git.Remote, error) {
repoURL, err := ParseURL(remoteURL)
if err != nil {
return nil, err
}
remotes, err := r.Remotes()
if err != nil {
return nil, err
}
var localRemote *git.Remote
for _, r := range remotes {
for _, u := range r.Config().URLs {
remoteURL, _ := ParseURL(u)
if remoteURL.Host == repoURL.Host && remoteURL.Path == repoURL.Path {
localRemote = r
break
}
}
if localRemote != nil {
break
}
}
// if no match found, create a new remote
if localRemote == nil {
localRemote, err = r.CreateRemote(&git_config.RemoteConfig{
Name: newRemoteName,
URLs: []string{remoteURL},
})
if err != nil {
return nil, err
}
}
return localRemote, nil
}

27
modules/git/repo.go Normal file
View File

@ -0,0 +1,27 @@
// 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 git
import (
"gopkg.in/src-d/go-git.v4"
)
// TeaRepo is a go-git Repository, with an extended high level interface.
type TeaRepo struct {
*git.Repository
}
// RepoForWorkdir tries to open the git repository in the local directory
// for reading or modification.
func RepoForWorkdir() (*TeaRepo, error) {
repo, err := git.PlainOpenWithOptions("./", &git.PlainOpenOptions{
DetectDotGit: true,
})
if err != nil {
return nil, err
}
return &TeaRepo{repo}, nil
}

View File

@ -36,6 +36,11 @@ func (p *URLParser) Parse(rawURL string) (u *url.URL, err error) {
u.Path = strings.TrimPrefix(u.Path, "/")
}
// .git suffix is optional and breaks normalization
if strings.HasSuffix(u.Path, ".git") {
u.Path = strings.TrimSuffix(u.Path, ".git")
}
return
}