Merge remote-tracking branch 'origin' into go-gh-term

This commit is contained in:
Mislav Marohnić 2022-10-24 17:07:24 +02:00
commit 76ad94cc35
No known key found for this signature in database
132 changed files with 4160 additions and 1625 deletions

View file

@ -1,5 +0,0 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/go/.devcontainer/base.Dockerfile
# VARIANT Defined in devcontainer.json
ARG VARIANT="1.18"
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}

View file

@ -1,22 +1,24 @@
{
"extensions": [
"golang.go"
],
"build": {
"dockerfile": "Dockerfile",
"args": {
"VARIANT": "1.18"
}
"image": "mcr.microsoft.com/devcontainers/go:1.18",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {}
},
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
"remoteUser": "vscode",
"customizations": {
"vscode": {
"extensions": [
"golang.go"
],
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
}
}
},
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
],
"remoteUser": "vscode"
]
}

View file

@ -147,7 +147,7 @@ jobs:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Prepare PATH
id: setupmsbuild
uses: microsoft/setup-msbuild@v1.0.3
uses: microsoft/setup-msbuild@v1.1.3
- name: Build MSI
id: buildmsi
shell: bash

View file

@ -1,5 +0,0 @@
{
"search.exclude": {
"vendor/**": true
}
}

View file

@ -15,7 +15,18 @@ type Comments struct {
}
}
func (cs Comments) CurrentUserComments() []Comment {
var comments []Comment
for _, c := range cs.Nodes {
if c.ViewerDidAuthor {
comments = append(comments, c)
}
}
return comments
}
type Comment struct {
ID string `json:"id"`
Author Author `json:"author"`
AuthorAssociation string `json:"authorAssociation"`
Body string `json:"body"`
@ -24,6 +35,8 @@ type Comment struct {
IsMinimized bool `json:"isMinimized"`
MinimizedReason string `json:"minimizedReason"`
ReactionGroups ReactionGroups `json:"reactionGroups"`
URL string `json:"url,omitempty"`
ViewerDidAuthor bool `json:"viewerDidAuthor"`
}
type CommentCreateInput struct {
@ -31,6 +44,11 @@ type CommentCreateInput struct {
SubjectId string
}
type CommentUpdateInput struct {
Body string
CommentId string
}
func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) {
var mutation struct {
AddComment struct {
@ -57,6 +75,34 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
return mutation.AddComment.CommentEdge.Node.URL, nil
}
func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (string, error) {
var mutation struct {
UpdateIssueComment struct {
IssueComment struct {
URL string
}
} `graphql:"updateIssueComment(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.UpdateIssueCommentInput{
Body: githubv4.String(params.Body),
ID: githubv4.ID(params.CommentId),
},
}
err := client.Mutate(repoHost, "CommentUpdate", &mutation, variables)
if err != nil {
return "", err
}
return mutation.UpdateIssueComment.IssueComment.URL, nil
}
func (c Comment) Identifier() string {
return c.ID
}
func (c Comment) AuthorLogin() string {
return c.Author.Login
}
@ -86,7 +132,7 @@ func (c Comment) IsHidden() bool {
}
func (c Comment) Link() string {
return ""
return c.URL
}
func (c Comment) Reactions() ReactionGroups {

View file

@ -244,3 +244,7 @@ func (i Issue) Link() string {
func (i Issue) Identifier() string {
return i.ID
}
func (i Issue) CurrentUserComments() []Comment {
return i.Comments.CurrentUserComments()
}

View file

@ -214,6 +214,10 @@ func (pr PullRequest) Identifier() string {
return pr.ID
}
func (pr PullRequest) CurrentUserComments() []Comment {
return pr.Comments.CurrentUserComments()
}
func (pr PullRequest) IsOpen() bool {
return pr.State == "OPEN"
}

View file

@ -30,6 +30,7 @@ type PullRequestReviews struct {
}
type PullRequestReview struct {
ID string `json:"id"`
Author Author `json:"author"`
AuthorAssociation string `json:"authorAssociation"`
Body string `json:"body"`
@ -67,6 +68,10 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu
return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
}
func (prr PullRequestReview) Identifier() string {
return prr.ID
}
func (prr PullRequestReview) AuthorLogin() string {
return prr.Author.Login
}

View file

@ -23,6 +23,7 @@ func shortenQuery(q string) string {
var issueComments = shortenQuery(`
comments(first: 100) {
nodes {
id,
author{login},
authorAssociation,
body,
@ -30,7 +31,9 @@ var issueComments = shortenQuery(`
includesCreatedEdit,
isMinimized,
minimizedReason,
reactionGroups{content,users{totalCount}}
reactionGroups{content,users{totalCount}},
url,
viewerDidAuthor
},
pageInfo{hasNextPage,endCursor},
totalCount
@ -182,7 +185,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string {
state,
targetUrl,
createdAt,
isRequired(pullRequestId: %[2]s)
isRequired(pullRequestId: %[2]s)
},
...on CheckRun {
name,
@ -192,7 +195,7 @@ func RequiredStatusCheckRollupGraphQL(prID, after string) string {
startedAt,
completedAt,
detailsUrl,
isRequired(pullRequestId: %[2]s)
isRequired(pullRequestId: %[2]s)
}
},
pageInfo{hasNextPage,endCursor}

View file

@ -2,6 +2,7 @@
package context
import (
"context"
"errors"
"sort"
@ -138,7 +139,8 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo
}
// cache the result to git config
err := git.SetRemoteResolution(remote.Name, resolution)
c := &git.Client{}
err := c.SetRemoteResolution(context.Background(), remote.Name, resolution)
return selectedRepo, err
}

695
git/client.go Normal file
View file

@ -0,0 +1,695 @@
package git
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/url"
"os/exec"
"path"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/safeexec"
)
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
// ErrNotOnAnyBranch indicates that the user is in detached HEAD state.
var ErrNotOnAnyBranch = errors.New("git: not on any branch")
type NotInstalled struct {
message string
err error
}
func (e *NotInstalled) Error() string {
return e.message
}
func (e *NotInstalled) Unwrap() error {
return e.err
}
type GitError struct {
ExitCode int
Stderr string
err error
}
func (ge *GitError) Error() string {
if ge.Stderr == "" {
return fmt.Sprintf("failed to run git: %v", ge.err)
}
return fmt.Sprintf("failed to run git: %s", ge.Stderr)
}
func (ge *GitError) Unwrap() error {
return ge.err
}
type gitCommand struct {
*exec.Cmd
}
func (gc *gitCommand) Run() error {
// This is a hack in order to not break the hundreds of
// existing tests that rely on `run.PrepareCmd` to be invoked.
err := run.PrepareCmd(gc.Cmd).Run()
if err != nil {
ge := GitError{err: err}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
ge.Stderr = string(exitError.Stderr)
ge.ExitCode = exitError.ExitCode()
}
return &ge
}
return nil
}
func (gc *gitCommand) Output() ([]byte, error) {
gc.Stdout = nil
gc.Stderr = nil
// This is a hack in order to not break the hundreds of
// existing tests that rely on `run.PrepareCmd` to be invoked.
out, err := run.PrepareCmd(gc.Cmd).Output()
if err != nil {
ge := GitError{err: err}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
ge.Stderr = string(exitError.Stderr)
ge.ExitCode = exitError.ExitCode()
}
err = &ge
}
return out, err
}
func (gc *gitCommand) setRepoDir(repoDir string) {
for i, arg := range gc.Args {
if arg == "-C" {
gc.Args[i+1] = repoDir
return
}
}
gc.Args = append(gc.Args[:3], gc.Args[1:]...)
gc.Args[1] = "-C"
gc.Args[2] = repoDir
}
// Allow individual commands to be modified from the default client options.
type CommandModifier func(*gitCommand)
func WithStderr(stderr io.Writer) CommandModifier {
return func(gc *gitCommand) {
gc.Stderr = stderr
}
}
func WithStdout(stdout io.Writer) CommandModifier {
return func(gc *gitCommand) {
gc.Stdout = stdout
}
}
func WithStdin(stdin io.Reader) CommandModifier {
return func(gc *gitCommand) {
gc.Stdin = stdin
}
}
func WithRepoDir(repoDir string) CommandModifier {
return func(gc *gitCommand) {
gc.setRepoDir(repoDir)
}
}
type Client struct {
GhPath string
RepoDir string
GitPath string
Stderr io.Writer
Stdin io.Reader
Stdout io.Writer
commandContext func(ctx context.Context, name string, args ...string) *exec.Cmd
mu sync.Mutex
}
func (c *Client) Command(ctx context.Context, args ...string) (*gitCommand, error) {
if c.RepoDir != "" {
args = append([]string{"-C", c.RepoDir}, args...)
}
commandContext := exec.CommandContext
if c.commandContext != nil {
commandContext = c.commandContext
}
var err error
c.mu.Lock()
if c.GitPath == "" {
c.GitPath, err = resolveGitPath()
}
c.mu.Unlock()
if err != nil {
return nil, err
}
cmd := commandContext(ctx, c.GitPath, args...)
cmd.Stderr = c.Stderr
cmd.Stdin = c.Stdin
cmd.Stdout = c.Stdout
return &gitCommand{cmd}, nil
}
func resolveGitPath() (string, error) {
path, err := safeexec.LookPath("git")
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
programName := "git"
if runtime.GOOS == "windows" {
programName = "Git for Windows"
}
return "", &NotInstalled{
message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName),
err: err,
}
}
return "", err
}
return path, nil
}
// AuthenticatedCommand is a wrapper around Command that included configuration to use gh
// as the credential helper for git.
func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*gitCommand, error) {
preArgs := []string{"-c", "credential.helper="}
if c.GhPath == "" {
// Assumes that gh is in PATH.
c.GhPath = "gh"
}
credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath)
preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper))
args = append(preArgs, args...)
return c.Command(ctx, args...)
}
func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) {
remoteArgs := []string{"remote", "-v"}
remoteCmd, err := c.Command(ctx, remoteArgs...)
if err != nil {
return nil, err
}
remoteOut, remoteErr := remoteCmd.Output()
if remoteErr != nil {
return nil, remoteErr
}
configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`}
configCmd, err := c.Command(ctx, configArgs...)
if err != nil {
return nil, err
}
configOut, configErr := configCmd.Output()
if configErr != nil {
// Ignore exit code 1 as it means there are no resolved remotes.
var gitErr *GitError
if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 {
return nil, gitErr
}
}
remotes := parseRemotes(outputLines(remoteOut))
populateResolvedRemotes(remotes, outputLines(configOut))
sort.Sort(remotes)
return remotes, nil
}
func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) {
args := []string{"remote", "add"}
for _, branch := range trackingBranches {
args = append(args, "-t", branch)
}
args = append(args, "-f", name, urlStr)
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
for _, mod := range mods {
mod(cmd)
}
if _, err := cmd.Output(); err != nil {
return nil, err
}
var urlParsed *url.URL
if strings.HasPrefix(urlStr, "https") {
urlParsed, err = url.Parse(urlStr)
if err != nil {
return nil, err
}
} else {
urlParsed, err = ParseURL(urlStr)
if err != nil {
return nil, err
}
}
remote := &Remote{
Name: name,
FetchURL: urlParsed,
PushURL: urlParsed,
}
return remote, nil
}
func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error {
args := []string{"remote", "set-url", name, url}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error {
args := []string{"config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
// CurrentBranch reads the checked-out branch for the git repository.
func (c *Client) CurrentBranch(ctx context.Context) (string, error) {
args := []string{"symbolic-ref", "--quiet", "HEAD"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
var gitErr *GitError
if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 {
gitErr.Stderr = "not on any branch"
return "", gitErr
}
return "", err
}
branch := firstLine(out)
return strings.TrimPrefix(branch, "refs/heads/"), nil
}
// ShowRefs resolves fully-qualified refs to commit hashes.
func (c *Client) ShowRefs(ctx context.Context, ref ...string) ([]Ref, error) {
args := append([]string{"show-ref", "--verify", "--"}, ref...)
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
// This functionality relies on parsing output from the git command despite
// an error status being returned from git.
out, err := cmd.Output()
var refs []Ref
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
refs = append(refs, Ref{
Hash: parts[0],
Name: parts[1],
})
}
return refs, err
}
func (c *Client) Config(ctx context.Context, name string) (string, error) {
args := []string{"config", name}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
var gitErr *GitError
if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 {
gitErr.Stderr = fmt.Sprintf("unknown config key %s", name)
return "", gitErr
}
return "", err
}
return firstLine(out), nil
}
func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) {
args := []string{"status", "--porcelain"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return 0, err
}
out, err := cmd.Output()
if err != nil {
return 0, err
}
lines := strings.Split(string(out), "\n")
count := 0
for _, l := range lines {
if l != "" {
count++
}
}
return count, nil
}
func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) {
args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
out, err := cmd.Output()
if err != nil {
return nil, err
}
commits := []*Commit{}
sha := 0
title := 1
for _, line := range outputLines(out) {
split := strings.SplitN(line, ",", 2)
if len(split) != 2 {
continue
}
commits = append(commits, &Commit{
Sha: split[sha],
Title: split[title],
})
}
if len(commits) == 0 {
return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
}
return commits, nil
}
func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, error) {
args := []string{"-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:" + format, sha}
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
out, err := cmd.Output()
if err != nil {
return nil, err
}
return out, nil
}
func (c *Client) LastCommit(ctx context.Context) (*Commit, error) {
output, err := c.lookupCommit(ctx, "HEAD", "%H,%s")
if err != nil {
return nil, err
}
idx := bytes.IndexByte(output, ',')
return &Commit{
Sha: string(output[0:idx]),
Title: strings.TrimSpace(string(output[idx+1:])),
}, nil
}
func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) {
output, err := c.lookupCommit(ctx, sha, "%b")
return string(output), err
}
// Push publishes a git ref to a remote and sets up upstream configuration.
func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error {
args := []string{"push", "--set-upstream", remote, ref}
//TODO: Use AuthenticatedCommand
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
for _, mod := range mods {
mod(cmd)
}
return cmd.Run()
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config.
func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return
}
out, err := cmd.Output()
if err != nil {
return
}
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
keys := strings.Split(parts[0], ".")
switch keys[len(keys)-1] {
case "remote":
if strings.Contains(parts[1], ":") {
u, err := ParseURL(parts[1])
if err != nil {
continue
}
cfg.RemoteURL = u
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
case "merge":
cfg.MergeRef = parts[1]
}
}
return
}
func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error {
args := []string{"branch", "-D", branch}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool {
args := []string{"rev-parse", "--verify", "refs/heads/" + branch}
cmd, err := c.Command(ctx, args...)
if err != nil {
return false
}
_, err = cmd.Output()
return err == nil
}
func (c *Client) CheckoutBranch(ctx context.Context, branch string) error {
args := []string{"checkout", branch}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error {
track := fmt.Sprintf("%s/%s", remoteName, branch)
args := []string{"checkout", "-b", branch, "--track", track}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) Pull(ctx context.Context, remote, branch string) error {
args := []string{"pull", "--ff-only", remote, branch}
//TODO: Use AuthenticatedCommand
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
return cmd.Run()
}
func (c *Client) Clone(ctx context.Context, cloneURL string, args []string) (string, error) {
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone
// otherwise, parse the URL to determine where git cloned it to so we can return it
if target != "" {
cloneArgs = append(cloneArgs, target)
} else {
target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
//TODO: Use AuthenticatedCommand
cmd, err := c.Command(ctx, cloneArgs...)
if err != nil {
return "", err
}
err = cmd.Run()
if err != nil {
return "", err
}
return target, nil
}
// ToplevelDir returns the top-level directory path of the current repository.
func (c *Client) ToplevelDir(ctx context.Context) (string, error) {
args := []string{"rev-parse", "--show-toplevel"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
return "", err
}
return firstLine(out), nil
}
func (c *Client) GitDir(ctx context.Context) (string, error) {
args := []string{"rev-parse", "--git-dir"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
return "", err
}
return firstLine(out), nil
}
// Show current directory relative to the top-level directory of repository.
func (c *Client) PathFromRoot(ctx context.Context) string {
args := []string{"rev-parse", "--show-prefix"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return ""
}
out, err := cmd.Output()
if err != nil {
return ""
}
if path := firstLine(out); path != "" {
return path[:len(path)-1]
}
return ""
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}
func firstLine(output []byte) string {
if i := bytes.IndexAny(output, "\n"); i >= 0 {
return string(output)[0:i]
}
return string(output)
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
if len(args) > 0 {
if !strings.HasPrefix(args[0], "-") {
target, args = args[0], args[1:]
}
}
return
}
func parseRemotes(remotesStr []string) RemoteSet {
remotes := RemoteSet{}
for _, r := range remotesStr {
match := remoteRE.FindStringSubmatch(r)
if match == nil {
continue
}
name := strings.TrimSpace(match[1])
urlStr := strings.TrimSpace(match[2])
urlType := strings.TrimSpace(match[3])
url, err := ParseURL(urlStr)
if err != nil {
continue
}
var rem *Remote
if len(remotes) > 0 {
rem = remotes[len(remotes)-1]
if name != rem.Name {
rem = nil
}
}
if rem == nil {
rem = &Remote{Name: name}
remotes = append(remotes, rem)
}
switch urlType {
case "fetch":
rem.FetchURL = url
case "push":
rem.PushURL = url
}
}
return remotes
}
func populateResolvedRemotes(remotes RemoteSet, resolved []string) {
for _, l := range resolved {
parts := strings.SplitN(l, " ", 2)
if len(parts) < 2 {
continue
}
rp := strings.SplitN(parts[0], ".", 3)
if len(rp) < 2 {
continue
}
name := rp[1]
for _, r := range remotes {
if r.Name == name {
r.Resolved = parts[1]
break
}
}
}
}

399
git/client_test.go Normal file
View file

@ -0,0 +1,399 @@
package git
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"github.com/cli/cli/v2/internal/run"
"github.com/stretchr/testify/assert"
)
func TestClientCommand(t *testing.T) {
tests := []struct {
name string
repoDir string
gitPath string
wantExe string
wantArgs []string
}{
{
name: "creates command",
gitPath: "path/to/git",
wantExe: "path/to/git",
wantArgs: []string{"path/to/git", "ref-log"},
},
{
name: "adds repo directory configuration",
repoDir: "path/to/repo",
gitPath: "path/to/git",
wantExe: "path/to/git",
wantArgs: []string{"path/to/git", "-C", "path/to/repo", "ref-log"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
in, out, errOut := &bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}
client := Client{
Stdin: in,
Stdout: out,
Stderr: errOut,
RepoDir: tt.repoDir,
GitPath: tt.gitPath,
}
cmd, err := client.Command(context.Background(), "ref-log")
assert.NoError(t, err)
assert.Equal(t, tt.wantExe, cmd.Path)
assert.Equal(t, tt.wantArgs, cmd.Args)
assert.Equal(t, in, cmd.Stdin)
assert.Equal(t, out, cmd.Stdout)
assert.Equal(t, errOut, cmd.Stderr)
})
}
}
func TestClientAuthenticatedCommand(t *testing.T) {
tests := []struct {
name string
path string
wantArgs []string
}{
{
name: "adds credential helper config options",
path: "path/to/gh",
wantArgs: []string{"git", "-c", "credential.helper=", "-c", "credential.helper=!\"path/to/gh\" auth git-credential", "fetch"},
},
{
name: "fallback when GhPath is not set",
wantArgs: []string{"git", "-c", "credential.helper=", "-c", "credential.helper=!\"gh\" auth git-credential", "fetch"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
client := Client{
GhPath: tt.path,
GitPath: "git",
}
cmd, err := client.AuthenticatedCommand(context.Background(), "fetch")
assert.NoError(t, err)
assert.Equal(t, tt.wantArgs, cmd.Args)
})
}
}
func TestClientRemotes(t *testing.T) {
tempDir := t.TempDir()
initRepo(t, tempDir)
gitDir := filepath.Join(tempDir, ".git")
remoteFile := filepath.Join(gitDir, "config")
remotes := `
[remote "origin"]
url = git@example.com:monalisa/origin.git
[remote "test"]
url = git://github.com/hubot/test.git
gh-resolved = other
[remote "upstream"]
url = https://github.com/monalisa/upstream.git
gh-resolved = base
[remote "github"]
url = git@github.com:hubot/github.git
`
f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755)
assert.NoError(t, err)
_, err = f.Write([]byte(remotes))
assert.NoError(t, err)
err = f.Close()
assert.NoError(t, err)
client := Client{
RepoDir: tempDir,
}
rs, err := client.Remotes(context.Background())
assert.NoError(t, err)
assert.Equal(t, 4, len(rs))
assert.Equal(t, "upstream", rs[0].Name)
assert.Equal(t, "base", rs[0].Resolved)
assert.Equal(t, "github", rs[1].Name)
assert.Equal(t, "", rs[1].Resolved)
assert.Equal(t, "origin", rs[2].Name)
assert.Equal(t, "", rs[2].Resolved)
assert.Equal(t, "test", rs[3].Name)
assert.Equal(t, "other", rs[3].Resolved)
}
func TestClientRemotesNoResolvedRemote(t *testing.T) {
tempDir := t.TempDir()
initRepo(t, tempDir)
gitDir := filepath.Join(tempDir, ".git")
remoteFile := filepath.Join(gitDir, "config")
remotes := `
[remote "origin"]
url = git@example.com:monalisa/origin.git
[remote "test"]
url = git://github.com/hubot/test.git
[remote "upstream"]
url = https://github.com/monalisa/upstream.git
[remote "github"]
url = git@github.com:hubot/github.git
`
f, err := os.OpenFile(remoteFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755)
assert.NoError(t, err)
_, err = f.Write([]byte(remotes))
assert.NoError(t, err)
err = f.Close()
assert.NoError(t, err)
client := Client{
RepoDir: tempDir,
}
rs, err := client.Remotes(context.Background())
assert.NoError(t, err)
assert.Equal(t, 4, len(rs))
assert.Equal(t, "upstream", rs[0].Name)
assert.Equal(t, "github", rs[1].Name)
assert.Equal(t, "origin", rs[2].Name)
assert.Equal(t, "", rs[2].Resolved)
assert.Equal(t, "test", rs[3].Name)
}
func TestParseRemotes(t *testing.T) {
remoteList := []string{
"mona\tgit@github.com:monalisa/myfork.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat-push.git (push)",
"upstream\thttps://example.com/nowhere.git (fetch)",
"upstream\thttps://github.com/hubot/tools (push)",
"zardoz\thttps://example.com/zed.git (push)",
"koke\tgit://github.com/koke/grit.git (fetch)",
"koke\tgit://github.com/koke/grit.git (push)",
}
r := parseRemotes(remoteList)
assert.Equal(t, 5, len(r))
assert.Equal(t, "mona", r[0].Name)
assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String())
assert.Nil(t, r[0].PushURL)
assert.Equal(t, "origin", r[1].Name)
assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path)
assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path)
assert.Equal(t, "upstream", r[2].Name)
assert.Equal(t, "example.com", r[2].FetchURL.Host)
assert.Equal(t, "github.com", r[2].PushURL.Host)
assert.Equal(t, "zardoz", r[3].Name)
assert.Nil(t, r[3].FetchURL)
assert.Equal(t, "https://example.com/zed.git", r[3].PushURL.String())
assert.Equal(t, "koke", r[4].Name)
assert.Equal(t, "/koke/grit.git", r[4].FetchURL.Path)
assert.Equal(t, "/koke/grit.git", r[4].PushURL.Path)
}
func TestClientLastCommit(t *testing.T) {
client := Client{
RepoDir: "./fixtures/simple.git",
}
c, err := client.LastCommit(context.Background())
assert.NoError(t, err)
assert.Equal(t, "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha)
assert.Equal(t, "Second commit", c.Title)
}
func TestClientCommitBody(t *testing.T) {
client := Client{
RepoDir: "./fixtures/simple.git",
}
body, err := client.CommitBody(context.Background(), "6f1a2405cace1633d89a79c74c65f22fe78f9659")
assert.NoError(t, err)
assert.Equal(t, "I'm starting to get the hang of things\n", body)
}
func TestClientUncommittedChangeCount(t *testing.T) {
tests := []struct {
name string
expected int
output string
}{
{
name: "no changes",
expected: 0,
output: "",
},
{
name: "one change",
expected: 1,
output: " M poem.txt",
},
{
name: "untracked file",
expected: 2,
output: " M poem.txt\n?? new.txt",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git status --porcelain`, 0, tt.output)
client := Client{}
ucc, err := client.UncommittedChangeCount(context.Background())
assert.NoError(t, err)
assert.Equal(t, tt.expected, ucc)
})
}
}
func TestClientCurrentBranch(t *testing.T) {
tests := []struct {
name string
stub string
expected string
}{
{
name: "branch name",
stub: "branch-name\n",
expected: "branch-name",
},
{
name: "ref",
stub: "refs/heads/branch-name\n",
expected: "branch-name",
},
{
name: "escaped ref",
stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n",
expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`git symbolic-ref --quiet HEAD`, 0, tt.stub)
client := Client{}
branch, err := client.CurrentBranch(context.Background())
assert.NoError(t, err)
assert.Equal(t, tt.expected, branch)
})
}
}
func TestClientCurrentBranch_detached_head(t *testing.T) {
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`git symbolic-ref --quiet HEAD`, 1, "")
client := Client{}
_, err := client.CurrentBranch(context.Background())
assert.EqualError(t, err, "failed to run git: not on any branch")
}
func TestParseCloneArgs(t *testing.T) {
type wanted struct {
args []string
dir string
}
tests := []struct {
name string
args []string
want wanted
}{
{
name: "args and target",
args: []string{"target_directory", "-o", "upstream", "--depth", "1"},
want: wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "target_directory",
},
},
{
name: "only args",
args: []string{"-o", "upstream", "--depth", "1"},
want: wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "",
},
},
{
name: "only target",
args: []string{"target_directory"},
want: wanted{
args: []string{},
dir: "target_directory",
},
},
{
name: "no args",
args: []string{},
want: wanted{
args: []string{},
dir: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, dir := parseCloneArgs(tt.args)
got := wanted{args: args, dir: dir}
assert.Equal(t, got, tt.want)
})
}
}
func TestClientAddRemote(t *testing.T) {
tests := []struct {
title string
name string
url string
dir string
branches []string
want string
}{
{
title: "fetch all",
name: "test",
url: "URL",
dir: "DIRECTORY",
branches: []string{},
want: "git -C DIRECTORY remote add -f test URL",
},
{
title: "fetch specific branches only",
name: "test",
url: "URL",
dir: "DIRECTORY",
branches: []string{"trunk", "dev"},
want: "git -C DIRECTORY remote add -t trunk -t dev -f test URL",
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(tt.want, 0, "")
client := Client{
RepoDir: tt.dir,
}
_, err := client.AddRemote(context.Background(), tt.name, tt.url, tt.branches)
assert.NoError(t, err)
})
}
}
func initRepo(t *testing.T, dir string) {
errBuf := &bytes.Buffer{}
inBuf := &bytes.Buffer{}
outBuf := &bytes.Buffer{}
client := Client{
RepoDir: dir,
Stderr: errBuf,
Stdin: inBuf,
Stdout: outBuf,
}
cmd, err := client.Command(context.Background(), []string{"init", "--quiet"}...)
assert.NoError(t, err)
_, err = cmd.Output()
assert.NoError(t, err)
}

View file

@ -1,442 +0,0 @@
package git
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path"
"regexp"
"runtime"
"strings"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/safeexec"
)
// ErrNotOnAnyBranch indicates that the user is in detached HEAD state
var ErrNotOnAnyBranch = errors.New("git: not on any branch")
// Ref represents a git commit reference
type Ref struct {
Hash string
Name string
}
// TrackingRef represents a ref for a remote tracking branch
type TrackingRef struct {
RemoteName string
BranchName string
}
func (r TrackingRef) String() string {
return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
}
// ShowRefs resolves fully-qualified refs to commit hashes
func ShowRefs(ref ...string) ([]Ref, error) {
args := append([]string{"show-ref", "--verify", "--"}, ref...)
showRef, err := GitCommand(args...)
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(showRef).Output()
var refs []Ref
for _, line := range outputLines(output) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
refs = append(refs, Ref{
Hash: parts[0],
Name: parts[1],
})
}
return refs, err
}
// CurrentBranch reads the checked-out branch for the git repository
func CurrentBranch() (string, error) {
refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD")
if err != nil {
return "", err
}
stderr := bytes.Buffer{}
refCmd.Stderr = &stderr
output, err := run.PrepareCmd(refCmd).Output()
if err == nil {
// Found the branch name
return getBranchShortName(output), nil
}
if stderr.Len() == 0 {
// Detached head
return "", ErrNotOnAnyBranch
}
return "", fmt.Errorf("%sgit: %s", stderr.String(), err)
}
func listRemotesForPath(path string) ([]string, error) {
remoteCmd, err := GitCommand("-C", path, "remote", "-v")
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func listRemotes() ([]string, error) {
remoteCmd, err := GitCommand("remote", "-v")
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func Config(name string) (string, error) {
configCmd, err := GitCommand("config", name)
if err != nil {
return "", err
}
output, err := run.PrepareCmd(configCmd).Output()
if err != nil {
return "", fmt.Errorf("unknown config key: %s", name)
}
return firstLine(output), nil
}
type NotInstalled struct {
message string
error
}
func (e *NotInstalled) Error() string {
return e.message
}
func GitCommand(args ...string) (*exec.Cmd, error) {
gitExe, err := safeexec.LookPath("git")
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
programName := "git"
if runtime.GOOS == "windows" {
programName = "Git for Windows"
}
return nil, &NotInstalled{
message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName),
error: err,
}
}
return nil, err
}
return exec.Command(gitExe, args...), nil
}
func UncommittedChangeCount() (int, error) {
statusCmd, err := GitCommand("status", "--porcelain")
if err != nil {
return 0, err
}
output, err := run.PrepareCmd(statusCmd).Output()
if err != nil {
return 0, err
}
lines := strings.Split(string(output), "\n")
count := 0
for _, l := range lines {
if l != "" {
count++
}
}
return count, nil
}
type Commit struct {
Sha string
Title string
}
func Commits(baseRef, headRef string) ([]*Commit, error) {
logCmd, err := GitCommand(
"-c", "log.ShowSignature=false",
"log", "--pretty=format:%H,%s",
"--cherry", fmt.Sprintf("%s...%s", baseRef, headRef))
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(logCmd).Output()
if err != nil {
return []*Commit{}, err
}
commits := []*Commit{}
sha := 0
title := 1
for _, line := range outputLines(output) {
split := strings.SplitN(line, ",", 2)
if len(split) != 2 {
continue
}
commits = append(commits, &Commit{
Sha: split[sha],
Title: split[title],
})
}
if len(commits) == 0 {
return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
}
return commits, nil
}
func lookupCommit(sha, format string) ([]byte, error) {
logCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:"+format, sha)
if err != nil {
return nil, err
}
return run.PrepareCmd(logCmd).Output()
}
func LastCommit() (*Commit, error) {
output, err := lookupCommit("HEAD", "%H,%s")
if err != nil {
return nil, err
}
idx := bytes.IndexByte(output, ',')
return &Commit{
Sha: string(output[0:idx]),
Title: strings.TrimSpace(string(output[idx+1:])),
}, nil
}
func CommitBody(sha string) (string, error) {
output, err := lookupCommit(sha, "%b")
return string(output), err
}
// Push publishes a git ref to a remote and sets up upstream configuration
func Push(remote string, ref string, cmdIn io.ReadCloser, cmdOut, cmdErr io.Writer) error {
pushCmd, err := GitCommand("push", "--set-upstream", remote, ref)
if err != nil {
return err
}
pushCmd.Stdin = cmdIn
pushCmd.Stdout = cmdOut
pushCmd.Stderr = cmdErr
return run.PrepareCmd(pushCmd).Run()
}
type BranchConfig struct {
RemoteName string
RemoteURL *url.URL
MergeRef string
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config
func ReadBranchConfig(branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
configCmd, err := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix))
if err != nil {
return
}
output, err := run.PrepareCmd(configCmd).Output()
if err != nil {
return
}
for _, line := range outputLines(output) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
keys := strings.Split(parts[0], ".")
switch keys[len(keys)-1] {
case "remote":
if strings.Contains(parts[1], ":") {
u, err := ParseURL(parts[1])
if err != nil {
continue
}
cfg.RemoteURL = u
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
case "merge":
cfg.MergeRef = parts[1]
}
}
return
}
func DeleteLocalBranch(branch string) error {
branchCmd, err := GitCommand("branch", "-D", branch)
if err != nil {
return err
}
return run.PrepareCmd(branchCmd).Run()
}
func HasLocalBranch(branch string) bool {
configCmd, err := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
if err != nil {
return false
}
_, err = run.PrepareCmd(configCmd).Output()
return err == nil
}
func CheckoutBranch(branch string) error {
configCmd, err := GitCommand("checkout", branch)
if err != nil {
return err
}
return run.PrepareCmd(configCmd).Run()
}
func CheckoutNewBranch(remoteName, branch string) error {
track := fmt.Sprintf("%s/%s", remoteName, branch)
configCmd, err := GitCommand("checkout", "-b", branch, "--track", track)
if err != nil {
return err
}
return run.PrepareCmd(configCmd).Run()
}
// pull changes from remote branch without version history
func Pull(remote, branch string) error {
pullCmd, err := GitCommand("pull", "--ff-only", remote, branch)
if err != nil {
return err
}
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
pullCmd.Stdin = os.Stdin
return run.PrepareCmd(pullCmd).Run()
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
if len(args) > 0 {
if !strings.HasPrefix(args[0], "-") {
target, args = args[0], args[1:]
}
}
return
}
func RunClone(cloneURL string, args []string) (target string, err error) {
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone
// otherwise, parse the URL to determine where git cloned it to so we can return it
if target != "" {
cloneArgs = append(cloneArgs, target)
} else {
target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
cloneCmd, err := GitCommand(cloneArgs...)
if err != nil {
return "", err
}
cloneCmd.Stdin = os.Stdin
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
err = run.PrepareCmd(cloneCmd).Run()
return
}
func AddNamedRemote(url, name, dir string, branches []string) error {
args := []string{"-C", dir, "remote", "add"}
for _, branch := range branches {
args = append(args, "-t", branch)
}
args = append(args, "-f", name, url)
cloneCmd, err := GitCommand(args...)
if err != nil {
return err
}
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
return run.PrepareCmd(cloneCmd).Run()
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}
// ToplevelDir returns the top-level directory path of the current repository
func ToplevelDir() (string, error) {
showCmd, err := GitCommand("rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}
// ToplevelDirFromPath returns the top-level given path of the current repository
func GetDirFromPath(p string) (string, error) {
showCmd, err := GitCommand("-C", p, "rev-parse", "--git-dir")
if err != nil {
return "", err
}
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}
func PathFromRepoRoot() string {
showCmd, err := GitCommand("rev-parse", "--show-prefix")
if err != nil {
return ""
}
output, err := run.PrepareCmd(showCmd).Output()
if err != nil {
return ""
}
if path := firstLine(output); path != "" {
return path[:len(path)-1]
}
return ""
}
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}
func firstLine(output []byte) string {
if i := bytes.IndexAny(output, "\n"); i >= 0 {
return string(output)[0:i]
}
return string(output)
}
func getBranchShortName(output []byte) string {
branch := firstLine(output)
return strings.TrimPrefix(branch, "refs/heads/")
}

View file

@ -1,213 +0,0 @@
package git
import (
"reflect"
"testing"
"github.com/cli/cli/v2/internal/run"
)
func TestLastCommit(t *testing.T) {
t.Setenv("GIT_DIR", "./fixtures/simple.git")
c, err := LastCommit()
if err != nil {
t.Fatalf("LastCommit error: %v", err)
}
if c.Sha != "6f1a2405cace1633d89a79c74c65f22fe78f9659" {
t.Errorf("expected sha %q, got %q", "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha)
}
if c.Title != "Second commit" {
t.Errorf("expected title %q, got %q", "Second commit", c.Title)
}
}
func TestCommitBody(t *testing.T) {
t.Setenv("GIT_DIR", "./fixtures/simple.git")
body, err := CommitBody("6f1a2405cace1633d89a79c74c65f22fe78f9659")
if err != nil {
t.Fatalf("CommitBody error: %v", err)
}
if body != "I'm starting to get the hang of things\n" {
t.Errorf("expected %q, got %q", "I'm starting to get the hang of things\n", body)
}
}
/*
NOTE: below this are stubbed git tests, i.e. those that do not actually invoke `git`. If possible, utilize
`setGitDir()` to allow new tests to interact with `git`. For write operations, you can use `t.TempDir()` to
host a temporary git repository that is safe to be changed.
*/
func Test_UncommittedChangeCount(t *testing.T) {
type c struct {
Label string
Expected int
Output string
}
cases := []c{
{Label: "no changes", Expected: 0, Output: ""},
{Label: "one change", Expected: 1, Output: " M poem.txt"},
{Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"},
}
for _, v := range cases {
t.Run(v.Label, func(t *testing.T) {
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git status --porcelain`, 0, v.Output)
ucc, _ := UncommittedChangeCount()
if ucc != v.Expected {
t.Errorf("UncommittedChangeCount() = %d, expected %d", ucc, v.Expected)
}
})
}
}
func Test_CurrentBranch(t *testing.T) {
type c struct {
Stub string
Expected string
}
cases := []c{
{
Stub: "branch-name\n",
Expected: "branch-name",
},
{
Stub: "refs/heads/branch-name\n",
Expected: "branch-name",
},
{
Stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n",
Expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space",
},
}
for _, v := range cases {
cs, teardown := run.Stub()
cs.Register(`git symbolic-ref --quiet HEAD`, 0, v.Stub)
result, err := CurrentBranch()
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
if result != v.Expected {
t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected)
}
teardown(t)
}
}
func Test_CurrentBranch_detached_head(t *testing.T) {
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`git symbolic-ref --quiet HEAD`, 1, "")
_, err := CurrentBranch()
if err == nil {
t.Fatal("expected an error, got nil")
}
if err != ErrNotOnAnyBranch {
t.Errorf("got unexpected error: %s instead of %s", err, ErrNotOnAnyBranch)
}
}
func TestParseExtraCloneArgs(t *testing.T) {
type Wanted struct {
args []string
dir string
}
tests := []struct {
name string
args []string
want Wanted
}{
{
name: "args and target",
args: []string{"target_directory", "-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "target_directory",
},
},
{
name: "only args",
args: []string{"-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "",
},
},
{
name: "only target",
args: []string{"target_directory"},
want: Wanted{
args: []string{},
dir: "target_directory",
},
},
{
name: "no args",
args: []string{},
want: Wanted{
args: []string{},
dir: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, dir := parseCloneArgs(tt.args)
got := Wanted{
args: args,
dir: dir,
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %#v want %#v", got, tt.want)
}
})
}
}
func TestAddNamedRemote(t *testing.T) {
tests := []struct {
title string
name string
url string
dir string
branches []string
want string
}{
{
title: "fetch all",
name: "test",
url: "URL",
dir: "DIRECTORY",
branches: []string{},
want: "git -C DIRECTORY remote add -f test URL",
},
{
title: "fetch specific branches only",
name: "test",
url: "URL",
dir: "DIRECTORY",
branches: []string{"trunk", "dev"},
want: "git -C DIRECTORY remote add -t trunk -t dev -f test URL",
},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(tt.want, 0, "")
err := AddNamedRemote(tt.url, tt.name, tt.dir, tt.branches)
if err != nil {
t.Fatalf("error running command `git remote add -f`: %v", err)
}
})
}
}

76
git/objects.go Normal file
View file

@ -0,0 +1,76 @@
package git
import (
"net/url"
"strings"
)
// RemoteSet is a slice of git remotes.
type RemoteSet []*Remote
func (r RemoteSet) Len() int { return len(r) }
func (r RemoteSet) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
func (r RemoteSet) Less(i, j int) bool {
return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name)
}
func remoteNameSortScore(name string) int {
switch strings.ToLower(name) {
case "upstream":
return 3
case "github":
return 2
case "origin":
return 1
default:
return 0
}
}
// Remote is a parsed git remote.
type Remote struct {
Name string
Resolved string
FetchURL *url.URL
PushURL *url.URL
}
func (r *Remote) String() string {
return r.Name
}
func NewRemote(name string, u string) *Remote {
pu, _ := url.Parse(u)
return &Remote{
Name: name,
FetchURL: pu,
PushURL: pu,
}
}
// Ref represents a git commit reference.
type Ref struct {
Hash string
Name string
}
// TrackingRef represents a ref for a remote tracking branch.
type TrackingRef struct {
RemoteName string
BranchName string
}
func (r TrackingRef) String() string {
return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
}
type Commit struct {
Sha string
Title string
}
type BranchConfig struct {
RemoteName string
RemoteURL *url.URL
MergeRef string
}

View file

@ -1,169 +0,0 @@
package git
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/cli/cli/v2/internal/run"
)
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
// RemoteSet is a slice of git remotes
type RemoteSet []*Remote
func NewRemote(name string, u string) *Remote {
pu, _ := url.Parse(u)
return &Remote{
Name: name,
FetchURL: pu,
PushURL: pu,
}
}
// Remote is a parsed git remote
type Remote struct {
Name string
Resolved string
FetchURL *url.URL
PushURL *url.URL
}
func (r *Remote) String() string {
return r.Name
}
func remotes(path string, remoteList []string) (RemoteSet, error) {
remotes := parseRemotes(remoteList)
// this is affected by SetRemoteResolution
remoteCmd, err := GitCommand("-C", path, "config", "--get-regexp", `^remote\..*\.gh-resolved$`)
if err != nil {
return nil, err
}
output, _ := run.PrepareCmd(remoteCmd).Output()
for _, l := range outputLines(output) {
parts := strings.SplitN(l, " ", 2)
if len(parts) < 2 {
continue
}
rp := strings.SplitN(parts[0], ".", 3)
if len(rp) < 2 {
continue
}
name := rp[1]
for _, r := range remotes {
if r.Name == name {
r.Resolved = parts[1]
break
}
}
}
return remotes, nil
}
func RemotesForPath(path string) (RemoteSet, error) {
list, err := listRemotesForPath(path)
if err != nil {
return nil, err
}
return remotes(path, list)
}
// Remotes gets the git remotes set for the current repo
func Remotes() (RemoteSet, error) {
list, err := listRemotes()
if err != nil {
return nil, err
}
return remotes(".", list)
}
func parseRemotes(gitRemotes []string) (remotes RemoteSet) {
for _, r := range gitRemotes {
match := remoteRE.FindStringSubmatch(r)
if match == nil {
continue
}
name := strings.TrimSpace(match[1])
urlStr := strings.TrimSpace(match[2])
urlType := strings.TrimSpace(match[3])
var rem *Remote
if len(remotes) > 0 {
rem = remotes[len(remotes)-1]
if name != rem.Name {
rem = nil
}
}
if rem == nil {
rem = &Remote{Name: name}
remotes = append(remotes, rem)
}
u, err := ParseURL(urlStr)
if err != nil {
continue
}
switch urlType {
case "fetch":
rem.FetchURL = u
case "push":
rem.PushURL = u
}
}
return
}
// AddRemote adds a new git remote and auto-fetches objects from it
func AddRemote(name, u string) (*Remote, error) {
addCmd, err := GitCommand("remote", "add", "-f", name, u)
if err != nil {
return nil, err
}
err = run.PrepareCmd(addCmd).Run()
if err != nil {
return nil, err
}
var urlParsed *url.URL
if strings.HasPrefix(u, "https") {
urlParsed, err = url.Parse(u)
if err != nil {
return nil, err
}
} else {
urlParsed, err = ParseURL(u)
if err != nil {
return nil, err
}
}
return &Remote{
Name: name,
FetchURL: urlParsed,
PushURL: urlParsed,
}, nil
}
func UpdateRemoteURL(name, u string) error {
addCmd, err := GitCommand("remote", "set-url", name, u)
if err != nil {
return err
}
return run.PrepareCmd(addCmd).Run()
}
func SetRemoteResolution(name, resolution string) error {
addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution)
if err != nil {
return err
}
return run.PrepareCmd(addCmd).Run()
}

View file

@ -1,35 +0,0 @@
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parseRemotes(t *testing.T) {
remoteList := []string{
"mona\tgit@github.com:monalisa/myfork.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat-push.git (push)",
"upstream\thttps://example.com/nowhere.git (fetch)",
"upstream\thttps://github.com/hubot/tools (push)",
"zardoz\thttps://example.com/zed.git (push)",
}
r := parseRemotes(remoteList)
assert.Equal(t, 4, len(r))
assert.Equal(t, "mona", r[0].Name)
assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String())
if r[0].PushURL != nil {
t.Errorf("expected no PushURL, got %q", r[0].PushURL)
}
assert.Equal(t, "origin", r[1].Name)
assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path)
assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path)
assert.Equal(t, "upstream", r[2].Name)
assert.Equal(t, "example.com", r[2].FetchURL.Host)
assert.Equal(t, "github.com", r[2].PushURL.Host)
assert.Equal(t, "zardoz", r[3].Name)
}

15
go.mod
View file

@ -6,9 +6,9 @@ require (
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.18.1
github.com/charmbracelet/glamour v0.5.0
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
github.com/charmbracelet/lipgloss v0.5.0
github.com/cli/go-gh v0.1.1-0.20220913125123-04019861008e
github.com/cli/go-gh v0.1.2
github.com/cli/oauth v0.9.0
github.com/cli/safeexec v1.0.0
github.com/cpuguy83/go-md2man/v2 v2.0.2
@ -35,7 +35,9 @@ require (
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/text v0.3.7
golang.org/x/text v0.3.8
google.golang.org/grpc v1.49.0
google.golang.org/protobuf v1.27.1
gopkg.in/yaml.v3 v3.0.1
)
@ -43,7 +45,7 @@ require (
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cli/browser v1.1.0 // indirect
github.com/cli/shurcooL-graphql v0.0.1 // indirect
github.com/cli/shurcooL-graphql v0.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/fatih/color v1.7.0 // indirect
@ -55,7 +57,7 @@ require (
github.com/itchyny/timefmt-go v0.1.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/microcosm-cc/bluemonday v1.0.19 // indirect
github.com/microcosm-cc/bluemonday v1.0.20 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.12.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
@ -67,9 +69,10 @@ require (
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/yuin/goldmark v1.4.4 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
golang.org/x/net v0.0.0-20220923203811-8be639271d50 // indirect
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)

35
go.sum
View file

@ -46,8 +46,8 @@ github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd3
github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/glamour v0.5.0 h1:wu15ykPdB7X6chxugG/NNfDUbyyrCLV9XBalj5wdu3g=
github.com/charmbracelet/glamour v0.5.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@ -58,14 +58,14 @@ github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY=
github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
github.com/cli/go-gh v0.1.1-0.20220913125123-04019861008e h1:zK2hqxSk5D/Jt4o+0NVH/qdEFh7fUhgGkhbukwPMzQU=
github.com/cli/go-gh v0.1.1-0.20220913125123-04019861008e/go.mod h1:UKRuMl3ZaitTvO4LPWj5bVw7QwZHnLu0S0lI9WWbdpc=
github.com/cli/go-gh v0.1.2 h1:DoiHIo7uuK51Tw5dmawHfIMcBq9CsNNZ2uQTPkP4pLU=
github.com/cli/go-gh v0.1.2/go.mod h1:bqxLdCoTZ73BuiPEJx4olcO/XKhVZaFDchFagYRBweE=
github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc=
github.com/cli/oauth v0.9.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/shurcooL-graphql v0.0.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3SocsKM=
github.com/cli/shurcooL-graphql v0.0.1/go.mod h1:U7gCSuMZP/Qy7kbqkk5PrqXEeDgtfG5K+W+u8weorps=
github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk=
github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
@ -186,7 +186,6 @@ github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVc
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@ -198,13 +197,13 @@ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/microcosm-cc/bluemonday v1.0.19 h1:OI7hoF5FY4pFz2VA//RN8TfM0YJ2dJcl4P4APrCWy6c=
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
@ -314,11 +313,10 @@ golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b h1:PxfKdU9lEEDYjdIzOtC4qFWgkU2rGHdKlKowJSMN9h0=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220923203811-8be639271d50 h1:vKyz8L3zkd+xrMeIaBsQ/MNVPVFSffdaU3ZyYlBGFnI=
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -350,7 +348,6 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -373,6 +370,7 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@ -385,8 +383,9 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -485,6 +484,7 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@ -498,6 +498,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -509,8 +511,9 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

View file

@ -210,6 +210,8 @@ const (
CodespaceStateShutdown = "Shutdown"
// CodespaceStateStarting is the state for a starting codespace environment.
CodespaceStateStarting = "Starting"
// CodespaceStateRebuilding is the state for a rebuilding codespace environment.
CodespaceStateRebuilding = "Rebuilding"
)
type CodespaceConnection struct {
@ -527,7 +529,7 @@ type Machine struct {
}
// GetCodespacesMachines returns the codespaces machines for the given repo, branch and location.
func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*Machine, error) {
func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*Machine, error) {
reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
@ -537,6 +539,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
q := req.URL.Query()
q.Add("location", location)
q.Add("ref", branch)
q.Add("devcontainer_path", devcontainerPath)
req.URL.RawQuery = q.Encode()
a.setHeaders(req)

View file

@ -0,0 +1,145 @@
package grpc
// gRPC client implementation to be able to connect to the gRPC server and perform the following operations:
// - Start a remote JupyterLab server
import (
"context"
"fmt"
"net"
"strconv"
"time"
"github.com/cli/cli/v2/internal/codespaces/grpc/jupyter"
"github.com/cli/cli/v2/pkg/liveshare"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
const (
ConnectionTimeout = 5 * time.Second
RequestTimeout = 30 * time.Second
)
const (
codespacesInternalPort = 16634
codespacesInternalSessionName = "CodespacesInternal"
)
type Client struct {
conn *grpc.ClientConn
token string
listener net.Listener
jupyterClient jupyter.JupyterServerHostClient
cancelPF context.CancelFunc
}
type liveshareSession interface {
KeepAlive(string)
OpenStreamingChannel(context.Context, liveshare.ChannelID) (ssh.Channel, error)
StartSharing(context.Context, string, int) (liveshare.ChannelID, error)
}
// Finds a free port to listen on and creates a new gRPC client that connects to that port
func Connect(ctx context.Context, session liveshareSession, token string) (*Client, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0))
if err != nil {
return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err)
}
localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port)
client := &Client{
token: token,
listener: listener,
}
// Create a cancelable context to be able to cancel background tasks
// if we encounter an error while connecting to the gRPC server
connectctx, cancel := context.WithCancel(context.Background())
defer func() {
if err != nil {
cancel()
}
}()
ch := make(chan error, 2) // Buffered channel to ensure we don't block on the goroutine
// Ensure we close the port forwarder if we encounter an error
// or once the gRPC connection is closed. pfcancel is retained
// to close the PF whenever we close the gRPC connection.
pfctx, pfcancel := context.WithCancel(connectctx)
client.cancelPF = pfcancel
// Tunnel the remote gRPC server port to the local port
go func() {
fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true)
ch <- fwd.ForwardToListener(pfctx, listener)
}()
var conn *grpc.ClientConn
go func() {
// Attempt to connect to the port
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
}
conn, err = grpc.DialContext(connectctx, localAddress, opts...)
ch <- err // nil if we successfully connected
}()
// Wait for the connection to be established or for the context to be cancelled
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-ch:
if err != nil {
return nil, err
}
}
client.conn = conn
client.jupyterClient = jupyter.NewJupyterServerHostClient(conn)
return client, nil
}
// Closes the gRPC connection
func (g *Client) Close() error {
g.cancelPF()
// Closing the local listener effectively closes the gRPC connection
if err := g.listener.Close(); err != nil {
g.conn.Close() // If we fail to close the listener, explicitly close the gRPC connection and ignore any error
return fmt.Errorf("failed to close local tcp port listener: %w", err)
}
return nil
}
// Appends the authentication token to the gRPC context
func (g *Client) appendMetadata(ctx context.Context) context.Context {
return metadata.AppendToOutgoingContext(ctx, "Authorization", "Bearer "+g.token)
}
// Starts a remote JupyterLab server to allow the user to connect to the codespace via JupyterLab in their browser
func (g *Client) StartJupyterServer(ctx context.Context) (port int, serverUrl string, err error) {
ctx = g.appendMetadata(ctx)
response, err := g.jupyterClient.GetRunningServer(ctx, &jupyter.GetRunningServerRequest{})
if err != nil {
return 0, "", fmt.Errorf("failed to invoke JupyterLab RPC: %w", err)
}
if !response.Result {
return 0, "", fmt.Errorf("failed to start JupyterLab: %s", response.Message)
}
port, err = strconv.Atoi(response.Port)
if err != nil {
return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err)
}
return port, response.ServerUrl, err
}

View file

@ -0,0 +1,84 @@
package grpc
import (
"context"
"fmt"
"log"
"os"
"testing"
grpctest "github.com/cli/cli/v2/internal/codespaces/grpc/test"
)
func startServer(t *testing.T) {
t.Helper()
if os.Getenv("GITHUB_ACTIONS") == "true" {
t.Skip("fails intermittently in CI: https://github.com/cli/cli/issues/5663")
}
ctx, cancel := context.WithCancel(context.Background())
// Start the gRPC server in the background
go func() {
err := grpctest.StartServer(ctx)
if err != nil && err != context.Canceled {
log.Println(fmt.Errorf("error starting test server: %v", err))
}
}()
// Stop the gRPC server when the test is done
t.Cleanup(func() {
cancel()
})
}
func connect(t *testing.T) (client *Client) {
t.Helper()
client, err := Connect(context.Background(), &grpctest.Session{}, "token")
if err != nil {
t.Fatalf("error connecting to internal server: %v", err)
}
t.Cleanup(func() {
client.Close()
})
return client
}
// Test that the gRPC client returns the correct port and URL when the JupyterLab server starts successfully
func TestStartJupyterServerSuccess(t *testing.T) {
startServer(t)
client := connect(t)
port, url, err := client.StartJupyterServer(context.Background())
if err != nil {
t.Fatalf("expected %v, got %v", nil, err)
}
if port != grpctest.JupyterPort {
t.Fatalf("expected %d, got %d", grpctest.JupyterPort, port)
}
if url != grpctest.JupyterServerUrl {
t.Fatalf("expected %s, got %s", grpctest.JupyterServerUrl, url)
}
}
// Test that the gRPC client returns an error when the JupyterLab server fails to start
func TestStartJupyterServerFailure(t *testing.T) {
startServer(t)
client := connect(t)
grpctest.JupyterMessage = "error message"
grpctest.JupyterResult = false
errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", grpctest.JupyterMessage)
port, url, err := client.StartJupyterServer(context.Background())
if err.Error() != errorMessage {
t.Fatalf("expected %v, got %v", errorMessage, err)
}
if port != 0 {
t.Fatalf("expected %d, got %d", 0, port)
}
if url != "" {
t.Fatalf("expected %s, got %s", "", url)
}
}

View file

@ -0,0 +1,241 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.0
// protoc v3.21.3
// source: JupyterServerHostService.v1.proto
package jupyter
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetRunningServerRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *GetRunningServerRequest) Reset() {
*x = GetRunningServerRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_JupyterServerHostService_v1_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetRunningServerRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRunningServerRequest) ProtoMessage() {}
func (x *GetRunningServerRequest) ProtoReflect() protoreflect.Message {
mi := &file_JupyterServerHostService_v1_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRunningServerRequest.ProtoReflect.Descriptor instead.
func (*GetRunningServerRequest) Descriptor() ([]byte, []int) {
return file_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{0}
}
type GetRunningServerResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Result bool `protobuf:"varint,1,opt,name=Result,proto3" json:"Result,omitempty"`
Message string `protobuf:"bytes,2,opt,name=Message,proto3" json:"Message,omitempty"`
Port string `protobuf:"bytes,3,opt,name=Port,proto3" json:"Port,omitempty"`
ServerUrl string `protobuf:"bytes,4,opt,name=ServerUrl,proto3" json:"ServerUrl,omitempty"`
}
func (x *GetRunningServerResponse) Reset() {
*x = GetRunningServerResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_JupyterServerHostService_v1_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetRunningServerResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRunningServerResponse) ProtoMessage() {}
func (x *GetRunningServerResponse) ProtoReflect() protoreflect.Message {
mi := &file_JupyterServerHostService_v1_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRunningServerResponse.ProtoReflect.Descriptor instead.
func (*GetRunningServerResponse) Descriptor() ([]byte, []int) {
return file_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{1}
}
func (x *GetRunningServerResponse) GetResult() bool {
if x != nil {
return x.Result
}
return false
}
func (x *GetRunningServerResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *GetRunningServerResponse) GetPort() string {
if x != nil {
return x.Port
}
return ""
}
func (x *GetRunningServerResponse) GetServerUrl() string {
if x != nil {
return x.ServerUrl
}
return ""
}
var File_JupyterServerHostService_v1_proto protoreflect.FileDescriptor
var file_JupyterServerHostService_v1_proto_rawDesc = []byte{
0x0a, 0x21, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48,
0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x70, 0x72,
0x6f, 0x74, 0x6f, 0x12, 0x2b, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e,
0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76,
0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31,
0x22, 0x19, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65,
0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x7e, 0x0a, 0x18, 0x47,
0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52,
0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c,
0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12,
0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
0x52, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72,
0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x0a,
0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
0x52, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x32, 0xb5, 0x01, 0x0a, 0x11,
0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73,
0x74, 0x12, 0x9f, 0x01, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x44, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61,
0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72,
0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x45, 0x2e, 0x43,
0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a,
0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74,
0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75,
0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x2f, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72,
0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_JupyterServerHostService_v1_proto_rawDescOnce sync.Once
file_JupyterServerHostService_v1_proto_rawDescData = file_JupyterServerHostService_v1_proto_rawDesc
)
func file_JupyterServerHostService_v1_proto_rawDescGZIP() []byte {
file_JupyterServerHostService_v1_proto_rawDescOnce.Do(func() {
file_JupyterServerHostService_v1_proto_rawDescData = protoimpl.X.CompressGZIP(file_JupyterServerHostService_v1_proto_rawDescData)
})
return file_JupyterServerHostService_v1_proto_rawDescData
}
var file_JupyterServerHostService_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_JupyterServerHostService_v1_proto_goTypes = []interface{}{
(*GetRunningServerRequest)(nil), // 0: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest
(*GetRunningServerResponse)(nil), // 1: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse
}
var file_JupyterServerHostService_v1_proto_depIdxs = []int32{
0, // 0: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:input_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest
1, // 1: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:output_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse
1, // [1:2] is the sub-list for method output_type
0, // [0:1] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_JupyterServerHostService_v1_proto_init() }
func file_JupyterServerHostService_v1_proto_init() {
if File_JupyterServerHostService_v1_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_JupyterServerHostService_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetRunningServerRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_JupyterServerHostService_v1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetRunningServerResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_JupyterServerHostService_v1_proto_rawDesc,
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_JupyterServerHostService_v1_proto_goTypes,
DependencyIndexes: file_JupyterServerHostService_v1_proto_depIdxs,
MessageInfos: file_JupyterServerHostService_v1_proto_msgTypes,
}.Build()
File_JupyterServerHostService_v1_proto = out.File
file_JupyterServerHostService_v1_proto_rawDesc = nil
file_JupyterServerHostService_v1_proto_goTypes = nil
file_JupyterServerHostService_v1_proto_depIdxs = nil
}

View file

@ -0,0 +1,19 @@
syntax = "proto3";
option go_package = "./jupyter";
package Codespaces.Grpc.JupyterServerHostService.v1;
service JupyterServerHost {
rpc GetRunningServer (GetRunningServerRequest) returns (GetRunningServerResponse);
}
message GetRunningServerRequest {
}
message GetRunningServerResponse {
bool Result = 1;
string Message = 2;
string Port = 3;
string ServerUrl = 4;
}

View file

@ -0,0 +1,105 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.21.3
// source: JupyterServerHostService.v1.proto
package jupyter
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
// JupyterServerHostClient is the client API for JupyterServerHost service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type JupyterServerHostClient interface {
GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error)
}
type jupyterServerHostClient struct {
cc grpc.ClientConnInterface
}
func NewJupyterServerHostClient(cc grpc.ClientConnInterface) JupyterServerHostClient {
return &jupyterServerHostClient{cc}
}
func (c *jupyterServerHostClient) GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error) {
out := new(GetRunningServerResponse)
err := c.cc.Invoke(ctx, "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// JupyterServerHostServer is the server API for JupyterServerHost service.
// All implementations must embed UnimplementedJupyterServerHostServer
// for forward compatibility
type JupyterServerHostServer interface {
GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error)
mustEmbedUnimplementedJupyterServerHostServer()
}
// UnimplementedJupyterServerHostServer must be embedded to have forward compatible implementations.
type UnimplementedJupyterServerHostServer struct {
}
func (UnimplementedJupyterServerHostServer) GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRunningServer not implemented")
}
func (UnimplementedJupyterServerHostServer) mustEmbedUnimplementedJupyterServerHostServer() {}
// UnsafeJupyterServerHostServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to JupyterServerHostServer will
// result in compilation errors.
type UnsafeJupyterServerHostServer interface {
mustEmbedUnimplementedJupyterServerHostServer()
}
func RegisterJupyterServerHostServer(s grpc.ServiceRegistrar, srv JupyterServerHostServer) {
s.RegisterService(&JupyterServerHost_ServiceDesc, srv)
}
func _JupyterServerHost_GetRunningServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRunningServerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(JupyterServerHostServer).GetRunningServer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(JupyterServerHostServer).GetRunningServer(ctx, req.(*GetRunningServerRequest))
}
return interceptor(ctx, in, info, handler)
}
// JupyterServerHost_ServiceDesc is the grpc.ServiceDesc for JupyterServerHost service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var JupyterServerHost_ServiceDesc = grpc.ServiceDesc{
ServiceName: "Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost",
HandlerType: (*JupyterServerHostServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetRunningServer",
Handler: _JupyterServerHost_GetRunningServer_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "JupyterServerHostService.v1.proto",
}

View file

@ -0,0 +1,34 @@
package test
import (
"io"
"net"
)
type Channel struct {
conn net.Conn
}
func (c *Channel) Read(data []byte) (int, error) {
return c.conn.Read(data)
}
func (c *Channel) Write(data []byte) (int, error) {
return c.conn.Write(data)
}
func (c *Channel) Close() error {
return c.conn.Close()
}
func (c *Channel) CloseWrite() error {
return nil
}
func (c *Channel) SendRequest(name string, wantReply bool, payload []byte) (bool, error) {
return false, nil
}
func (c *Channel) Stderr() io.ReadWriter {
return nil
}

View file

@ -0,0 +1,62 @@
package test
import (
"context"
"fmt"
"net"
"strconv"
"github.com/cli/cli/v2/internal/codespaces/grpc/jupyter"
"google.golang.org/grpc"
)
const (
ServerPort = 50051
)
var (
JupyterPort = 1234
JupyterServerUrl = "http://localhost:1234?token=1234"
JupyterMessage = ""
JupyterResult = true
)
type server struct {
jupyter.UnimplementedJupyterServerHostServer
}
func (s *server) GetRunningServer(ctx context.Context, in *jupyter.GetRunningServerRequest) (*jupyter.GetRunningServerResponse, error) {
return &jupyter.GetRunningServerResponse{
Port: strconv.Itoa(JupyterPort),
ServerUrl: JupyterServerUrl,
Message: JupyterMessage,
Result: JupyterResult,
}, nil
}
// Starts the mock gRPC server listening on port 50051
func StartServer(ctx context.Context) error {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
defer listener.Close()
s := grpc.NewServer()
jupyter.RegisterJupyterServerHostServer(s, &server{})
ch := make(chan error, 1)
go func() {
if err := s.Serve(listener); err != nil {
ch <- fmt.Errorf("failed to serve: %v", err)
}
}()
select {
case <-ctx.Done():
s.Stop()
return ctx.Err()
case err := <-ch:
return err
}
}

View file

@ -0,0 +1,31 @@
package test
import (
"context"
"fmt"
"net"
"github.com/cli/cli/v2/pkg/liveshare"
"golang.org/x/crypto/ssh"
)
type Session struct {
channel ssh.Channel
}
func (s *Session) KeepAlive(reason string) {
}
func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
if err != nil {
return liveshare.ChannelID{}, err
}
s.channel = &Channel{conn}
return liveshare.ChannelID{}, nil
}
// Creates mock SSH channel connected to the mock gRPC server
func (s *Session) OpenStreamingChannel(ctx context.Context, id liveshare.ChannelID) (ssh.Channel, error) {
return s.channel, nil
}

View file

@ -76,6 +76,10 @@ func (e CmdError) Error() string {
return fmt.Sprintf("%s%s: %s", msg, e.Args[0], e.Err)
}
func (e CmdError) Unwrap() error {
return e.Err
}
func printArgs(w io.Writer, args []string) error {
if len(args) > 0 {
// print commands, but omit the full path to an executable

View file

@ -0,0 +1,52 @@
package tableprinter
import (
"strings"
"time"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/go-gh/pkg/tableprinter"
)
type TablePrinter struct {
tableprinter.TablePrinter
isTTY bool
}
func (t *TablePrinter) HeaderRow(columns ...string) {
if !t.isTTY {
return
}
for _, col := range columns {
t.AddField(strings.ToUpper(col))
}
t.EndRow()
}
func (tp *TablePrinter) AddTimeField(t time.Time, c func(string) string) {
tf := t.Format(time.RFC3339)
if tp.isTTY {
// TODO: use a static time.Now
tf = text.FuzzyAgo(time.Now(), t)
}
tp.AddField(tf, tableprinter.WithColor(c))
}
var (
WithTruncate = tableprinter.WithTruncate
WithColor = tableprinter.WithColor
)
func New(ios *iostreams.IOStreams) *TablePrinter {
maxWidth := 80
isTTY := ios.IsStdoutTTY()
if isTTY {
maxWidth = ios.TerminalWidth()
}
tp := tableprinter.New(ios.Out, isTTY, maxWidth)
return &TablePrinter{
TablePrinter: tp,
isTTY: isTTY,
}
}

View file

@ -54,6 +54,7 @@ func listRun(opts *ListOptions) error {
return cmdutil.NewNoResultsError("no aliases configured")
}
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
tp := utils.NewTablePrinter(opts.IO)
keys := []string{}
for alias := range aliasMap {

View file

@ -7,6 +7,7 @@ import (
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
@ -20,6 +21,7 @@ type LoginOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
HttpClient func() (*http.Client, error)
GitClient *git.Client
Prompter shared.Prompt
MainExecutable string
@ -38,6 +40,7 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
IO: f.IOStreams,
Config: f.Config,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Prompter: f.Prompter,
}
@ -183,6 +186,7 @@ func loginRun(opts *LoginOptions) error {
Executable: opts.MainExecutable,
GitProtocol: opts.GitProtocol,
Prompter: opts.Prompter,
GitClient: opts.GitClient,
})
}

View file

@ -8,6 +8,7 @@ import (
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/internal/run"
@ -597,6 +598,8 @@ func Test_loginRun_Survey(t *testing.T) {
}
tt.opts.Prompter = pm
tt.opts.GitClient = &git.Client{GitPath: "some/path/git"}
rs, restoreRun := run.Stub()
defer restoreRun(t)
if tt.runStubs != nil {

View file

@ -6,6 +6,7 @@ import (
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/authflow"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
@ -17,7 +18,8 @@ import (
type RefreshOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
httpClient *http.Client
HttpClient *http.Client
GitClient *git.Client
Prompter shared.Prompt
MainExecutable string
@ -37,7 +39,8 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
_, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes, interactive)
return err
},
httpClient: &http.Client{},
HttpClient: &http.Client{},
GitClient: f.GitClient,
Prompter: f.Prompter,
}
@ -122,7 +125,7 @@ func refreshRun(opts *RefreshOptions) error {
var additionalScopes []string
if oldToken, _ := cfg.AuthToken(hostname); oldToken != "" {
if oldScopes, err := shared.GetScopes(opts.httpClient, hostname, oldToken); err == nil {
if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil {
for _, s := range strings.Split(oldScopes, ",") {
s = strings.TrimSpace(s)
if s != "" {
@ -135,6 +138,7 @@ func refreshRun(opts *RefreshOptions) error {
credentialFlow := &shared.GitCredentialFlow{
Executable: opts.MainExecutable,
Prompter: opts.Prompter,
GitClient: opts.GitClient,
}
gitProtocol, _ := cfg.GetOrDefault(hostname, "git_protocol")
if opts.Interactive && gitProtocol == "https" {

View file

@ -272,7 +272,7 @@ func Test_refreshRun(t *testing.T) {
}, nil
},
)
tt.opts.httpClient = &http.Client{Transport: httpReg}
tt.opts.HttpClient = &http.Client{Transport: httpReg}
pm := &prompter.PrompterMock{}
if tt.prompterStubs != nil {

View file

@ -34,6 +34,7 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr
RunE: func(cmd *cobra.Command, args []string) error {
opts.gitConfigure = &shared.GitCredentialFlow{
Executable: f.Executable(),
GitClient: f.GitClient,
}
if runF != nil {

View file

@ -2,6 +2,7 @@ package shared
import (
"bytes"
"context"
"errors"
"fmt"
"path/filepath"
@ -10,13 +11,13 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/run"
"github.com/google/shlex"
)
type GitCredentialFlow struct {
Executable string
Prompter Prompt
GitClient *git.Client
shouldSetup bool
helper string
@ -25,7 +26,7 @@ type GitCredentialFlow struct {
func (flow *GitCredentialFlow) Prompt(hostname string) error {
var gitErr error
flow.helper, gitErr = gitCredentialHelper(hostname)
flow.helper, gitErr = gitCredentialHelper(flow.GitClient, hostname)
if isOurCredentialHelper(flow.helper) {
flow.scopes = append(flow.scopes, "workflow")
return nil
@ -60,6 +61,9 @@ func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error
}
func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error {
gitClient := flow.GitClient
ctx := context.Background()
if flow.helper == "" {
credHelperKeys := []string{
gitCredentialHelperKey(hostname),
@ -77,18 +81,18 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s
break
}
// first use a blank value to indicate to git we want to sever the chain of credential helpers
preConfigureCmd, err := git.GitCommand("config", "--global", "--replace-all", credHelperKey, "")
preConfigureCmd, err := gitClient.Command(ctx, "config", "--global", "--replace-all", credHelperKey, "")
if err != nil {
configErr = err
break
}
if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil {
if _, err = preConfigureCmd.Output(); err != nil {
configErr = err
break
}
// second configure the actual helper for this host
configureCmd, err := git.GitCommand(
configureCmd, err := gitClient.Command(ctx,
"config", "--global", "--add",
credHelperKey,
fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)),
@ -96,7 +100,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s
if err != nil {
configErr = err
} else {
configErr = run.PrepareCmd(configureCmd).Run()
_, configErr = configureCmd.Output()
}
}
@ -104,7 +108,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s
}
// clear previous cached credentials
rejectCmd, err := git.GitCommand("credential", "reject")
rejectCmd, err := gitClient.Command(ctx, "credential", "reject")
if err != nil {
return err
}
@ -114,12 +118,12 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s
host=%s
`, hostname))
err = run.PrepareCmd(rejectCmd).Run()
_, err = rejectCmd.Output()
if err != nil {
return err
}
approveCmd, err := git.GitCommand("credential", "approve")
approveCmd, err := gitClient.Command(ctx, "credential", "approve")
if err != nil {
return err
}
@ -131,7 +135,7 @@ func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password s
password=%s
`, hostname, username, password))
err = run.PrepareCmd(approveCmd).Run()
_, err = approveCmd.Output()
if err != nil {
return err
}
@ -144,12 +148,13 @@ func gitCredentialHelperKey(hostname string) string {
return fmt.Sprintf("credential.%s.helper", host)
}
func gitCredentialHelper(hostname string) (helper string, err error) {
helper, err = git.Config(gitCredentialHelperKey(hostname))
func gitCredentialHelper(gitClient *git.Client, hostname string) (helper string, err error) {
ctx := context.Background()
helper, err = gitClient.Config(ctx, gitCredentialHelperKey(hostname))
if helper != "" {
return
}
helper, err = git.Config("credential.helper")
helper, err = gitClient.Config(ctx, "credential.helper")
return
}

View file

@ -3,6 +3,7 @@ package shared
import (
"testing"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/run"
)
@ -15,6 +16,7 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) {
f := GitCredentialFlow{
Executable: "gh",
helper: "osxkeychain",
GitClient: &git.Client{GitPath: "some/path/git"},
}
if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil {
@ -61,6 +63,7 @@ func TestGitCredentialsSetup_setOurs_GH(t *testing.T) {
f := GitCredentialFlow{
Executable: "/path/to/gh",
helper: "",
GitClient: &git.Client{GitPath: "some/path/git"},
}
if err := f.gitCredentialSetup("github.com", "monalisa", "PASSWD"); err != nil {
@ -92,6 +95,7 @@ func TestGitCredentialSetup_setOurs_nonGH(t *testing.T) {
f := GitCredentialFlow{
Executable: "/path/to/gh",
helper: "",
GitClient: &git.Client{GitPath: "some/path/git"},
}
if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil {

View file

@ -8,6 +8,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/authflow"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/cmd/ssh-key/add"
@ -27,6 +28,7 @@ type LoginOptions struct {
IO *iostreams.IOStreams
Config iconfig
HTTPClient *http.Client
GitClient *git.Client
Hostname string
Interactive bool
Web bool
@ -63,7 +65,11 @@ func Login(opts *LoginOptions) error {
var additionalScopes []string
credentialFlow := &GitCredentialFlow{Executable: opts.Executable, Prompter: opts.Prompter}
credentialFlow := &GitCredentialFlow{
Executable: opts.Executable,
Prompter: opts.Prompter,
GitClient: opts.GitClient,
}
if opts.Interactive && gitProtocol == "https" {
if err := credentialFlow.Prompt(hostname); err != nil {
return err

View file

@ -1,6 +1,7 @@
package browse
import (
"context"
"fmt"
"net/http"
"net/url"
@ -41,11 +42,13 @@ type BrowseOptions struct {
func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Command {
opts := &BrowseOptions{
Browser: f.Browser,
HttpClient: f.HttpClient,
IO: f.IOStreams,
PathFromRepoRoot: git.PathFromRepoRoot,
GitClient: &localGitClient{},
Browser: f.Browser,
HttpClient: f.HttpClient,
IO: f.IOStreams,
PathFromRepoRoot: func() string {
return f.GitClient.PathFromRoot(context.Background())
},
GitClient: &localGitClient{client: f.GitClient},
}
cmd := &cobra.Command{
@ -269,14 +272,18 @@ type gitClient interface {
LastCommit() (*git.Commit, error)
}
type localGitClient struct{}
type localGitClient struct {
client *git.Client
}
type remoteGitClient struct {
repo func() (ghrepo.Interface, error)
httpClient func() (*http.Client, error)
}
func (gc *localGitClient) LastCommit() (*git.Commit, error) { return git.LastCommit() }
func (gc *localGitClient) LastCommit() (*git.Commit, error) {
return gc.client.LastCommit(context.Background())
}
func (gc *remoteGitClient) LastCommit() (*git.Commit, error) {
httpClient, err := gc.httpClient()

View file

@ -466,7 +466,7 @@ func Test_runBrowse(t *testing.T) {
}
opts.Browser = &browser
if opts.PathFromRepoRoot == nil {
opts.PathFromRepoRoot = git.PathFromRepoRoot
opts.PathFromRepoRoot = func() string { return "" }
}
err := runBrowse(&opts)

View file

@ -66,6 +66,7 @@ type liveshareSession interface {
StartSharing(context.Context, string, int) (liveshare.ChannelID, error)
StartSSHServer(context.Context) (int, string, error)
StartSSHServerWithOptions(context.Context, liveshare.StartSSHServerOptions) (int, string, error)
RebuildContainer(context.Context) error
}
// Connects to a codespace using Live Share and returns that session
@ -101,7 +102,7 @@ type apiClient interface {
CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
EditCodespace(ctx context.Context, codespaceName string, params *api.EditCodespaceParams) (*api.Codespace, error)
GetRepository(ctx context.Context, nwo string) (*api.Repository, error)
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error)
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error)
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
ListDevContainers(ctx context.Context, repoID int, branch string, limit int) (devcontainers []api.DevContainerEntry, err error)
GetCodespaceRepoSuggestions(ctx context.Context, partialSearch string, params api.RepoSearchParameters) ([]string, error)

View file

@ -225,7 +225,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
}
}
machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location)
machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath)
if err != nil {
return fmt.Errorf("error getting machine type: %w", err)
}
@ -411,8 +411,8 @@ func (a *App) showStatus(ctx context.Context, codespace *api.Codespace) error {
}
// getMachineName prompts the user to select the machine type, or validates the machine if non-empty.
func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string) (string, error) {
machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location)
func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string, devcontainerPath string) (string, error) {
machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location, devcontainerPath)
if err != nil {
return "", fmt.Errorf("error requesting machine instance types: %w", err)
}

View file

@ -45,7 +45,7 @@ func TestApp_Create(t *testing.T) {
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",
@ -99,7 +99,7 @@ func TestApp_Create(t *testing.T) {
Type: "User",
}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",
@ -136,6 +136,77 @@ func TestApp_Create(t *testing.T) {
},
wantStdout: "monalisa-dotfiles-abcd1234\n",
},
{
name: "create codespace with devcontainer path results in selecting the correct machine type",
fields: fields{
apiClient: &apiClientMock{
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
return &api.Repository{
ID: 1234,
FullName: nwo,
DefaultBranch: "main",
}, nil
},
GetCodespaceBillableOwnerFunc: func(ctx context.Context, nwo string) (*api.User, error) {
return &api.User{
Login: "monalisa",
Type: "User",
}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
if devcontainerPath == "" {
return []*api.Machine{
{
Name: "GIGA",
DisplayName: "Gigabits of a machine",
},
}, nil
} else {
return []*api.Machine{
{
Name: "MEGA",
DisplayName: "Megabits of a machine",
},
{
Name: "GIGA",
DisplayName: "Gigabits of a machine",
},
}, nil
}
},
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
if params.Branch != "main" {
return nil, fmt.Errorf("got branch %q, want %q", params.Branch, "main")
}
if params.IdleTimeoutMinutes != 30 {
return nil, fmt.Errorf("idle timeout minutes was %v", params.IdleTimeoutMinutes)
}
if params.RetentionPeriodMinutes != nil {
return nil, fmt.Errorf("retention period minutes expected nil, was %v", params.RetentionPeriodMinutes)
}
if params.DevContainerPath != ".devcontainer/foobar/devcontainer.json" {
return nil, fmt.Errorf("got dev container path %q, want %q", params.DevContainerPath, ".devcontainer/foobar/devcontainer.json")
}
return &api.Codespace{
Name: "monalisa-dotfiles-abcd1234",
Machine: api.CodespaceMachine{
Name: "MEGA",
DisplayName: "Megabits of a machine",
},
}, nil
},
},
},
opts: createOptions{
repo: "monalisa/dotfiles",
branch: "",
machine: "MEGA",
showStatus: false,
idleTimeout: 30 * time.Minute,
devContainerPath: ".devcontainer/foobar/devcontainer.json",
},
wantStdout: "monalisa-dotfiles-abcd1234\n",
},
{
name: "create codespace with default branch with default devcontainer if no path provided and no devcontainer files exist in the repo",
fields: fields{
@ -156,7 +227,7 @@ func TestApp_Create(t *testing.T) {
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
return []api.DevContainerEntry{}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",
@ -246,7 +317,7 @@ func TestApp_Create(t *testing.T) {
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
return []api.DevContainerEntry{}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",
@ -302,7 +373,7 @@ func TestApp_Create(t *testing.T) {
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",
@ -384,7 +455,7 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",
@ -429,7 +500,7 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
ListDevContainersFunc: func(ctx context.Context, repoID int, branch string, limit int) ([]api.DevContainerEntry, error) {
return []api.DevContainerEntry{{Path: ".devcontainer/devcontainer.json"}}, nil
},
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error) {
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
return []*api.Machine{
{
Name: "GIGA",

View file

@ -6,6 +6,7 @@ import (
"net"
"strings"
"github.com/cli/cli/v2/internal/codespaces/grpc"
"github.com/cli/cli/v2/pkg/liveshare"
"github.com/spf13/cobra"
)
@ -44,11 +45,17 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) {
defer safeClose(session, &err)
a.StartProgressIndicatorWithLabel("Starting JupyterLab on codespace")
serverPort, serverUrl, err := session.StartJupyterServer(ctx)
a.StopProgressIndicator()
client, err := connectToGRPCServer(ctx, session, codespace.Connection.SessionToken)
if err != nil {
return fmt.Errorf("failed to connect to internal server: %w", err)
}
defer safeClose(client, &err)
serverPort, serverUrl, err := startJupyterServer(ctx, client)
if err != nil {
return fmt.Errorf("failed to start JupyterLab server: %w", err)
}
a.StopProgressIndicator()
// Pass 0 to pick a random port
listen, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0))
@ -80,3 +87,27 @@ func (a *App) Jupyter(ctx context.Context, codespaceName string) (err error) {
return nil // success
}
}
func connectToGRPCServer(ctx context.Context, session liveshareSession, token string) (*grpc.Client, error) {
ctx, cancel := context.WithTimeout(ctx, grpc.ConnectionTimeout)
defer cancel()
client, err := grpc.Connect(ctx, session, token)
if err != nil {
return nil, fmt.Errorf("error connecting to internal server: %w", err)
}
return client, nil
}
func startJupyterServer(ctx context.Context, client *grpc.Client) (int, string, error) {
ctx, cancel := context.WithTimeout(ctx, grpc.RequestTimeout)
defer cancel()
serverPort, serverUrl, err := client.StartJupyterServer(ctx)
if err != nil {
return 0, "", fmt.Errorf("failed to start JupyterLab server: %w", err)
}
return serverPort, serverUrl, nil
}

View file

@ -86,6 +86,7 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo
return cmdutil.NewNoResultsError("no codespaces found")
}
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
tp := utils.NewTablePrinter(a.io)
if tp.IsTTY() {
tp.AddField("NAME", nil, nil)

View file

@ -37,7 +37,7 @@ import (
// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
// panic("mock out the GetCodespaceRepositoryContents method")
// },
// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) {
// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) {
// panic("mock out the GetCodespacesMachines method")
// },
// GetOrgMemberCodespaceFunc: func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error) {
@ -87,7 +87,7 @@ type apiClientMock struct {
GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
// GetCodespacesMachinesFunc mocks the GetCodespacesMachines method.
GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error)
GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error)
// GetOrgMemberCodespaceFunc mocks the GetOrgMemberCodespace method.
GetOrgMemberCodespaceFunc func(ctx context.Context, orgName string, userName string, codespaceName string) (*api.Codespace, error)
@ -180,6 +180,8 @@ type apiClientMock struct {
Branch string
// Location is the location argument value.
Location string
// DevcontainerPath is the devcontainerPath argument value.
DevcontainerPath string
}
// GetOrgMemberCodespace holds details about calls to the GetOrgMemberCodespace method.
GetOrgMemberCodespace []struct {
@ -522,41 +524,45 @@ func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct {
}
// GetCodespacesMachines calls GetCodespacesMachinesFunc.
func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) {
func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location string, devcontainerPath string) ([]*api.Machine, error) {
if mock.GetCodespacesMachinesFunc == nil {
panic("apiClientMock.GetCodespacesMachinesFunc: method is nil but apiClient.GetCodespacesMachines was just called")
}
callInfo := struct {
Ctx context.Context
RepoID int
Branch string
Location string
Ctx context.Context
RepoID int
Branch string
Location string
DevcontainerPath string
}{
Ctx: ctx,
RepoID: repoID,
Branch: branch,
Location: location,
Ctx: ctx,
RepoID: repoID,
Branch: branch,
Location: location,
DevcontainerPath: devcontainerPath,
}
mock.lockGetCodespacesMachines.Lock()
mock.calls.GetCodespacesMachines = append(mock.calls.GetCodespacesMachines, callInfo)
mock.lockGetCodespacesMachines.Unlock()
return mock.GetCodespacesMachinesFunc(ctx, repoID, branch, location)
return mock.GetCodespacesMachinesFunc(ctx, repoID, branch, location, devcontainerPath)
}
// GetCodespacesMachinesCalls gets all the calls that were made to GetCodespacesMachines.
// Check the length with:
// len(mockedapiClient.GetCodespacesMachinesCalls())
func (mock *apiClientMock) GetCodespacesMachinesCalls() []struct {
Ctx context.Context
RepoID int
Branch string
Location string
Ctx context.Context
RepoID int
Branch string
Location string
DevcontainerPath string
} {
var calls []struct {
Ctx context.Context
RepoID int
Branch string
Location string
Ctx context.Context
RepoID int
Branch string
Location string
DevcontainerPath string
}
mock.lockGetCodespacesMachines.RLock()
calls = mock.calls.GetCodespacesMachines

View file

@ -103,6 +103,7 @@ func (a *App) ListPorts(ctx context.Context, codespaceName string, exporter cmdu
}
cs := a.io.ColorScheme()
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
tp := utils.NewTablePrinter(a.io)
if tp.IsTTY() {

View file

@ -0,0 +1,56 @@
package codespace
import (
"context"
"fmt"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/spf13/cobra"
)
func newRebuildCmd(app *App) *cobra.Command {
var codespace string
rebuildCmd := &cobra.Command{
Use: "rebuild",
Short: "Rebuild a codespace",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return app.Rebuild(cmd.Context(), codespace)
},
}
rebuildCmd.Flags().StringVarP(&codespace, "codespace", "c", "", "Name of the codespace")
return rebuildCmd
}
func (a *App) Rebuild(ctx context.Context, codespaceName string) (err error) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
return err
}
// There's no need to rebuild again because users can't modify their codespace while it rebuilds
if codespace.State == api.CodespaceStateRebuilding {
fmt.Fprintf(a.io.Out, "%s is already rebuilding\n", codespace.Name)
return nil
}
session, err := startLiveShareSession(ctx, codespace, a, false, "")
if err != nil {
return fmt.Errorf("starting Live Share session: %w", err)
}
defer safeClose(session, &err)
err = session.RebuildContainer(ctx)
if err != nil {
return fmt.Errorf("rebuilding codespace via session: %w", err)
}
fmt.Fprintf(a.io.Out, "%s is rebuilding\n", codespace.Name)
return nil
}

View file

@ -0,0 +1,36 @@
package codespace
import (
"context"
"testing"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/iostreams"
)
func TestAlreadyRebuildingCodespace(t *testing.T) {
rebuildingCodespace := &api.Codespace{
Name: "rebuildingCodespace",
State: api.CodespaceStateRebuilding,
}
app := testingRebuildApp(*rebuildingCodespace)
err := app.Rebuild(context.Background(), "rebuildingCodespace")
if err != nil {
t.Errorf("rebuilding a codespace that was already rebuilding: %v", err)
}
}
func testingRebuildApp(mockCodespace api.Codespace) *App {
apiMock := &apiClientMock{
GetCodespaceFunc: func(_ context.Context, name string, _ bool) (*api.Codespace, error) {
if name == mockCodespace.Name {
return &mockCodespace, nil
}
return nil, nil
},
}
ios, _, _, _ := iostreams.Test()
return NewApp(ios, nil, apiMock, nil)
}

View file

@ -22,6 +22,7 @@ func NewRootCmd(app *App) *cobra.Command {
root.AddCommand(newCpCmd(app))
root.AddCommand(newStopCmd(app))
root.AddCommand(newSelectCmd(app))
root.AddCommand(newRebuildCmd(app))
return root
}

View file

@ -50,6 +50,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
return cmdutil.NewNoResultsError("no installed extensions found")
}
cs := io.ColorScheme()
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
t := utils.NewTablePrinter(io)
for _, c := range cmds {
var repo string
@ -124,6 +125,9 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
} else if errors.Is(err, commitNotFoundErr) {
return fmt.Errorf("%s %s does not exist in %s",
cs.FailureIcon(), cs.Cyan(pinFlag), args[0])
} else if errors.Is(err, repositoryNotFoundErr) {
return fmt.Errorf("%s Could not find extension '%s' on host %s",
cs.FailureIcon(), args[0], repo.RepoHost())
}
return err
}

View file

@ -87,6 +87,25 @@ func TestNewCmdExtension(t *testing.T) {
}
},
},
{
name: "error extension not found",
args: []string{"install", "owner/gh-some-ext"},
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
em.ListFunc = func() []extensions.Extension {
return []extensions.Extension{}
}
em.InstallFunc = func(_ ghrepo.Interface, _ string) error {
return repositoryNotFoundErr
}
return func(t *testing.T) {
installCalls := em.InstallCalls()
assert.Equal(t, 1, len(installCalls))
assert.Equal(t, "gh-some-ext", installCalls[0].InterfaceMoqParam.RepoName())
}
},
wantErr: true,
errMsg: "X Could not find extension 'owner/gh-some-ext' on host github.com",
},
{
name: "install local extension with pin",
args: []string{"install", ".", "--pin", "v1.0.0"},

View file

@ -13,32 +13,54 @@ import (
"github.com/cli/cli/v2/internal/ghrepo"
)
func hasScript(httpClient *http.Client, repo ghrepo.Interface) (hs bool, err error) {
func repoExists(httpClient *http.Client, repo ghrepo.Interface) (bool, error) {
url := fmt.Sprintf("%srepos/%s/%s", ghinstance.RESTPrefix(repo.RepoHost()), repo.RepoOwner(), repo.RepoName())
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return false, err
}
resp, err := httpClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case 200:
return true, nil
case 404:
return false, nil
default:
return false, api.HandleHTTPError(resp)
}
}
func hasScript(httpClient *http.Client, repo ghrepo.Interface) (bool, error) {
path := fmt.Sprintf("repos/%s/%s/contents/%s",
repo.RepoOwner(), repo.RepoName(), repo.RepoName())
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return
return false, err
}
resp, err := httpClient.Do(req)
if err != nil {
return
return false, err
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return
return false, nil
}
if resp.StatusCode > 299 {
err = api.HandleHTTPError(resp)
return
return false, err
}
hs = true
return
return true, nil
}
type releaseAsset struct {
@ -80,8 +102,9 @@ func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string)
return err
}
var releaseNotFoundErr = errors.New("release not found")
var commitNotFoundErr = errors.New("commit not found")
var releaseNotFoundErr = errors.New("release not found")
var repositoryNotFoundErr = errors.New("repository not found")
// fetchLatestRelease finds the latest published release for a repository.
func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) {
@ -98,6 +121,9 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re
}
defer resp.Body.Close()
if resp.StatusCode == 404 {
return nil, releaseNotFoundErr
}
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
@ -162,7 +188,7 @@ func fetchCommitSHA(httpClient *http.Client, baseRepo ghrepo.Interface, targetRe
return "", err
}
req.Header.Set("Accept", "application/vnd.github.VERSION.sha")
req.Header.Set("Accept", "application/vnd.github.v3.sha")
resp, err := httpClient.Do(req)
if err != nil {
return "", err

View file

@ -2,6 +2,7 @@ package extension
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
@ -339,7 +340,15 @@ type binManifest struct {
func (m *Manager) Install(repo ghrepo.Interface, target string) error {
isBin, err := isBinExtension(m.client, repo)
if err != nil {
return fmt.Errorf("could not check for binary extension: %w", err)
if errors.Is(err, releaseNotFoundErr) {
if ok, err := repoExists(m.client, repo); err != nil {
return err
} else if !ok {
return repositoryNotFoundErr
}
} else {
return fmt.Errorf("could not check for binary extension: %w", err)
}
}
if isBin {
return m.installBin(repo, target)
@ -760,11 +769,6 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err
var r *release
r, err = fetchLatestRelease(client, repo)
if err != nil {
httpErr, ok := err.(api.HTTPError)
if ok && httpErr.StatusCode == 404 {
err = nil
return
}
return
}
@ -786,7 +790,8 @@ func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err
}
func repoFromPath(path string) (ghrepo.Interface, error) {
remotes, err := git.RemotesForPath(path)
gitClient := &git.Client{RepoDir: path}
remotes, err := gitClient.Remotes(context.Background())
if err != nil {
return nil, err
}

View file

@ -844,6 +844,32 @@ func TestManager_Install_binary(t *testing.T) {
assert.Equal(t, "", stderr.String())
}
func TestManager_repo_not_found(t *testing.T) {
repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com")
reg := httpmock.Registry{}
defer reg.Verify(t)
reg.Register(
httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"),
httpmock.StatusStringResponse(404, `{}`))
reg.Register(
httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext"),
httpmock.StatusStringResponse(404, `{}`))
ios, _, stdout, stderr := iostreams.Test()
tempDir := t.TempDir()
m := newTestManager(tempDir, &http.Client{Transport: &reg}, ios)
if err := m.Install(repo, ""); err != repositoryNotFoundErr {
t.Errorf("expected repositoryNotFoundErr, got: %v", err)
}
assert.Equal(t, "", stdout.String())
assert.Equal(t, "", stderr.String())
}
func TestManager_Create(t *testing.T) {
chdirTemp(t)
ios, _, stdout, stderr := iostreams.Test()

View file

@ -1,6 +1,7 @@
package factory
import (
"context"
"fmt"
"net/http"
"os"
@ -8,7 +9,7 @@ import (
"time"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/context"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/config"
@ -25,17 +26,18 @@ var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`)
func New(appVersion string) *cmdutil.Factory {
f := &cmdutil.Factory{
Config: configFunc(), // No factory dependencies
Branch: branchFunc(), // No factory dependencies
ExecutableName: "gh",
}
f.IOStreams = ioStreams(f) // Depends on Config
f.HttpClient = httpClientFunc(f, appVersion) // Depends on Config, IOStreams, and appVersion
f.Remotes = remotesFunc(f) // Depends on Config
f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable
f.Remotes = remotesFunc(f) // Depends on Config, and GitClient
f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes
f.Prompter = newPrompter(f) // Depends on Config and IOStreams
f.Browser = newBrowser(f) // Depends on Config, and IOStreams
f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams
f.Branch = branchFunc(f) // Depends on GitClient
return f
}
@ -63,7 +65,7 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
if err != nil {
return nil, err
}
repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "")
repoContext, err := ghContext.ResolveRemotesToRepos(remotes, apiClient, "")
if err != nil {
return nil, err
}
@ -76,10 +78,12 @@ func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
}
}
func remotesFunc(f *cmdutil.Factory) func() (context.Remotes, error) {
func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) {
rr := &remoteResolver{
readRemotes: git.Remotes,
getConfig: f.Config,
readRemotes: func() (git.RemoteSet, error) {
return f.GitClient.Remotes(context.Background())
},
getConfig: f.Config,
}
return rr.Resolver()
}
@ -106,6 +110,18 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client,
}
}
func newGitClient(f *cmdutil.Factory) *git.Client {
io := f.IOStreams
ghPath := f.Executable()
client := &git.Client{
GhPath: ghPath,
Stderr: io.ErrOut,
Stdin: io.In,
Stdout: io.Out,
}
return client
}
func newBrowser(f *cmdutil.Factory) browser.Browser {
io := f.IOStreams
return browser.New("", io.Out, io.ErrOut)
@ -129,9 +145,9 @@ func configFunc() func() (config.Config, error) {
}
}
func branchFunc() func() (string, error) {
func branchFunc(f *cmdutil.Factory) func() (string, error) {
return func() (string, error) {
currentBranch, err := git.CurrentBranch()
currentBranch, err := f.GitClient.CurrentBranch(context.Background())
if err != nil {
return "", fmt.Errorf("could not determine current branch: %w", err)
}

View file

@ -4,6 +4,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"github.com/cli/cli/v2/git"
@ -435,6 +436,44 @@ func TestSSOURL(t *testing.T) {
}
}
func TestNewGitClient(t *testing.T) {
tests := []struct {
name string
config config.Config
executable string
wantAuthHosts []string
wantGhPath string
}{
{
name: "creates git client",
config: defaultConfig(),
executable: filepath.Join("path", "to", "gh"),
wantAuthHosts: []string{"nonsense.com"},
wantGhPath: filepath.Join("path", "to", "gh"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := New("1")
f.Config = func() (config.Config, error) {
if tt.config == nil {
return config.NewBlankConfig(), nil
} else {
return tt.config, nil
}
}
f.ExecutableName = tt.executable
ios, _, _, _ := iostreams.Test()
f.IOStreams = ios
c := newGitClient(f)
assert.Equal(t, tt.wantGhPath, c.GhPath)
assert.Equal(t, ios.In, c.Stdin)
assert.Equal(t, ios.Out, c.Stdout)
assert.Equal(t, ios.ErrOut, c.Stderr)
})
}
}
func defaultConfig() *config.ConfigMock {
cfg := config.NewFromString("")
cfg.Set("nonsense.com", "oauth_token", "BLAH")

View file

@ -1,6 +1,7 @@
package clone
import (
"context"
"fmt"
"net/http"
@ -16,6 +17,7 @@ import (
type CloneOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (config.Config, error)
IO *iostreams.IOStreams
@ -28,6 +30,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm
opts := &CloneOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
}
@ -84,7 +87,7 @@ func cloneRun(opts *CloneOptions) error {
gistURL = formatRemoteURL(hostname, gistURL, protocol)
}
_, err := git.RunClone(gistURL, opts.GitArgs)
_, err := opts.GitClient.Clone(context.Background(), gistURL, opts.GitArgs)
if err != nil {
return err
}

View file

@ -5,6 +5,7 @@ import (
"strings"
"testing"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -25,6 +26,7 @@ func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error)
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
GitClient: &git.Client{GitPath: "some/path/git"},
}
cmd := NewCmdClone(fac, nil)

View file

@ -95,6 +95,7 @@ func listRun(opts *ListOptions) error {
cs := opts.IO.ColorScheme()
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
tp := utils.NewTablePrinter(opts.IO)
for _, gist := range gists {

View file

@ -0,0 +1,101 @@
package delete
import (
"fmt"
"net/http"
"strconv"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type DeleteOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
HttpClient func() (*http.Client, error)
KeyID string
Confirmed bool
Prompter prompter.Prompter
}
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
opts := &DeleteOptions{
HttpClient: f.HttpClient,
Config: f.Config,
IO: f.IOStreams,
Prompter: f.Prompter,
}
cmd := &cobra.Command{
Use: "delete <key-id>",
Short: "Delete a GPG key from your GitHub account",
Args: cmdutil.ExactArgs(1, "cannot delete: key id required"),
RunE: func(cmd *cobra.Command, args []string) error {
opts.KeyID = args[0]
if !opts.IO.CanPrompt() && !opts.Confirmed {
return cmdutil.FlagErrorf("--confirm required when not running interactively")
}
if runF != nil {
return runF(opts)
}
return deleteRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.Confirmed, "confirm", "y", false, "Skip the confirmation prompt")
return cmd
}
func deleteRun(opts *DeleteOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
cfg, err := opts.Config()
if err != nil {
return err
}
host, _ := cfg.DefaultHost()
gpgKeys, err := getGPGKeys(httpClient, host)
if err != nil {
return err
}
id := ""
for _, gpgKey := range gpgKeys {
if gpgKey.KeyID == opts.KeyID {
id = strconv.Itoa(gpgKey.ID)
break
}
}
if id == "" {
return fmt.Errorf("unable to delete GPG key %s: either the GPG key is not found or it is not owned by you", opts.KeyID)
}
if !opts.Confirmed {
if err := opts.Prompter.ConfirmDeletion(opts.KeyID); err != nil {
return err
}
}
err = deleteGPGKey(httpClient, host, id)
if err != nil {
return nil
}
if opts.IO.IsStdoutTTY() {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.Out, "%s GPG key %s deleted from your account\n", cs.SuccessIcon(), opts.KeyID)
}
return nil
}

View file

@ -0,0 +1,210 @@
package delete
import (
"bytes"
"net/http"
"testing"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/prompter"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdDelete(t *testing.T) {
tests := []struct {
name string
tty bool
input string
output DeleteOptions
wantErr bool
wantErrMsg string
}{
{
name: "tty",
tty: true,
input: "ABC123",
output: DeleteOptions{KeyID: "ABC123", Confirmed: false},
},
{
name: "confirm flag tty",
tty: true,
input: "ABC123 --confirm",
output: DeleteOptions{KeyID: "ABC123", Confirmed: true},
},
{
name: "shorthand confirm flag tty",
tty: true,
input: "ABC123 -y",
output: DeleteOptions{KeyID: "ABC123", Confirmed: true},
},
{
name: "no tty",
input: "ABC123",
wantErr: true,
wantErrMsg: "--confirm required when not running interactively",
},
{
name: "confirm flag no tty",
input: "ABC123 --confirm",
output: DeleteOptions{KeyID: "ABC123", Confirmed: true},
},
{
name: "shorthand confirm flag no tty",
input: "ABC123 -y",
output: DeleteOptions{KeyID: "ABC123", Confirmed: true},
},
{
name: "no args",
input: "",
wantErr: true,
wantErrMsg: "cannot delete: key id required",
},
{
name: "too many args",
input: "ABC123 XYZ",
wantErr: true,
wantErrMsg: "too many arguments",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ios, _, _, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
ios.SetStdoutTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: ios,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var cmdOpts *DeleteOptions
cmd := NewCmdDelete(f, func(opts *DeleteOptions) error {
cmdOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantErr {
assert.Error(t, err)
assert.EqualError(t, err, tt.wantErrMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.KeyID, cmdOpts.KeyID)
assert.Equal(t, tt.output.Confirmed, cmdOpts.Confirmed)
})
}
}
func Test_deleteRun(t *testing.T) {
keysResp := "[{\"id\":123,\"key_id\":\"ABC123\"}]"
tests := []struct {
name string
tty bool
opts DeleteOptions
httpStubs func(*httpmock.Registry)
prompterStubs func(*prompter.PrompterMock)
wantStdout string
wantErr bool
wantErrMsg string
}{
{
name: "delete tty",
tty: true,
opts: DeleteOptions{KeyID: "ABC123", Confirmed: false},
prompterStubs: func(pm *prompter.PrompterMock) {
pm.ConfirmDeletionFunc = func(_ string) error {
return nil
}
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp))
reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusStringResponse(204, ""))
},
wantStdout: "✓ GPG key ABC123 deleted from your account\n",
},
{
name: "delete with confirm flag tty",
tty: true,
opts: DeleteOptions{KeyID: "ABC123", Confirmed: true},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp))
reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusStringResponse(204, ""))
},
wantStdout: "✓ GPG key ABC123 deleted from your account\n",
},
{
name: "not found tty",
tty: true,
opts: DeleteOptions{KeyID: "ABC123", Confirmed: true},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, "[]"))
},
wantErr: true,
wantErrMsg: "unable to delete GPG key ABC123: either the GPG key is not found or it is not owned by you",
},
{
name: "delete no tty",
opts: DeleteOptions{KeyID: "ABC123", Confirmed: true},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, keysResp))
reg.Register(httpmock.REST("DELETE", "user/gpg_keys/123"), httpmock.StatusStringResponse(204, ""))
},
wantStdout: "",
},
{
name: "not found no tty",
opts: DeleteOptions{KeyID: "ABC123", Confirmed: true},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "user/gpg_keys"), httpmock.StatusStringResponse(200, "[]"))
},
wantErr: true,
wantErrMsg: "unable to delete GPG key ABC123: either the GPG key is not found or it is not owned by you",
},
}
for _, tt := range tests {
pm := &prompter.PrompterMock{}
if tt.prompterStubs != nil {
tt.prompterStubs(pm)
}
tt.opts.Prompter = pm
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
tt.opts.Config = func() (config.Config, error) {
return config.NewBlankConfig(), nil
}
ios, _, stdout, _ := iostreams.Test()
ios.SetStdinTTY(tt.tty)
ios.SetStdoutTTY(tt.tty)
tt.opts.IO = ios
t.Run(tt.name, func(t *testing.T) {
err := deleteRun(&tt.opts)
reg.Verify(t)
if tt.wantErr {
assert.Error(t, err)
assert.EqualError(t, err, tt.wantErrMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantStdout, stdout.String())
})
}
}

View file

@ -0,0 +1,68 @@
package delete
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
)
type gpgKey struct {
ID int
KeyID string `json:"key_id"`
}
func deleteGPGKey(httpClient *http.Client, host, id string) error {
url := fmt.Sprintf("%suser/gpg_keys/%s", ghinstance.RESTPrefix(host), id)
req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return err
}
resp, err := httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return api.HandleHTTPError(resp)
}
return nil
}
func getGPGKeys(httpClient *http.Client, host string) ([]gpgKey, error) {
resource := "user/gpg_keys"
url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(host), resource, 100)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var keys []gpgKey
err = json.Unmarshal(b, &keys)
if err != nil {
return nil, err
}
return keys, nil
}

View file

@ -2,6 +2,7 @@ package key
import (
cmdAdd "github.com/cli/cli/v2/pkg/cmd/gpg-key/add"
cmdDelete "github.com/cli/cli/v2/pkg/cmd/gpg-key/delete"
cmdList "github.com/cli/cli/v2/pkg/cmd/gpg-key/list"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
@ -14,8 +15,9 @@ func NewCmdGPGKey(f *cmdutil.Factory) *cobra.Command {
Long: "Manage GPG keys registered with your GitHub account.",
}
cmd.AddCommand(cmdList.NewCmdList(f, nil))
cmd.AddCommand(cmdAdd.NewCmdAdd(f, nil))
cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))
cmd.AddCommand(cmdList.NewCmdList(f, nil))
return cmd
}

View file

@ -71,6 +71,7 @@ func listRun(opts *ListOptions) error {
return cmdutil.NewNoResultsError("no GPG keys present in the GitHub account")
}
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
t := utils.NewTablePrinter(opts.IO)
cs := opts.IO.ColorScheme()
now := time.Now()

View file

@ -40,7 +40,11 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
if err != nil {
return nil, nil, err
}
return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], []string{"id", "url"})
fields := []string{"id", "url"}
if opts.EditLast {
fields = append(fields, "comments")
}
return issueShared.IssueFromArgWithFields(httpClient, f.BaseRepo, args[0], fields)
}
return prShared.CommentablePreRun(cmd, opts)
},
@ -64,6 +68,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) e
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
return cmd
}

View file

@ -8,6 +8,7 @@ import (
"path/filepath"
"testing"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
@ -200,7 +201,7 @@ func Test_commentRun(t *testing.T) {
InputType: 0,
Body: "",
InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@ -208,6 +209,22 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "interactive editor with edit last",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
EditLast: true,
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
{
name: "non-interactive web",
input: &shared.CommentableOptions{
@ -219,6 +236,18 @@ func Test_commentRun(t *testing.T) {
},
stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive web with edit last",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
OpenInBrowser: func(string) error { return nil },
},
stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n",
},
{
name: "non-interactive editor",
input: &shared.CommentableOptions{
@ -226,13 +255,28 @@ func Test_commentRun(t *testing.T) {
InputType: shared.InputTypeEditor,
Body: "",
EditSurvey: func() (string, error) { return "comment body", nil },
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "non-interactive editor with edit last",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
{
name: "non-interactive inline",
input: &shared.CommentableOptions{
@ -245,6 +289,19 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
},
{
name: "non-interactive inline with edit last",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
Body: "comment body",
EditLast: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-111\n",
},
}
for _, tt := range tests {
ios, _, stdout, stderr := iostreams.Test()
@ -263,7 +320,14 @@ func Test_commentRun(t *testing.T) {
return &http.Client{Transport: reg}, nil
}
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return &mockCommentable{}, ghrepo.New("OWNER", "REPO"), nil
return &api.Issue{
ID: "ISSUE-ID",
URL: "https://github.com/OWNER/REPO/issues/123",
Comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.Author{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-111", ViewerDidAuthor: true},
{ID: "id2", Author: api.Author{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/issues/123#issuecomment-222"},
}},
}, ghrepo.New("OWNER", "REPO"), nil
}
t.Run(tt.name, func(t *testing.T) {
@ -275,15 +339,6 @@ func Test_commentRun(t *testing.T) {
}
}
type mockCommentable struct{}
func (c mockCommentable) Identifier() string {
return "ISSUE-ID"
}
func (c mockCommentable) Link() string {
return "https://github.com/OWNER/REPO/issues/123"
}
func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentCreate\b`),
@ -297,3 +352,17 @@ func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
}),
)
}
func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentUpdate\b`),
httpmock.GraphQLMutation(`
{ "data": { "updateIssueComment": { "issueComment": {
"url": "https://github.com/OWNER/REPO/issues/123#issuecomment-111"
} } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "id1", inputs["id"])
assert.Equal(t, "comment body", inputs["body"])
}),
)
}

View file

@ -185,6 +185,9 @@ func listRun(opts *ListOptions) error {
if err != nil {
return err
}
if len(listResult.Issues) == 0 && opts.Exporter == nil {
return prShared.ListNoResults(ghrepo.FullName(baseRepo), "issue", !filterOptions.IsDefault())
}
if err := opts.IO.StartPager(); err == nil {
defer opts.IO.StopPager()
@ -199,9 +202,6 @@ func listRun(opts *ListOptions) error {
if listResult.SearchCapped {
fmt.Fprintln(opts.IO.ErrOut, "warning: this query uses the Search API which is capped at 1000 results maximum")
}
if len(listResult.Issues) == 0 {
return prShared.ListNoResults(ghrepo.FullName(baseRepo), "issue", !filterOptions.IsDefault())
}
if isTerminal {
title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, !filterOptions.IsDefault())
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title)

View file

@ -15,6 +15,7 @@ import (
func PrintIssues(io *iostreams.IOStreams, now time.Time, prefix string, totalCount int, issues []api.Issue) {
cs := io.ColorScheme()
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
table := utils.NewTablePrinter(io)
for _, issue := range issues {
issueNum := strconv.Itoa(issue.Number)

View file

@ -134,6 +134,7 @@ func listRun(opts *listOptions) error {
func printLabels(io *iostreams.IOStreams, labels []label) error {
cs := io.ColorScheme()
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
table := utils.NewTablePrinter(io)
for _, label := range labels {

View file

@ -1,30 +1,28 @@
package checkout
import (
"context"
"fmt"
"net/http"
"os"
"os/exec"
"strings"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/context"
cliContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/safeexec"
"github.com/spf13/cobra"
)
type CheckoutOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (config.Config, error)
IO *iostreams.IOStreams
Remotes func() (context.Remotes, error)
Remotes func() (cliContext.Remotes, error)
Branch func() (string, error)
Finder shared.PRFinder
@ -40,6 +38,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
opts := &CheckoutOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
@ -127,11 +126,11 @@ func checkoutRun(opts *CheckoutOptions) error {
}
if opts.RecurseSubmodules {
cmdQueue = append(cmdQueue, []string{"git", "submodule", "sync", "--recursive"})
cmdQueue = append(cmdQueue, []string{"git", "submodule", "update", "--init", "--recursive"})
cmdQueue = append(cmdQueue, []string{"submodule", "sync", "--recursive"})
cmdQueue = append(cmdQueue, []string{"submodule", "update", "--init", "--recursive"})
}
err = executeCmds(cmdQueue)
err = executeCmds(opts.GitClient, cmdQueue)
if err != nil {
return err
}
@ -139,7 +138,7 @@ func checkoutRun(opts *CheckoutOptions) error {
return nil
}
func cmdsForExistingRemote(remote *context.Remote, pr *api.PullRequest, opts *CheckoutOptions) [][]string {
func cmdsForExistingRemote(remote *cliContext.Remote, pr *api.PullRequest, opts *CheckoutOptions) [][]string {
var cmds [][]string
remoteBranch := fmt.Sprintf("%s/%s", remote.Name, pr.HeadRefName)
@ -148,7 +147,7 @@ func cmdsForExistingRemote(remote *context.Remote, pr *api.PullRequest, opts *Ch
refSpec += fmt.Sprintf(":refs/remotes/%s", remoteBranch)
}
cmds = append(cmds, []string{"git", "fetch", remote.Name, refSpec})
cmds = append(cmds, []string{"fetch", remote.Name, refSpec})
localBranch := pr.HeadRefName
if opts.BranchName != "" {
@ -157,17 +156,17 @@ func cmdsForExistingRemote(remote *context.Remote, pr *api.PullRequest, opts *Ch
switch {
case opts.Detach:
cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"})
case localBranchExists(localBranch):
cmds = append(cmds, []string{"git", "checkout", localBranch})
cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"})
case localBranchExists(opts.GitClient, localBranch):
cmds = append(cmds, []string{"checkout", localBranch})
if opts.Force {
cmds = append(cmds, []string{"git", "reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
cmds = append(cmds, []string{"reset", "--hard", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
} else {
// TODO: check if non-fast-forward and suggest to use `--force`
cmds = append(cmds, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
cmds = append(cmds, []string{"merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
}
default:
cmds = append(cmds, []string{"git", "checkout", "-b", localBranch, "--track", remoteBranch})
cmds = append(cmds, []string{"checkout", "-b", localBranch, "--track", remoteBranch})
}
return cmds
@ -178,8 +177,8 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB
ref := fmt.Sprintf("refs/pull/%d/head", pr.Number)
if opts.Detach {
cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref})
cmds = append(cmds, []string{"git", "checkout", "--detach", "FETCH_HEAD"})
cmds = append(cmds, []string{"fetch", baseURLOrName, ref})
cmds = append(cmds, []string{"checkout", "--detach", "FETCH_HEAD"})
return cmds
}
@ -194,22 +193,22 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB
currentBranch, _ := opts.Branch()
if localBranch == currentBranch {
// PR head matches currently checked out branch
cmds = append(cmds, []string{"git", "fetch", baseURLOrName, ref})
cmds = append(cmds, []string{"fetch", baseURLOrName, ref})
if opts.Force {
cmds = append(cmds, []string{"git", "reset", "--hard", "FETCH_HEAD"})
cmds = append(cmds, []string{"reset", "--hard", "FETCH_HEAD"})
} else {
// TODO: check if non-fast-forward and suggest to use `--force`
cmds = append(cmds, []string{"git", "merge", "--ff-only", "FETCH_HEAD"})
cmds = append(cmds, []string{"merge", "--ff-only", "FETCH_HEAD"})
}
} else {
if opts.Force {
cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"})
cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch), "--force"})
} else {
// TODO: check if non-fast-forward and suggest to use `--force`
cmds = append(cmds, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)})
cmds = append(cmds, []string{"fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, localBranch)})
}
cmds = append(cmds, []string{"git", "checkout", localBranch})
cmds = append(cmds, []string{"checkout", localBranch})
}
remote := baseURLOrName
@ -219,39 +218,36 @@ func cmdsForMissingRemote(pr *api.PullRequest, baseURLOrName, repoHost, defaultB
remote = ghrepo.FormatRemoteURL(headRepo, protocol)
mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName)
}
if missingMergeConfigForBranch(localBranch) {
if missingMergeConfigForBranch(opts.GitClient, localBranch) {
// .remote is needed for `git pull` to work
// .pushRemote is needed for `git push` to work, if user has set `remote.pushDefault`.
// see https://git-scm.com/docs/git-config#Documentation/git-config.txt-branchltnamegtremote
cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.remote", localBranch), remote})
cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote})
cmds = append(cmds, []string{"git", "config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef})
cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.remote", localBranch), remote})
cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.pushRemote", localBranch), remote})
cmds = append(cmds, []string{"config", fmt.Sprintf("branch.%s.merge", localBranch), mergeRef})
}
return cmds
}
func missingMergeConfigForBranch(b string) bool {
mc, err := git.Config(fmt.Sprintf("branch.%s.merge", b))
func missingMergeConfigForBranch(client *git.Client, b string) bool {
mc, err := client.Config(context.Background(), fmt.Sprintf("branch.%s.merge", b))
return err != nil || mc == ""
}
func localBranchExists(b string) bool {
_, err := git.ShowRefs("refs/heads/" + b)
func localBranchExists(client *git.Client, b string) bool {
_, err := client.ShowRefs(context.Background(), "refs/heads/"+b)
return err == nil
}
func executeCmds(cmdQueue [][]string) error {
func executeCmds(client *git.Client, cmdQueue [][]string) error {
for _, args := range cmdQueue {
// TODO: reuse the result of this lookup across loop iteration
exe, err := safeexec.LookPath(args[0])
//TODO: Use AuthenticatedCommand
cmd, err := client.Command(context.Background(), args...)
if err != nil {
return err
}
cmd := exec.Command(exe, args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := run.PrepareCmd(cmd).Run(); err != nil {
if err := cmd.Run(); err != nil {
return err
}
}

View file

@ -197,6 +197,8 @@ func Test_checkoutRun(t *testing.T) {
return remotes, nil
}
opts.GitClient = &git.Client{GitPath: "some/path/git"}
err := checkoutRun(opts)
if (err != nil) != tt.wantErr {
t.Errorf("want error: %v, got: %v", tt.wantErr, err)
@ -234,6 +236,7 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cl
Branch: func() (string, error) {
return branch, nil
},
GitClient: &git.Client{GitPath: "some/path/git"},
}
cmd := NewCmdCheckout(factory, nil)

View file

@ -24,20 +24,7 @@ type checkCounts struct {
Skipping int
}
func aggregateChecks(pr *api.PullRequest, requiredChecks bool) ([]check, checkCounts, error) {
checks := []check{}
counts := checkCounts{}
if len(pr.StatusCheckRollup.Nodes) == 0 {
return checks, counts, fmt.Errorf("no commit found on the pull request")
}
rollup := pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes
if len(rollup) == 0 {
return checks, counts, fmt.Errorf("no checks reported on the '%s' branch", pr.HeadRefName)
}
checkContexts := pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes
func aggregateChecks(checkContexts []api.CheckContext, requiredChecks bool) (checks []check, counts checkCounts) {
for _, c := range eliminateDuplicates(checkContexts) {
if requiredChecks && !c.IsRequired {
continue
@ -86,12 +73,7 @@ func aggregateChecks(pr *api.PullRequest, requiredChecks bool) ([]check, checkCo
checks = append(checks, item)
}
if len(checks) == 0 && requiredChecks {
return checks, counts, fmt.Errorf("no required checks reported on the '%s' branch", pr.HeadRefName)
}
return checks, counts, nil
return
}
// eliminateDuplicates filters a set of checks to only the most recent ones if the set includes repeated runs

View file

@ -1,6 +1,7 @@
package checks
import (
"errors"
"fmt"
"net/http"
"time"
@ -132,6 +133,15 @@ func checksRun(opts *ChecksOptions) error {
return clientErr
}
var checks []check
var counts checkCounts
var err error
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
if err != nil {
return err
}
if opts.Watch {
opts.IO.StartAlternateScreenBuffer()
} else {
@ -143,23 +153,8 @@ func checksRun(opts *ChecksOptions) error {
}
}
var checks []check
var counts checkCounts
// Do not return err until we can StopAlternateScreenBuffer()
var err error
for {
err = populateStatusChecks(client, repo, pr)
if err != nil {
break
}
checks, counts, err = aggregateChecks(pr, opts.Required)
if err != nil {
break
}
if counts.Pending != 0 && opts.Watch {
opts.IO.RefreshScreen()
cs := opts.IO.ColorScheme()
@ -177,6 +172,11 @@ func checksRun(opts *ChecksOptions) error {
}
time.Sleep(opts.Interval)
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
if err != nil {
break
}
}
opts.IO.StopAlternateScreenBuffer()
@ -200,7 +200,7 @@ func checksRun(opts *ChecksOptions) error {
return nil
}
func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {
func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest, requiredChecks bool) ([]check, checkCounts, error) {
apiClient := api.NewClientFromHTTP(client)
type response struct {
@ -208,7 +208,7 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu
}
query := fmt.Sprintf(`
query PullRequestStatusChecks($id: ID!, $endCursor: String!) {
query PullRequestStatusChecks($id: ID!, $endCursor: String) {
node(id: $id) {
...on PullRequest {
%s
@ -221,18 +221,16 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu
}
statusCheckRollup := api.CheckContexts{}
endCursor := ""
for {
variables["endCursor"] = endCursor
var resp response
err := apiClient.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return err
return nil, checkCounts{}, err
}
if len(resp.Node.StatusCheckRollup.Nodes) == 0 {
return nil
return nil, checkCounts{}, errors.New("no commit found on the pull request")
}
result := resp.Node.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts
@ -244,18 +242,16 @@ func populateStatusChecks(client *http.Client, repo ghrepo.Interface, pr *api.Pu
if !result.PageInfo.HasNextPage {
break
}
endCursor = result.PageInfo.EndCursor
variables["endCursor"] = result.PageInfo.EndCursor
}
statusCheckRollup.PageInfo.HasNextPage = false
if len(statusCheckRollup.Nodes) == 0 {
return nil, checkCounts{}, fmt.Errorf("no checks reported on the '%s' branch", pr.HeadRefName)
}
pr.StatusCheckRollup.Nodes = []api.StatusCheckRollupNode{{
Commit: api.StatusCheckRollupCommit{
StatusCheckRollup: api.CommitStatusCheckRollup{
Contexts: statusCheckRollup,
},
},
}}
return nil
checks, counts := aggregateChecks(statusCheckRollup.Nodes, requiredChecks)
if len(checks) == 0 && requiredChecks {
return checks, counts, fmt.Errorf("no required checks reported on the '%s' branch", pr.HeadRefName)
}
return checks, counts, nil
}

View file

@ -76,6 +76,7 @@ func printSummary(io *iostreams.IOStreams, counts checkCounts) {
}
func printTable(io *iostreams.IOStreams, checks []check) error {
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
tp := utils.NewTablePrinter(io)
sort.Slice(checks, func(i, j int) bool {

View file

@ -1,6 +1,7 @@
package close
import (
"context"
"fmt"
"net/http"
@ -15,6 +16,7 @@ import (
type CloseOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
IO *iostreams.IOStreams
Branch func() (string, error)
@ -30,6 +32,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
opts := &CloseOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Branch: f.Branch,
}
@ -108,9 +111,10 @@ func closeRun(opts *CloseOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Red), pr.Number, pr.Title)
if opts.DeleteBranch {
ctx := context.Background()
branchSwitchString := ""
apiClient := api.NewClientFromHTTP(httpClient)
localBranchExists := git.HasLocalBranch(pr.HeadRefName)
localBranchExists := opts.GitClient.HasLocalBranch(ctx, pr.HeadRefName)
if opts.DeleteLocalBranch {
if localBranchExists {
@ -125,13 +129,13 @@ func closeRun(opts *CloseOptions) error {
if err != nil {
return err
}
err = git.CheckoutBranch(branchToSwitchTo)
err = opts.GitClient.CheckoutBranch(ctx, branchToSwitchTo)
if err != nil {
return err
}
}
if err := git.DeleteLocalBranch(pr.HeadRefName); err != nil {
if err := opts.GitClient.DeleteLocalBranch(ctx, pr.HeadRefName); err != nil {
return fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err)
}

View file

@ -9,6 +9,7 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
@ -72,6 +73,7 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err
Branch: func() (string, error) {
return "trunk", nil
},
GitClient: &git.Client{GitPath: "some/path/git"},
}
cmd := NewCmdClose(factory, nil)

View file

@ -41,11 +41,15 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
if len(args) > 0 {
selector = args[0]
}
fields := []string{"id", "url"}
if opts.EditLast {
fields = append(fields, "comments")
}
finder := shared.NewFinder(f)
opts.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return finder.Find(shared.FindOptions{
Selector: selector,
Fields: []string{"id", "url"},
Fields: fields,
})
}
return shared.CommentablePreRun(cmd, opts)
@ -70,6 +74,7 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
cmd.Flags().StringVarP(&bodyFile, "body-file", "F", "", "Read body text from `file` (use \"-\" to read from standard input)")
cmd.Flags().BoolP("editor", "e", false, "Skip prompts and open the text editor to write the body in")
cmd.Flags().BoolP("web", "w", false, "Open the web browser to write the comment")
cmd.Flags().BoolVar(&opts.EditLast, "edit-last", false, "Edit the last comment of the same author")
return cmd
}

View file

@ -221,7 +221,7 @@ func Test_commentRun(t *testing.T) {
InputType: 0,
Body: "",
InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
@ -229,6 +229,22 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "interactive editor with edit last",
input: &shared.CommentableOptions{
Interactive: true,
InputType: 0,
Body: "",
EditLast: true,
InteractiveEditSurvey: func(string) (string, error) { return "comment body", nil },
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
{
name: "non-interactive web",
input: &shared.CommentableOptions{
@ -240,6 +256,18 @@ func Test_commentRun(t *testing.T) {
},
stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive web with edit last",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeWeb,
Body: "",
EditLast: true,
OpenInBrowser: func(string) error { return nil },
},
stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
name: "non-interactive editor",
input: &shared.CommentableOptions{
@ -247,13 +275,28 @@ func Test_commentRun(t *testing.T) {
InputType: shared.InputTypeEditor,
Body: "",
EditSurvey: func() (string, error) { return "comment body", nil },
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive editor with edit last",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeEditor,
Body: "",
EditLast: true,
EditSurvey: func(string) (string, error) { return "comment body", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
{
name: "non-interactive inline",
input: &shared.CommentableOptions{
@ -266,6 +309,19 @@ func Test_commentRun(t *testing.T) {
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
},
{
name: "non-interactive inline with edit last",
input: &shared.CommentableOptions{
Interactive: false,
InputType: shared.InputTypeInline,
Body: "comment body",
EditLast: true,
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockCommentUpdate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-111\n",
},
}
for _, tt := range tests {
ios, _, stdout, stderr := iostreams.Test()
@ -287,6 +343,10 @@ func Test_commentRun(t *testing.T) {
return &api.PullRequest{
Number: 123,
URL: "https://github.com/OWNER/REPO/pull/123",
Comments: api.Comments{Nodes: []api.Comment{
{ID: "id1", Author: api.Author{Login: "octocat"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-111", ViewerDidAuthor: true},
{ID: "id2", Author: api.Author{Login: "monalisa"}, URL: "https://github.com/OWNER/REPO/pull/123#issuecomment-222"},
}},
}, ghrepo.New("OWNER", "REPO"), nil
}
@ -311,3 +371,17 @@ func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
}),
)
}
func mockCommentUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentUpdate\b`),
httpmock.GraphQLMutation(`
{ "data": { "updateIssueComment": { "issueComment": {
"url": "https://github.com/OWNER/REPO/pull/123#issuecomment-111"
} } } }`,
func(inputs map[string]interface{}) {
assert.Equal(t, "id1", inputs["id"])
assert.Equal(t, "comment body", inputs["body"])
}),
)
}

View file

@ -1,6 +1,7 @@
package create
import (
"context"
"errors"
"fmt"
"net/http"
@ -12,7 +13,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/context"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/config"
@ -32,9 +33,10 @@ type iprompter interface {
type CreateOptions struct {
// This struct stores user input and factory functions
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (config.Config, error)
IO *iostreams.IOStreams
Remotes func() (context.Remotes, error)
Remotes func() (ghContext.Remotes, error)
Branch func() (string, error)
Browser browser.Browser
Prompter iprompter
@ -68,22 +70,24 @@ type CreateOptions struct {
type CreateContext struct {
// This struct stores contextual data about the creation process and is for building up enough
// data to create a pull request
RepoContext *context.ResolvedRemotes
RepoContext *ghContext.ResolvedRemotes
BaseRepo *api.Repository
HeadRepo ghrepo.Interface
BaseTrackingBranch string
BaseBranch string
HeadBranch string
HeadBranchLabel string
HeadRemote *context.Remote
HeadRemote *ghContext.Remote
IsPushEnabled bool
Client *api.Client
GitClient *git.Client
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := &CreateOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
@ -369,15 +373,16 @@ func createRun(opts *CreateOptions) (err error) {
func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error {
baseRef := ctx.BaseTrackingBranch
headRef := ctx.HeadBranch
gitClient := ctx.GitClient
commits, err := git.Commits(baseRef, headRef)
commits, err := gitClient.Commits(context.Background(), baseRef, headRef)
if err != nil {
return err
}
if len(commits) == 1 {
state.Title = commits[0].Title
body, err := git.CommitBody(commits[0].Sha)
body, err := gitClient.CommitBody(context.Background(), commits[0].Sha)
if err != nil {
return err
}
@ -395,11 +400,11 @@ func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) e
return nil
}
func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef {
func determineTrackingBranch(gitClient *git.Client, remotes ghContext.Remotes, headBranch string) *git.TrackingRef {
refsForLookup := []string{"HEAD"}
var trackingRefs []git.TrackingRef
headBranchConfig := git.ReadBranchConfig(headBranch)
headBranchConfig := gitClient.ReadBranchConfig(context.Background(), headBranch)
if headBranchConfig.RemoteName != "" {
tr := git.TrackingRef{
RemoteName: headBranchConfig.RemoteName,
@ -418,7 +423,7 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr
refsForLookup = append(refsForLookup, tr.String())
}
resolvedRefs, _ := git.ShowRefs(refsForLookup...)
resolvedRefs, _ := gitClient.ShowRefs(context.Background(), refsForLookup...)
if len(resolvedRefs) > 1 {
for _, r := range resolvedRefs[1:] {
if r.Hash != resolvedRefs[0].Hash {
@ -480,7 +485,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
return nil, err
}
repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
repoContext, err := ghContext.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
if err != nil {
return nil, err
}
@ -515,16 +520,17 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
headBranch = headBranch[idx+1:]
}
if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
gitClient := opts.GitClient
if ucc, err := gitClient.UncommittedChangeCount(context.Background()); err == nil && ucc > 0 {
fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", text.Pluralize(ucc, "uncommitted change"))
}
var headRepo ghrepo.Interface
var headRemote *context.Remote
var headRemote *ghContext.Remote
if isPushEnabled {
// determine whether the head branch is already pushed to a remote
if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil {
if pushedTo := determineTrackingBranch(gitClient, remotes, headBranch); pushedTo != nil {
isPushEnabled = false
if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
headRepo = r
@ -625,6 +631,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
IsPushEnabled: isPushEnabled,
RepoContext: repoContext,
Client: client,
GitClient: gitClient,
}, nil
}
@ -713,11 +720,12 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
// TODO: prevent clashes with another remote of a same name
gitRemote, err := git.AddRemote("fork", headRepoURL)
gitClient := ctx.GitClient
gitRemote, err := gitClient.AddRemote(context.Background(), "fork", headRepoURL, []string{})
if err != nil {
return fmt.Errorf("error adding remote: %w", err)
}
headRemote = &context.Remote{
headRemote = &ghContext.Remote{
Remote: gitRemote,
Repo: headRepo,
}
@ -729,12 +737,11 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
pushTries := 0
maxPushTries := 3
for {
r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
defer r.Flush()
cmdErr := r
cmdIn := opts.IO.In
cmdOut := opts.IO.Out
if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", ctx.HeadBranch), cmdIn, cmdOut, cmdErr); err != nil {
w := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
defer w.Flush()
gitClient := ctx.GitClient
ref := fmt.Sprintf("HEAD:%s", ctx.HeadBranch)
if err := gitClient.Push(context.Background(), headRemote.Name, ref, git.WithStderr(w)); err != nil {
if didForkRepo && pushTries < maxPushTries {
pushTries++
// first wait 2 seconds after forking, then 4s, then 6s

View file

@ -869,6 +869,7 @@ func Test_createRun(t *testing.T) {
return branch, nil
}
opts.Finder = shared.NewMockFinder(branch, nil, nil)
opts.GitClient = &git.Client{GitPath: "some/path/git"}
cleanSetup := func() {}
if tt.setup != nil {
cleanSetup = tt.setup(&opts, t)
@ -985,7 +986,8 @@ func Test_determineTrackingBranch(t *testing.T) {
tt.cmdStubs(cs)
ref := determineTrackingBranch(tt.remotes, "feature")
gitClient := &git.Client{GitPath: "some/path/git"}
ref := determineTrackingBranch(gitClient, tt.remotes, "feature")
tt.assert(ref, t)
})
}

View file

@ -11,8 +11,10 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
@ -22,6 +24,7 @@ import (
type DiffOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
Browser browser.Browser
Finder shared.PRFinder
@ -29,12 +32,14 @@ type DiffOptions struct {
UseColor bool
Patch bool
NameOnly bool
BrowserMode bool
}
func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command {
opts := &DiffOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Browser: f.Browser,
}
var colorFlag string
@ -46,7 +51,9 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
View changes in a pull request.
Without an argument, the pull request that belongs to the current branch
is selected.
is selected.
With '--web', open the pull request diff in a web browser instead.
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@ -81,6 +88,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
cmdutil.StringEnumFlag(cmd, &colorFlag, "color", "", "auto", []string{"always", "never", "auto"}, "Use color in diff output")
cmd.Flags().BoolVar(&opts.Patch, "patch", false, "Display diff in patch format")
cmd.Flags().BoolVar(&opts.NameOnly, "name-only", false, "Display only names of changed files")
cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open the pull request diff in the browser")
return cmd
}
@ -90,11 +98,24 @@ func diffRun(opts *DiffOptions) error {
Selector: opts.SelectorArg,
Fields: []string{"number"},
}
if opts.BrowserMode {
findOptions.Fields = []string{"url"}
}
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
if opts.BrowserMode {
openUrl := fmt.Sprintf("%s/files", pr.URL)
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openUrl))
}
return opts.Browser.Browse(openUrl)
}
httpClient, err := opts.HttpClient()
if err != nil {
return err

View file

@ -9,12 +9,12 @@ import (
"testing"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmd/pr/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/google/go-cmp/cmp"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -92,6 +92,16 @@ func Test_NewCmdDiff(t *testing.T) {
isTTY: true,
wantErr: "invalid argument \"doublerainbow\" for \"--color\" flag: valid values are {always|never|auto}",
},
{
name: "web mode",
args: "123 --web",
isTTY: true,
want: DiffOptions{
SelectorArg: "123",
UseColor: true,
BrowserMode: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -130,19 +140,22 @@ func Test_NewCmdDiff(t *testing.T) {
assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg)
assert.Equal(t, tt.want.UseColor, opts.UseColor)
assert.Equal(t, tt.want.BrowserMode, opts.BrowserMode)
})
}
}
func Test_diffRun(t *testing.T) {
pr := &api.PullRequest{Number: 123}
pr := &api.PullRequest{Number: 123, URL: "https://github.com/OWNER/REPO/pull/123"}
tests := []struct {
name string
opts DiffOptions
rawDiff string
wantAccept string
wantStdout string
name string
opts DiffOptions
wantFields []string
wantStdout string
wantStderr string
wantBrowsedURL string
httpStubs func(*httpmock.Registry)
}{
{
name: "no color",
@ -151,9 +164,11 @@ func Test_diffRun(t *testing.T) {
UseColor: false,
Patch: false,
},
rawDiff: fmt.Sprintf(testDiff, "", "", "", ""),
wantAccept: "application/vnd.github.v3.diff",
wantFields: []string{"number"},
wantStdout: fmt.Sprintf(testDiff, "", "", "", ""),
httpStubs: func(reg *httpmock.Registry) {
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
},
},
{
name: "with color",
@ -162,9 +177,11 @@ func Test_diffRun(t *testing.T) {
UseColor: true,
Patch: false,
},
rawDiff: fmt.Sprintf(testDiff, "", "", "", ""),
wantAccept: "application/vnd.github.v3.diff",
wantFields: []string{"number"},
wantStdout: fmt.Sprintf(testDiff, "\x1b[m", "\x1b[1;38m", "\x1b[32m", "\x1b[31m"),
httpStubs: func(reg *httpmock.Registry) {
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
},
},
{
name: "patch format",
@ -173,9 +190,11 @@ func Test_diffRun(t *testing.T) {
UseColor: false,
Patch: true,
},
rawDiff: fmt.Sprintf(testDiff, "", "", "", ""),
wantAccept: "application/vnd.github.v3.patch",
wantFields: []string{"number"},
wantStdout: fmt.Sprintf(testDiff, "", "", "", ""),
httpStubs: func(reg *httpmock.Registry) {
stubDiffRequest(reg, "application/vnd.github.v3.patch", fmt.Sprintf(testDiff, "", "", "", ""))
},
},
{
name: "name only",
@ -185,51 +204,51 @@ func Test_diffRun(t *testing.T) {
Patch: false,
NameOnly: true,
},
rawDiff: fmt.Sprintf(testDiff, "", "", "", ""),
wantAccept: "application/vnd.github.v3.diff",
wantFields: []string{"number"},
wantStdout: ".github/workflows/releases.yml\nMakefile\n",
httpStubs: func(reg *httpmock.Registry) {
stubDiffRequest(reg, "application/vnd.github.v3.diff", fmt.Sprintf(testDiff, "", "", "", ""))
},
},
{
name: "web mode",
opts: DiffOptions{
SelectorArg: "123",
BrowserMode: true,
},
wantFields: []string{"url"},
wantStderr: "Opening github.com/OWNER/REPO/pull/123/files in your browser.\n",
wantBrowsedURL: "https://github.com/OWNER/REPO/pull/123/files",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
var gotAccept string
httpReg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
func(req *http.Request) (*http.Response, error) {
gotAccept = req.Header.Get("Accept")
return &http.Response{
StatusCode: 200,
Request: req,
Body: io.NopCloser(strings.NewReader(tt.rawDiff)),
}, nil
})
opts := tt.opts
opts.HttpClient = func() (*http.Client, error) {
if tt.httpStubs != nil {
tt.httpStubs(httpReg)
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: httpReg}, nil
}
ios, _, stdout, stderr := iostreams.Test()
opts.IO = ios
finder := shared.NewMockFinder("123", pr, ghrepo.New("OWNER", "REPO"))
finder.ExpectFields([]string{"number"})
opts.Finder = finder
browser := &browser.Stub{}
tt.opts.Browser = browser
if err := diffRun(&opts); err != nil {
t.Fatalf("unexpected error: %s", err)
}
if diff := cmp.Diff(tt.wantStdout, stdout.String()); diff != "" {
t.Errorf("command output did not match:\n%s", diff)
}
if stderr.String() != "" {
t.Errorf("unexpected stderr output: %s", stderr.String())
}
if gotAccept != tt.wantAccept {
t.Errorf("unexpected Accept header: %s", gotAccept)
}
ios, _, stdout, stderr := iostreams.Test()
ios.SetStdoutTTY(true)
tt.opts.IO = ios
finder := shared.NewMockFinder("123", pr, ghrepo.New("OWNER", "REPO"))
finder.ExpectFields(tt.wantFields)
tt.opts.Finder = finder
err := diffRun(&tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
assert.Equal(t, tt.wantBrowsedURL, browser.BrowsedURL())
})
}
}
@ -349,3 +368,23 @@ func Test_changedFileNames(t *testing.T) {
}
}
}
func stubDiffRequest(reg *httpmock.Registry, accept, diff string) {
reg.Register(
func(req *http.Request) bool {
if !strings.EqualFold(req.Method, "GET") {
return false
}
if req.URL.EscapedPath() != "/repos/OWNER/REPO/pulls/123" {
return false
}
return req.Header.Get("Accept") == accept
},
func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 200,
Request: req,
Body: io.NopCloser(strings.NewReader(diff)),
}, nil
})
}

View file

@ -175,6 +175,9 @@ func listRun(opts *ListOptions) error {
if err != nil {
return err
}
if len(listResult.PullRequests) == 0 && opts.Exporter == nil {
return shared.ListNoResults(ghrepo.FullName(baseRepo), "pull request", !filters.IsDefault())
}
err = opts.IO.StartPager()
if err != nil {
@ -189,15 +192,13 @@ func listRun(opts *ListOptions) error {
if listResult.SearchCapped {
fmt.Fprintln(opts.IO.ErrOut, "warning: this query uses the Search API which is capped at 1000 results maximum")
}
if len(listResult.PullRequests) == 0 {
return shared.ListNoResults(ghrepo.FullName(baseRepo), "pull request", !filters.IsDefault())
}
if opts.IO.IsStdoutTTY() {
title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, !filters.IsDefault())
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title)
}
cs := opts.IO.ColorScheme()
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
table := utils.NewTablePrinter(opts.IO)
for _, pr := range listResult.PullRequests {
prNum := strconv.Itoa(pr.Number)

View file

@ -1,6 +1,7 @@
package merge
import (
"context"
"errors"
"fmt"
"net/http"
@ -8,7 +9,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/context"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
@ -26,9 +27,10 @@ type editor interface {
type MergeOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
IO *iostreams.IOStreams
Branch func() (string, error)
Remotes func() (context.Remotes, error)
Remotes func() (ghContext.Remotes, error)
Finder shared.PRFinder
@ -60,6 +62,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
opts := &MergeOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Branch: f.Branch,
Remotes: f.Remotes,
}
@ -224,7 +227,7 @@ func (m *mergeContext) warnIfDiverged() {
return
}
localBranchLastCommit, err := git.LastCommit()
localBranchLastCommit, err := m.opts.GitClient.LastCommit(context.Background())
if err != nil {
return
}
@ -396,6 +399,8 @@ func (m *mergeContext) deleteLocalBranch() error {
return err
}
ctx := context.Background()
// branch the command was run on is the same as the pull request branch
if currentBranch == m.pr.HeadRefName {
remotes, err := m.opts.Remotes()
@ -409,24 +414,24 @@ func (m *mergeContext) deleteLocalBranch() error {
}
targetBranch := m.pr.BaseRefName
if git.HasLocalBranch(targetBranch) {
if err := git.CheckoutBranch(targetBranch); err != nil {
if m.opts.GitClient.HasLocalBranch(ctx, targetBranch) {
if err := m.opts.GitClient.CheckoutBranch(ctx, targetBranch); err != nil {
return err
}
} else {
if err := git.CheckoutNewBranch(baseRemote.Name, targetBranch); err != nil {
if err := m.opts.GitClient.CheckoutNewBranch(ctx, baseRemote.Name, targetBranch); err != nil {
return err
}
}
if err := git.Pull(baseRemote.Name, targetBranch); err != nil {
if err := m.opts.GitClient.Pull(ctx, baseRemote.Name, targetBranch); err != nil {
_ = m.warnf(fmt.Sprintf("%s warning: not possible to fast-forward to: %q\n", m.cs.WarningIcon(), targetBranch))
}
m.switchedToBranch = targetBranch
}
if err := git.DeleteLocalBranch(m.pr.HeadRefName); err != nil {
if err := m.opts.GitClient.DeleteLocalBranch(ctx, m.pr.HeadRefName); err != nil {
return fmt.Errorf("failed to delete local branch %s: %w", m.cs.Cyan(m.pr.HeadRefName), err)
}
@ -503,7 +508,7 @@ func NewMergeContext(opts *MergeOptions) (*mergeContext, error) {
deleteBranch: opts.DeleteBranch,
crossRepoPR: pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner(),
autoMerge: opts.AutoMergeEnable && !isImmediatelyMergeable(pr.MergeStateStatus),
localBranchExists: opts.CanDeleteLocalBranch && git.HasLocalBranch(pr.HeadRefName),
localBranchExists: opts.CanDeleteLocalBranch && opts.GitClient.HasLocalBranch(context.Background(), pr.HeadRefName),
mergeQueueRequired: pr.IsMergeQueueEnabled,
}, nil
}
@ -730,7 +735,7 @@ func allowsAdminOverride(status string) bool {
}
}
func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *context.Remote {
func remoteForMergeConflictResolution(baseRepo ghrepo.Interface, pr *api.PullRequest, opts *MergeOptions) *ghContext.Remote {
if !mergeConflictStatus(pr.MergeStateStatus) || !opts.CanDeleteLocalBranch {
return nil
}

View file

@ -270,6 +270,7 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t
},
}, nil
},
GitClient: &git.Client{GitPath: "some/path/git"},
}
cmd := NewCmdMerge(factory, nil)

View file

@ -29,20 +29,23 @@ const (
type Commentable interface {
Link() string
Identifier() string
CurrentUserComments() []api.Comment
}
type CommentableOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
RetrieveCommentable func() (Commentable, ghrepo.Interface, error)
EditSurvey func() (string, error)
InteractiveEditSurvey func() (string, error)
EditSurvey func(string) (string, error)
InteractiveEditSurvey func(string) (string, error)
ConfirmSubmitSurvey func() (bool, error)
OpenInBrowser func(string) error
Interactive bool
InputType InputType
Body string
EditLast bool
Quiet bool
Host string
}
func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
@ -81,7 +84,14 @@ func CommentableRun(opts *CommentableOptions) error {
if err != nil {
return err
}
opts.Host = repo.RepoHost()
if opts.EditLast {
return updateComment(commentable, opts)
}
return createComment(commentable, opts)
}
func createComment(commentable Commentable, opts *CommentableOptions) error {
switch opts.InputType {
case InputTypeWeb:
openURL := commentable.Link() + "#issuecomment-new"
@ -91,10 +101,11 @@ func CommentableRun(opts *CommentableOptions) error {
return opts.OpenInBrowser(openURL)
case InputTypeEditor:
var body string
var err error
if opts.Interactive {
body, err = opts.InteractiveEditSurvey()
body, err = opts.InteractiveEditSurvey("")
} else {
body, err = opts.EditSurvey()
body, err = opts.EditSurvey("")
}
if err != nil {
return err
@ -116,15 +127,77 @@ func CommentableRun(opts *CommentableOptions) error {
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
params := api.CommentCreateInput{Body: opts.Body, SubjectId: commentable.Identifier()}
url, err := api.CommentCreate(apiClient, repo.RepoHost(), params)
url, err := api.CommentCreate(apiClient, opts.Host, params)
if err != nil {
return err
}
if !opts.Quiet {
fmt.Fprintln(opts.IO.Out, url)
}
return nil
}
func updateComment(commentable Commentable, opts *CommentableOptions) error {
comments := commentable.CurrentUserComments()
if len(comments) == 0 {
return fmt.Errorf("no comments found for current user")
}
lastComment := &comments[len(comments)-1]
switch opts.InputType {
case InputTypeWeb:
openURL := lastComment.Link()
if opts.IO.IsStdoutTTY() && !opts.Quiet {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", text.DisplayURL(openURL))
}
return opts.OpenInBrowser(openURL)
case InputTypeEditor:
var body string
var err error
initialValue := lastComment.Content()
if opts.Interactive {
body, err = opts.InteractiveEditSurvey(initialValue)
} else {
body, err = opts.EditSurvey(initialValue)
}
if err != nil {
return err
}
opts.Body = body
}
if opts.Interactive {
cont, err := opts.ConfirmSubmitSurvey()
if err != nil {
return err
}
if !cont {
return errors.New("Discarding...")
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
params := api.CommentUpdateInput{Body: opts.Body, CommentId: lastComment.Identifier()}
url, err := api.CommentUpdate(apiClient, opts.Host, params)
if err != nil {
return err
}
if !opts.Quiet {
fmt.Fprintln(opts.IO.Out, url)
}
return nil
}
@ -138,8 +211,8 @@ func CommentableConfirmSubmitSurvey() (bool, error) {
return confirm, err
}
func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) {
return func() (string, error) {
func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func(string) (string, error) {
return func(initialValue string) (string, error) {
editorCommand, err := cmdutil.DetermineEditor(cf)
if err != nil {
return "", err
@ -147,17 +220,17 @@ func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iost
cs := io.ColorScheme()
fmt.Fprintf(io.Out, "- %s to draft your comment in %s... ", cs.Bold("Press Enter"), cs.Bold(surveyext.EditorName(editorCommand)))
_ = waitForEnter(io.In)
return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut)
return surveyext.Edit(editorCommand, "*.md", initialValue, io.In, io.Out, io.ErrOut)
}
}
func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) {
return func() (string, error) {
func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func(string) (string, error) {
return func(initialValue string) (string, error) {
editorCommand, err := cmdutil.DetermineEditor(cf)
if err != nil {
return "", err
}
return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut)
return surveyext.Edit(editorCommand, "*.md", initialValue, io.In, io.Out, io.ErrOut)
}
}

View file

@ -13,6 +13,7 @@ import (
)
type Comment interface {
Identifier() string
AuthorLogin() string
Association() string
Content() string

View file

@ -53,12 +53,14 @@ func NewFinder(factory *cmdutil.Factory) PRFinder {
}
return &finder{
baseRepoFn: factory.BaseRepo,
branchFn: factory.Branch,
remotesFn: factory.Remotes,
httpClient: factory.HttpClient,
progress: factory.IOStreams,
branchConfig: git.ReadBranchConfig,
baseRepoFn: factory.BaseRepo,
branchFn: factory.Branch,
remotesFn: factory.Remotes,
httpClient: factory.HttpClient,
progress: factory.IOStreams,
branchConfig: func(s string) git.BranchConfig {
return factory.GitClient.ReadBranchConfig(context.Background(), s)
},
}
}

View file

@ -6,9 +6,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/githubtemplate"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
"github.com/cli/cli/v2/pkg/surveyext"
@ -369,18 +367,3 @@ func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher
return nil
}
func FindTemplates(dir, path string) ([]string, string) {
if dir == "" {
rootDir, err := git.ToplevelDir()
if err != nil {
return []string{}, ""
}
dir = rootDir
}
templateFiles := githubtemplate.FindNonLegacy(dir, path)
legacyTemplate := githubtemplate.FindLegacy(dir, path)
return templateFiles, legacyTemplate
}

View file

@ -1,6 +1,7 @@
package shared
import (
"context"
"fmt"
"net/http"
"time"
@ -233,7 +234,8 @@ func (m *templateManager) fetch() error {
dir := m.rootDir
if dir == "" {
var err error
dir, err = git.ToplevelDir()
gitClient := &git.Client{}
dir, err = gitClient.ToplevelDir(context.Background())
if err != nil {
return nil // abort silently
}

View file

@ -1,6 +1,7 @@
package status
import (
"context"
"errors"
"fmt"
"net/http"
@ -9,7 +10,7 @@ import (
"strings"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/context"
ghContext "github.com/cli/cli/v2/context"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
@ -22,10 +23,11 @@ import (
type StatusOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Remotes func() (ghContext.Remotes, error)
Branch func() (string, error)
HasRepoOverride bool
@ -37,6 +39,7 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co
opts := &StatusOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
@ -86,7 +89,7 @@ func statusRun(opts *StatusOptions) error {
}
remotes, _ := opts.Remotes()
currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(baseRepo, currentBranch, remotes)
currentPRNumber, currentPRHeadRef, err = prSelectorForCurrentBranch(opts.GitClient, baseRepo, currentBranch, remotes)
if err != nil {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
@ -165,9 +168,9 @@ func statusRun(opts *StatusOptions) error {
return nil
}
func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem context.Remotes) (prNumber int, selector string, err error) {
func prSelectorForCurrentBranch(gitClient *git.Client, baseRepo ghrepo.Interface, prHeadRef string, rem ghContext.Remotes) (prNumber int, selector string, err error) {
selector = prHeadRef
branchConfig := git.ReadBranchConfig(prHeadRef)
branchConfig := gitClient.ReadBranchConfig(context.Background(), prHeadRef)
// the branch is configured to merge a special PR head ref
prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)

View file

@ -52,6 +52,7 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t
}
return branch, nil
},
GitClient: &git.Client{GitPath: "some/path/git"},
}
cmd := NewCmdStatus(factory, nil)
@ -328,7 +329,8 @@ func Test_prSelectorForCurrentBranch(t *testing.T) {
Repo: repo,
},
}
prNum, headRef, err := prSelectorForCurrentBranch(repo, "Frederick888/main", rem)
gitClient := &git.Client{GitPath: "some/path/git"}
prNum, headRef, err := prSelectorForCurrentBranch(gitClient, repo, "Frederick888/main", rem)
if err != nil {
t.Fatalf("prSelectorForCurrentBranch error: %v", err)
}

View file

@ -2,6 +2,7 @@ package create
import (
"bytes"
"context"
"errors"
"fmt"
"io"
@ -13,7 +14,6 @@ import (
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmd/release/shared"
"github.com/cli/cli/v2/pkg/cmdutil"
@ -27,6 +27,7 @@ type CreateOptions struct {
IO *iostreams.IOStreams
Config func() (config.Config, error)
HttpClient func() (*http.Client, error)
GitClient *git.Client
BaseRepo func() (ghrepo.Interface, error)
Edit func(string, string, string, io.Reader, io.Writer, io.Writer) (string, error)
@ -57,6 +58,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts := &CreateOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
Edit: surveyext.Edit,
}
@ -222,7 +224,7 @@ func createRun(opts *CreateOptions) error {
var tagDescription string
if opts.RepoOverride == "" {
tagDescription, _ = gitTagInfo(opts.TagName)
tagDescription, _ = gitTagInfo(opts.GitClient, opts.TagName)
// If there is a local tag with the same name as specified
// the user may not want to create a new tag on the remote
// as the local one might be annotated or signed.
@ -269,10 +271,10 @@ func createRun(opts *CreateOptions) error {
}
if generatedNotes == nil {
if opts.NotesStartTag != "" {
commits, _ := changelogForRange(fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef))
commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", opts.NotesStartTag, headRef))
generatedChangelog = generateChangelog(commits)
} else if prevTag, err := detectPreviousTag(headRef); err == nil {
commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef))
} else if prevTag, err := detectPreviousTag(opts.GitClient, headRef); err == nil {
commits, _ := changelogForRange(opts.GitClient, fmt.Sprintf("%s..%s", prevTag, headRef))
generatedChangelog = generateChangelog(commits)
}
}
@ -470,21 +472,21 @@ func createRun(opts *CreateOptions) error {
return nil
}
func gitTagInfo(tagName string) (string, error) {
cmd, err := git.GitCommand("tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
func gitTagInfo(client *git.Client, tagName string) (string, error) {
cmd, err := client.Command(context.Background(), "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
if err != nil {
return "", err
}
b, err := run.PrepareCmd(cmd).Output()
b, err := cmd.Output()
return string(b), err
}
func detectPreviousTag(headRef string) (string, error) {
cmd, err := git.GitCommand("describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
func detectPreviousTag(client *git.Client, headRef string) (string, error) {
cmd, err := client.Command(context.Background(), "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
if err != nil {
return "", err
}
b, err := run.PrepareCmd(cmd).Output()
b, err := cmd.Output()
return strings.TrimSpace(string(b)), err
}
@ -493,12 +495,12 @@ type logEntry struct {
Body string
}
func changelogForRange(refRange string) ([]logEntry, error) {
cmd, err := git.GitCommand("-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
func changelogForRange(client *git.Client, refRange string) ([]logEntry, error) {
cmd, err := client.Command(context.Background(), "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
if err != nil {
return nil, err
}
b, err := run.PrepareCmd(cmd).Output()
b, err := cmd.Output()
if err != nil {
return nil, err
}

View file

@ -10,6 +10,7 @@ import (
"path/filepath"
"testing"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
@ -688,6 +689,8 @@ func Test_createRun(t *testing.T) {
return ghrepo.FromFullName("OWNER/REPO")
}
tt.opts.GitClient = &git.Client{GitPath: "some/path/git"}
err := createRun(&tt.opts)
if tt.wantErr != "" {
require.EqualError(t, err, tt.wantErr)
@ -1050,6 +1053,8 @@ func Test_createRun_interactive(t *testing.T) {
return val, nil
}
tt.opts.GitClient = &git.Client{GitPath: "some/path/git"}
t.Run(tt.name, func(t *testing.T) {
//nolint:staticcheck // SA1019: prompt.NewAskStubber is deprecated: use PrompterMock
as := prompt.NewAskStubber(t)

View file

@ -3,13 +3,12 @@ package list
import (
"fmt"
"net/http"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/tableprinter"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/spf13/cobra"
)
@ -76,14 +75,15 @@ func listRun(opts *ListOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err)
}
table := utils.NewTablePrinter(opts.IO)
table := tableprinter.New(opts.IO)
iofmt := opts.IO.ColorScheme()
table.HeaderRow("Title", "Type", "Tag name", "Published")
for _, rel := range releases {
title := text.RemoveExcessiveWhitespace(rel.Name)
if title == "" {
title = rel.TagName
}
table.AddField(title, nil, nil)
table.AddField(title)
badge := ""
var badgeColor func(string) string
@ -97,23 +97,15 @@ func listRun(opts *ListOptions) error {
badge = "Pre-release"
badgeColor = iofmt.Yellow
}
table.AddField(badge, nil, badgeColor)
table.AddField(badge, tableprinter.WithColor(badgeColor))
tagName := rel.TagName
if table.IsTTY() {
tagName = fmt.Sprintf("(%s)", tagName)
}
table.AddField(tagName, nil, nil)
table.AddField(rel.TagName, tableprinter.WithTruncate(nil))
pubDate := rel.PublishedAt
if rel.PublishedAt.IsZero() {
pubDate = rel.CreatedAt
}
publishedAt := pubDate.Format(time.RFC3339)
if table.IsTTY() {
publishedAt = text.FuzzyAgo(time.Now(), pubDate)
}
table.AddField(publishedAt, nil, iofmt.Gray)
table.AddTimeField(pubDate, iofmt.Gray)
table.EndRow()
}
err = table.Render()

View file

@ -103,10 +103,11 @@ func Test_listRun(t *testing.T) {
LimitResults: 30,
},
wantStdout: heredoc.Doc(`
v1.1.0 Draft (v1.1.0) about 1 day ago
The big 1.0 Latest (v1.0.0) about 1 day ago
1.0 release candidate Pre-release (v1.0.0-pre.2) about 1 day ago
New features (v0.9.2) about 1 day ago
TITLE TYPE TAG NAME PUBLISHED
v1.1.0 Draft v1.1.0 about 1 day ago
The big 1.0 Latest v1.0.0 about 1 day ago
1.0 release candidate Pre-release v1.0.0-pre.2 about 1 day ago
New features v0.9.2 about 1 day ago
`),
wantStderr: ``,
},

View file

@ -152,6 +152,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
if len(release.Assets) > 0 {
fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets"))
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
table := utils.NewTablePrinter(io)
for _, a := range release.Assets {
table.AddField(a.Name, nil, nil)

View file

@ -1,6 +1,7 @@
package clone
import (
"context"
"fmt"
"net/http"
"strings"
@ -18,6 +19,7 @@ import (
type CloneOptions struct {
HttpClient func() (*http.Client, error)
GitClient *git.Client
Config func() (config.Config, error)
IO *iostreams.IOStreams
@ -30,6 +32,7 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm
opts := &CloneOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
GitClient: f.GitClient,
Config: f.Config,
}
@ -152,7 +155,9 @@ func cloneRun(opts *CloneOptions) error {
canonicalCloneURL = strings.TrimSuffix(canonicalCloneURL, ".git") + ".wiki.git"
}
cloneDir, err := git.RunClone(canonicalCloneURL, opts.GitArgs)
gitClient := opts.GitClient
ctx := context.Background()
cloneDir, err := gitClient.Clone(ctx, canonicalCloneURL, opts.GitArgs)
if err != nil {
return err
}
@ -170,7 +175,7 @@ func cloneRun(opts *CloneOptions) error {
upstreamName = canonicalRepo.Parent.RepoOwner()
}
err = git.AddNamedRemote(upstreamURL, upstreamName, cloneDir, []string{canonicalRepo.Parent.DefaultBranchRef.Name})
_, err = gitClient.AddRemote(ctx, upstreamName, upstreamURL, []string{canonicalRepo.Parent.DefaultBranchRef.Name}, git.WithRepoDir(cloneDir))
if err != nil {
return err
}

Some files were not shown because too many files have changed in this diff Show more