diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..4bdfc347a8d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: +- package-ecosystem: gomod + directory: "/" + schedule: + interval: "daily" +- package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9e3848c26a2..d46c0bcf330 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,7 +2,11 @@ name: Code Scanning on: push: + branches: [trunk] pull_request: + branches: [trunk] + paths-ignore: + - '**/*.md' schedule: - cron: "0 0 * * 0" diff --git a/.github/workflows/prauto.yml b/.github/workflows/prauto.yml index 20a88b31eb3..58930656acb 100644 --- a/.github/workflows/prauto.yml +++ b/.github/workflows/prauto.yml @@ -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 diff --git a/README.md b/README.md index 71630a5ccd2..1259738e219 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/queries_pr.go b/api/queries_pr.go index 388446f42ff..744b4c9e291 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -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 diff --git a/api/queries_repo.go b/api/queries_repo.go index b1e41370d6e..c9d8b46f9ef 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -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 { diff --git a/go.mod b/go.mod index 89885a50a88..73e9d82f767 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 190c0edb7fe..a6210c53254 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/codespaces/api/api.go b/internal/codespaces/api/api.go index 531b2b3f856..1087e58283f 100644 --- a/internal/codespaces/api/api.go +++ b/internal/codespaces/api/api.go @@ -34,10 +34,13 @@ import ( "fmt" "io/ioutil" "net/http" + "net/url" + "regexp" "strconv" "strings" "time" + "github.com/cli/cli/v2/api" "github.com/opentracing/opentracing-go" ) @@ -158,14 +161,14 @@ type Codespace struct { RepositoryNWO string `json:"repository_nwo"` OwnerLogin string `json:"owner_login"` Environment CodespaceEnvironment `json:"environment"` + Connection CodespaceConnection `json:"connection"` } const CodespaceStateProvisioned = "provisioned" type CodespaceEnvironment struct { - State string `json:"state"` - Connection CodespaceEnvironmentConnection `json:"connection"` - GitStatus CodespaceEnvironmentGitStatus `json:"gitStatus"` + State string `json:"state"` + GitStatus CodespaceEnvironmentGitStatus `json:"gitStatus"` } type CodespaceEnvironmentGitStatus struct { @@ -182,7 +185,7 @@ const ( CodespaceEnvironmentStateAvailable = "Available" ) -type CodespaceEnvironmentConnection struct { +type CodespaceConnection struct { SessionID string `json:"sessionId"` SessionToken string `json:"sessionToken"` RelayEndpoint string `json:"relayEndpoint"` @@ -190,62 +193,70 @@ type CodespaceEnvironmentConnection struct { HostPublicKeys []string `json:"hostPublicKeys"` } -// codespacesListResponse is the response body for the `/user/codespaces` endpoint -type getCodespacesListResponse struct { - Codespaces []*Codespace `json:"codespaces"` - TotalCount int `json:"total_count"` -} +// 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 + } + + 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) -// ListCodespaces returns a list of codespaces for the user. -// It consumes all pages returned by the API until all codespaces have been fetched. -func (a *API) ListCodespaces(ctx context.Context) (codespaces []*Codespace, err error) { - per_page := 100 - for page := 1; ; page++ { - response, err := a.fetchCodespaces(ctx, page, per_page) + resp, err := a.do(ctx, req, "/user/codespaces") if err != nil { - return nil, err + return nil, fmt.Errorf("error making request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, api.HandleHTTPError(resp) + } + + 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 page*per_page >= response.TotalCount { + + 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 + } } return codespaces, nil } -func (a *API) fetchCodespaces(ctx context.Context, page int, per_page int) (response *getCodespacesListResponse, err error) { - req, err := http.NewRequest( - http.MethodGet, a.githubAPI+"/user/codespaces", nil, - ) - if err != nil { - return nil, fmt.Errorf("error creating request: %w", err) - } - a.setHeaders(req) - q := req.URL.Query() - q.Add("page", strconv.Itoa(page)) - q.Add("per_page", strconv.Itoa(per_page)) - - req.URL.RawQuery = q.Encode() - resp, err := a.do(ctx, req, "/user/codespaces") - if err != nil { - return nil, fmt.Errorf("error making request: %w", err) - } - defer resp.Body.Close() - - b, err := ioutil.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("error reading response body: %w", err) - } - - if resp.StatusCode != http.StatusOK { - return nil, jsonErrorResponse(b) - } +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) - if err := json.Unmarshal(b, &response); err != nil { - return nil, fmt.Errorf("error unmarshaling response: %w", err) +func findNextPage(linkValue string) string { + for _, m := range linkRE.FindAllStringSubmatch(linkValue, -1) { + if len(m) > 2 && m[2] == "next" { + return m[1] + } } - return response, nil + return "" } // GetCodespace returns the user codespace based on the provided name. @@ -330,7 +341,7 @@ func (a *API) StartCodespace(ctx context.Context, codespaceName string) error { if len(b) > 100 { b = append(b[:97], "..."...) } - return fmt.Errorf("failed to start codespace: %s", b) + return fmt.Errorf("failed to start codespace: %s (%s)", b, resp.Status) } return nil diff --git a/internal/codespaces/api/api_test.go b/internal/codespaces/api/api_test.go index a9d81e44235..e8748fa9a11 100644 --- a/internal/codespaces/api/api_test.go +++ b/internal/codespaces/api/api_test.go @@ -44,13 +44,19 @@ func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) TotalCount: finalTotal, } - if page == 1 { + switch page { + case 1: response.Codespaces = generateCodespaceList(0, per_page) response.TotalCount = initalTotal - } else if page == 2 { + w.Header().Set("Link", fmt.Sprintf(`; rel="last", ; rel="next"`, r.Host, per_page)) + case 2: response.Codespaces = generateCodespaceList(per_page, per_page*2) response.TotalCount = finalTotal - } else { + w.Header().Set("Link", fmt.Sprintf(`; 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") } @@ -59,7 +65,7 @@ func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) })) } -func TestListCodespaces(t *testing.T) { +func TestListCodespaces_limited(t *testing.T) { svr := createFakeListEndpointServer(t, 200, 200) defer svr.Close() @@ -69,25 +75,24 @@ 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) != 200 { - t.Fatalf("expected 100 codespace, got %d", len(codespaces)) + t.Fatalf("expected 200 codespace, got %d", len(codespaces)) } - 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 TestMidIterationDeletion(t *testing.T) { - svr := createFakeListEndpointServer(t, 200, 199) +func TestListCodespaces_unlimited(t *testing.T) { + svr := createFakeListEndpointServer(t, 200, 200) defer svr.Close() api := API{ @@ -96,30 +101,18 @@ func TestMidIterationDeletion(t *testing.T) { token: "faketoken", } ctx := context.TODO() - codespaces, err := api.ListCodespaces(ctx) + codespaces, err := api.ListCodespaces(ctx, -1) if err != nil { t.Fatal(err) } - if len(codespaces) != 200 { - t.Fatalf("expected 200 codespace, got %d", len(codespaces)) - } -} - -func TestMidIterationAddition(t *testing.T) { - svr := createFakeListEndpointServer(t, 199, 200) - defer svr.Close() - api := API{ - githubAPI: svr.URL, - client: &http.Client{}, - token: "faketoken", + if len(codespaces) != 250 { + t.Fatalf("expected 250 codespace, got %d", len(codespaces)) } - ctx := context.TODO() - codespaces, err := api.ListCodespaces(ctx) - if err != nil { - t.Fatal(err) + if codespaces[0].Name != "codespace-0" { + t.Fatalf("expected codespace-0, got %s", codespaces[0].Name) } - if len(codespaces) != 200 { - t.Fatalf("expected 200 codespace, got %d", len(codespaces)) + if codespaces[249].Name != "codespace-249" { + t.Fatalf("expected codespace-249, got %s", codespaces[0].Name) } } diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index c9475ad4de2..55ec2a25ac2 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -23,10 +23,10 @@ type liveshareLogger interface { } func connectionReady(codespace *api.Codespace) bool { - return codespace.Environment.Connection.SessionID != "" && - codespace.Environment.Connection.SessionToken != "" && - codespace.Environment.Connection.RelayEndpoint != "" && - codespace.Environment.Connection.RelaySAS != "" && + return codespace.Connection.SessionID != "" && + codespace.Connection.SessionToken != "" && + codespace.Connection.RelayEndpoint != "" && + codespace.Connection.RelaySAS != "" && codespace.Environment.State == api.CodespaceEnvironmentStateAvailable } @@ -75,11 +75,11 @@ func ConnectToLiveshare(ctx context.Context, log logger, sessionLogger liveshare return liveshare.Connect(ctx, liveshare.Options{ ClientName: "gh", - SessionID: codespace.Environment.Connection.SessionID, - SessionToken: codespace.Environment.Connection.SessionToken, - RelaySAS: codespace.Environment.Connection.RelaySAS, - RelayEndpoint: codespace.Environment.Connection.RelayEndpoint, - HostPublicKeys: codespace.Environment.Connection.HostPublicKeys, + SessionID: codespace.Connection.SessionID, + SessionToken: codespace.Connection.SessionToken, + RelaySAS: codespace.Connection.RelaySAS, + RelayEndpoint: codespace.Connection.RelayEndpoint, + HostPublicKeys: codespace.Connection.HostPublicKeys, Logger: sessionLogger, }) } diff --git a/internal/docs/man_test.go b/internal/docs/man_test.go index d72ea7214ed..dd1df2e9b7a 100644 --- a/internal/docs/man_test.go +++ b/internal/docs/man_test.go @@ -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) } } diff --git a/pkg/cmd/codespace/common.go b/pkg/cmd/codespace/common.go index d499953449a..721a5c992d6 100644 --- a/pkg/cmd/codespace/common.go +++ b/pkg/cmd/codespace/common.go @@ -37,7 +37,7 @@ func NewApp(logger *output.Logger, apiClient apiClient) *App { type apiClient interface { GetUser(ctx context.Context) (*api.User, error) GetCodespace(ctx context.Context, name string, includeConnection bool) (*api.Codespace, error) - ListCodespaces(ctx context.Context) ([]*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) @@ -51,7 +51,7 @@ type apiClient interface { 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) } diff --git a/pkg/cmd/codespace/delete.go b/pkg/cmd/codespace/delete.go index 2d526dca36e..3b1d5e44557 100644 --- a/pkg/cmd/codespace/delete.go +++ b/pkg/cmd/codespace/delete.go @@ -62,7 +62,7 @@ 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) } diff --git a/pkg/cmd/codespace/delete_test.go b/pkg/cmd/codespace/delete_test.go index 68c7892de5d..0251e4529b3 100644 --- a/pkg/cmd/codespace/delete_test.go +++ b/pkg/cmd/codespace/delete_test.go @@ -168,7 +168,7 @@ 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 { diff --git a/pkg/cmd/codespace/list.go b/pkg/cmd/codespace/list.go index 4bc649bf7dc..2dbbbc7c7ee 100644 --- a/pkg/cmd/codespace/list.go +++ b/pkg/cmd/codespace/list.go @@ -6,28 +6,35 @@ import ( "os" "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) } diff --git a/pkg/cmd/codespace/mock_api.go b/pkg/cmd/codespace/mock_api.go index df9b6f3b223..d46d793f903 100644 --- a/pkg/cmd/codespace/mock_api.go +++ b/pkg/cmd/codespace/mock_api.go @@ -43,7 +43,7 @@ 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, name string) error { @@ -84,7 +84,7 @@ 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, name string) error @@ -162,6 +162,8 @@ 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 { @@ -508,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 diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 789b57b9169..f1529a63d2b 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -214,8 +214,8 @@ func TestNewCmdExtension(t *testing.T) { args: []string{"list"}, managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { em.ListFunc = func(bool) []extensions.Extension { - ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", updateAvailable: false} - ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", updateAvailable: true} + ex1 := &Extension{path: "cli/gh-test", url: "https://github.com/cli/gh-test", currentVersion: "1", latestVersion: "1"} + ex2 := &Extension{path: "cli/gh-test2", url: "https://github.com/cli/gh-test2", currentVersion: "1", latestVersion: "2"} return []extensions.Extension{ex1, ex2} } return func(t *testing.T) { diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index 39d81c52a66..ead9204cd39 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -7,11 +7,20 @@ import ( const manifestName = "manifest.yml" +type ExtensionKind int + +const ( + GitKind ExtensionKind = iota + BinaryKind +) + type Extension struct { - path string - url string - isLocal bool - updateAvailable bool + path string + url string + isLocal bool + currentVersion string + latestVersion string + kind ExtensionKind } func (e *Extension) Name() string { @@ -31,5 +40,11 @@ func (e *Extension) IsLocal() bool { } func (e *Extension) UpdateAvailable() bool { - return e.updateAvailable + if e.isLocal || + e.currentVersion == "" || + e.latestVersion == "" || + e.currentVersion == e.latestVersion { + return false + } + return true } diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index f238db10b61..68071ced0f8 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -14,6 +14,7 @@ import ( "path/filepath" "runtime" "strings" + "sync" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/v2/api" @@ -103,146 +104,195 @@ func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Wri func (m *Manager) List(includeMetadata bool) []extensions.Extension { exts, _ := m.list(includeMetadata) - return exts + r := make([]extensions.Extension, len(exts)) + for i, v := range exts { + val := v + r[i] = &val + } + return r } -func (m *Manager) list(includeMetadata bool) ([]extensions.Extension, error) { +func (m *Manager) list(includeMetadata bool) ([]Extension, error) { dir := m.installDir() entries, err := ioutil.ReadDir(dir) if err != nil { return nil, err } - var results []extensions.Extension + var results []Extension for _, f := range entries { if !strings.HasPrefix(f.Name(), "gh-") { continue } - ext, err := m.parseExtensionDir(f, includeMetadata) - if err != nil { - return nil, err + var ext Extension + var err error + if f.IsDir() { + ext, err = m.parseExtensionDir(f) + if err != nil { + return nil, err + } + results = append(results, ext) + } else { + ext, err = m.parseExtensionFile(f) + if err != nil { + return nil, err + } + results = append(results, ext) } - results = append(results, ext) + } + + if includeMetadata { + m.populateLatestVersions(results) } return results, nil } -func (m *Manager) parseExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) { +func (m *Manager) parseExtensionFile(fi fs.FileInfo) (Extension, error) { + ext := Extension{isLocal: true} + id := m.installDir() + exePath := filepath.Join(id, fi.Name(), fi.Name()) + if !isSymlink(fi.Mode()) { + // if this is a regular file, its contents is the local directory of the extension + p, err := readPathFromFile(filepath.Join(id, fi.Name())) + if err != nil { + return ext, err + } + exePath = filepath.Join(p, fi.Name()) + } + ext.path = exePath + return ext, nil +} + +func (m *Manager) parseExtensionDir(fi fs.FileInfo) (Extension, error) { id := m.installDir() if _, err := os.Stat(filepath.Join(id, fi.Name(), manifestName)); err == nil { - return m.parseBinaryExtensionDir(fi, includeMetadata) + return m.parseBinaryExtensionDir(fi) } - return m.parseGitExtensionDir(fi, includeMetadata) + return m.parseGitExtensionDir(fi) } -func (m *Manager) parseBinaryExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) { +func (m *Manager) parseBinaryExtensionDir(fi fs.FileInfo) (Extension, error) { id := m.installDir() exePath := filepath.Join(id, fi.Name(), fi.Name()) + ext := Extension{path: exePath, kind: BinaryKind} manifestPath := filepath.Join(id, fi.Name(), manifestName) manifest, err := os.ReadFile(manifestPath) if err != nil { - return nil, fmt.Errorf("could not open %s for reading: %w", manifestPath, err) + return ext, fmt.Errorf("could not open %s for reading: %w", manifestPath, err) } - var bm binManifest err = yaml.Unmarshal(manifest, &bm) if err != nil { - return nil, fmt.Errorf("could not parse %s: %w", manifestPath, err) + return ext, fmt.Errorf("could not parse %s: %w", manifestPath, err) } - repo := ghrepo.NewWithHost(bm.Owner, bm.Name, bm.Host) - - var remoteURL string - var updateAvailable bool - - if includeMetadata { - remoteURL = ghrepo.GenerateRepoURL(repo, "") - var r *release - r, err = fetchLatestRelease(m.client, repo) - if err != nil { - return nil, fmt.Errorf("failed to get release info for %s: %w", ghrepo.FullName(repo), err) - } - if bm.Tag != r.Tag { - updateAvailable = true - } - } - - return &Extension{ - path: exePath, - url: remoteURL, - updateAvailable: updateAvailable, - }, nil + remoteURL := ghrepo.GenerateRepoURL(repo, "") + ext.url = remoteURL + ext.currentVersion = bm.Tag + return ext, nil } -func (m *Manager) parseGitExtensionDir(fi fs.FileInfo, includeMetadata bool) (*Extension, error) { - // TODO untangle local from this since local might be binary or git +func (m *Manager) parseGitExtensionDir(fi fs.FileInfo) (Extension, error) { id := m.installDir() - var remoteUrl string - updateAvailable := false - isLocal := false exePath := filepath.Join(id, fi.Name(), fi.Name()) - if fi.IsDir() { - if includeMetadata { - remoteUrl = m.getRemoteUrl(fi.Name()) - updateAvailable = m.checkUpdateAvailable(fi.Name()) - } - } else { - isLocal = true - if !isSymlink(fi.Mode()) { - // if this is a regular file, its contents is the local directory of the extension - p, err := readPathFromFile(filepath.Join(id, fi.Name())) - if err != nil { - return nil, err - } - exePath = filepath.Join(p, fi.Name()) - } - } - - return &Extension{ - path: exePath, - url: remoteUrl, - isLocal: isLocal, - updateAvailable: updateAvailable, + remoteUrl := m.getRemoteUrl(fi.Name()) + currentVersion := m.getCurrentVersion(fi.Name()) + return Extension{ + path: exePath, + url: remoteUrl, + isLocal: false, + currentVersion: currentVersion, + kind: GitKind, }, nil } -func (m *Manager) getRemoteUrl(extension string) string { +// getCurrentVersion determines the current version for non-local git extensions. +func (m *Manager) getCurrentVersion(extension string) string { gitExe, err := m.lookPath("git") if err != nil { return "" } dir := m.installDir() gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git") - cmd := m.newCommand(gitExe, gitDir, "config", "remote.origin.url") - url, err := cmd.Output() + cmd := m.newCommand(gitExe, gitDir, "rev-parse", "HEAD") + localSha, err := cmd.Output() if err != nil { return "" } - return strings.TrimSpace(string(url)) + return string(bytes.TrimSpace(localSha)) } -func (m *Manager) checkUpdateAvailable(extension string) bool { +// getRemoteUrl determines the remote URL for non-local git extensions. +func (m *Manager) getRemoteUrl(extension string) string { gitExe, err := m.lookPath("git") if err != nil { - return false + return "" } dir := m.installDir() gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git") - cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD") - lsRemote, err := cmd.Output() + cmd := m.newCommand(gitExe, gitDir, "config", "remote.origin.url") + url, err := cmd.Output() if err != nil { - return false + return "" } - remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0] - cmd = m.newCommand(gitExe, gitDir, "rev-parse", "HEAD") - localSha, err := cmd.Output() - if err != nil { - return false + return strings.TrimSpace(string(url)) +} + +func (m *Manager) populateLatestVersions(exts []Extension) { + size := len(exts) + type result struct { + index int + version string + } + ch := make(chan result, size) + var wg sync.WaitGroup + wg.Add(size) + for idx, ext := range exts { + go func(i int, e Extension) { + defer wg.Done() + version, _ := m.getLatestVersion(e) + ch <- result{index: i, version: version} + }(idx, ext) + } + wg.Wait() + close(ch) + for r := range ch { + ext := &exts[r.index] + ext.latestVersion = r.version + } +} + +func (m *Manager) getLatestVersion(ext Extension) (string, error) { + if ext.isLocal { + return "", fmt.Errorf("unable to get latest version for local extensions") + } + if ext.kind == GitKind { + gitExe, err := m.lookPath("git") + if err != nil { + return "", err + } + extDir := filepath.Dir(ext.path) + gitDir := "--git-dir=" + filepath.Join(extDir, ".git") + cmd := m.newCommand(gitExe, gitDir, "ls-remote", "origin", "HEAD") + lsRemote, err := cmd.Output() + if err != nil { + return "", err + } + remoteSha := bytes.SplitN(lsRemote, []byte("\t"), 2)[0] + return string(remoteSha), nil + } else { + repo, err := ghrepo.FromFullName(ext.url) + if err != nil { + return "", err + } + r, err := fetchLatestRelease(m.client, repo) + if err != nil { + return "", err + } + return r.Tag, nil } - localSha = bytes.TrimSpace(localSha) - return !bytes.Equal(remoteSha, localSha) } func (m *Manager) InstallLocal(dir string) error { diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index 5f8f43d465d..d89fe868ae2 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -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 { diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index adf45a123c8..67ee822d98a 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -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"), + 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, "") + 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_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"), - 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, "--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(t *testing.T) { +func TestPRDiff_tty_diff(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) diff --git a/pkg/cmd/repo/archive/archive.go b/pkg/cmd/repo/archive/archive.go new file mode 100644 index 00000000000..c4d6be2bbdb --- /dev/null +++ b/pkg/cmd/repo/archive/archive.go @@ -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 ", + 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 +} diff --git a/pkg/cmd/repo/archive/archive_test.go b/pkg/cmd/repo/archive/archive_test.go new file mode 100644 index 00000000000..6ed5ecb8f7e --- /dev/null +++ b/pkg/cmd/repo/archive/archive_test.go @@ -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()) + }) + } +} diff --git a/pkg/cmd/repo/archive/http.go b/pkg/cmd/repo/archive/http.go new file mode 100644 index 00000000000..814fbcd334a --- /dev/null +++ b/pkg/cmd/repo/archive/http.go @@ -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 +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 252c322e9bd..11c36b012ce 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -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" @@ -42,6 +43,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoSyncCmd.NewCmdSync(f, nil)) cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil)) + cmd.AddCommand(repoArchiveCmd.NewCmdArchive(f, nil)) return cmd } diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go index dee1df6779a..fe2584a901e 100644 --- a/pkg/cmd/repo/view/http.go +++ b/pkg/cmd/repo/view/http.go @@ -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 diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index 38524f1a2f1..ef8a7dfa2e8 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -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 }