Merge branch 'trunk' into repo-delete

This commit is contained in:
Meijke 2021-10-12 11:02:27 -07:00 committed by GitHub
commit 8c9049a364
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 975 additions and 576 deletions

10
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,10 @@
version: 2
updates:
- package-ecosystem: gomod
directory: "/"
schedule:
interval: "daily"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

View file

@ -2,7 +2,11 @@ name: Code Scanning
on:
push:
branches: [trunk]
pull_request:
branches: [trunk]
paths-ignore:
- '**/*.md'
schedule:
- cron: "0 0 * * 0"

View file

@ -15,6 +15,7 @@ jobs:
PRNUM: ${{ github.event.pull_request.number }}
PRHEAD: ${{ github.event.pull_request.head.label }}
PRAUTHOR: ${{ github.event.pull_request.user.login }}
PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
if: "!github.event.pull_request.draft"
run: |
commentPR () {
@ -42,7 +43,7 @@ jobs:
' -f colID="$(colID "Needs review")" -f prID="$PRID"
}
if gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null
if [ "$PR_AUTHOR_TYPE" = "Bot" ] || gh api orgs/cli/public_members/$PRAUTHOR --silent 2>/dev/null
then
if ! errtext="$(addToBoard 2>&1)"
then

5
CODEOWNERS Normal file
View file

@ -0,0 +1,5 @@
* @cli/code-reviewers
pkg/cmd/codespace/* @cli/codespaces
pkg/liveshare/* @cli/codespaces
internal/codespaces/* @cli/codespaces

View file

@ -19,7 +19,7 @@ If anything feels off, or if you feel that some functionality is missing, please
### macOS
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], and as a downloadable binary from the [releases page][].
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], [Spack][], and as a downloadable binary from the [releases page][].
#### Homebrew
@ -41,15 +41,21 @@ If anything feels off, or if you feel that some functionality is missing, please
Additional Conda installation options available on the [gh-feedstock page](https://github.com/conda-forge/gh-feedstock#installing-gh).
#### Spack
| Install: | Upgrade: |
| ------------------ | ---------------------------------------- |
| `spack install gh` | `spack uninstall gh && spack install gh` |
### Linux & BSD
`gh` is available via [Homebrew](#homebrew), [Conda](#Conda), and as downloadable binaries from the [releases page][].
`gh` is available via [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), and as downloadable binaries from the [releases page][].
For instructions on specific distributions and package managers, see [Linux & BSD installation](./docs/install_linux.md).
### Windows
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#Conda), and as downloadable MSI.
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#conda), and as downloadable MSI.
#### WinGet
@ -99,6 +105,7 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
[scoop]: https://scoop.sh
[Chocolatey]: https://chocolatey.org
[Conda]: https://docs.conda.io/en/latest/
[Spack]: https://spack.io
[releases page]: https://github.com/cli/cli/releases/latest
[hub]: https://github.com/github/hub
[contributing]: ./.github/CONTRIBUTING.md

View file

@ -2,9 +2,7 @@ package api
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
@ -267,30 +265,6 @@ func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
}
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) {
url := fmt.Sprintf("%srepos/%s/pulls/%d",
ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github.v3.diff; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode == 404 {
return nil, errors.New("pull request not found")
} else if resp.StatusCode != 200 {
return nil, HandleHTTPError(resp)
}
return resp.Body, nil
}
type pullRequestFeature struct {
HasReviewDecision bool
HasStatusCheckRollup bool

View file

@ -230,6 +230,25 @@ func (r Repository) ViewerCanTriage() bool {
}
}
func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*Repository, error) {
query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {%s}
}`, RepositoryGraphQL(fields))
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
}
var result struct {
Repository Repository
}
if err := client.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
return nil, err
}
return InitRepoHostname(&result.Repository, repo.RepoHost()), nil
}
func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
query := `
fragment repo on Repository {

22
go.mod
View file

@ -5,27 +5,27 @@ go 1.16
require (
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.11.1
github.com/briandowns/spinner v1.16.0
github.com/charmbracelet/glamour v0.3.0
github.com/cli/browser v1.1.0
github.com/cli/oauth v0.8.0
github.com/cli/safeexec v1.0.0
github.com/cpuguy83/go-md2man/v2 v2.0.0
github.com/creack/pty v1.1.13
github.com/cpuguy83/go-md2man/v2 v2.0.1
github.com/creack/pty v1.1.16
github.com/fatih/camelcase v1.0.0
github.com/gabriel-vasile/mimetype v1.1.2
github.com/google/go-cmp v0.5.5
github.com/gabriel-vasile/mimetype v1.4.0
github.com/google/go-cmp v0.5.6
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/go-version v1.2.1
github.com/hashicorp/go-version v1.3.0
github.com/henvic/httpretty v0.0.6
github.com/itchyny/gojq v0.12.4
github.com/itchyny/gojq v0.12.5
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.8
github.com/mattn/go-isatty v0.0.13
github.com/mattn/go-colorable v0.1.11
github.com/mattn/go-isatty v0.0.14
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5
github.com/muesli/termenv v0.8.1
github.com/muesli/termenv v0.9.0
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
github.com/olekukonko/tablewriter v0.0.5
github.com/opentracing/opentracing-go v1.1.0
@ -39,7 +39,7 @@ require (
github.com/stretchr/testify v1.7.0
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6
golang.org/x/term v0.0.0-20210503060354-a79de5458b56
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
)

56
go.sum
View file

@ -62,8 +62,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
github.com/bketelsen/crypt v0.0.4/go.mod h1:aI6NrJ0pMGgvZKL1iVgXLnfIFJtfV+bKCoqOes/6LfM=
github.com/briandowns/spinner v1.11.1 h1:OixPqDEcX3juo5AjQZAnFPbeUA0jvkp2qzB5gOZJ/L0=
github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/briandowns/spinner v1.16.0 h1:DFmp6hEaIx2QXXuqSJmtfSBSAjRmpGiKG6ip2Wm/yOs=
github.com/briandowns/spinner v1.16.0/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/glamour v0.3.0 h1:3H+ZrKlSg8s+WU6V7eF2eRVYt8lCueffbi7r2+ffGkc=
github.com/charmbracelet/glamour v0.3.0/go.mod h1:TzF0koPZhqq0YVBNL100cPHznAAjVj7fksX2RInwjGw=
@ -87,10 +87,11 @@ github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnht
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.13 h1:rTPnd/xocYRjutMfqide2zle1u96upp1gm6eUHKi7us=
github.com/creack/pty v1.1.13/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.16 h1:vfetlOf3A+9YKggibynnX9mnFjuSVvkRj+IWpcTSLEQ=
github.com/creack/pty v1.1.16/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -110,8 +111,8 @@ github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwo
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.1.2 h1:gaPnPcNor5aZSVCJVSGipcpbgMWiAAj9z182ocSGbHU=
github.com/gabriel-vasile/mimetype v1.1.2/go.mod h1:6CDPel/o/3/s4+bp6kIbsWATq8pmgOisOPG40CJa6To=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -160,8 +161,9 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
@ -203,8 +205,8 @@ github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerX
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go-version v1.3.0 h1:McDWVJIU/y+u1BRV06dPaLfLCaT7fUTJLp5r04x7iNw=
github.com/hashicorp/go-version v1.3.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/go.net v0.0.1/go.mod h1:hjKkEWcCURg++eb33jQU7oqQcI9XDCnUzHA0oac0k90=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@ -222,8 +224,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA=
github.com/itchyny/gojq v0.12.4 h1:8zgOZWMejEWCLjbF/1mWY7hY7QEARm7dtuhC6Bp4R8o=
github.com/itchyny/gojq v0.12.4/go.mod h1:EQUSKgW/YaOxmXpAwGiowFDO4i2Rmtk5+9dFyeiymAg=
github.com/itchyny/gojq v0.12.5 h1:6SJ1BQ1VAwJAlIvLSIZmqHP/RUEq3qfVWvsRxrqhsD0=
github.com/itchyny/gojq v0.12.5/go.mod h1:3e1hZXv+Kwvdp6V9HXpVrvddiHVApi5EDZwS+zLFeiE=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@ -248,16 +250,18 @@ github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPK
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.11 h1:nQ+aFkoE2TMGc0b68U2OKSexC+eq46+XwZzWXHRmPYs=
github.com/mattn/go-colorable v0.1.11/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.13 h1:qdl+GuBjcsKKDco5BsxPJlId98mSWNKqYA+Co0SC1yA=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10 h1:CoZ3S2P7pvtP45xOtBw+/mDL2z0RKI576gSkzRRpdGg=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU=
github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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=
@ -278,8 +282,9 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
github.com/muesli/reflow v0.2.0/go.mod h1:qT22vjVmM9MIUeLgsVYe/Ye7eZlbv9dZjL3dVhUqLX8=
github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5 h1:T+Fc6qGlSfM+z0JPlp+n5rijvlg6C6JYFSNaqnCifDU=
github.com/muesli/reflow v0.2.1-0.20210502190812-c80126ec2ad5/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/termenv v0.8.1 h1:9q230czSP3DHVpkaPDXGp0TOfAwyjyYwXlUCQxQSaBk=
github.com/muesli/termenv v0.8.1/go.mod h1:kzt/D/4a88RoheZmwfqorY3A+tnsSMA9HJC/fQSFKo0=
github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@ -300,15 +305,15 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b h1:0/ecDXh/HTHRtSDSFnD2/Ta1yQ5J76ZspVY4u0/jGFk=
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
@ -428,8 +433,9 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125 h1:Ugb8sMTWuWRC3+sz5WeN/4kejDx9BvIwnPUiJBjJE+8=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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=
@ -499,10 +505,13 @@ golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56 h1:b8jxX3zqjpqb2LklXPzKSGJhzyxCOZSz8ncv8Nv+y7w=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
@ -512,8 +521,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.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5 h1:i6eZZ+zk0SOf0xgBpEpPD18qWcJda6q1sxt3S0kzyUQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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=

View file

@ -34,15 +34,19 @@ import (
"fmt"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/cli/cli/v2/api"
"github.com/opentracing/opentracing-go"
)
const githubAPI = "https://api.github.com"
// API is the interface to the codespace service.
type API struct {
token string
client httpClient
@ -53,6 +57,7 @@ type httpClient interface {
Do(req *http.Request) (*http.Response, error)
}
// New creates a new API client with the given token and HTTP client.
func New(token string, httpClient httpClient) *API {
return &API{
token: token,
@ -61,10 +66,12 @@ func New(token string, httpClient httpClient) *API {
}
}
// User represents a GitHub user.
type User struct {
Login string `json:"login"`
}
// GetUser returns the user associated with the given token.
func (a *API) GetUser(ctx context.Context) (*User, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/user", nil)
if err != nil {
@ -95,6 +102,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) {
return &response, nil
}
// jsonErrorResponse returns the error message from a JSON response.
func jsonErrorResponse(b []byte) error {
var response struct {
Message string `json:"message"`
@ -106,10 +114,12 @@ func jsonErrorResponse(b []byte) error {
return errors.New(response.Message)
}
// Repository represents a GitHub repository.
type Repository struct {
ID int `json:"id"`
}
// GetRepository returns the repository associated with the given owner and name.
func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+strings.ToLower(nwo), nil)
if err != nil {
@ -140,11 +150,12 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error
return &response, nil
}
// Codespace represents a codespace.
type Codespace struct {
Name string `json:"name"`
GUID string `json:"guid"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at"`
State string `json:"state"`
Branch string `json:"branch"`
RepositoryName string `json:"repository_name"`
RepositoryNWO string `json:"repository_nwo"`
@ -152,6 +163,8 @@ type Codespace struct {
Environment CodespaceEnvironment `json:"environment"`
}
const CodespaceStateProvisioned = "provisioned"
type CodespaceEnvironment struct {
State string `json:"state"`
Connection CodespaceEnvironmentConnection `json:"connection"`
@ -168,6 +181,7 @@ type CodespaceEnvironmentGitStatus struct {
}
const (
// CodespaceEnvironmentStateAvailable is the state for a running codespace environment.
CodespaceEnvironmentStateAvailable = "Available"
)
@ -179,107 +193,94 @@ type CodespaceEnvironmentConnection struct {
HostPublicKeys []string `json:"hostPublicKeys"`
}
func (a *API) ListCodespaces(ctx context.Context) ([]*Codespace, error) {
req, err := http.NewRequest(
http.MethodGet, a.githubAPI+"/user/codespaces", nil,
)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
// ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from
// the API until all codespaces have been fetched.
func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Codespace, err error) {
perPage := 100
if limit > 0 && limit < 100 {
perPage = limit
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
listURL := fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage)
for {
req, err := http.NewRequest(http.MethodGet, listURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
resp, err := a.do(ctx, req, "/user/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, jsonErrorResponse(b)
}
var response struct {
Codespaces []*Codespace `json:"codespaces"`
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return response.Codespaces, nil
}
type getCodespaceTokenRequest struct {
MintRepositoryToken bool `json:"mint_repository_token"`
}
type getCodespaceTokenResponse struct {
RepositoryToken string `json:"repository_token"`
}
// ErrNotProvisioned is returned by GetCodespacesToken to indicate that the
// creation of a codespace is not yet complete and that the caller should try again.
var ErrNotProvisioned = errors.New("codespace not provisioned")
func (a *API) GetCodespaceToken(ctx context.Context, ownerLogin, codespaceName string) (string, error) {
reqBody, err := json.Marshal(getCodespaceTokenRequest{true})
if err != nil {
return "", fmt.Errorf("error preparing request body: %w", err)
}
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/vscs_internal/user/"+ownerLogin+"/codespaces/"+codespaceName+"/token",
bytes.NewBuffer(reqBody),
)
if err != nil {
return "", fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*/token")
if err != nil {
return "", fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
b, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("error reading response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusUnprocessableEntity {
return "", ErrNotProvisioned
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
return "", jsonErrorResponse(b)
var response struct {
Codespaces []*Codespace `json:"codespaces"`
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
nextURL := findNextPage(resp.Header.Get("Link"))
codespaces = append(codespaces, response.Codespaces...)
if nextURL == "" || (limit > 0 && len(codespaces) >= limit) {
break
}
if newPerPage := limit - len(codespaces); limit > 0 && newPerPage < 100 {
u, _ := url.Parse(nextURL)
q := u.Query()
q.Set("per_page", strconv.Itoa(newPerPage))
u.RawQuery = q.Encode()
listURL = u.String()
} else {
listURL = nextURL
}
}
var response getCodespaceTokenResponse
if err := json.Unmarshal(b, &response); err != nil {
return "", fmt.Errorf("error unmarshaling response: %w", err)
}
return response.RepositoryToken, nil
return codespaces, nil
}
func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string) (*Codespace, error) {
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
func findNextPage(linkValue string) string {
for _, m := range linkRE.FindAllStringSubmatch(linkValue, -1) {
if len(m) > 2 && m[2] == "next" {
return m[1]
}
}
return ""
}
// GetCodespace returns the user codespace based on the provided name.
// If the codespace is not found, an error is returned.
// If includeConnection is true, it will return the connection information for the codespace.
func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeConnection bool) (*Codespace, error) {
req, err := http.NewRequest(
http.MethodGet,
a.githubAPI+"/vscs_internal/user/"+owner+"/codespaces/"+codespace,
a.githubAPI+"/user/codespaces/"+codespaceName,
nil,
)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
// TODO: use a.setHeaders()
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*")
if includeConnection {
q := req.URL.Query()
q.Add("internal", "true")
q.Add("refresh", "true")
req.URL.RawQuery = q.Encode()
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
@ -302,19 +303,20 @@ func (a *API) GetCodespace(ctx context.Context, token, owner, codespace string)
return &response, nil
}
func (a *API) StartCodespace(ctx context.Context, token string, codespace *Codespace) error {
// StartCodespace starts a codespace for the user.
// If the codespace is already running, the returned error from the API is ignored.
func (a *API) StartCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/vscs_internal/proxy/environments/"+codespace.GUID+"/start",
a.githubAPI+"/user/codespaces/"+codespaceName+"/start",
nil,
)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
// TODO: use a.setHeaders()
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.do(ctx, req, "/vscs_internal/proxy/environments/*/start")
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*/start")
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@ -326,19 +328,20 @@ func (a *API) StartCodespace(ctx context.Context, token string, codespace *Codes
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusConflict {
// 409 means the codespace is already running which we can safely ignore
return nil
}
// Error response may be a numeric code or a JSON {"message": "..."}.
if bytes.HasPrefix(b, []byte("{")) {
return jsonErrorResponse(b) // probably JSON
}
if len(b) > 100 {
b = append(b[:97], "..."...)
}
if strings.TrimSpace(string(b)) == "7" {
// Non-HTTP 200 with error code 7 (EnvironmentNotShutdown) is benign.
// Ignore it.
} else {
return fmt.Errorf("failed to start codespace: %s", b)
}
return fmt.Errorf("failed to start codespace: %s", b)
}
return nil
@ -348,6 +351,7 @@ type getCodespaceRegionLocationResponse struct {
Current string `json:"current"`
}
// GetCodespaceRegionLocation returns the closest codespace location for the user.
func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) {
req, err := http.NewRequest(http.MethodGet, "https://online.visualstudio.com/api/v1/locations", nil)
if err != nil {
@ -377,13 +381,15 @@ func (a *API) GetCodespaceRegionLocation(ctx context.Context) (string, error) {
return response.Current, nil
}
type SKU struct {
type Machine struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
}
func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Repository, branch, location string) ([]*SKU, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/vscs_internal/user/"+user.Login+"/skus", nil)
// 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) {
reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
@ -391,11 +397,10 @@ func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Rep
q := req.URL.Query()
q.Add("location", location)
q.Add("ref", branch)
q.Add("repository_id", strconv.Itoa(repository.ID))
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/skus")
resp, err := a.do(ctx, req, "/repositories/*/codespaces/machines")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
@ -411,18 +416,17 @@ func (a *API) GetCodespacesSKUs(ctx context.Context, user *User, repository *Rep
}
var response struct {
SKUs []*SKU `json:"skus"`
Machines []*Machine `json:"machines"`
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return response.SKUs, nil
return response.Machines, nil
}
// CreateCodespaceParams are the required parameters for provisioning a Codespace.
type CreateCodespaceParams struct {
User string
RepositoryID int
Branch, Machine, Location string
}
@ -449,21 +453,16 @@ func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
token, err := a.GetCodespaceToken(ctx, params.User, codespace.Name)
if err != nil {
if err == ErrNotProvisioned {
// Do nothing. We expect this to fail until the codespace is provisioned
continue
}
return nil, fmt.Errorf("failed to get codespace token: %w", err)
}
codespace, err = a.GetCodespace(ctx, token, params.User, codespace.Name)
codespace, err = a.GetCodespace(ctx, codespace.Name, false)
if err != nil {
return nil, fmt.Errorf("failed to get codespace: %w", err)
}
// we continue to poll until the codespace shows as provisioned
if codespace.State != CodespaceStateProvisioned {
continue
}
return codespace, nil
}
}
@ -520,20 +519,15 @@ func (a *API) startCreate(ctx context.Context, repoID int, machine, branch, loca
return &response, nil
}
func (a *API) DeleteCodespace(ctx context.Context, user string, codespaceName string) error {
token, err := a.GetCodespaceToken(ctx, user, codespaceName)
if err != nil {
return fmt.Errorf("error getting codespace token: %w", err)
}
req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/vscs_internal/user/"+user+"/codespaces/"+codespaceName, nil)
// DeleteCodespace deletes the given codespace.
func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/user/codespaces/"+codespaceName, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
// TODO: use a.setHeaders()
req.Header.Set("Authorization", "Bearer "+token)
resp, err := a.do(ctx, req, "/vscs_internal/user/*/codespaces/*")
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@ -622,6 +616,8 @@ func (a *API) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) {
return b, nil
}
// do executes the given request and returns the response. It creates an
// opentracing span to track the length of the request.
func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) {
// TODO(adonovan): use NewRequestWithContext(ctx) and drop ctx parameter.
span, ctx := opentracing.StartSpanFromContext(ctx, spanName)
@ -630,6 +626,7 @@ func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http
return a.client.Do(req)
}
// setHeaders sets the required headers for the API.
func (a *API) setHeaders(req *http.Request) {
if a.token != "" {
req.Header.Set("Authorization", "Bearer "+a.token)

View file

@ -6,26 +6,67 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"testing"
)
func TestListCodespaces(t *testing.T) {
codespaces := []*Codespace{
{
Name: "testcodespace",
CreatedAt: "2021-08-09T10:10:24+02:00",
LastUsedAt: "2021-08-09T13:10:24+02:00",
},
func generateCodespaceList(start int, end int) []*Codespace {
codespacesList := []*Codespace{}
for i := start; i < end; i++ {
codespacesList = append(codespacesList, &Codespace{
Name: fmt.Sprintf("codespace-%d", i),
})
}
svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
return codespacesList
}
func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/codespaces" {
t.Fatal("Incorrect path")
}
page := 1
if r.URL.Query().Get("page") != "" {
page, _ = strconv.Atoi(r.URL.Query().Get("page"))
}
per_page := 0
if r.URL.Query().Get("per_page") != "" {
per_page, _ = strconv.Atoi(r.URL.Query().Get("per_page"))
}
response := struct {
Codespaces []*Codespace `json:"codespaces"`
TotalCount int `json:"total_count"`
}{
Codespaces: codespaces,
Codespaces: []*Codespace{},
TotalCount: finalTotal,
}
switch page {
case 1:
response.Codespaces = generateCodespaceList(0, per_page)
response.TotalCount = initalTotal
w.Header().Set("Link", fmt.Sprintf(`<http://%[1]s/user/codespaces?page=3&per_page=%[2]d>; rel="last", <http://%[1]s/user/codespaces?page=2&per_page=%[2]d>; rel="next"`, r.Host, per_page))
case 2:
response.Codespaces = generateCodespaceList(per_page, per_page*2)
response.TotalCount = finalTotal
w.Header().Set("Link", fmt.Sprintf(`<http://%s/user/codespaces?page=3&per_page=%d>; rel="next"`, r.Host, per_page))
case 3:
response.Codespaces = generateCodespaceList(per_page*2, per_page*3-per_page/2)
response.TotalCount = finalTotal
default:
t.Fatal("Should not check extra page")
}
data, _ := json.Marshal(response)
fmt.Fprint(w, string(data))
}))
}
func TestListCodespaces_limited(t *testing.T) {
svr := createFakeListEndpointServer(t, 200, 200)
defer svr.Close()
api := API{
@ -34,17 +75,44 @@ func TestListCodespaces(t *testing.T) {
token: "faketoken",
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx)
codespaces, err := api.ListCodespaces(ctx, 200)
if err != nil {
t.Fatal(err)
}
if len(codespaces) != 1 {
t.Fatalf("expected 1 codespace, got %d", len(codespaces))
if len(codespaces) != 200 {
t.Fatalf("expected 200 codespace, got %d", len(codespaces))
}
if codespaces[0].Name != "testcodespace" {
t.Fatalf("expected testcodespace, got %s", codespaces[0].Name)
if codespaces[0].Name != "codespace-0" {
t.Fatalf("expected codespace-0, got %s", codespaces[0].Name)
}
if codespaces[199].Name != "codespace-199" {
t.Fatalf("expected codespace-199, got %s", codespaces[0].Name)
}
}
func TestListCodespaces_unlimited(t *testing.T) {
svr := createFakeListEndpointServer(t, 200, 200)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
token: "faketoken",
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, -1)
if err != nil {
t.Fatal(err)
}
if len(codespaces) != 250 {
t.Fatalf("expected 250 codespace, got %d", len(codespaces))
}
if codespaces[0].Name != "codespace-0" {
t.Fatalf("expected codespace-0, got %s", codespaces[0].Name)
}
if codespaces[249].Name != "codespace-249" {
t.Fatalf("expected codespace-249, got %s", codespaces[0].Name)
}
}

View file

@ -24,19 +24,18 @@ func connectionReady(codespace *api.Codespace) bool {
}
type apiClient interface {
GetCodespace(ctx context.Context, token, user, name string) (*api.Codespace, error)
GetCodespaceToken(ctx context.Context, user, codespace string) (string, error)
StartCodespace(ctx context.Context, token string, codespace *api.Codespace) error
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
StartCodespace(ctx context.Context, name string) error
}
// ConnectToLiveshare waits for a Codespace to become running,
// and connects to it using a Live Share session.
func ConnectToLiveshare(ctx context.Context, log logger, apiClient apiClient, userLogin, token string, codespace *api.Codespace) (*liveshare.Session, error) {
func ConnectToLiveshare(ctx context.Context, log logger, apiClient apiClient, codespace *api.Codespace) (*liveshare.Session, error) {
var startedCodespace bool
if codespace.Environment.State != api.CodespaceEnvironmentStateAvailable {
startedCodespace = true
log.Print("Starting your codespace...")
if err := apiClient.StartCodespace(ctx, token, codespace); err != nil {
if err := apiClient.StartCodespace(ctx, codespace.Name); err != nil {
return nil, fmt.Errorf("error starting codespace: %w", err)
}
}
@ -55,7 +54,7 @@ func ConnectToLiveshare(ctx context.Context, log logger, apiClient apiClient, us
}
var err error
codespace, err = apiClient.GetCodespace(ctx, token, userLogin, codespace.Name)
codespace, err = apiClient.GetCodespace(ctx, codespace.Name, true)
if err != nil {
return nil, fmt.Errorf("error getting codespace: %w", err)
}

View file

@ -36,13 +36,8 @@ type PostCreateState struct {
// PollPostCreateStates watches for state changes in a codespace,
// and calls the supplied poller for each batch of state changes.
// It runs until it encounters an error, including cancellation of the context.
func PollPostCreateStates(ctx context.Context, log logger, apiClient apiClient, user *api.User, codespace *api.Codespace, poller func([]PostCreateState)) (err error) {
token, err := apiClient.GetCodespaceToken(ctx, user.Login, codespace.Name)
if err != nil {
return fmt.Errorf("getting codespace token: %w", err)
}
session, err := ConnectToLiveshare(ctx, log, apiClient, user.Login, token, codespace)
func PollPostCreateStates(ctx context.Context, log logger, apiClient apiClient, codespace *api.Codespace, poller func([]PostCreateState)) (err error) {
session, err := ConnectToLiveshare(ctx, log, apiClient, codespace)
if err != nil {
return fmt.Errorf("connect to Live Share: %w", err)
}
@ -83,7 +78,7 @@ func PollPostCreateStates(ctx context.Context, log logger, apiClient apiClient,
return fmt.Errorf("connection failed: %w", err)
case <-t.C:
states, err := getPostCreateOutput(ctx, localPort, codespace, sshUser)
states, err := getPostCreateOutput(ctx, localPort, sshUser)
if err != nil {
return fmt.Errorf("get post create output: %w", err)
}
@ -93,7 +88,7 @@ func PollPostCreateStates(ctx context.Context, log logger, apiClient apiClient,
}
}
func getPostCreateOutput(ctx context.Context, tunnelPort int, codespace *api.Codespace, user string) ([]PostCreateState, error) {
func getPostCreateOutput(ctx context.Context, tunnelPort int, user string) ([]PostCreateState, error) {
cmd, err := NewRemoteCommand(
ctx, tunnelPort, fmt.Sprintf("%s@localhost", user),
"cat /workspaces/.codespaces/shared/postCreateOutput.json",

View file

@ -101,7 +101,7 @@ func TestGenManSeeAlso(t *testing.T) {
if err := assertNextLineEquals(scanner, ".PP"); err != nil {
t.Fatalf("First line after SEE ALSO wasn't break-indent: %v", err)
}
if err := assertNextLineEquals(scanner, `\fBroot\-bbb(1)\fP, \fBroot\-ccc(1)\fP`); err != nil {
if err := assertNextLineEquals(scanner, `\fBroot-bbb(1)\fP, \fBroot-ccc(1)\fP`); err != nil {
t.Fatalf("Second line after SEE ALSO wasn't correct: %v", err)
}
}

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"context"

View file

@ -1,6 +1,6 @@
package ghcs
package codespace
// This file defines functions common to the entire ghcs command set.
// This file defines functions common to the entire codespace command set.
import (
"context"
@ -9,6 +9,7 @@ import (
"io"
"os"
"sort"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
@ -33,29 +34,30 @@ func NewApp(logger *output.Logger, apiClient apiClient) *App {
//go:generate moq -fmt goimports -rm -skip-ensure -out mock_api.go . apiClient
type apiClient interface {
GetUser(ctx context.Context) (*api.User, error)
GetCodespaceToken(ctx context.Context, user, name string) (string, error)
GetCodespace(ctx context.Context, token, user, name string) (*api.Codespace, error)
ListCodespaces(ctx context.Context) ([]*api.Codespace, error)
DeleteCodespace(ctx context.Context, user, name string) error
StartCodespace(ctx context.Context, token string, codespace *api.Codespace) error
GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
ListCodespaces(ctx context.Context, limit int) ([]*api.Codespace, error)
DeleteCodespace(ctx context.Context, name string) error
StartCodespace(ctx context.Context, name string) error
CreateCodespace(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
GetRepository(ctx context.Context, nwo string) (*api.Repository, error)
AuthorizedKeys(ctx context.Context, user string) ([]byte, error)
GetCodespaceRegionLocation(ctx context.Context) (string, error)
GetCodespacesSKUs(ctx context.Context, user *api.User, repository *api.Repository, branch, location string) ([]*api.SKU, error)
GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*api.Machine, error)
GetCodespaceRepositoryContents(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
}
var errNoCodespaces = errors.New("you have no codespaces")
func chooseCodespace(ctx context.Context, apiClient apiClient) (*api.Codespace, error) {
codespaces, err := apiClient.ListCodespaces(ctx)
codespaces, err := apiClient.ListCodespaces(ctx, -1)
if err != nil {
return nil, fmt.Errorf("error getting codespaces: %w", err)
}
return chooseCodespaceFromList(ctx, codespaces)
}
// chooseCodespaceFromList returns the selected codespace from the list,
// or an error if there are no codespaces.
func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace) (*api.Codespace, error) {
if len(codespaces) == 0 {
return nil, errNoCodespaces
@ -65,14 +67,47 @@ func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace) (
return codespaces[i].CreatedAt > codespaces[j].CreatedAt
})
codespacesByName := make(map[string]*api.Codespace)
codespacesNames := make([]string, 0, len(codespaces))
for _, codespace := range codespaces {
codespacesByName[codespace.Name] = codespace
codespacesNames = append(codespacesNames, codespace.Name)
type codespaceWithIndex struct {
cs codespace
idx int
}
sshSurvey := []*survey.Question{
namesWithConflict := make(map[string]bool)
codespacesByName := make(map[string]codespaceWithIndex)
codespacesNames := make([]string, 0, len(codespaces))
for _, apiCodespace := range codespaces {
cs := codespace{apiCodespace}
csName := cs.displayName(false, false)
displayNameWithGitStatus := cs.displayName(false, true)
_, hasExistingConflict := namesWithConflict[csName]
if seenCodespace, ok := codespacesByName[csName]; ok || hasExistingConflict {
// There is an existing codespace on the repo and branch.
// We need to disambiguate by adding the codespace name
// to the existing entry and the one we are processing now.
if !hasExistingConflict {
fullDisplayName := seenCodespace.cs.displayName(true, false)
fullDisplayNameWithGitStatus := seenCodespace.cs.displayName(true, true)
codespacesByName[fullDisplayName] = codespaceWithIndex{seenCodespace.cs, seenCodespace.idx}
codespacesNames[seenCodespace.idx] = fullDisplayNameWithGitStatus
delete(codespacesByName, csName) // delete the existing map entry with old name
// All other codespaces with the same name should update
// to their specific name, this tracks conflicting names going forward
namesWithConflict[csName] = true
}
// update this codespace names to include the name to disambiguate
csName = cs.displayName(true, false)
displayNameWithGitStatus = cs.displayName(true, true)
}
codespacesByName[csName] = codespaceWithIndex{cs, len(codespacesNames)}
codespacesNames = append(codespacesNames, displayNameWithGitStatus)
}
csSurvey := []*survey.Question{
{
Name: "codespace",
Prompt: &survey.Select{
@ -87,44 +122,36 @@ func chooseCodespaceFromList(ctx context.Context, codespaces []*api.Codespace) (
var answers struct {
Codespace string
}
if err := ask(sshSurvey, &answers); err != nil {
if err := ask(csSurvey, &answers); err != nil {
return nil, fmt.Errorf("error getting answers: %w", err)
}
codespace := codespacesByName[answers.Codespace]
return codespace, nil
// Codespaces are indexed without the git status included as compared
// to how it is displayed in the prompt, so the git status symbol needs
// cleaning up in case it is included.
selectedCodespace := strings.Replace(answers.Codespace, gitStatusDirty, "", -1)
return codespacesByName[selectedCodespace].cs.Codespace, nil
}
// getOrChooseCodespace prompts the user to choose a codespace if the codespaceName is empty.
// It then fetches the codespace token and the codespace record.
func getOrChooseCodespace(ctx context.Context, apiClient apiClient, user *api.User, codespaceName string) (codespace *api.Codespace, token string, err error) {
// It then fetches the codespace record with full connection details.
func getOrChooseCodespace(ctx context.Context, apiClient apiClient, codespaceName string) (codespace *api.Codespace, err error) {
if codespaceName == "" {
codespace, err = chooseCodespace(ctx, apiClient)
if err != nil {
if err == errNoCodespaces {
return nil, "", err
return nil, err
}
return nil, "", fmt.Errorf("choosing codespace: %w", err)
}
codespaceName = codespace.Name
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting codespace token: %w", err)
return nil, fmt.Errorf("choosing codespace: %w", err)
}
} else {
token, err = apiClient.GetCodespaceToken(ctx, user.Login, codespaceName)
codespace, err = apiClient.GetCodespace(ctx, codespaceName, true)
if err != nil {
return nil, "", fmt.Errorf("getting codespace token for given codespace: %w", err)
}
codespace, err = apiClient.GetCodespace(ctx, token, user.Login, codespaceName)
if err != nil {
return nil, "", fmt.Errorf("getting full codespace details: %w", err)
return nil, fmt.Errorf("getting full codespace details: %w", err)
}
}
return codespace, token, nil
return codespace, nil
}
func safeClose(closer io.Closer, err *error) {
@ -183,3 +210,44 @@ func noArgsConstraint(cmd *cobra.Command, args []string) error {
}
return nil
}
type codespace struct {
*api.Codespace
}
// displayName returns the repository nwo and branch.
// If includeName is true, the name of the codespace is included.
// If includeGitStatus is true, the branch will include a star if
// the codespace has unsaved changes.
func (c codespace) displayName(includeName, includeGitStatus bool) string {
branch := c.Branch
if includeGitStatus {
branch = c.branchWithGitStatus()
}
if includeName {
return fmt.Sprintf(
"%s: %s [%s]", c.RepositoryNWO, branch, c.Name,
)
}
return c.RepositoryNWO + ": " + branch
}
// gitStatusDirty represents an unsaved changes status.
const gitStatusDirty = "*"
// branchWithGitStatus returns the branch with a star
// if the branch is currently being worked on.
func (c codespace) branchWithGitStatus() string {
if c.hasUnsavedChanges() {
return c.Branch + gitStatusDirty
}
return c.Branch
}
// hasUnsavedChanges returns whether the environment has
// unsaved changes.
func (c codespace) hasUnsavedChanges() bool {
return c.Environment.GitStatus.HasUncommitedChanges || c.Environment.GitStatus.HasUnpushedChanges
}

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"context"
@ -71,7 +71,7 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
return fmt.Errorf("error getting codespace user: %w", userResult.Err)
}
machine, err := getMachineName(ctx, opts.machine, userResult.User, repository, branch, locationResult.Location, a.apiClient)
machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, locationResult.Location)
if err != nil {
return fmt.Errorf("error getting machine type: %w", err)
}
@ -81,7 +81,6 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
a.logger.Print("Creating your codespace...")
codespace, err := a.apiClient.CreateCodespace(ctx, &api.CreateCodespaceParams{
User: userResult.User.Login,
RepositoryID: repository.ID,
Branch: branch,
Machine: machine,
@ -157,7 +156,7 @@ func showStatus(ctx context.Context, log *output.Logger, apiClient apiClient, us
}
}
err := codespaces.PollPostCreateStates(ctx, log, apiClient, user, codespace, poller)
err := codespaces.PollPostCreateStates(ctx, log, apiClient, codespace, poller)
if err != nil {
if errors.Is(err, context.Canceled) && breakNextState {
return nil // we cancelled the context to stop polling, we can ignore the error
@ -234,8 +233,8 @@ func getBranchName(branch string) (string, error) {
}
// getMachineName prompts the user to select the machine type, or validates the machine if non-empty.
func getMachineName(ctx context.Context, machine string, user *api.User, repo *api.Repository, branch, location string, apiClient apiClient) (string, error) {
skus, err := apiClient.GetCodespacesSKUs(ctx, user, repo, branch, location)
func getMachineName(ctx context.Context, apiClient apiClient, repoID int, machine, branch, location string) (string, error) {
machines, err := apiClient.GetCodespacesMachines(ctx, repoID, branch, location)
if err != nil {
return "", fmt.Errorf("error requesting machine instance types: %w", err)
}
@ -243,55 +242,55 @@ func getMachineName(ctx context.Context, machine string, user *api.User, repo *a
// if user supplied a machine type, it must be valid
// if no machine type was supplied, we don't error if there are no machine types for the current repo
if machine != "" {
for _, sku := range skus {
if machine == sku.Name {
for _, m := range machines {
if machine == m.Name {
return machine, nil
}
}
availableSKUs := make([]string, len(skus))
for i := 0; i < len(skus); i++ {
availableSKUs[i] = skus[i].Name
availableMachines := make([]string, len(machines))
for i := 0; i < len(machines); i++ {
availableMachines[i] = machines[i].Name
}
return "", fmt.Errorf("there is no such machine for the repository: %s\nAvailable machines: %v", machine, availableSKUs)
} else if len(skus) == 0 {
return "", fmt.Errorf("there is no such machine for the repository: %s\nAvailable machines: %v", machine, availableMachines)
} else if len(machines) == 0 {
return "", nil
}
if len(skus) == 1 {
return skus[0].Name, nil // VS Code does not prompt for SKU if there is only one, this makes us consistent with that behavior
if len(machines) == 1 {
// VS Code does not prompt for machine if there is only one, this makes us consistent with that behavior
return machines[0].Name, nil
}
skuNames := make([]string, 0, len(skus))
skuByName := make(map[string]*api.SKU)
for _, sku := range skus {
nameParts := camelcase.Split(sku.Name)
machineNames := make([]string, 0, len(machines))
machineByName := make(map[string]*api.Machine)
for _, m := range machines {
nameParts := camelcase.Split(m.Name)
machineName := strings.Title(strings.ToLower(nameParts[0]))
skuName := fmt.Sprintf("%s - %s", machineName, sku.DisplayName)
skuNames = append(skuNames, skuName)
skuByName[skuName] = sku
machineName = fmt.Sprintf("%s - %s", machineName, m.DisplayName)
machineNames = append(machineNames, machineName)
machineByName[machineName] = m
}
skuSurvey := []*survey.Question{
machineSurvey := []*survey.Question{
{
Name: "sku",
Name: "machine",
Prompt: &survey.Select{
Message: "Choose Machine Type:",
Options: skuNames,
Default: skuNames[0],
Options: machineNames,
Default: machineNames[0],
},
Validate: survey.Required,
},
}
var skuAnswers struct{ SKU string }
if err := ask(skuSurvey, &skuAnswers); err != nil {
return "", fmt.Errorf("error getting SKU: %w", err)
var machineAnswers struct{ Machine string }
if err := ask(machineSurvey, &machineAnswers); err != nil {
return "", fmt.Errorf("error getting machine: %w", err)
}
sku := skuByName[skuAnswers.SKU]
machine = sku.Name
selectedMachine := machineByName[machineAnswers.Machine]
return machine, nil
return selectedMachine.Name, nil
}

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"context"
@ -58,16 +58,11 @@ func newDeleteCmd(app *App) *cobra.Command {
return deleteCmd
}
func (a *App) Delete(ctx context.Context, opts deleteOptions) error {
user, err := a.apiClient.GetUser(ctx)
if err != nil {
return fmt.Errorf("error getting user: %w", err)
}
func (a *App) Delete(ctx context.Context, opts deleteOptions) (err error) {
var codespaces []*api.Codespace
nameFilter := opts.codespaceName
if nameFilter == "" {
codespaces, err = a.apiClient.ListCodespaces(ctx)
codespaces, err = a.apiClient.ListCodespaces(ctx, -1)
if err != nil {
return fmt.Errorf("error getting codespaces: %w", err)
}
@ -80,13 +75,7 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) error {
nameFilter = c.Name
}
} else {
// TODO: this token is discarded and then re-requested later in DeleteCodespace
token, err := a.apiClient.GetCodespaceToken(ctx, user.Login, nameFilter)
if err != nil {
return fmt.Errorf("error getting codespace token: %w", err)
}
codespace, err := a.apiClient.GetCodespace(ctx, token, user.Login, nameFilter)
codespace, err := a.apiClient.GetCodespace(ctx, nameFilter, false)
if err != nil {
return fmt.Errorf("error fetching codespace information: %w", err)
}
@ -132,7 +121,7 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) error {
for _, c := range codespacesToDelete {
codespaceName := c.Name
g.Go(func() error {
if err := a.apiClient.DeleteCodespace(ctx, user.Login, codespaceName); err != nil {
if err := a.apiClient.DeleteCodespace(ctx, codespaceName); err != nil {
_, _ = a.logger.Errorf("error deleting codespace %q: %v\n", codespaceName, err)
return err
}
@ -143,19 +132,25 @@ func (a *App) Delete(ctx context.Context, opts deleteOptions) error {
if err := g.Wait(); err != nil {
return errors.New("some codespaces failed to delete")
}
noun := "Codespace"
if len(codespacesToDelete) > 1 {
noun = noun + "s"
}
a.logger.Println(noun + " deleted.")
return nil
}
func confirmDeletion(p prompter, codespace *api.Codespace, isInteractive bool) (bool, error) {
gs := codespace.Environment.GitStatus
hasUnsavedChanges := gs.HasUncommitedChanges || gs.HasUnpushedChanges
if !hasUnsavedChanges {
func confirmDeletion(p prompter, apiCodespace *api.Codespace, isInteractive bool) (bool, error) {
cs := codespace{apiCodespace}
if !cs.hasUnsavedChanges() {
return true, nil
}
if !isInteractive {
return false, fmt.Errorf("codespace %s has unsaved changes (use --force to override)", codespace.Name)
return false, fmt.Errorf("codespace %s has unsaved changes (use --force to override)", cs.Name)
}
return p.Confirm(fmt.Sprintf("Codespace %s has unsaved changes. OK to delete?", codespace.Name))
return p.Confirm(fmt.Sprintf("Codespace %s has unsaved changes. OK to delete?", cs.Name))
}
type surveyPrompter struct{}

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"bytes"
@ -156,10 +156,7 @@ func TestDelete(t *testing.T) {
GetUserFunc: func(_ context.Context) (*api.User, error) {
return user, nil
},
DeleteCodespaceFunc: func(_ context.Context, userLogin, name string) error {
if userLogin != user.Login {
return fmt.Errorf("unexpected user %q", userLogin)
}
DeleteCodespaceFunc: func(_ context.Context, name string) error {
if tt.deleteErr != nil {
return tt.deleteErr
}
@ -167,23 +164,11 @@ func TestDelete(t *testing.T) {
},
}
if tt.opts.codespaceName == "" {
apiMock.ListCodespacesFunc = func(_ context.Context) ([]*api.Codespace, error) {
apiMock.ListCodespacesFunc = func(_ context.Context, num int) ([]*api.Codespace, error) {
return tt.codespaces, nil
}
} else {
apiMock.GetCodespaceTokenFunc = func(_ context.Context, userLogin, name string) (string, error) {
if userLogin != user.Login {
return "", fmt.Errorf("unexpected user %q", userLogin)
}
return "CS_TOKEN", nil
}
apiMock.GetCodespaceFunc = func(_ context.Context, token, userLogin, name string) (*api.Codespace, error) {
if userLogin != user.Login {
return nil, fmt.Errorf("unexpected user %q", userLogin)
}
if token != "CS_TOKEN" {
return nil, fmt.Errorf("unexpected token %q", token)
}
apiMock.GetCodespaceFunc = func(_ context.Context, name string, includeConnection bool) (*api.Codespace, error) {
return tt.codespaces[0], nil
}
}
@ -209,9 +194,6 @@ func TestDelete(t *testing.T) {
if (err != nil) != tt.wantErr {
t.Errorf("delete() error = %v, wantErr %v", err, tt.wantErr)
}
if n := len(apiMock.GetUserCalls()); n != 1 {
t.Errorf("GetUser invoked %d times, expected %d", n, 1)
}
var gotDeleted []string
for _, delArgs := range apiMock.DeleteCodespaceCalls() {
gotDeleted = append(gotDeleted, delArgs.Name)

View file

@ -1,58 +1,57 @@
package ghcs
package codespace
import (
"context"
"fmt"
"os"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/cmd/codespace/output"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/spf13/cobra"
)
func newListCmd(app *App) *cobra.Command {
var asJSON bool
var limit int
listCmd := &cobra.Command{
Use: "list",
Short: "List your codespaces",
Args: noArgsConstraint,
RunE: func(cmd *cobra.Command, args []string) error {
return app.List(cmd.Context(), asJSON)
if limit < 1 {
return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", limit)}
}
return app.List(cmd.Context(), asJSON, limit)
},
}
listCmd.Flags().BoolVar(&asJSON, "json", false, "Output as JSON")
listCmd.Flags().IntVarP(&limit, "limit", "L", 30, "Maximum number of codespaces to list")
return listCmd
}
func (a *App) List(ctx context.Context, asJSON bool) error {
codespaces, err := a.apiClient.ListCodespaces(ctx)
func (a *App) List(ctx context.Context, asJSON bool, limit int) error {
codespaces, err := a.apiClient.ListCodespaces(ctx, limit)
if err != nil {
return fmt.Errorf("error getting codespaces: %w", err)
}
table := output.NewTable(os.Stdout, asJSON)
table.SetHeader([]string{"Name", "Repository", "Branch", "State", "Created At"})
for _, codespace := range codespaces {
for _, apiCodespace := range codespaces {
cs := codespace{apiCodespace}
table.Append([]string{
codespace.Name,
codespace.RepositoryNWO,
codespace.Branch + dirtyStar(codespace.Environment.GitStatus),
codespace.Environment.State,
codespace.CreatedAt,
cs.Name,
cs.RepositoryNWO,
cs.branchWithGitStatus(),
cs.Environment.State,
cs.CreatedAt,
})
}
table.Render()
return nil
}
func dirtyStar(status api.CodespaceEnvironmentGitStatus) string {
if status.HasUncommitedChanges || status.HasUnpushedChanges {
return "*"
}
return ""
}

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"context"
@ -46,12 +46,12 @@ func (a *App) Logs(ctx context.Context, codespaceName string, follow bool) (err
authkeys <- checkAuthorizedKeys(ctx, a.apiClient, user.Login)
}()
codespace, token, err := getOrChooseCodespace(ctx, a.apiClient, user, codespaceName)
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %w", err)
}
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, user.Login, token, codespace)
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, codespace)
if err != nil {
return fmt.Errorf("connecting to Live Share: %w", err)
}

View file

@ -1,7 +1,7 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package ghcs
package codespace
import (
"context"
@ -22,10 +22,10 @@ import (
// CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
// panic("mock out the CreateCodespace method")
// },
// DeleteCodespaceFunc: func(ctx context.Context, user string, name string) error {
// DeleteCodespaceFunc: func(ctx context.Context, name string) error {
// panic("mock out the DeleteCodespace method")
// },
// GetCodespaceFunc: func(ctx context.Context, token string, user string, name string) (*api.Codespace, error) {
// GetCodespaceFunc: func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) {
// panic("mock out the GetCodespace method")
// },
// GetCodespaceRegionLocationFunc: func(ctx context.Context) (string, error) {
@ -34,11 +34,8 @@ import (
// GetCodespaceRepositoryContentsFunc: func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error) {
// panic("mock out the GetCodespaceRepositoryContents method")
// },
// GetCodespaceTokenFunc: func(ctx context.Context, user string, name string) (string, error) {
// panic("mock out the GetCodespaceToken method")
// },
// GetCodespacesSKUsFunc: func(ctx context.Context, user *api.User, repository *api.Repository, branch string, location string) ([]*api.SKU, error) {
// panic("mock out the GetCodespacesSKUs method")
// GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error) {
// panic("mock out the GetCodespacesMachines method")
// },
// GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
// panic("mock out the GetRepository method")
@ -46,10 +43,10 @@ import (
// GetUserFunc: func(ctx context.Context) (*api.User, error) {
// panic("mock out the GetUser method")
// },
// ListCodespacesFunc: func(ctx context.Context) ([]*api.Codespace, error) {
// ListCodespacesFunc: func(ctx context.Context, limit int) ([]*api.Codespace, error) {
// panic("mock out the ListCodespaces method")
// },
// StartCodespaceFunc: func(ctx context.Context, token string, codespace *api.Codespace) error {
// StartCodespaceFunc: func(ctx context.Context, name string) error {
// panic("mock out the StartCodespace method")
// },
// }
@ -66,10 +63,10 @@ type apiClientMock struct {
CreateCodespaceFunc func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error)
// DeleteCodespaceFunc mocks the DeleteCodespace method.
DeleteCodespaceFunc func(ctx context.Context, user string, name string) error
DeleteCodespaceFunc func(ctx context.Context, name string) error
// GetCodespaceFunc mocks the GetCodespace method.
GetCodespaceFunc func(ctx context.Context, token string, user string, name string) (*api.Codespace, error)
GetCodespaceFunc func(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error)
// GetCodespaceRegionLocationFunc mocks the GetCodespaceRegionLocation method.
GetCodespaceRegionLocationFunc func(ctx context.Context) (string, error)
@ -77,11 +74,8 @@ type apiClientMock struct {
// GetCodespaceRepositoryContentsFunc mocks the GetCodespaceRepositoryContents method.
GetCodespaceRepositoryContentsFunc func(ctx context.Context, codespace *api.Codespace, path string) ([]byte, error)
// GetCodespaceTokenFunc mocks the GetCodespaceToken method.
GetCodespaceTokenFunc func(ctx context.Context, user string, name string) (string, error)
// GetCodespacesSKUsFunc mocks the GetCodespacesSKUs method.
GetCodespacesSKUsFunc func(ctx context.Context, user *api.User, repository *api.Repository, branch string, location string) ([]*api.SKU, error)
// GetCodespacesMachinesFunc mocks the GetCodespacesMachines method.
GetCodespacesMachinesFunc func(ctx context.Context, repoID int, branch string, location string) ([]*api.Machine, error)
// GetRepositoryFunc mocks the GetRepository method.
GetRepositoryFunc func(ctx context.Context, nwo string) (*api.Repository, error)
@ -90,10 +84,10 @@ type apiClientMock struct {
GetUserFunc func(ctx context.Context) (*api.User, error)
// ListCodespacesFunc mocks the ListCodespaces method.
ListCodespacesFunc func(ctx context.Context) ([]*api.Codespace, error)
ListCodespacesFunc func(ctx context.Context, limit int) ([]*api.Codespace, error)
// StartCodespaceFunc mocks the StartCodespace method.
StartCodespaceFunc func(ctx context.Context, token string, codespace *api.Codespace) error
StartCodespaceFunc func(ctx context.Context, name string) error
// calls tracks calls to the methods.
calls struct {
@ -115,8 +109,6 @@ type apiClientMock struct {
DeleteCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// User is the user argument value.
User string
// Name is the name argument value.
Name string
}
@ -124,12 +116,10 @@ type apiClientMock struct {
GetCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Token is the token argument value.
Token string
// User is the user argument value.
User string
// Name is the name argument value.
Name string
// IncludeConnection is the includeConnection argument value.
IncludeConnection bool
}
// GetCodespaceRegionLocation holds details about calls to the GetCodespaceRegionLocation method.
GetCodespaceRegionLocation []struct {
@ -145,23 +135,12 @@ type apiClientMock struct {
// Path is the path argument value.
Path string
}
// GetCodespaceToken holds details about calls to the GetCodespaceToken method.
GetCodespaceToken []struct {
// GetCodespacesMachines holds details about calls to the GetCodespacesMachines method.
GetCodespacesMachines []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// User is the user argument value.
User string
// Name is the name argument value.
Name string
}
// GetCodespacesSKUs holds details about calls to the GetCodespacesSKUs method.
GetCodespacesSKUs []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// User is the user argument value.
User *api.User
// Repository is the repository argument value.
Repository *api.Repository
// RepoID is the repoID argument value.
RepoID int
// Branch is the branch argument value.
Branch string
// Location is the location argument value.
@ -183,15 +162,15 @@ type apiClientMock struct {
ListCodespaces []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Limit is the limit argument value.
Limit int
}
// StartCodespace holds details about calls to the StartCodespace method.
StartCodespace []struct {
// Ctx is the ctx argument value.
Ctx context.Context
// Token is the token argument value.
Token string
// Codespace is the codespace argument value.
Codespace *api.Codespace
// Name is the name argument value.
Name string
}
}
lockAuthorizedKeys sync.RWMutex
@ -200,8 +179,7 @@ type apiClientMock struct {
lockGetCodespace sync.RWMutex
lockGetCodespaceRegionLocation sync.RWMutex
lockGetCodespaceRepositoryContents sync.RWMutex
lockGetCodespaceToken sync.RWMutex
lockGetCodespacesSKUs sync.RWMutex
lockGetCodespacesMachines sync.RWMutex
lockGetRepository sync.RWMutex
lockGetUser sync.RWMutex
lockListCodespaces sync.RWMutex
@ -279,23 +257,21 @@ func (mock *apiClientMock) CreateCodespaceCalls() []struct {
}
// DeleteCodespace calls DeleteCodespaceFunc.
func (mock *apiClientMock) DeleteCodespace(ctx context.Context, user string, name string) error {
func (mock *apiClientMock) DeleteCodespace(ctx context.Context, name string) error {
if mock.DeleteCodespaceFunc == nil {
panic("apiClientMock.DeleteCodespaceFunc: method is nil but apiClient.DeleteCodespace was just called")
}
callInfo := struct {
Ctx context.Context
User string
Name string
}{
Ctx: ctx,
User: user,
Name: name,
}
mock.lockDeleteCodespace.Lock()
mock.calls.DeleteCodespace = append(mock.calls.DeleteCodespace, callInfo)
mock.lockDeleteCodespace.Unlock()
return mock.DeleteCodespaceFunc(ctx, user, name)
return mock.DeleteCodespaceFunc(ctx, name)
}
// DeleteCodespaceCalls gets all the calls that were made to DeleteCodespace.
@ -303,12 +279,10 @@ func (mock *apiClientMock) DeleteCodespace(ctx context.Context, user string, nam
// len(mockedapiClient.DeleteCodespaceCalls())
func (mock *apiClientMock) DeleteCodespaceCalls() []struct {
Ctx context.Context
User string
Name string
} {
var calls []struct {
Ctx context.Context
User string
Name string
}
mock.lockDeleteCodespace.RLock()
@ -318,41 +292,37 @@ func (mock *apiClientMock) DeleteCodespaceCalls() []struct {
}
// GetCodespace calls GetCodespaceFunc.
func (mock *apiClientMock) GetCodespace(ctx context.Context, token string, user string, name string) (*api.Codespace, error) {
func (mock *apiClientMock) GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) {
if mock.GetCodespaceFunc == nil {
panic("apiClientMock.GetCodespaceFunc: method is nil but apiClient.GetCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Token string
User string
Name string
Ctx context.Context
Name string
IncludeConnection bool
}{
Ctx: ctx,
Token: token,
User: user,
Name: name,
Ctx: ctx,
Name: name,
IncludeConnection: includeConnection,
}
mock.lockGetCodespace.Lock()
mock.calls.GetCodespace = append(mock.calls.GetCodespace, callInfo)
mock.lockGetCodespace.Unlock()
return mock.GetCodespaceFunc(ctx, token, user, name)
return mock.GetCodespaceFunc(ctx, name, includeConnection)
}
// GetCodespaceCalls gets all the calls that were made to GetCodespace.
// Check the length with:
// len(mockedapiClient.GetCodespaceCalls())
func (mock *apiClientMock) GetCodespaceCalls() []struct {
Ctx context.Context
Token string
User string
Name string
Ctx context.Context
Name string
IncludeConnection bool
} {
var calls []struct {
Ctx context.Context
Token string
User string
Name string
Ctx context.Context
Name string
IncludeConnection bool
}
mock.lockGetCodespace.RLock()
calls = mock.calls.GetCodespace
@ -430,89 +400,46 @@ func (mock *apiClientMock) GetCodespaceRepositoryContentsCalls() []struct {
return calls
}
// GetCodespaceToken calls GetCodespaceTokenFunc.
func (mock *apiClientMock) GetCodespaceToken(ctx context.Context, user string, name string) (string, error) {
if mock.GetCodespaceTokenFunc == nil {
panic("apiClientMock.GetCodespaceTokenFunc: method is nil but apiClient.GetCodespaceToken was just called")
// GetCodespacesMachines calls GetCodespacesMachinesFunc.
func (mock *apiClientMock) GetCodespacesMachines(ctx context.Context, repoID int, branch string, location 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
User string
Name string
Ctx context.Context
RepoID int
Branch string
Location string
}{
Ctx: ctx,
User: user,
Name: name,
Ctx: ctx,
RepoID: repoID,
Branch: branch,
Location: location,
}
mock.lockGetCodespaceToken.Lock()
mock.calls.GetCodespaceToken = append(mock.calls.GetCodespaceToken, callInfo)
mock.lockGetCodespaceToken.Unlock()
return mock.GetCodespaceTokenFunc(ctx, user, name)
mock.lockGetCodespacesMachines.Lock()
mock.calls.GetCodespacesMachines = append(mock.calls.GetCodespacesMachines, callInfo)
mock.lockGetCodespacesMachines.Unlock()
return mock.GetCodespacesMachinesFunc(ctx, repoID, branch, location)
}
// GetCodespaceTokenCalls gets all the calls that were made to GetCodespaceToken.
// GetCodespacesMachinesCalls gets all the calls that were made to GetCodespacesMachines.
// Check the length with:
// len(mockedapiClient.GetCodespaceTokenCalls())
func (mock *apiClientMock) GetCodespaceTokenCalls() []struct {
Ctx context.Context
User string
Name string
// len(mockedapiClient.GetCodespacesMachinesCalls())
func (mock *apiClientMock) GetCodespacesMachinesCalls() []struct {
Ctx context.Context
RepoID int
Branch string
Location string
} {
var calls []struct {
Ctx context.Context
User string
Name string
Ctx context.Context
RepoID int
Branch string
Location string
}
mock.lockGetCodespaceToken.RLock()
calls = mock.calls.GetCodespaceToken
mock.lockGetCodespaceToken.RUnlock()
return calls
}
// GetCodespacesSKUs calls GetCodespacesSKUsFunc.
func (mock *apiClientMock) GetCodespacesSKUs(ctx context.Context, user *api.User, repository *api.Repository, branch string, location string) ([]*api.SKU, error) {
if mock.GetCodespacesSKUsFunc == nil {
panic("apiClientMock.GetCodespacesSKUsFunc: method is nil but apiClient.GetCodespacesSKUs was just called")
}
callInfo := struct {
Ctx context.Context
User *api.User
Repository *api.Repository
Branch string
Location string
}{
Ctx: ctx,
User: user,
Repository: repository,
Branch: branch,
Location: location,
}
mock.lockGetCodespacesSKUs.Lock()
mock.calls.GetCodespacesSKUs = append(mock.calls.GetCodespacesSKUs, callInfo)
mock.lockGetCodespacesSKUs.Unlock()
return mock.GetCodespacesSKUsFunc(ctx, user, repository, branch, location)
}
// GetCodespacesSKUsCalls gets all the calls that were made to GetCodespacesSKUs.
// Check the length with:
// len(mockedapiClient.GetCodespacesSKUsCalls())
func (mock *apiClientMock) GetCodespacesSKUsCalls() []struct {
Ctx context.Context
User *api.User
Repository *api.Repository
Branch string
Location string
} {
var calls []struct {
Ctx context.Context
User *api.User
Repository *api.Repository
Branch string
Location string
}
mock.lockGetCodespacesSKUs.RLock()
calls = mock.calls.GetCodespacesSKUs
mock.lockGetCodespacesSKUs.RUnlock()
mock.lockGetCodespacesMachines.RLock()
calls = mock.calls.GetCodespacesMachines
mock.lockGetCodespacesMachines.RUnlock()
return calls
}
@ -583,29 +510,33 @@ func (mock *apiClientMock) GetUserCalls() []struct {
}
// ListCodespaces calls ListCodespacesFunc.
func (mock *apiClientMock) ListCodespaces(ctx context.Context) ([]*api.Codespace, error) {
func (mock *apiClientMock) ListCodespaces(ctx context.Context, limit int) ([]*api.Codespace, error) {
if mock.ListCodespacesFunc == nil {
panic("apiClientMock.ListCodespacesFunc: method is nil but apiClient.ListCodespaces was just called")
}
callInfo := struct {
Ctx context.Context
Ctx context.Context
Limit int
}{
Ctx: ctx,
Ctx: ctx,
Limit: limit,
}
mock.lockListCodespaces.Lock()
mock.calls.ListCodespaces = append(mock.calls.ListCodespaces, callInfo)
mock.lockListCodespaces.Unlock()
return mock.ListCodespacesFunc(ctx)
return mock.ListCodespacesFunc(ctx, limit)
}
// ListCodespacesCalls gets all the calls that were made to ListCodespaces.
// Check the length with:
// len(mockedapiClient.ListCodespacesCalls())
func (mock *apiClientMock) ListCodespacesCalls() []struct {
Ctx context.Context
Ctx context.Context
Limit int
} {
var calls []struct {
Ctx context.Context
Ctx context.Context
Limit int
}
mock.lockListCodespaces.RLock()
calls = mock.calls.ListCodespaces
@ -614,37 +545,33 @@ func (mock *apiClientMock) ListCodespacesCalls() []struct {
}
// StartCodespace calls StartCodespaceFunc.
func (mock *apiClientMock) StartCodespace(ctx context.Context, token string, codespace *api.Codespace) error {
func (mock *apiClientMock) StartCodespace(ctx context.Context, name string) error {
if mock.StartCodespaceFunc == nil {
panic("apiClientMock.StartCodespaceFunc: method is nil but apiClient.StartCodespace was just called")
}
callInfo := struct {
Ctx context.Context
Token string
Codespace *api.Codespace
Ctx context.Context
Name string
}{
Ctx: ctx,
Token: token,
Codespace: codespace,
Ctx: ctx,
Name: name,
}
mock.lockStartCodespace.Lock()
mock.calls.StartCodespace = append(mock.calls.StartCodespace, callInfo)
mock.lockStartCodespace.Unlock()
return mock.StartCodespaceFunc(ctx, token, codespace)
return mock.StartCodespaceFunc(ctx, name)
}
// StartCodespaceCalls gets all the calls that were made to StartCodespace.
// Check the length with:
// len(mockedapiClient.StartCodespaceCalls())
func (mock *apiClientMock) StartCodespaceCalls() []struct {
Ctx context.Context
Token string
Codespace *api.Codespace
Ctx context.Context
Name string
} {
var calls []struct {
Ctx context.Context
Token string
Codespace *api.Codespace
Ctx context.Context
Name string
}
mock.lockStartCodespace.RLock()
calls = mock.calls.StartCodespace

View file

@ -1,7 +1,7 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package ghcs
package codespace
import (
"sync"

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"bytes"
@ -49,12 +49,7 @@ func newPortsCmd(app *App) *cobra.Command {
// ListPorts lists known ports in a codespace.
func (a *App) ListPorts(ctx context.Context, codespaceName string, asJSON bool) (err error) {
user, err := a.apiClient.GetUser(ctx)
if err != nil {
return fmt.Errorf("error getting user: %w", err)
}
codespace, token, err := getOrChooseCodespace(ctx, a.apiClient, user, codespaceName)
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
// TODO(josebalius): remove special handling of this error here and it other places
if err == errNoCodespaces {
@ -65,7 +60,7 @@ func (a *App) ListPorts(ctx context.Context, codespaceName string, asJSON bool)
devContainerCh := getDevContainer(ctx, a.apiClient, codespace)
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, user.Login, token, codespace)
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, codespace)
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}
@ -79,7 +74,7 @@ func (a *App) ListPorts(ctx context.Context, codespaceName string, asJSON bool)
devContainerResult := <-devContainerCh
if devContainerResult.err != nil {
// Warn about failure to read the devcontainer file. Not a ghcs command error.
// Warn about failure to read the devcontainer file. Not a codespace command error.
_, _ = a.logger.Errorf("Failed to get port names: %v\n", devContainerResult.err.Error())
}
@ -191,12 +186,7 @@ func newPortsPrivateCmd(app *App) *cobra.Command {
}
func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName, sourcePort string, public bool) (err error) {
user, err := a.apiClient.GetUser(ctx)
if err != nil {
return fmt.Errorf("error getting user: %w", err)
}
codespace, token, err := getOrChooseCodespace(ctx, a.apiClient, user, codespaceName)
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
if err == errNoCodespaces {
return err
@ -204,7 +194,7 @@ func (a *App) UpdatePortVisibility(ctx context.Context, codespaceName, sourcePor
return fmt.Errorf("error getting codespace: %w", err)
}
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, user.Login, token, codespace)
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, codespace)
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}
@ -255,12 +245,7 @@ func (a *App) ForwardPorts(ctx context.Context, codespaceName string, ports []st
return fmt.Errorf("get port pairs: %w", err)
}
user, err := a.apiClient.GetUser(ctx)
if err != nil {
return fmt.Errorf("error getting user: %w", err)
}
codespace, token, err := getOrChooseCodespace(ctx, a.apiClient, user, codespaceName)
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
if err == errNoCodespaces {
return err
@ -268,7 +253,7 @@ func (a *App) ForwardPorts(ctx context.Context, codespaceName string, ports []st
return fmt.Errorf("error getting codespace: %w", err)
}
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, user.Login, token, codespace)
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, codespace)
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"github.com/spf13/cobra"
@ -8,7 +8,7 @@ var version = "DEV" // Replaced in the release build process (by GoReleaser or H
func NewRootCmd(app *App) *cobra.Command {
root := &cobra.Command{
Use: "ghcs",
Use: "codespace",
SilenceUsage: true, // don't print usage message after each error (see #80)
SilenceErrors: false, // print errors automatically so that main need not
Long: `Unofficial CLI tool to manage GitHub Codespaces.

View file

@ -1,4 +1,4 @@
package ghcs
package codespace
import (
"context"
@ -45,12 +45,12 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, sshProfile, codespaceNa
authkeys <- checkAuthorizedKeys(ctx, a.apiClient, user.Login)
}()
codespace, token, err := getOrChooseCodespace(ctx, a.apiClient, user, codespaceName)
codespace, err := getOrChooseCodespace(ctx, a.apiClient, codespaceName)
if err != nil {
return fmt.Errorf("get or choose codespace: %w", err)
}
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, user.Login, token, codespace)
session, err := codespaces.ConnectToLiveshare(ctx, a.logger, a.apiClient, codespace)
if err != nil {
return fmt.Errorf("error connecting to Live Share: %w", err)
}

View file

@ -46,6 +46,7 @@ func NewManager(io *iostreams.IOStreams) *Manager {
platform: func() string {
return fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH)
},
io: io,
}
}

View file

@ -11,6 +11,8 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"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/iostreams"
@ -25,6 +27,7 @@ type DiffOptions struct {
SelectorArg string
UseColor string
Patch bool
}
func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Command {
@ -70,6 +73,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
}
cmd.Flags().StringVar(&opts.UseColor, "color", "auto", "Use color in diff output: {always|never|auto}")
cmd.Flags().BoolVar(&opts.Patch, "patch", false, "Display diff in patch format")
return cmd
}
@ -88,9 +92,8 @@ func diffRun(opts *DiffOptions) error {
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number)
diff, err := fetchDiff(httpClient, baseRepo, pr.Number, opts.Patch)
if err != nil {
return fmt.Errorf("could not find pull request diff: %w", err)
}
@ -132,6 +135,36 @@ func diffRun(opts *DiffOptions) error {
return nil
}
func fetchDiff(httpClient *http.Client, baseRepo ghrepo.Interface, prNumber int, asPatch bool) (io.ReadCloser, error) {
url := fmt.Sprintf(
"%srepos/%s/pulls/%d",
ghinstance.RESTPrefix(baseRepo.RepoHost()),
ghrepo.FullName(baseRepo),
prNumber,
)
acceptType := "application/vnd.github.v3.diff"
if asPatch {
acceptType = "application/vnd.github.v3.patch"
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", acceptType)
resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
return nil, api.HandleHTTPError(resp)
}
return resp.Body, nil
}
var diffHeaderPrefixes = []string{"+++", "---", "diff", "index"}
func isHeaderLine(dl string) bool {

View file

@ -4,6 +4,7 @@ import (
"bytes"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/cli/cli/v2/api"
@ -140,26 +141,67 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s
}, err
}
func TestPRDiff_notty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
func TestPRDiff_notty_diff(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
shared.RunCommandFinder("", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO"))
http.Register(
var gotAccept string
httpReg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
httpmock.StringResponse(testDiff))
func(req *http.Request) (*http.Response, error) {
gotAccept = req.Header.Get("Accept")
return &http.Response{
StatusCode: 200,
Request: req,
Body: ioutil.NopCloser(strings.NewReader(testDiff)),
}, nil
})
output, err := runCommand(http, nil, false, "")
output, err := runCommand(httpReg, nil, false, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if diff := cmp.Diff(testDiff, output.String()); diff != "" {
t.Errorf("command output did not match:\n%s", diff)
}
if gotAccept != "application/vnd.github.v3.diff" {
t.Errorf("unexpected Accept header: %s", gotAccept)
}
}
func TestPRDiff_tty(t *testing.T) {
func TestPRDiff_notty_patch(t *testing.T) {
httpReg := &httpmock.Registry{}
defer httpReg.Verify(t)
shared.RunCommandFinder("", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO"))
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: ioutil.NopCloser(strings.NewReader(testDiff)),
}, nil
})
output, err := runCommand(httpReg, nil, false, "--patch")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if diff := cmp.Diff(testDiff, output.String()); diff != "" {
t.Errorf("command output did not match:\n%s", diff)
}
if gotAccept != "application/vnd.github.v3.patch" {
t.Errorf("unexpected Accept header: %s", gotAccept)
}
}
func TestPRDiff_tty_diff(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)

View file

@ -0,0 +1,99 @@
package archive
import (
"fmt"
"net/http"
"strings"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/spf13/cobra"
)
type ArchiveOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
RepoArg string
}
func NewCmdArchive(f *cmdutil.Factory, runF func(*ArchiveOptions) error) *cobra.Command {
opts := &ArchiveOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
DisableFlagsInUseLine: true,
Use: "archive <repository>",
Short: "Archive a repository",
Long: "Archive a GitHub repository.",
Args: cmdutil.ExactArgs(1, "cannot archive: repository argument required"),
RunE: func(cmd *cobra.Command, args []string) error {
opts.RepoArg = args[0]
if runF != nil {
return runF(opts)
}
return archiveRun(opts)
},
}
return cmd
}
func archiveRun(opts *ArchiveOptions) error {
cs := opts.IO.ColorScheme()
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
var toArchive ghrepo.Interface
archiveURL := opts.RepoArg
if !strings.Contains(archiveURL, "/") {
currentUser, err := api.CurrentLoginName(apiClient, ghinstance.Default())
if err != nil {
return err
}
archiveURL = currentUser + "/" + archiveURL
}
toArchive, err = ghrepo.FromFullName(archiveURL)
if err != nil {
return fmt.Errorf("argument error: %w", err)
}
fields := []string{"name", "owner", "isArchived", "id"}
repo, err := api.FetchRepository(apiClient, toArchive, fields)
if err != nil {
return err
}
fullName := ghrepo.FullName(toArchive)
if repo.IsArchived {
fmt.Fprintf(opts.IO.ErrOut,
"%s Repository %s is already archived\n",
cs.WarningIcon(),
fullName)
return nil
}
err = archiveRepo(httpClient, repo)
if err != nil {
return fmt.Errorf("API called failed: %w", err)
}
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out,
"%s Archived repository %s\n",
cs.SuccessIcon(),
fullName)
}
return nil
}

View file

@ -0,0 +1,165 @@
package archive
import (
"bytes"
"net/http"
"testing"
"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"
)
// probably redundant
func TestNewCmdArchive(t *testing.T) {
tests := []struct {
name string
input string
tty bool
output ArchiveOptions
wantErr bool
errMsg string
}{
{
name: "valid repo",
input: "cli/cli",
tty: true,
output: ArchiveOptions{
RepoArg: "cli/cli",
},
},
{
name: "no argument",
input: "",
wantErr: true,
tty: true,
output: ArchiveOptions{
RepoArg: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdinTTY(tt.tty)
io.SetStdoutTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *ArchiveOptions
cmd := NewCmdArchive(f, func(opts *ArchiveOptions) error {
gotOpts = 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)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.RepoArg, gotOpts.RepoArg)
})
}
}
func Test_ArchiveRun(t *testing.T) {
tests := []struct {
name string
opts ArchiveOptions
httpStubs func(*httpmock.Registry)
isTTY bool
wantStdout string
wantStderr string
}{
{
name: "unarchived repo tty",
opts: ArchiveOptions{RepoArg: "OWNER/REPO"},
wantStdout: "✓ Archived repository OWNER/REPO\n",
isTTY: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{ "data": { "repository": {
"id": "THE-ID",
"isArchived": false} } }`))
reg.Register(
httpmock.GraphQL(`mutation ArchiveRepository\b`),
httpmock.StringResponse(`{}`))
},
},
{
name: "unarchived repo notty",
opts: ArchiveOptions{RepoArg: "OWNER/REPO"},
isTTY: false,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{ "data": { "repository": {
"id": "THE-ID",
"isArchived": false} } }`))
reg.Register(
httpmock.GraphQL(`mutation ArchiveRepository\b`),
httpmock.StringResponse(`{}`))
},
},
{
name: "archived repo tty",
opts: ArchiveOptions{RepoArg: "OWNER/REPO"},
wantStderr: "! Repository OWNER/REPO is already archived\n",
isTTY: true,
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{ "data": { "repository": {
"id": "THE-ID",
"isArchived": true } } }`))
},
},
{
name: "archived repo notty",
opts: ArchiveOptions{RepoArg: "OWNER/REPO"},
isTTY: false,
wantStderr: "! Repository OWNER/REPO is already archived\n",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{ "data": { "repository": {
"id": "THE-ID",
"isArchived": true } } }`))
},
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, stdout, stderr := iostreams.Test()
tt.opts.IO = io
t.Run(tt.name, func(t *testing.T) {
defer reg.Verify(t)
io.SetStdoutTTY(tt.isTTY)
io.SetStderrTTY(tt.isTTY)
err := archiveRun(&tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.wantStdout, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
})
}
}

View file

@ -0,0 +1,32 @@
package archive
import (
"context"
"net/http"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
func archiveRepo(client *http.Client, repo *api.Repository) error {
var mutation struct {
ArchiveRepository struct {
Repository struct {
ID string
}
} `graphql:"archiveRepository(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.ArchiveRepositoryInput{
RepositoryID: repo.ID,
},
}
host := repo.RepoHost()
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(host), client)
err := gql.MutateNamed(context.Background(), "ArchiveRepository", &mutation, variables)
return err
}

View file

@ -2,6 +2,7 @@ package repo
import (
"github.com/MakeNowJust/heredoc"
repoArchiveCmd "github.com/cli/cli/v2/pkg/cmd/repo/archive"
repoCloneCmd "github.com/cli/cli/v2/pkg/cmd/repo/clone"
repoCreateCmd "github.com/cli/cli/v2/pkg/cmd/repo/create"
creditsCmd "github.com/cli/cli/v2/pkg/cmd/repo/credits"
@ -44,6 +45,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil))
cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil))
cmd.AddCommand(repoDeleteCmd.NewCmdDelete(f, nil))
cmd.AddCommand(repoArchiveCmd.NewCmdArchive(f, nil))
return cmd
}

View file

@ -12,25 +12,6 @@ import (
var NotFoundError = errors.New("not found")
func fetchRepository(apiClient *api.Client, repo ghrepo.Interface, fields []string) (*api.Repository, error) {
query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {%s}
}`, api.RepositoryGraphQL(fields))
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
}
var result struct {
Repository api.Repository
}
if err := apiClient.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
return nil, err
}
return api.InitRepoHostname(&result.Repository, repo.RepoHost()), nil
}
type RepoReadme struct {
Filename string
Content string

View file

@ -111,7 +111,7 @@ func viewRun(opts *ViewOptions) error {
fields = opts.Exporter.Fields()
}
repo, err := fetchRepository(apiClient, toView, fields)
repo, err := api.FetchRepository(apiClient, toView, fields)
if err != nil {
return err
}