Drop features to reduce friction on first use #395

Open
noerw wants to merge 5 commits from noerw/tea:drop-features into main
18 changed files with 92 additions and 223 deletions

View File

@ -28,14 +28,7 @@ var LoginFlag = cli.StringFlag{
var RepoFlag = cli.StringFlag{
Name: "repo",
Aliases: []string{"r"},
Usage: "Override local repository path or gitea repository slug to interact with. Optional",
}
// RemoteFlag provides flag to specify remote repository
var RemoteFlag = cli.StringFlag{
Name: "remote",
Aliases: []string{"R"},
Usage: "Discover Gitea login from remote. Optional",
Usage: "Override remote repository to interact with. Optional. Format: `repo` or `owner/repo`",
}
// OutputFlag provides flag to specify output type
@ -82,7 +75,6 @@ var LoginOutputFlags = []cli.Flag{
var LoginRepoFlags = []cli.Flag{
&LoginFlag,
&RepoFlag,
&RemoteFlag,
}
// AllDefaultFlags defines flags that should be available
@ -91,7 +83,6 @@ var LoginRepoFlags = []cli.Flag{
// https://github.com/urfave/cli/issues/585
var AllDefaultFlags = append([]cli.Flag{
&RepoFlag,
&RemoteFlag,
}, LoginOutputFlags...)
// IssuePRFlags defines flags that should be available on issue & pr listing flags.

View File

@ -49,11 +49,6 @@ var CmdLoginAdd = cli.Command{
EnvVars: []string{"GITEA_SERVER_PASSWORD"},
Usage: "Password for basic auth (will create token)",
},
&cli.StringFlag{
Name: "ssh-key",
Aliases: []string{"s"},
Usage: "Path to a SSH key to use, overrides auto-discovery",
},
&cli.BoolFlag{
Name: "insecure",
Aliases: []string{"i"},
@ -75,7 +70,6 @@ func runLoginAdd(ctx *cli.Context) error {
ctx.String("token"),
ctx.String("user"),
ctx.String("password"),
ctx.String("ssh-key"),
ctx.String("url"),
ctx.Bool("insecure"))
}

View File

@ -22,7 +22,6 @@ var CmdOrganizationDelete = cli.Command{
Action: RunOrganizationDelete,
Flags: []cli.Flag{
&flags.LoginFlag,
&flags.RemoteFlag,
},
}

View File

@ -9,6 +9,7 @@ import (
"code.gitea.io/tea/modules/context"
"code.gitea.io/tea/modules/interact"
"code.gitea.io/tea/modules/task"
"code.gitea.io/tea/modules/utils"
"github.com/urfave/cli/v2"
)
@ -23,7 +24,11 @@ var CmdPullsCreate = cli.Command{
Flags: append([]cli.Flag{
&cli.StringFlag{
Name: "head",
Usage: "Set head branch (default is current one)",
Usage: "Set head branch (default is locally checked out branch)",
},
&cli.StringFlag{
Name: "head-repo",
Usage: "Set head repo (default is remote repo for local branch)",
},
&cli.StringFlag{
Name: "base",
@ -48,12 +53,16 @@ func runPullsCreate(cmd *cli.Context) error {
return err
}
headRepoSlug := ctx.String("head-repo") // may contain `owner` or `owner/repo`
headOwner, _ := utils.GetOwnerAndRepo(headRepoSlug, headRepoSlug)
return task.CreatePull(
ctx.Login,
ctx.Owner,
ctx.Repo,
ctx.String("base"),
ctx.String("head"),
headOwner,
opts,
)
}

View File

@ -69,8 +69,10 @@ func formatBuiltWith(Tags string) string {
var appDescription = `tea is a productivity helper for Gitea. It can be used to manage most entities on one
or multiple Gitea instances and provides local helpers like 'tea pull checkout'.
tea makes use of context provided by the repository in $PWD if available, but is still
usable independently of $PWD. Configuration is persisted in $XDG_CONFIG_HOME/tea.
usable independently of $PWD. tea works best in a upstream/fork workflow, when the local
main branch tracks the upstream repo. Configuration is persisted in $XDG_CONFIG_HOME/tea.
`
var helpTemplate = bold(`

View File

@ -80,38 +80,22 @@ func InitCommand(ctx *cli.Context) *TeaContext {
// these flags are used as overrides to the context detection via local git repo
repoFlag := ctx.String("repo")
loginFlag := ctx.String("login")
remoteFlag := ctx.String("remote")
var (
c TeaContext
err error
repoPath string // empty means PWD
repoFlagPathExists bool
c TeaContext
err error
)
// check if repoFlag can be interpreted as path to local repo.
if len(repoFlag) != 0 {
if repoFlagPathExists, err = utils.DirExists(repoFlag); err != nil {
// try to read git repo & extract context, ignoring if PWD is not a repo
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(""); err != nil {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
log.Fatal(err.Error())
}
if repoFlagPathExists {
repoPath = repoFlag
}
}
if len(repoFlag) == 0 || repoFlagPathExists {
// try to read git repo & extract context, ignoring if PWD is not a repo
if c.LocalRepo, c.Login, c.RepoSlug, err = contextFromLocalRepo(repoPath, remoteFlag); err != nil {
if err == errNotAGiteaRepo || err == gogit.ErrRepositoryNotExists {
// we can deal with that, commands needing the optional values use ctx.Ensure()
} else {
log.Fatal(err.Error())
}
}
}
if len(repoFlag) != 0 && !repoFlagPathExists {
// if repoFlag is not a valid path, use it to override repoSlug
if len(repoFlag) != 0 {
c.RepoSlug = repoFlag
}
@ -144,7 +128,7 @@ and then run your command again.`)
}
// contextFromLocalRepo discovers login & repo slug from the default branch remote of the given local repo
func contextFromLocalRepo(repoPath, remoteValue string) (*git.TeaRepo, *config.Login, string, error) {
func contextFromLocalRepo(repoPath string) (*git.TeaRepo, *config.Login, string, error) {
repo, err := git.RepoFromPath(repoPath)
if err != nil {
return nil, nil, "", err
@ -160,7 +144,8 @@ func contextFromLocalRepo(repoPath, remoteValue string) (*git.TeaRepo, *config.L
}
// if only one remote exists
if len(gitConfig.Remotes) >= 1 && len(remoteValue) == 0 {
remoteValue := ""
if len(gitConfig.Remotes) >= 1 {
for remote := range gitConfig.Remotes {
remoteValue = remote
}

View File

@ -6,71 +6,20 @@ package git
import (
"fmt"
"io/ioutil"
"net/url"
"code.gitea.io/tea/modules/utils"
git_transport "github.com/go-git/go-git/v5/plumbing/transport"
gogit_http "github.com/go-git/go-git/v5/plumbing/transport/http"
gogit_ssh "github.com/go-git/go-git/v5/plumbing/transport/ssh"
"golang.org/x/crypto/ssh"
)
type pwCallback = func(string) (string, error)
// GetAuthForURL returns the appropriate AuthMethod to be used in Push() / Pull()
// operations depending on the protocol, and prompts the user for credentials if
// necessary.
func GetAuthForURL(remoteURL *url.URL, authToken, keyFile string, passwordCallback pwCallback) (git_transport.AuthMethod, error) {
func GetAuthForURL(remoteURL *url.URL, authToken string) (git_transport.AuthMethod, error) {
switch remoteURL.Scheme {
case "http", "https":
// gitea supports push/pull via app token as username.
return &gogit_http.BasicAuth{Password: "", Username: authToken}, nil
case "ssh":
// try to select right key via ssh-agent. if it fails, try to read a key manually
user := remoteURL.User.Username()
auth, err := gogit_ssh.DefaultAuthBuilder(user)
if err != nil {
signer, err2 := readSSHPrivKey(keyFile, passwordCallback)
if err2 != nil {
return nil, err2
}
auth = &gogit_ssh.PublicKeys{User: user, Signer: signer}
}
return auth, nil
}
return nil, fmt.Errorf("don't know how to handle url scheme %v", remoteURL.Scheme)
}
func readSSHPrivKey(keyFile string, passwordCallback pwCallback) (sig ssh.Signer, err error) {
if keyFile != "" {
keyFile, err = utils.AbsPathWithExpansion(keyFile)
} else {
keyFile, err = utils.AbsPathWithExpansion("~/.ssh/id_rsa")
}
if err != nil {
return nil, err
}
sshKey, err := ioutil.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("can not read ssh key '%s'", keyFile)
}
sig, err = ssh.ParsePrivateKey(sshKey)
if _, ok := err.(*ssh.PassphraseMissingError); ok && passwordCallback != nil {
// allow for up to 3 password attempts
for i := 0; i < 3; i++ {
var pass string
pass, err = passwordCallback(keyFile)
if err != nil {
return nil, err
}
sig, err = ssh.ParsePrivateKeyWithPassphrase(sshKey, []byte(pass))
if err == nil {
break
}
}
}
return sig, err
}

View File

@ -58,12 +58,13 @@ func (r TeaRepo) TeaDeleteLocalBranch(branch *git_config.Branch) error {
}
// TeaDeleteRemoteBranch removes the given branch on the given remote via git protocol
func (r TeaRepo) TeaDeleteRemoteBranch(remoteName, remoteBranch string, auth git_transport.AuthMethod) error {
func (r TeaRepo) TeaDeleteRemoteBranch(remoteName, remoteBranch, remoteURLOverride string, auth git_transport.AuthMethod) error {
// delete remote branch via git protocol:
// an empty source in the refspec means remote deletion to git 🙃
refspec := fmt.Sprintf(":%s", git_plumbing.NewBranchReferenceName(remoteBranch))
return r.Push(&git.PushOptions{
RemoteName: remoteName,
RemoteURL: remoteURLOverride,
RefSpecs: []git_config.RefSpec{git_config.RefSpec(refspec)},
Prune: true,
Auth: auth,

View File

@ -53,3 +53,13 @@ func ParseURL(rawURL string) (u *url.URL, err error) {
p := &URLParser{}
return p.Parse(rawURL)
}
// Takes the output of ParseURL and normalizes it to a gitea https url.
func ToHttpsURL(u *url.URL, insecure bool) *url.URL {
u.User = nil
u.Scheme = "https"
if insecure {
u.Scheme = "http"
}
return u
}

View File

@ -15,7 +15,7 @@ import (
// CreateLogin create an login interactive
func CreateLogin() error {
var name, token, user, passwd, sshKey, giteaURL string
var name, token, user, passwd, giteaURL string
var insecure = false
promptI := &survey.Input{Message: "URL of Gitea instance: "}
@ -64,28 +64,13 @@ func CreateLogin() error {
}
}
var optSettings bool
promptYN = &survey.Confirm{
Message: "Set Optional settings: ",
Message: "Allow Insecure connections: ",
Default: false,
}
if err = survey.AskOne(promptYN, &optSettings); err != nil {
if err = survey.AskOne(promptYN, &insecure); err != nil {
return err
}
if optSettings {
promptI = &survey.Input{Message: "SSH Key Path (leave empty for auto-discovery):"}
if err := survey.AskOne(promptI, &sshKey); err != nil {
return err
}
promptYN = &survey.Confirm{
Message: "Allow Insecure connections: ",
Default: false,
}
if err = survey.AskOne(promptYN, &insecure); err != nil {
return err
}
}
return task.CreateLogin(name, token, user, passwd, sshKey, giteaURL, insecure)
return task.CreateLogin(name, token, user, passwd, giteaURL, insecure)
}

View File

@ -52,8 +52,6 @@ func CreatePull(login *config.Login, owner, repo string) error {
return err
}
head = task.GetHeadSpec(headOwner, headBranch, owner)
opts := gitea.CreateIssueOption{Title: task.GetDefaultPRTitle(head)}
if err = promptIssueProperties(login, owner, repo, &opts); err != nil {
return err
@ -65,5 +63,6 @@ func CreatePull(login *config.Login, owner, repo string) error {
repo,
base,
head,
headOwner,
&opts)
}

View File

@ -16,7 +16,7 @@ import (
)
// CreateLogin create a login to be stored in config
func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bool) error {
func CreateLogin(name, token, user, passwd, giteaURL string, insecure bool) error {
// checks ...
// ... if we have a url
if len(giteaURL) == 0 {
@ -52,7 +52,6 @@ func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bo
URL: serverURL.String(),
Token: token,
Insecure: insecure,
SSHKey: sshKey,
Created: time.Now().Unix(),
}
@ -81,13 +80,6 @@ func CreateLogin(name, token, user, passwd, sshKey, giteaURL string, insecure bo
// so we just use the hostname
login.SSHHost = serverURL.Hostname()
if len(sshKey) == 0 {
login.SSHKey, err = findSSHKey(client)
if err != nil {
fmt.Printf("Warning: problem while finding a SSH key: %s\n", err)
}
}
if err = config.AddLogin(&login); err != nil {
return err
}

View File

@ -1,79 +0,0 @@
// 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 (
"encoding/base64"
"io/ioutil"
"path/filepath"
"strings"
"code.gitea.io/tea/modules/utils"
"code.gitea.io/sdk/gitea"
"golang.org/x/crypto/ssh"
)
// findSSHKey retrieves the ssh keys registered in gitea, and tries to find
// a matching private key in ~/.ssh/. If no match is found, path is empty.
func findSSHKey(client *gitea.Client) (string, error) {
// get keys registered on gitea instance
keys, _, err := client.ListMyPublicKeys(gitea.ListPublicKeysOptions{})
if err != nil || len(keys) == 0 {
return "", err
}
// enumerate ~/.ssh/*.pub files
glob, err := utils.AbsPathWithExpansion("~/.ssh/*.pub")
if err != nil {
return "", err
}
localPubkeyPaths, err := filepath.Glob(glob)
if err != nil {
return "", err
}
// parse each local key with present privkey & compare fingerprints to online keys
for _, pubkeyPath := range localPubkeyPaths {
var pubkeyFile []byte
pubkeyFile, err = ioutil.ReadFile(pubkeyPath)
if err != nil {
continue
}
fields := strings.Split(string(pubkeyFile), " ")
if len(fields) < 2 { // first word is key type, second word is key material
continue
}
var keymaterial []byte
keymaterial, err = base64.StdEncoding.DecodeString(fields[1])
if err != nil {
continue
}
var pubkey ssh.PublicKey
pubkey, err = ssh.ParsePublicKey(keymaterial)
if err != nil {
continue
}
privkeyPath := strings.TrimSuffix(pubkeyPath, ".pub")
var exists bool
exists, err = utils.FileExist(privkeyPath)
if err != nil || !exists {
continue
}
// if pubkey fingerprints match, return path to corresponding privkey.
fingerprint := ssh.FingerprintSHA256(pubkey)
for _, key := range keys {
if fingerprint == key.Fingerprint {
return privkeyPath, nil
}
}
}
return "", err
}

View File

@ -90,11 +90,15 @@ func doPRFetch(
if err != nil {
return "", err
}
auth, err := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback)
url = local_git.ToHttpsURL(url, login.Insecure)
auth, err := local_git.GetAuthForURL(url, login.Token)
if err != nil {
return "", err
}
fetchOpts := &git.FetchOptions{Auth: auth}
fetchOpts := &git.FetchOptions{
Auth: auth,
RemoteURL: url.String(),
}
if isRemoteDeleted(pr) {
// When the head branch is already deleted, pr.Head.Ref points to
// `refs/pull/<idx>/head`, where the commits stay available.

View File

@ -101,11 +101,12 @@ call me again with the --ignore-sha flag`, remoteBranch)
if err != nil {
return err
}
auth, err := local_git.GetAuthForURL(url, login.Token, login.SSHKey, callback)
url = local_git.ToHttpsURL(url, login.Insecure)
auth, err := local_git.GetAuthForURL(url, login.Token)
if err != nil {
return err
}
err = r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, auth)
err = r.TeaDeleteRemoteBranch(branch.Remote, remoteBranch, url.String(), auth)
}
return err
}

View File

@ -16,7 +16,7 @@ import (
)
// CreatePull creates a PR in the given repo and prints the result
func CreatePull(login *config.Login, repoOwner, repoName, base, head string, opts *gitea.CreateIssueOption) error {
func CreatePull(login *config.Login, repoOwner, repoName, base, head, headOwner string, opts *gitea.CreateIssueOption) error {
// open local git repo
localRepo, err := local_git.RepoForWorkdir()
if err != nil {
@ -31,13 +31,24 @@ func CreatePull(login *config.Login, repoOwner, repoName, base, head string, opt
}
}
// default is current one
if len(head) == 0 {
headOwner, headBranch, err := GetDefaultPRHead(localRepo)
if err != nil {
return err
}
headBranch := head
// default is current branch & associated repo owner
defaultHeadOwner, defaultHeadBranch, err := GetDefaultPRHead(localRepo)
if err != nil {
return err
}
if len(headBranch) == 0 {
headBranch = defaultHeadBranch
}
if len(headOwner) == 0 {
headBranch = defaultHeadOwner
}
// only override head if unspecified, or headOwner was set, for backwards
// compatibility: still support `owner:branch` inputs for head var
if len(head) == 0 || len(headOwner) != 0 {
head = GetHeadSpec(headOwner, headBranch, repoOwner)
}

View File

@ -91,6 +91,8 @@ func (o *CloneOptions) Validate() error {
type PullOptions struct {
// Name of the remote to be pulled. If empty, uses the default.
RemoteName string
// RemoteURL overrides the remote repo address with a custom URL
RemoteURL string
// Remote branch to clone. If empty, uses HEAD.
ReferenceName plumbing.ReferenceName
// Fetch only ReferenceName if true.
@ -147,7 +149,9 @@ const (
type FetchOptions struct {
// Name of the remote to fetch from. Defaults to origin.
RemoteName string
RefSpecs []config.RefSpec
// RemoteURL overrides the remote repo address with a custom URL
RemoteURL string
RefSpecs []config.RefSpec
// Depth limit fetching to the specified number of commits from the tip of
// each remote branch history.
Depth int
@ -192,6 +196,8 @@ func (o *FetchOptions) Validate() error {
type PushOptions struct {
// RemoteName is the name of the remote to be pushed to.
RemoteName string
// RemoteURL overrides the remote repo address with a custom URL
RemoteURL string
// RefSpecs specify what destination ref to update with what source
// object. A refspec with empty src can be used to delete a reference.
RefSpecs []config.RefSpec
@ -569,6 +575,7 @@ func (o *CreateTagOptions) loadConfigTagger(r *Repository) error {
// ListOptions describes how a remote list should be performed.
type ListOptions struct {
// TODO: add remote URL
// Auth credentials, if required, to use with the remote repository.
Auth transport.AuthMethod
// InsecureSkipTLS skips ssl verify if protocal is https

View File

@ -9,6 +9,7 @@ import (
"github.com/go-git/go-billy/v5/osfs"
"github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/internal/url"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/cache"
"github.com/go-git/go-git/v5/plumbing/format/packfile"
@ -103,7 +104,11 @@ func (r *Remote) PushContext(ctx context.Context, o *PushOptions) (err error) {
return fmt.Errorf("remote names don't match: %s != %s", o.RemoteName, r.c.Name)
}
s, err := newSendPackSession(r.c.URLs[0], o.Auth, o.InsecureSkipTLS, o.CABundle)
if o.RemoteURL == "" {
o.RemoteURL = r.c.URLs[0]
}
s, err := newSendPackSession(o.RemoteURL, o.Auth, o.InsecureSkipTLS, o.CABundle)
if err != nil {
return err
}
@ -183,12 +188,12 @@ func (r *Remote) PushContext(ctx context.Context, o *PushOptions) (err error) {
var hashesToPush []plumbing.Hash
// Avoid the expensive revlist operation if we're only doing deletes.
if !allDelete {
if r.c.IsFirstURLLocal() {
if url.IsLocalEndpoint(o.RemoteURL) {
// If we're are pushing to a local repo, it might be much
// faster to use a local storage layer to get the commits
// to ignore, when calculating the object revlist.
localStorer := filesystem.NewStorage(
osfs.New(r.c.URLs[0]), cache.NewObjectLRUDefault())
osfs.New(o.RemoteURL), cache.NewObjectLRUDefault())
hashesToPush, err = revlist.ObjectsWithStorageForIgnores(
r.s, localStorer, objects, haves)
} else {
@ -314,7 +319,11 @@ func (r *Remote) fetch(ctx context.Context, o *FetchOptions) (sto storer.Referen
o.RefSpecs = r.c.Fetch
}
s, err := newUploadPackSession(r.c.URLs[0], o.Auth, o.InsecureSkipTLS, o.CABundle)
if o.RemoteURL == "" {
o.RemoteURL = r.c.URLs[0]
}
s, err := newUploadPackSession(o.RemoteURL, o.Auth, o.InsecureSkipTLS, o.CABundle)
if err != nil {
return nil, err
}