Merge remote-tracking branch 'origin' into go-gh-term
This commit is contained in:
commit
76ad94cc35
132 changed files with 4160 additions and 1625 deletions
|
|
@ -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}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
2
.github/workflows/releases.yml
vendored
2
.github/workflows/releases.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"search.exclude": {
|
||||
"vendor/**": true
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
695
git/client.go
Normal 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
399
git/client_test.go
Normal 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)
|
||||
}
|
||||
442
git/git.go
442
git/git.go
|
|
@ -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/")
|
||||
}
|
||||
213
git/git_test.go
213
git/git_test.go
|
|
@ -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
76
git/objects.go
Normal 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
|
||||
}
|
||||
169
git/remote.go
169
git/remote.go
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
15
go.mod
|
|
@ -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
35
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
145
internal/codespaces/grpc/client.go
Normal file
145
internal/codespaces/grpc/client.go
Normal 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
|
||||
}
|
||||
84
internal/codespaces/grpc/client_test.go
Normal file
84
internal/codespaces/grpc/client_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
}
|
||||
34
internal/codespaces/grpc/test/channel.go
Normal file
34
internal/codespaces/grpc/test/channel.go
Normal 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
|
||||
}
|
||||
62
internal/codespaces/grpc/test/server.go
Normal file
62
internal/codespaces/grpc/test/server.go
Normal 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
|
||||
}
|
||||
}
|
||||
31
internal/codespaces/grpc/test/session.go
Normal file
31
internal/codespaces/grpc/test/session.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
52
internal/tableprinter/table_printer.go
Normal file
52
internal/tableprinter/table_printer.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
56
pkg/cmd/codespace/rebuild.go
Normal file
56
pkg/cmd/codespace/rebuild.go
Normal 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
|
||||
}
|
||||
36
pkg/cmd/codespace/rebuild_test.go
Normal file
36
pkg/cmd/codespace/rebuild_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ®}, 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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
101
pkg/cmd/gpg-key/delete/delete.go
Normal file
101
pkg/cmd/gpg-key/delete/delete.go
Normal 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
|
||||
}
|
||||
210
pkg/cmd/gpg-key/delete/delete_test.go
Normal file
210
pkg/cmd/gpg-key/delete/delete_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
68
pkg/cmd/gpg-key/delete/http.go
Normal file
68
pkg/cmd/gpg-key/delete/http.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import (
|
|||
)
|
||||
|
||||
type Comment interface {
|
||||
Identifier() string
|
||||
AuthorLogin() string
|
||||
Association() string
|
||||
Content() string
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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$`)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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: ``,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue