diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 34448d38b..000000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -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} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cdb10785e..10bf64350 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -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" + ] } diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index a2a0c57c1..e6f99dd4c 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -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 diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 99a52d6d6..000000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "search.exclude": { - "vendor/**": true - } -} \ No newline at end of file diff --git a/api/queries_comments.go b/api/queries_comments.go index 8b39675f6..0e62351b1 100644 --- a/api/queries_comments.go +++ b/api/queries_comments.go @@ -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 { diff --git a/api/queries_issue.go b/api/queries_issue.go index ae409d729..93ef093b7 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -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() +} diff --git a/api/queries_pr.go b/api/queries_pr.go index e4f6013fa..af4e6bb0f 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -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" } diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go index edfda99dd..5fcb75e19 100644 --- a/api/queries_pr_review.go +++ b/api/queries_pr_review.go @@ -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 } diff --git a/api/query_builder.go b/api/query_builder.go index 4d7d27d11..2fb45bd81 100644 --- a/api/query_builder.go +++ b/api/query_builder.go @@ -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} diff --git a/context/context.go b/context/context.go index 45dfcfd97..568e3e623 100644 --- a/context/context.go +++ b/context/context.go @@ -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 } diff --git a/git/client.go b/git/client.go new file mode 100644 index 000000000..2cf6e01df --- /dev/null +++ b/git/client.go @@ -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 + } + } + } +} diff --git a/git/client_test.go b/git/client_test.go new file mode 100644 index 000000000..cff47358f --- /dev/null +++ b/git/client_test.go @@ -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) +} diff --git a/git/git.go b/git/git.go deleted file mode 100644 index defeae713..000000000 --- a/git/git.go +++ /dev/null @@ -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/") -} diff --git a/git/git_test.go b/git/git_test.go deleted file mode 100644 index b5812af9e..000000000 --- a/git/git_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/git/objects.go b/git/objects.go new file mode 100644 index 000000000..952b6c335 --- /dev/null +++ b/git/objects.go @@ -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 +} diff --git a/git/remote.go b/git/remote.go deleted file mode 100644 index bea81da90..000000000 --- a/git/remote.go +++ /dev/null @@ -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() -} diff --git a/git/remote_test.go b/git/remote_test.go deleted file mode 100644 index 382896590..000000000 --- a/git/remote_test.go +++ /dev/null @@ -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) -} diff --git a/go.mod b/go.mod index e2cbf9a23..a681c2c95 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index f24d36e0a..e15581064 100644 --- a/go.sum +++ b/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= diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index b8d44d9e0..c6b2a292f 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -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) diff --git a/internal/codespaces/grpc/client.go b/internal/codespaces/grpc/client.go new file mode 100644 index 000000000..76119d659 --- /dev/null +++ b/internal/codespaces/grpc/client.go @@ -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 +} diff --git a/internal/codespaces/grpc/client_test.go b/internal/codespaces/grpc/client_test.go new file mode 100644 index 000000000..c70e59ea6 --- /dev/null +++ b/internal/codespaces/grpc/client_test.go @@ -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) + } +} diff --git a/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go new file mode 100644 index 000000000..c48f3d0cf --- /dev/null +++ b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.pb.go @@ -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 +} diff --git a/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.proto b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.proto new file mode 100644 index 000000000..337e7cf41 --- /dev/null +++ b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1.proto @@ -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; +} diff --git a/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go new file mode 100644 index 000000000..8ba53014d --- /dev/null +++ b/internal/codespaces/grpc/jupyter/JupyterServerHostService.v1_grpc.pb.go @@ -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", +} diff --git a/internal/codespaces/grpc/test/channel.go b/internal/codespaces/grpc/test/channel.go new file mode 100644 index 000000000..eef42c4aa --- /dev/null +++ b/internal/codespaces/grpc/test/channel.go @@ -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 +} diff --git a/internal/codespaces/grpc/test/server.go b/internal/codespaces/grpc/test/server.go new file mode 100644 index 000000000..8af5efc29 --- /dev/null +++ b/internal/codespaces/grpc/test/server.go @@ -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 + } +} diff --git a/internal/codespaces/grpc/test/session.go b/internal/codespaces/grpc/test/session.go new file mode 100644 index 000000000..aba0f17ee --- /dev/null +++ b/internal/codespaces/grpc/test/session.go @@ -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 +} diff --git a/internal/run/run.go b/internal/run/run.go index d482e04cc..a7260db14 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -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 diff --git a/internal/tableprinter/table_printer.go b/internal/tableprinter/table_printer.go new file mode 100644 index 000000000..2e8d398eb --- /dev/null +++ b/internal/tableprinter/table_printer.go @@ -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, + } +} diff --git a/pkg/cmd/alias/list/list.go b/pkg/cmd/alias/list/list.go index a7ea40ac4..1039759e3 100644 --- a/pkg/cmd/alias/list/list.go +++ b/pkg/cmd/alias/list/list.go @@ -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 { diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index be12392d1..364abb9f0 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -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, }) } diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 649fdb48d..85e133b2b 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -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 { diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 7b1142e7e..c66fb5237 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -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" { diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index ffacb22d9..d1d6042c0 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -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 { diff --git a/pkg/cmd/auth/setupgit/setupgit.go b/pkg/cmd/auth/setupgit/setupgit.go index b5caefe5b..edc531235 100644 --- a/pkg/cmd/auth/setupgit/setupgit.go +++ b/pkg/cmd/auth/setupgit/setupgit.go @@ -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 { diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go index 9c9a1cf6b..5ddac14d5 100644 --- a/pkg/cmd/auth/shared/git_credential.go +++ b/pkg/cmd/auth/shared/git_credential.go @@ -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 } diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go index fe674e1d7..9d3e90cc4 100644 --- a/pkg/cmd/auth/shared/git_credential_test.go +++ b/pkg/cmd/auth/shared/git_credential_test.go @@ -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 { diff --git a/pkg/cmd/auth/shared/login_flow.go b/pkg/cmd/auth/shared/login_flow.go index 9cc9d9865..99d82b5f7 100644 --- a/pkg/cmd/auth/shared/login_flow.go +++ b/pkg/cmd/auth/shared/login_flow.go @@ -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 diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go index ddff8bd92..85572fdac 100644 --- a/pkg/cmd/browse/browse.go +++ b/pkg/cmd/browse/browse.go @@ -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() diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go index 91197cc96..75e615be0 100644 --- a/pkg/cmd/browse/browse_test.go +++ b/pkg/cmd/browse/browse_test.go @@ -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) diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index 46e69d97c..ee25854dc 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -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) diff --git a/pkg/cmd/codespace/create.go b/pkg/cmd/codespace/create.go index a6bbe627f..eceb68efb 100644 --- a/pkg/cmd/codespace/create.go +++ b/pkg/cmd/codespace/create.go @@ -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) } diff --git a/pkg/cmd/codespace/create_test.go b/pkg/cmd/codespace/create_test.go index cffe5fe43..7892de065 100644 --- a/pkg/cmd/codespace/create_test.go +++ b/pkg/cmd/codespace/create_test.go @@ -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", diff --git a/pkg/cmd/codespace/jupyter.go b/pkg/cmd/codespace/jupyter.go index 77bedb301..5c19c2ab1 100644 --- a/pkg/cmd/codespace/jupyter.go +++ b/pkg/cmd/codespace/jupyter.go @@ -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 +} diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index 310f1c658..71a869d77 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -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) diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go index 5727591d1..0ce9133ec 100644 --- a/pkg/cmd/codespace/mock_api.go +++ b/pkg/cmd/codespace/mock_api.go @@ -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 diff --git a/pkg/cmd/codespace/ports.go b/pkg/cmd/codespace/ports.go index 35d79e8b3..5b55f0c5d 100644 --- a/pkg/cmd/codespace/ports.go +++ b/pkg/cmd/codespace/ports.go @@ -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() { diff --git a/pkg/cmd/codespace/rebuild.go b/pkg/cmd/codespace/rebuild.go new file mode 100644 index 000000000..f2128d632 --- /dev/null +++ b/pkg/cmd/codespace/rebuild.go @@ -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 +} diff --git a/pkg/cmd/codespace/rebuild_test.go b/pkg/cmd/codespace/rebuild_test.go new file mode 100644 index 000000000..fff40fe1b --- /dev/null +++ b/pkg/cmd/codespace/rebuild_test.go @@ -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) +} diff --git a/pkg/cmd/codespace/root.go b/pkg/cmd/codespace/root.go index d700664b1..8439430aa 100644 --- a/pkg/cmd/codespace/root.go +++ b/pkg/cmd/codespace/root.go @@ -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 } diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index b19b80b55..995e4bfd4 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -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 } diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index fb4707320..46bc109af 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -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"}, diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index 5e3c23d69..2fae2f023 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -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 diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 8ca79537d..c9f638b87 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -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 } diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index e29dc0510..2dbd81870 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -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() diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 4f05b7c03..10bf5d72b 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -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) } diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index feef3e81f..2ba35b427 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -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") diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go index 41fa104fa..ed074e700 100644 --- a/pkg/cmd/gist/clone/clone.go +++ b/pkg/cmd/gist/clone/clone.go @@ -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 } diff --git a/pkg/cmd/gist/clone/clone_test.go b/pkg/cmd/gist/clone/clone_test.go index ccca76b25..cd6f71b6e 100644 --- a/pkg/cmd/gist/clone/clone_test.go +++ b/pkg/cmd/gist/clone/clone_test.go @@ -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) diff --git a/pkg/cmd/gist/list/list.go b/pkg/cmd/gist/list/list.go index bdf936d23..733b0d8f5 100644 --- a/pkg/cmd/gist/list/list.go +++ b/pkg/cmd/gist/list/list.go @@ -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 { diff --git a/pkg/cmd/gpg-key/delete/delete.go b/pkg/cmd/gpg-key/delete/delete.go new file mode 100644 index 000000000..bb5277a38 --- /dev/null +++ b/pkg/cmd/gpg-key/delete/delete.go @@ -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 ", + 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 +} diff --git a/pkg/cmd/gpg-key/delete/delete_test.go b/pkg/cmd/gpg-key/delete/delete_test.go new file mode 100644 index 000000000..2835f9cb2 --- /dev/null +++ b/pkg/cmd/gpg-key/delete/delete_test.go @@ -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()) + }) + } +} diff --git a/pkg/cmd/gpg-key/delete/http.go b/pkg/cmd/gpg-key/delete/http.go new file mode 100644 index 000000000..cfb11b9c3 --- /dev/null +++ b/pkg/cmd/gpg-key/delete/http.go @@ -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 +} diff --git a/pkg/cmd/gpg-key/gpg_key.go b/pkg/cmd/gpg-key/gpg_key.go index 66245208f..b1551c0b1 100644 --- a/pkg/cmd/gpg-key/gpg_key.go +++ b/pkg/cmd/gpg-key/gpg_key.go @@ -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 } diff --git a/pkg/cmd/gpg-key/list/list.go b/pkg/cmd/gpg-key/list/list.go index 28dfd570e..3a52c64fe 100644 --- a/pkg/cmd/gpg-key/list/list.go +++ b/pkg/cmd/gpg-key/list/list.go @@ -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() diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go index a3f1b0b4d..3bd9df598 100644 --- a/pkg/cmd/issue/comment/comment.go +++ b/pkg/cmd/issue/comment/comment.go @@ -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 } diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go index edf1900d3..4f747d882 100644 --- a/pkg/cmd/issue/comment/comment_test.go +++ b/pkg/cmd/issue/comment/comment_test.go @@ -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"]) + }), + ) +} diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 24f2565f2..71ed5ed0f 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -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) diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go index 84b26633a..efb8398ce 100644 --- a/pkg/cmd/issue/shared/display.go +++ b/pkg/cmd/issue/shared/display.go @@ -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) diff --git a/pkg/cmd/label/list.go b/pkg/cmd/label/list.go index 4efec440f..5ad056115 100644 --- a/pkg/cmd/label/list.go +++ b/pkg/cmd/label/list.go @@ -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 { diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go index 1063d9f36..8addfc4ae 100644 --- a/pkg/cmd/pr/checkout/checkout.go +++ b/pkg/cmd/pr/checkout/checkout.go @@ -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 } } diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go index df77e6676..7540bbacf 100644 --- a/pkg/cmd/pr/checkout/checkout_test.go +++ b/pkg/cmd/pr/checkout/checkout_test.go @@ -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) diff --git a/pkg/cmd/pr/checks/aggregate.go b/pkg/cmd/pr/checks/aggregate.go index 2a01b31d8..53f2bab5f 100644 --- a/pkg/cmd/pr/checks/aggregate.go +++ b/pkg/cmd/pr/checks/aggregate.go @@ -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 diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go index 67c897a30..65df87b15 100644 --- a/pkg/cmd/pr/checks/checks.go +++ b/pkg/cmd/pr/checks/checks.go @@ -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 } diff --git a/pkg/cmd/pr/checks/output.go b/pkg/cmd/pr/checks/output.go index d1adb7c54..bf5ecc839 100644 --- a/pkg/cmd/pr/checks/output.go +++ b/pkg/cmd/pr/checks/output.go @@ -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 { diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go index 7d80199b6..7e39005bd 100644 --- a/pkg/cmd/pr/close/close.go +++ b/pkg/cmd/pr/close/close.go @@ -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) } diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go index df9585fbb..b435fb8aa 100644 --- a/pkg/cmd/pr/close/close_test.go +++ b/pkg/cmd/pr/close/close_test.go @@ -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) diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go index 1ad79fd11..34644f62b 100644 --- a/pkg/cmd/pr/comment/comment.go +++ b/pkg/cmd/pr/comment/comment.go @@ -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 } diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go index a5c0edfb1..f72701859 100644 --- a/pkg/cmd/pr/comment/comment_test.go +++ b/pkg/cmd/pr/comment/comment_test.go @@ -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"]) + }), + ) +} diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index e9c664b88..75d4b58f1 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -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 diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index c83dd13a2..0764ffe3b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -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) }) } diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index b7ef11707..decfeb478 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -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 diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index af0256e5b..bb8eb87bf 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -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 + }) +} diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index 6c1be1bef..a56ac0e5d 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -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) diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 2972c09f9..dbf0923f7 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -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 } diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index d1bb21d8c..0e3ced350 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -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) diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go index 5d1037fb7..8f1f89137 100644 --- a/pkg/cmd/pr/shared/commentable.go +++ b/pkg/cmd/pr/shared/commentable.go @@ -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) } } diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go index e7d90a7e6..a05108d7b 100644 --- a/pkg/cmd/pr/shared/comments.go +++ b/pkg/cmd/pr/shared/comments.go @@ -13,6 +13,7 @@ import ( ) type Comment interface { + Identifier() string AuthorLogin() string Association() string Content() string diff --git a/pkg/cmd/pr/shared/finder.go b/pkg/cmd/pr/shared/finder.go index b700a7501..ed04c4295 100644 --- a/pkg/cmd/pr/shared/finder.go +++ b/pkg/cmd/pr/shared/finder.go @@ -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) + }, } } diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go index 31942c6a0..dd4ba085d 100644 --- a/pkg/cmd/pr/shared/survey.go +++ b/pkg/cmd/pr/shared/survey.go @@ -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 -} diff --git a/pkg/cmd/pr/shared/templates.go b/pkg/cmd/pr/shared/templates.go index 975bdd5da..7aab57978 100644 --- a/pkg/cmd/pr/shared/templates.go +++ b/pkg/cmd/pr/shared/templates.go @@ -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 } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index 784159564..e4434978b 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -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$`) diff --git a/pkg/cmd/pr/status/status_test.go b/pkg/cmd/pr/status/status_test.go index 31f28396e..c3187739d 100644 --- a/pkg/cmd/pr/status/status_test.go +++ b/pkg/cmd/pr/status/status_test.go @@ -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) } diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go index 73c74e18f..8c36218da 100644 --- a/pkg/cmd/release/create/create.go +++ b/pkg/cmd/release/create/create.go @@ -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 } diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go index 7fb6711b2..a6424c0d6 100644 --- a/pkg/cmd/release/create/create_test.go +++ b/pkg/cmd/release/create/create_test.go @@ -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) diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go index ef9f65f94..6d18c80c0 100644 --- a/pkg/cmd/release/list/list.go +++ b/pkg/cmd/release/list/list.go @@ -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() diff --git a/pkg/cmd/release/list/list_test.go b/pkg/cmd/release/list/list_test.go index c88185aad..4c4c0868e 100644 --- a/pkg/cmd/release/list/list_test.go +++ b/pkg/cmd/release/list/list_test.go @@ -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: ``, }, diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go index 38cdc6523..b0f130edb 100644 --- a/pkg/cmd/release/view/view.go +++ b/pkg/cmd/release/view/view.go @@ -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) diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go index ba289aa47..cb46d4572 100644 --- a/pkg/cmd/repo/clone/clone.go +++ b/pkg/cmd/repo/clone/clone.go @@ -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 } diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go index 34f2fe431..c6ec872be 100644 --- a/pkg/cmd/repo/clone/clone_test.go +++ b/pkg/cmd/repo/clone/clone_test.go @@ -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" @@ -104,6 +105,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) diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index b1eb2318e..931ffb926 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -1,6 +1,7 @@ package create import ( + "context" "errors" "fmt" "net/http" @@ -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/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -28,6 +28,7 @@ type iprompter interface { type CreateOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client Config func() (config.Config, error) IO *iostreams.IOStreams Prompter iprompter @@ -58,6 +59,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, Prompter: f.Prompter, } @@ -391,10 +393,10 @@ func createFromScratch(opts *CreateOptions) error { // use the template's default branch checkoutBranch = templateRepoMainBranch } - if err := localInit(opts.IO, remoteURL, repo.RepoName(), checkoutBranch); err != nil { + if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil { return err } - } else if _, err := git.RunClone(remoteURL, []string{}); err != nil { + } else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil { return err } } @@ -428,6 +430,7 @@ func createFromLocal(opts *CreateOptions) error { } repoPath := opts.Source + opts.GitClient.RepoDir = repoPath var baseRemote string if opts.Remote == "" { @@ -441,7 +444,7 @@ func createFromLocal(opts *CreateOptions) error { return err } - isRepo, err := isLocalRepo(repoPath) + isRepo, err := isLocalRepo(opts.GitClient) if err != nil { return err } @@ -452,7 +455,7 @@ func createFromLocal(opts *CreateOptions) error { return fmt.Errorf("%s is not a git repository. Run `git -C \"%s\" init` to initialize it", absPath, repoPath) } - committed, err := hasCommits(repoPath) + committed, err := hasCommits(opts.GitClient) if err != nil { return err } @@ -534,7 +537,7 @@ func createFromLocal(opts *CreateOptions) error { } } - if err := sourceInit(opts.IO, remoteURL, baseRemote, repoPath); err != nil { + if err := sourceInit(opts.GitClient, opts.IO, remoteURL, baseRemote); err != nil { return err } @@ -548,11 +551,11 @@ func createFromLocal(opts *CreateOptions) error { } if opts.Push { - repoPush, err := git.GitCommand("-C", repoPath, "push", "-u", baseRemote, "HEAD") + repoPush, err := opts.GitClient.Command(context.Background(), "push", "-u", baseRemote, "HEAD") if err != nil { return err } - err = run.PrepareCmd(repoPush).Run() + err = repoPush.Run() if err != nil { return err } @@ -564,17 +567,17 @@ func createFromLocal(opts *CreateOptions) error { return nil } -func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) error { +func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error { cs := io.ColorScheme() isTTY := io.IsStdoutTTY() stdout := io.Out - remoteAdd, err := git.GitCommand("-C", repoPath, "remote", "add", baseRemote, remoteURL) + remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL) if err != nil { return err } - err = run.PrepareCmd(remoteAdd).Run() + _, err = remoteAdd.Output() if err != nil { return fmt.Errorf("%s Unable to add remote %q", cs.FailureIcon(), baseRemote) } @@ -585,13 +588,12 @@ func sourceInit(io *iostreams.IOStreams, remoteURL, baseRemote, repoPath string) } // check if local repository has committed changes -func hasCommits(repoPath string) (bool, error) { - hasCommitsCmd, err := git.GitCommand("-C", repoPath, "rev-parse", "HEAD") +func hasCommits(gitClient *git.Client) (bool, error) { + hasCommitsCmd, err := gitClient.Command(context.Background(), "rev-parse", "HEAD") if err != nil { return false, err } - prepareCmd := run.PrepareCmd(hasCommitsCmd) - err = prepareCmd.Run() + _, err = hasCommitsCmd.Output() if err == nil { return true, nil } @@ -608,8 +610,8 @@ func hasCommits(repoPath string) (bool, error) { } // check if path is the top level directory of a git repo -func isLocalRepo(repoPath string) (bool, error) { - projectDir, projectDirErr := git.GetDirFromPath(repoPath) +func isLocalRepo(gitClient *git.Client) (bool, error) { + projectDir, projectDirErr := gitClient.GitDir(context.Background()) if projectDirErr != nil { var execError *exec.ExitError if errors.As(projectDirErr, &execError) { @@ -626,28 +628,26 @@ func isLocalRepo(repoPath string) (bool, error) { } // clone the checkout branch to specified path -func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) error { - gitInit, err := git.GitCommand("init", path) +func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error { + ctx := context.Background() + gitInit, err := gitClient.Command(ctx, "init", path) if err != nil { return err } - isTTY := io.IsStdoutTTY() - if isTTY { - gitInit.Stdout = io.Out - } - gitInit.Stderr = io.ErrOut - err = run.PrepareCmd(gitInit).Run() + _, err = gitInit.Output() if err != nil { return err } - gitRemoteAdd, err := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL) + // Clone the client so we do not modify the original client's RepoDir. + gc := cloneGitClient(gitClient) + gc.RepoDir = path + + gitRemoteAdd, err := gc.Command(ctx, "remote", "add", "origin", remoteURL) if err != nil { return err } - gitRemoteAdd.Stdout = io.Out - gitRemoteAdd.Stderr = io.ErrOut - err = run.PrepareCmd(gitRemoteAdd).Run() + _, err = gitRemoteAdd.Output() if err != nil { return err } @@ -656,24 +656,21 @@ func localInit(io *iostreams.IOStreams, remoteURL, path, checkoutBranch string) return nil } - gitFetch, err := git.GitCommand("-C", path, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) + gitFetch, err := gc.Command(ctx, "fetch", "origin", fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)) if err != nil { return err } - gitFetch.Stdout = io.Out - gitFetch.Stderr = io.ErrOut - err = run.PrepareCmd(gitFetch).Run() + err = gitFetch.Run() if err != nil { return err } - gitCheckout, err := git.GitCommand("-C", path, "checkout", checkoutBranch) + gitCheckout, err := gc.Command(ctx, "checkout", checkoutBranch) if err != nil { return err } - gitCheckout.Stdout = io.Out - gitCheckout.Stderr = io.ErrOut - return run.PrepareCmd(gitCheckout).Run() + _, err = gitCheckout.Output() + return err } func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) { @@ -738,3 +735,14 @@ func interactiveRepoInfo(prompter iprompter, defaultName string) (string, string return name, description, strings.ToUpper(visibilityOptions[selected]), nil } + +func cloneGitClient(c *git.Client) *git.Client { + return &git.Client{ + GhPath: c.GhPath, + RepoDir: c.RepoDir, + GitPath: c.GitPath, + Stderr: c.Stderr, + Stdin: c.Stdin, + Stdout: c.Stdout, + } +} diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 6c7c515d3..a1ce875f0 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -6,6 +6,7 @@ import ( "net/http" "testing" + "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" @@ -492,11 +493,7 @@ func Test_createRun(t *testing.T) { return config.NewBlankConfig(), nil } - cs, restoreRun := run.Stub() - defer restoreRun(t) - if tt.execStubs != nil { - tt.execStubs(cs) - } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} ios, _, stdout, stderr := iostreams.Test() ios.SetStdinTTY(tt.tty) @@ -504,6 +501,12 @@ func Test_createRun(t *testing.T) { tt.opts.IO = ios t.Run(tt.name, func(t *testing.T) { + cs, restoreRun := run.Stub() + defer restoreRun(t) + if tt.execStubs != nil { + tt.execStubs(cs) + } + defer reg.Verify(t) err := createRun(tt.opts) if tt.wantErr { diff --git a/pkg/cmd/repo/deploy-key/list/list.go b/pkg/cmd/repo/deploy-key/list/list.go index f682582ee..9504b3725 100644 --- a/pkg/cmd/repo/deploy-key/list/list.go +++ b/pkg/cmd/repo/deploy-key/list/list.go @@ -64,6 +64,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError(fmt.Sprintf("no deploy keys found in %s", ghrepo.FullName(repo))) } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter t := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() now := time.Now() diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index e3fe80247..e17e4b34d 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -1,6 +1,7 @@ package fork import ( + "context" "fmt" "net/http" "net/url" @@ -9,11 +10,10 @@ import ( "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" - "github.com/cli/cli/v2/internal/run" "github.com/cli/cli/v2/pkg/cmd/repo/shared" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" @@ -26,10 +26,11 @@ const defaultRemoteName = "origin" type ForkOptions 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) Since func(time.Time) time.Duration GitArgs []string @@ -52,6 +53,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman opts := &ForkOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Config: f.Config, BaseRepo: f.BaseRepo, Remotes: f.Remotes, @@ -227,6 +229,9 @@ func forkRun(opts *ForkOptions) error { } protocol, _ := cfg.Get(repoToFork.RepoHost(), "git_protocol") + gitClient := opts.GitClient + ctx := context.Background() + if inParent { remotes, err := opts.Remotes() if err != nil { @@ -265,6 +270,7 @@ func forkRun(opts *ForkOptions) error { return fmt.Errorf("failed to prompt: %w", err) } } + if remoteDesired { remoteName := opts.RemoteName remotes, err := opts.Remotes() @@ -275,11 +281,11 @@ func forkRun(opts *ForkOptions) error { if _, err := remotes.FindByName(remoteName); err == nil { if opts.Rename { renameTarget := "upstream" - renameCmd, err := git.GitCommand("remote", "rename", remoteName, renameTarget) + renameCmd, err := gitClient.Command(ctx, "remote", "rename", remoteName, renameTarget) if err != nil { return err } - err = run.PrepareCmd(renameCmd).Run() + _, err = renameCmd.Output() if err != nil { return err } @@ -290,7 +296,7 @@ func forkRun(opts *ForkOptions) error { forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - _, err = git.AddRemote(remoteName, forkedRepoCloneURL) + _, err = gitClient.AddRemote(ctx, remoteName, forkedRepoCloneURL, []string{}) if err != nil { return fmt.Errorf("failed to add remote: %w", err) } @@ -310,13 +316,13 @@ func forkRun(opts *ForkOptions) error { } if cloneDesired { forkedRepoURL := ghrepo.FormatRemoteURL(forkedRepo, protocol) - cloneDir, err := git.RunClone(forkedRepoURL, opts.GitArgs) + cloneDir, err := gitClient.Clone(ctx, forkedRepoURL, opts.GitArgs) if err != nil { return fmt.Errorf("failed to clone fork: %w", err) } upstreamURL := ghrepo.FormatRemoteURL(repoToFork, protocol) - err = git.AddNamedRemote(upstreamURL, "upstream", cloneDir, []string{}) + _, err = gitClient.AddRemote(ctx, "upstream", upstreamURL, []string{}, git.WithRepoDir(cloneDir)) if err != nil { return err } diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go index 111eb98f6..e5ffda36b 100644 --- a/pkg/cmd/repo/fork/fork_test.go +++ b/pkg/cmd/repo/fork/fork_test.go @@ -700,6 +700,8 @@ func TestRepoFork(t *testing.T) { return tt.remotes, nil } + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + //nolint:staticcheck // SA1019: prompt.InitAskStubber is deprecated: use NewAskStubber as, teardown := prompt.InitAskStubber() defer teardown() diff --git a/pkg/cmd/repo/list/list.go b/pkg/cmd/repo/list/list.go index 5a85485b1..a7e21e0ee 100644 --- a/pkg/cmd/repo/list/list.go +++ b/pkg/cmd/repo/list/list.go @@ -164,6 +164,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 _, repo := range listResult.Repositories { diff --git a/pkg/cmd/repo/rename/rename.go b/pkg/cmd/repo/rename/rename.go index 0b256d1d3..2d07cd8b0 100644 --- a/pkg/cmd/repo/rename/rename.go +++ b/pkg/cmd/repo/rename/rename.go @@ -1,13 +1,14 @@ package rename import ( + "context" "fmt" "net/http" "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" @@ -19,10 +20,11 @@ import ( type RenameOptions struct { HttpClient func() (*http.Client, error) + GitClient *git.Client IO *iostreams.IOStreams Config func() (config.Config, error) BaseRepo func() (ghrepo.Interface, error) - Remotes func() (context.Remotes, error) + Remotes func() (ghContext.Remotes, error) DoConfirm bool HasRepoOverride bool newRepoSelector string @@ -32,6 +34,7 @@ func NewCmdRename(f *cmdutil.Factory, runf func(*RenameOptions) error) *cobra.Co opts := &RenameOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + GitClient: f.GitClient, Remotes: f.Remotes, Config: f.Config, } @@ -145,7 +148,7 @@ func renameRun(opts *RenameOptions) error { return nil } -func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*context.Remote, error) { +func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameOptions) (*ghContext.Remote, error) { cfg, err := opts.Config() if err != nil { return nil, err @@ -167,6 +170,7 @@ func updateRemote(repo ghrepo.Interface, renamed ghrepo.Interface, opts *RenameO } remoteURL := ghrepo.FormatRemoteURL(renamed, protocol) - err = git.UpdateRemoteURL(remote.Name, remoteURL) + err = opts.GitClient.UpdateRemoteURL(context.Background(), remote.Name, remoteURL) + return remote, err } diff --git a/pkg/cmd/repo/rename/rename_test.go b/pkg/cmd/repo/rename/rename_test.go index 97e1aebb7..523b2ba4a 100644 --- a/pkg/cmd/repo/rename/rename_test.go +++ b/pkg/cmd/repo/rename/rename_test.go @@ -259,6 +259,8 @@ func TestRenameRun(t *testing.T) { ios.SetStdoutTTY(tt.tty) tt.opts.IO = ios + tt.opts.GitClient = &git.Client{GitPath: "some/path/git"} + t.Run(tt.name, func(t *testing.T) { defer reg.Verify(t) err := renameRun(&tt.opts) diff --git a/pkg/cmd/repo/sync/git.go b/pkg/cmd/repo/sync/git.go index 1ec86b053..fbb63a2ec 100644 --- a/pkg/cmd/repo/sync/git.go +++ b/pkg/cmd/repo/sync/git.go @@ -1,11 +1,11 @@ package sync import ( + "context" "fmt" "strings" "github.com/cli/cli/v2/git" - "github.com/cli/cli/v2/pkg/iostreams" ) type gitClient interface { @@ -22,12 +22,12 @@ type gitClient interface { } type gitExecuter struct { - io *iostreams.IOStreams + client *git.Client } func (g *gitExecuter) BranchRemote(branch string) (string, error) { args := []string{"rev-parse", "--symbolic-full-name", "--abbrev-ref", fmt.Sprintf("%s@{u}", branch)} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return "", err } @@ -40,60 +40,60 @@ func (g *gitExecuter) BranchRemote(branch string) (string, error) { } func (g *gitExecuter) UpdateBranch(branch, ref string) error { - cmd, err := git.GitCommand("update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) + cmd, err := g.client.Command(context.Background(), "update-ref", fmt.Sprintf("refs/heads/%s", branch), ref) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) CreateBranch(branch, ref, upstream string) error { - cmd, err := git.GitCommand("branch", branch, ref) + ctx := context.Background() + cmd, err := g.client.Command(ctx, "branch", branch, ref) if err != nil { return err } - if err := cmd.Run(); err != nil { + if _, err := cmd.Output(); err != nil { return err } - cmd, err = git.GitCommand("branch", "--set-upstream-to", upstream, branch) + cmd, err = g.client.Command(ctx, "branch", "--set-upstream-to", upstream, branch) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) CurrentBranch() (string, error) { - return git.CurrentBranch() + return g.client.CurrentBranch(context.Background()) } func (g *gitExecuter) Fetch(remote, ref string) error { args := []string{"fetch", "-q", remote, ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - cmd.Stdin = g.io.In - cmd.Stdout = g.io.Out - cmd.Stderr = g.io.ErrOut return cmd.Run() } func (g *gitExecuter) HasLocalBranch(branch string) bool { - return git.HasLocalBranch(branch) + return g.client.HasLocalBranch(context.Background(), branch) } func (g *gitExecuter) IsAncestor(ancestor, progeny string) (bool, error) { args := []string{"merge-base", "--is-ancestor", ancestor, progeny} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return false, err } - err = cmd.Run() + _, err = cmd.Output() return err == nil, nil } func (g *gitExecuter) IsDirty() (bool, error) { - cmd, err := git.GitCommand("status", "--untracked-files=no", "--porcelain") + cmd, err := g.client.Command(context.Background(), "status", "--untracked-files=no", "--porcelain") if err != nil { return false, err } @@ -109,18 +109,20 @@ func (g *gitExecuter) IsDirty() (bool, error) { func (g *gitExecuter) MergeFastForward(ref string) error { args := []string{"merge", "--ff-only", "--quiet", ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } func (g *gitExecuter) ResetHard(ref string) error { args := []string{"reset", "--hard", ref} - cmd, err := git.GitCommand(args...) + cmd, err := g.client.Command(context.Background(), args...) if err != nil { return err } - return cmd.Run() + _, err = cmd.Output() + return err } diff --git a/pkg/cmd/repo/sync/sync.go b/pkg/cmd/repo/sync/sync.go index 5e0841041..eebec4389 100644 --- a/pkg/cmd/repo/sync/sync.go +++ b/pkg/cmd/repo/sync/sync.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/http" - "regexp" "strings" "github.com/MakeNowJust/heredoc" @@ -17,6 +16,11 @@ import ( "github.com/spf13/cobra" ) +const ( + notFastForwardErrorMessage = "Update is not a fast forward" + branchDoesNotExistErrorMessage = "Reference does not exist" +) + type SyncOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams @@ -35,7 +39,7 @@ func NewCmdSync(f *cmdutil.Factory, runF func(*SyncOptions) error) *cobra.Comman IO: f.IOStreams, BaseRepo: f.BaseRepo, Remotes: f.Remotes, - Git: &gitExecuter{io: f.IOStreams}, + Git: &gitExecuter{client: f.GitClient}, } cmd := &cobra.Command{ @@ -315,11 +319,18 @@ func executeRemoteRepoSync(client *api.Client, destRepo, srcRepo ghrepo.Interfac // This is not a great way to detect the error returned by the API // Unfortunately API returns 422 for multiple reasons - notFastForwardErrorMessage := regexp.MustCompile(`^Update is not a fast forward$`) err = syncFork(client, destRepo, branchName, commit.Object.SHA, opts.Force) var httpErr api.HTTPError - if err != nil && errors.As(err, &httpErr) && notFastForwardErrorMessage.MatchString(httpErr.Message) { - return "", divergingError + if err != nil { + if errors.As(err, &httpErr) { + switch httpErr.Message { + case notFastForwardErrorMessage: + return "", divergingError + case branchDoesNotExistErrorMessage: + return "", fmt.Errorf("%s branch does not exist on %s repository", branchName, ghrepo.FullName(destRepo)) + } + } + return "", err } return fmt.Sprintf("%s:%s", srcRepo.RepoOwner(), branchName), nil diff --git a/pkg/cmd/repo/sync/sync_test.go b/pkg/cmd/repo/sync/sync_test.go index 8b72bf1f8..9e7e5b5b1 100644 --- a/pkg/cmd/repo/sync/sync_test.go +++ b/pkg/cmd/repo/sync/sync_test.go @@ -424,6 +424,39 @@ func Test_SyncRun(t *testing.T) { wantErr: true, errMsg: "can't sync because there are diverging changes; use `--force` to overwrite the destination branch", }, + { + name: "sync remote fork with parent and no existing branch on fork", + tty: true, + opts: &SyncOptions{ + DestArg: "OWNER/REPO-FORK", + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query RepositoryFindParent\b`), + httpmock.StringResponse(`{"data":{"repository":{"parent":{"name":"REPO","owner":{"login": "OWNER"}}}}}`)) + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(`{"data":{"repository":{"defaultBranchRef":{"name": "trunk"}}}}`)) + reg.Register( + httpmock.REST("POST", "repos/OWNER/REPO-FORK/merge-upstream"), + httpmock.StatusStringResponse(409, `{"message": "Merge conflict"}`)) + reg.Register( + httpmock.REST("GET", "repos/OWNER/REPO/git/refs/heads/trunk"), + httpmock.StringResponse(`{"object":{"sha":"0xDEADBEEF"}}`)) + reg.Register( + httpmock.REST("PATCH", "repos/OWNER/REPO-FORK/git/refs/heads/trunk"), + func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 422, + Request: req, + Header: map[string][]string{"Content-Type": {"application/json"}}, + Body: io.NopCloser(bytes.NewBufferString(`{"message":"Reference does not exist"}`)), + }, nil + }) + }, + wantErr: true, + errMsg: "trunk branch does not exist on OWNER/REPO-FORK repository", + }, } for _, tt := range tests { reg := &httpmock.Registry{} diff --git a/pkg/cmd/run/list/list.go b/pkg/cmd/run/list/list.go index 2bd57ca92..0fe685110 100644 --- a/pkg/cmd/run/list/list.go +++ b/pkg/cmd/run/list/list.go @@ -105,6 +105,9 @@ func listRun(opts *ListOptions) error { return fmt.Errorf("failed to get runs: %w", err) } runs := runsResult.WorkflowRuns + if len(runs) == 0 && opts.Exporter == nil { + return cmdutil.NewNoResultsError("no runs found") + } if err := opts.IO.StartPager(); err == nil { defer opts.IO.StopPager() @@ -116,10 +119,7 @@ func listRun(opts *ListOptions) error { return opts.Exporter.Write(opts.IO, runs) } - if len(runs) == 0 { - return cmdutil.NewNoResultsError("no runs found") - } - + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/search/repos/repos.go b/pkg/cmd/search/repos/repos.go index 23e5bfbef..25c796c37 100644 --- a/pkg/cmd/search/repos/repos.go +++ b/pkg/cmd/search/repos/repos.go @@ -138,6 +138,9 @@ func reposRun(opts *ReposOptions) error { if err != nil { return err } + if len(result.Items) == 0 && opts.Exporter == nil { + return cmdutil.NewNoResultsError("no repositories matched your search") + } if err := io.StartPager(); err == nil { defer io.StopPager() } else { @@ -146,9 +149,6 @@ func reposRun(opts *ReposOptions) error { if opts.Exporter != nil { return opts.Exporter.Write(io, result.Items) } - if len(result.Items) == 0 { - return cmdutil.NewNoResultsError("no repositories matched your search") - } return displayResults(io, opts.Now, result) } @@ -158,6 +158,7 @@ func displayResults(io *iostreams.IOStreams, now time.Time, results search.Repos now = time.Now() } cs := io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(io) for _, repo := range results.Items { tags := []string{visibilityLabel(repo)} diff --git a/pkg/cmd/search/shared/shared.go b/pkg/cmd/search/shared/shared.go index f670548b3..a5cdfe8dc 100644 --- a/pkg/cmd/search/shared/shared.go +++ b/pkg/cmd/search/shared/shared.go @@ -65,18 +65,7 @@ func SearchIssues(opts *IssuesOptions) error { if err != nil { return err } - - if err := io.StartPager(); err == nil { - defer io.StopPager() - } else { - fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err) - } - - if opts.Exporter != nil { - return opts.Exporter.Write(io, result.Items) - } - - if len(result.Items) == 0 { + if len(result.Items) == 0 && opts.Exporter == nil { var msg string switch opts.Entity { case Both: @@ -89,6 +78,16 @@ func SearchIssues(opts *IssuesOptions) error { return cmdutil.NewNoResultsError(msg) } + if err := io.StartPager(); err == nil { + defer io.StopPager() + } else { + fmt.Fprintf(io.ErrOut, "failed to start pager: %v\n", err) + } + + if opts.Exporter != nil { + return opts.Exporter.Write(io, result.Items) + } + return displayIssueResults(io, opts.Now, opts.Entity, result) } @@ -97,6 +96,7 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType, now = time.Now() } cs := io.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(io) for _, issue := range results.Items { if et == Both { @@ -114,12 +114,12 @@ func displayIssueResults(io *iostreams.IOStreams, now time.Time, et EntityType, issueNum = "#" + issueNum } if issue.IsPullRequest() { - tp.AddField(issueNum, nil, cs.ColorFromString(colorForPRState(issue.State))) + tp.AddField(issueNum, nil, cs.ColorFromString(colorForPRState(issue.State()))) } else { - tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State, issue.StateReason))) + tp.AddField(issueNum, nil, cs.ColorFromString(colorForIssueState(issue.State(), issue.StateReason))) } if !tp.IsTTY() { - tp.AddField(issue.State, nil, nil) + tp.AddField(issue.State(), nil, nil) } tp.AddField(text.RemoveExcessiveWhitespace(issue.Title), nil, nil) tp.AddField(listIssueLabels(&issue, cs, tp.IsTTY()), nil, nil) diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go index fe6c8c57b..8c4d4ca45 100644 --- a/pkg/cmd/search/shared/shared_test.go +++ b/pkg/cmd/search/shared/shared_test.go @@ -54,9 +54,9 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/blah/test", Number: 789, State: "open", Title: "some title", UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/blah/test", Number: 789, StateInternal: "open", Title: "some title", UpdatedAt: updatedAt}, }, Total: 300, }, nil @@ -76,8 +76,8 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequestLinks: search.PullRequestLinks{URL: "someurl"}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequest: search.PullRequest{URL: "someurl"}, UpdatedAt: updatedAt}, }, Total: 300, }, nil @@ -97,9 +97,9 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/blah/test", Number: 789, State: "open", Title: "some title", UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "something broken", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "closed", Title: "feature request", Labels: []search.Label{{Name: "enhancement"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/blah/test", Number: 789, StateInternal: "open", Title: "some title", UpdatedAt: updatedAt}, }, Total: 300, }, nil @@ -118,8 +118,8 @@ func TestSearchIssues(t *testing.T) { return search.IssuesResult{ IncompleteResults: false, Items: []search.Issue{ - {RepositoryURL: "github.com/test/cli", Number: 123, State: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, - {RepositoryURL: "github.com/what/what", Number: 456, State: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequestLinks: search.PullRequestLinks{URL: "someurl"}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/test/cli", Number: 123, StateInternal: "open", Title: "bug", Labels: []search.Label{{Name: "bug"}, {Name: "p1"}}, UpdatedAt: updatedAt}, + {RepositoryURL: "github.com/what/what", Number: 456, StateInternal: "open", Title: "fix bug", Labels: []search.Label{{Name: "fix"}}, PullRequest: search.PullRequest{URL: "someurl"}, UpdatedAt: updatedAt}, }, Total: 300, }, nil diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go index a7336570c..4c1a1d01d 100644 --- a/pkg/cmd/secret/list/list.go +++ b/pkg/cmd/secret/list/list.go @@ -146,6 +146,7 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) for _, secret := range secrets { tp.AddField(secret.Name, nil, nil) diff --git a/pkg/cmd/ssh-key/delete/delete.go b/pkg/cmd/ssh-key/delete/delete.go new file mode 100644 index 000000000..a11cbd769 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/delete.go @@ -0,0 +1,87 @@ +package delete + +import ( + "fmt" + "net/http" + + "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 ", + Short: "Delete an SSH 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() + key, err := getSSHKey(httpClient, host, opts.KeyID) + if err != nil { + return err + } + + if !opts.Confirmed { + if err := opts.Prompter.ConfirmDeletion(key.Title); err != nil { + return err + } + } + + err = deleteSSHKey(httpClient, host, opts.KeyID) + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + cs := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "%s SSH key %q (%s) deleted from your account\n", cs.SuccessIcon(), key.Title, opts.KeyID) + } + return nil +} diff --git a/pkg/cmd/ssh-key/delete/delete_test.go b/pkg/cmd/ssh-key/delete/delete_test.go new file mode 100644 index 000000000..437443c55 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/delete_test.go @@ -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: "123", + output: DeleteOptions{KeyID: "123", Confirmed: false}, + }, + { + name: "confirm flag tty", + tty: true, + input: "123 --confirm", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "shorthand confirm flag tty", + tty: true, + input: "123 -y", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "no tty", + input: "123", + wantErr: true, + wantErrMsg: "--confirm required when not running interactively", + }, + { + name: "confirm flag no tty", + input: "123 --confirm", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "shorthand confirm flag no tty", + input: "123 -y", + output: DeleteOptions{KeyID: "123", Confirmed: true}, + }, + { + name: "no args", + input: "", + wantErr: true, + wantErrMsg: "cannot delete: key id required", + }, + { + name: "too many args", + input: "123 456", + 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) { + keyResp := "{\"title\":\"My Key\"}" + 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: "123"}, + prompterStubs: func(pm *prompter.PrompterMock) { + pm.ConfirmDeletionFunc = func(_ string) error { + return nil + } + }, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ SSH key \"My Key\" (123) deleted from your account\n", + }, + { + name: "delete with confirm flag tty", + tty: true, + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "✓ SSH key \"My Key\" (123) deleted from your account\n", + }, + { + name: "not found tty", + tty: true, + opts: DeleteOptions{KeyID: "123"}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(404, "")) + }, + wantErr: true, + wantErrMsg: "HTTP 404 (https://api.github.com/user/keys/123)", + }, + { + name: "delete no tty", + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(200, keyResp)) + reg.Register(httpmock.REST("DELETE", "user/keys/123"), httpmock.StatusStringResponse(204, "")) + }, + wantStdout: "", + }, + { + name: "not found no tty", + opts: DeleteOptions{KeyID: "123", Confirmed: true}, + httpStubs: func(reg *httpmock.Registry) { + reg.Register(httpmock.REST("GET", "user/keys/123"), httpmock.StatusStringResponse(404, "")) + }, + wantErr: true, + wantErrMsg: "HTTP 404 (https://api.github.com/user/keys/123)", + }, + } + + 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()) + }) + } +} diff --git a/pkg/cmd/ssh-key/delete/http.go b/pkg/cmd/ssh-key/delete/http.go new file mode 100644 index 000000000..906ae6bc9 --- /dev/null +++ b/pkg/cmd/ssh-key/delete/http.go @@ -0,0 +1,66 @@ +package delete + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/ghinstance" +) + +type sshKey struct { + Title string +} + +func deleteSSHKey(httpClient *http.Client, host string, keyID string) error { + url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) + 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 getSSHKey(httpClient *http.Client, host string, keyID string) (*sshKey, error) { + url := fmt.Sprintf("%suser/keys/%s", ghinstance.RESTPrefix(host), keyID) + 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 key sshKey + err = json.Unmarshal(b, &key) + if err != nil { + return nil, err + } + + return &key, nil +} diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go index 5bde29b9e..376404d42 100644 --- a/pkg/cmd/ssh-key/list/list.go +++ b/pkg/cmd/ssh-key/list/list.go @@ -64,6 +64,7 @@ func listRun(opts *ListOptions) error { return cmdutil.NewNoResultsError("no SSH 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() diff --git a/pkg/cmd/ssh-key/ssh_key.go b/pkg/cmd/ssh-key/ssh_key.go index 312aeb779..809985737 100644 --- a/pkg/cmd/ssh-key/ssh_key.go +++ b/pkg/cmd/ssh-key/ssh_key.go @@ -2,6 +2,7 @@ package key import ( cmdAdd "github.com/cli/cli/v2/pkg/cmd/ssh-key/add" + cmdDelete "github.com/cli/cli/v2/pkg/cmd/ssh-key/delete" cmdList "github.com/cli/cli/v2/pkg/cmd/ssh-key/list" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/spf13/cobra" @@ -14,8 +15,9 @@ func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command { Long: "Manage SSH 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 } diff --git a/pkg/cmd/status/status.go b/pkg/cmd/status/status.go index 69a53686e..c73db711a 100644 --- a/pkg/cmd/status/status.go +++ b/pkg/cmd/status/status.go @@ -664,6 +664,7 @@ func statusRun(opts *StatusOptions) error { section := func(header string, items []StatusItem, width, rowLimit int) (string, error) { tableOut := &bytes.Buffer{} fmt.Fprintln(tableOut, cs.Bold(header)) + //nolint:staticcheck // SA1019: utils.NewTablePrinterWithOptions is deprecated: use internal/tableprinter tp := utils.NewTablePrinterWithOptions(opts.IO, utils.TablePrinterOptions{ IsTTY: opts.IO.IsStdoutTTY(), MaxWidth: width, diff --git a/pkg/cmd/workflow/list/list.go b/pkg/cmd/workflow/list/list.go index e2c897ab8..a18ebd065 100644 --- a/pkg/cmd/workflow/list/list.go +++ b/pkg/cmd/workflow/list/list.go @@ -92,6 +92,7 @@ func listRun(opts *ListOptions) error { fmt.Fprintf(opts.IO.ErrOut, "failed to start pager: %v\n", err) } + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) cs := opts.IO.ColorScheme() diff --git a/pkg/cmd/workflow/view/view.go b/pkg/cmd/workflow/view/view.go index 7d087c989..4dc4e7658 100644 --- a/pkg/cmd/workflow/view/view.go +++ b/pkg/cmd/workflow/view/view.go @@ -205,6 +205,7 @@ func viewWorkflowInfo(opts *ViewOptions, client *api.Client, repo ghrepo.Interfa out := opts.IO.Out cs := opts.IO.ColorScheme() + //nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter tp := utils.NewTablePrinter(opts.IO) // Header diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index d7357f1fa..e00e1a89a 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -7,6 +7,7 @@ import ( "strings" "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" "github.com/cli/cli/v2/internal/ghrepo" @@ -19,6 +20,7 @@ type Factory struct { IOStreams *iostreams.IOStreams Prompter prompter.Prompter Browser browser.Browser + GitClient *git.Client HttpClient func() (*http.Client, error) BaseRepo func() (ghrepo.Interface, error) diff --git a/pkg/liveshare/session.go b/pkg/liveshare/session.go index f912fa5ea..40bdf287b 100644 --- a/pkg/liveshare/session.go +++ b/pkg/liveshare/session.go @@ -123,6 +123,20 @@ func (s *Session) StartJupyterServer(ctx context.Context) (int, string, error) { return port, response.ServerUrl, nil } +func (s *Session) RebuildContainer(ctx context.Context) error { + var rebuildSuccess bool + err := s.rpc.do(ctx, "IEnvironmentConfigurationService.rebuildContainer", nil, &rebuildSuccess) + if err != nil { + return fmt.Errorf("invoking rebuild RPC: %w", err) + } + + if !rebuildSuccess { + return fmt.Errorf("couldn't rebuild codespace") + } + + return nil +} + // heartbeat runs until context cancellation, periodically checking whether there is a // reason to keep the connection alive, and if so, notifying the Live Share host to do so. // Heartbeat ensures it does not send more than one request every "interval" to ratelimit diff --git a/pkg/liveshare/session_test.go b/pkg/liveshare/session_test.go index cfe8ccd11..06000c344 100644 --- a/pkg/liveshare/session_test.go +++ b/pkg/liveshare/session_test.go @@ -399,6 +399,30 @@ func TestSessionHeartbeat(t *testing.T) { } } +func TestRebuild(t *testing.T) { + requestCount := 0 + getSharedServers := func(conn *jsonrpc2.Conn, req *jsonrpc2.Request) (interface{}, error) { + requestCount++ + return true, nil + } + testServer, session, err := makeMockSession( + livesharetest.WithService("IEnvironmentConfigurationService.rebuildContainer", getSharedServers), + ) + if err != nil { + t.Fatalf("creating mock session: %v", err) + } + defer testServer.Close() + + err = session.RebuildContainer(context.Background()) + if err != nil { + t.Fatalf("rebuilding codespace via mock session: %v", err) + } + + if requestCount == 0 { + t.Fatalf("no requests were made") + } +} + type mockLogger struct { sync.Mutex buf *bytes.Buffer diff --git a/pkg/search/result.go b/pkg/search/result.go index 4447f48ee..6ef1b8bc0 100644 --- a/pkg/search/result.go +++ b/pkg/search/result.go @@ -117,28 +117,42 @@ type User struct { } type Issue struct { - Assignees []User `json:"assignees"` - Author User `json:"user"` - AuthorAssociation string `json:"author_association"` - Body string `json:"body"` - ClosedAt time.Time `json:"closed_at"` - CommentsCount int `json:"comments"` - CreatedAt time.Time `json:"created_at"` - ID string `json:"node_id"` - Labels []Label `json:"labels"` - IsLocked bool `json:"locked"` - Number int `json:"number"` - PullRequestLinks PullRequestLinks `json:"pull_request"` - RepositoryURL string `json:"repository_url"` - State string `json:"state"` - StateReason string `json:"state_reason"` - Title string `json:"title"` - URL string `json:"html_url"` - UpdatedAt time.Time `json:"updated_at"` + Assignees []User `json:"assignees"` + Author User `json:"user"` + AuthorAssociation string `json:"author_association"` + Body string `json:"body"` + ClosedAt time.Time `json:"closed_at"` + CommentsCount int `json:"comments"` + CreatedAt time.Time `json:"created_at"` + ID string `json:"node_id"` + Labels []Label `json:"labels"` + IsLocked bool `json:"locked"` + Number int `json:"number"` + PullRequest PullRequest `json:"pull_request"` + RepositoryURL string `json:"repository_url"` + // StateInternal should not be used directly. Use State() instead. + StateInternal string `json:"state"` + StateReason string `json:"state_reason"` + Title string `json:"title"` + URL string `json:"html_url"` + UpdatedAt time.Time `json:"updated_at"` } -type PullRequestLinks struct { - URL string `json:"html_url"` +type PullRequest struct { + URL string `json:"html_url"` + MergedAt time.Time `json:"merged_at"` +} + +// the state of an issue or a pull request, +// may be either open or closed. +// for a pull request, the "merged" state is +// inferred from a value for merged_at and +// which we take return instead of the "closed" state. +func (issue Issue) State() string { + if !issue.PullRequest.MergedAt.IsZero() { + return "merged" + } + return issue.StateInternal } type Label struct { @@ -175,7 +189,7 @@ func (repo Repository) ExportData(fields []string) map[string]interface{} { } func (issue Issue) IsPullRequest() bool { - return issue.PullRequestLinks.URL != "" + return issue.PullRequest.URL != "" } func (issue Issue) ExportData(fields []string) map[string]interface{} { @@ -220,6 +234,8 @@ func (issue Issue) ExportData(fields []string) map[string]interface{} { "name": name, "nameWithOwner": nameWithOwner, } + case "state": + data[f] = issue.State() default: sf := fieldByName(v, f) data[f] = sf.Interface() diff --git a/pkg/search/result_test.go b/pkg/search/result_test.go index ba173e591..c933eb304 100644 --- a/pkg/search/result_test.go +++ b/pkg/search/result_test.go @@ -68,6 +68,26 @@ func TestIssueExportData(t *testing.T) { }, output: `{"assignees":[{"id":"","login":"test","type":""}],"body":"body","commentsCount":1,"isLocked":true,"labels":[{"color":"","description":"","id":"","name":"label1"},{"color":"","description":"","id":"","name":"label2"}],"repository":{"name":"repo","nameWithOwner":"owner/repo"},"title":"title","updatedAt":"2021-02-28T12:30:00Z"}`, }, + { + name: "state when issue", + fields: []string{"isPullRequest", "state"}, + issue: Issue{ + StateInternal: "closed", + }, + output: `{"isPullRequest":false,"state":"closed"}`, + }, + { + name: "state when pull request", + fields: []string{"isPullRequest", "state"}, + issue: Issue{ + PullRequest: PullRequest{ + MergedAt: time.Now(), + URL: "a-url", + }, + StateInternal: "closed", + }, + output: `{"isPullRequest":true,"state":"merged"}`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/utils/table_printer.go b/utils/table_printer.go index 1dfd8ef25..73f65a6a6 100644 --- a/utils/table_printer.go +++ b/utils/table_printer.go @@ -1,13 +1,11 @@ package utils import ( - "fmt" "io" - "sort" "strings" - "github.com/cli/cli/v2/internal/text" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/go-gh/pkg/tableprinter" ) type TablePrinter interface { @@ -23,12 +21,14 @@ type TablePrinterOptions struct { Out io.Writer } +// Deprecated: use internal/tableprinter func NewTablePrinter(io *iostreams.IOStreams) TablePrinter { return NewTablePrinterWithOptions(io, TablePrinterOptions{ IsTTY: io.IsStdoutTTY(), }) } +// Deprecated: use internal/tableprinter func NewTablePrinterWithOptions(ios *iostreams.IOStreams, opts TablePrinterOptions) TablePrinter { var out io.Writer if opts.Out != nil { @@ -36,212 +36,54 @@ func NewTablePrinterWithOptions(ios *iostreams.IOStreams, opts TablePrinterOptio } else { out = ios.Out } + var maxWidth int if opts.IsTTY { - var maxWidth int if opts.MaxWidth > 0 { maxWidth = opts.MaxWidth } else { maxWidth = ios.TerminalWidth() } - return &ttyTablePrinter{ - out: out, - maxWidth: maxWidth, - } } - return &tsvTablePrinter{ - out: out, + tp := tableprinter.New(out, opts.IsTTY, maxWidth) + return &printer{ + tp: tp, + isTTY: opts.IsTTY, } } -type tableField struct { - Text string - TruncateFunc func(int, string) string - ColorFunc func(string) string +type printer struct { + tp tableprinter.TablePrinter + colIndex int + isTTY bool } -func (f *tableField) DisplayWidth() int { - return text.DisplayWidth(f.Text) +func (p printer) IsTTY() bool { + return p.isTTY } -type ttyTablePrinter struct { - out io.Writer - maxWidth int - rows [][]tableField -} - -func (t ttyTablePrinter) IsTTY() bool { - return true -} - -func (t *ttyTablePrinter) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) { +func (p *printer) AddField(s string, truncateFunc func(int, string) string, colorFunc func(string) string) { if truncateFunc == nil { - truncateFunc = text.Truncate - } - if t.rows == nil { - t.rows = make([][]tableField, 1) - } - rowI := len(t.rows) - 1 - field := tableField{ - Text: s, - TruncateFunc: truncateFunc, - ColorFunc: colorFunc, - } - t.rows[rowI] = append(t.rows[rowI], field) -} - -func (t *ttyTablePrinter) EndRow() { - t.rows = append(t.rows, []tableField{}) -} - -func (t *ttyTablePrinter) Render() error { - if len(t.rows) == 0 { - return nil - } - - delim := " " - numCols := len(t.rows[0]) - colWidths := t.calculateColumnWidths(len(delim)) - - for _, row := range t.rows { - for col, field := range row { - if col > 0 { - _, err := fmt.Fprint(t.out, delim) - if err != nil { - return err - } - } - truncVal := field.TruncateFunc(colWidths[col], field.Text) - if col < numCols-1 { - // pad value with spaces on the right - if padWidth := colWidths[col] - field.DisplayWidth(); padWidth > 0 { - truncVal += strings.Repeat(" ", padWidth) - } - } - if field.ColorFunc != nil { - truncVal = field.ColorFunc(truncVal) - } - _, err := fmt.Fprint(t.out, truncVal) - if err != nil { - return err - } - } - if len(row) > 0 { - _, err := fmt.Fprint(t.out, "\n") - if err != nil { - return err - } + // Disallow ever truncating the 1st colum or any URL value + if p.colIndex == 0 || isURL(s) { + p.tp.AddField(s, tableprinter.WithTruncate(nil), tableprinter.WithColor(colorFunc)) + } else { + p.tp.AddField(s, tableprinter.WithColor(colorFunc)) } + } else { + p.tp.AddField(s, tableprinter.WithTruncate(truncateFunc), tableprinter.WithColor(colorFunc)) } - return nil + p.colIndex++ } -func (t *ttyTablePrinter) calculateColumnWidths(delimSize int) []int { - numCols := len(t.rows[0]) - allColWidths := make([][]int, numCols) - for _, row := range t.rows { - for col, field := range row { - allColWidths[col] = append(allColWidths[col], field.DisplayWidth()) - } - } - - // calculate max & median content width per column - maxColWidths := make([]int, numCols) - // medianColWidth := make([]int, numCols) - for col := 0; col < numCols; col++ { - widths := allColWidths[col] - sort.Ints(widths) - maxColWidths[col] = widths[len(widths)-1] - // medianColWidth[col] = widths[(len(widths)+1)/2] - } - - colWidths := make([]int, numCols) - // never truncate the first column - colWidths[0] = maxColWidths[0] - // never truncate the last column if it contains URLs - if strings.HasPrefix(t.rows[0][numCols-1].Text, "https://") { - colWidths[numCols-1] = maxColWidths[numCols-1] - } - - availWidth := func() int { - setWidths := 0 - for col := 0; col < numCols; col++ { - setWidths += colWidths[col] - } - return t.maxWidth - delimSize*(numCols-1) - setWidths - } - numFixedCols := func() int { - fixedCols := 0 - for col := 0; col < numCols; col++ { - if colWidths[col] > 0 { - fixedCols++ - } - } - return fixedCols - } - - // set the widths of short columns - if w := availWidth(); w > 0 { - if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 { - perColumn := w / numFlexColumns - for col := 0; col < numCols; col++ { - if max := maxColWidths[col]; max < perColumn { - colWidths[col] = max - } - } - } - } - - firstFlexCol := -1 - // truncate long columns to the remaining available width - if numFlexColumns := numCols - numFixedCols(); numFlexColumns > 0 { - perColumn := availWidth() / numFlexColumns - for col := 0; col < numCols; col++ { - if colWidths[col] == 0 { - if firstFlexCol == -1 { - firstFlexCol = col - } - if max := maxColWidths[col]; max < perColumn { - colWidths[col] = max - } else if perColumn > 0 { - colWidths[col] = perColumn - } - } - } - } - - // add remainder to the first flex column - if w := availWidth(); w > 0 && firstFlexCol > -1 { - colWidths[firstFlexCol] += w - if max := maxColWidths[firstFlexCol]; max < colWidths[firstFlexCol] { - colWidths[firstFlexCol] = max - } - } - - return colWidths +func (p *printer) EndRow() { + p.tp.EndRow() + p.colIndex = 0 } -type tsvTablePrinter struct { - out io.Writer - currentCol int +func (p *printer) Render() error { + return p.tp.Render() } -func (t tsvTablePrinter) IsTTY() bool { - return false -} - -func (t *tsvTablePrinter) AddField(text string, _ func(int, string) string, _ func(string) string) { - if t.currentCol > 0 { - fmt.Fprint(t.out, "\t") - } - fmt.Fprint(t.out, text) - t.currentCol++ -} - -func (t *tsvTablePrinter) EndRow() { - fmt.Fprint(t.out, "\n") - t.currentCol = 0 -} - -func (t *tsvTablePrinter) Render() error { - return nil +func isURL(s string) bool { + return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://") } diff --git a/utils/table_printer_test.go b/utils/table_printer_test.go deleted file mode 100644 index 2e3e4e803..000000000 --- a/utils/table_printer_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package utils - -import ( - "bytes" - "testing" -) - -func Test_ttyTablePrinter_truncate(t *testing.T) { - buf := bytes.Buffer{} - tp := &ttyTablePrinter{ - out: &buf, - maxWidth: 5, - } - - tp.AddField("1", nil, nil) - tp.AddField("hello", nil, nil) - tp.EndRow() - tp.AddField("2", nil, nil) - tp.AddField("world", nil, nil) - tp.EndRow() - - err := tp.Render() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - - expected := "1 he\n2 wo\n" - if buf.String() != expected { - t.Errorf("expected: %q, got: %q", expected, buf.String()) - } -}