Merge branch 'trunk' into gh-search-code
This commit is contained in:
commit
b8f86f54ad
133 changed files with 3967 additions and 515 deletions
0
CODEOWNERS → .github/CODEOWNERS
vendored
0
CODEOWNERS → .github/CODEOWNERS
vendored
25
.github/workflows/deployment.yml
vendored
Normal file
25
.github/workflows/deployment.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
name: Deployment
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
required: true
|
||||
type: string
|
||||
go_version:
|
||||
default: "1.19"
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: ${{ inputs.go_version }}
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -1,5 +1,8 @@
|
|||
/bin
|
||||
/share/bash-completion/completions
|
||||
/share/fish/vendor_completions.d
|
||||
/share/man/man1
|
||||
/share/zsh/site-functions
|
||||
/gh-cli
|
||||
.envrc
|
||||
/dist
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ before:
|
|||
hooks:
|
||||
- go mod tidy
|
||||
- make manpages GH_VERSION={{.Version}}
|
||||
- make completions
|
||||
|
||||
builds:
|
||||
- <<: &build_defaults
|
||||
|
|
@ -70,3 +71,9 @@ nfpms:
|
|||
contents:
|
||||
- src: "./share/man/man1/gh*.1"
|
||||
dst: "/usr/share/man/man1"
|
||||
- src: "./share/bash-completion/completions/gh"
|
||||
dst: "/usr/share/bash-completion/completions/gh"
|
||||
- src: "./share/fish/vendor_completions.d/gh.fish"
|
||||
dst: "/usr/share/fish/vendor_completions.d/gh.fish"
|
||||
- src: "./share/zsh/site-functions/_gh"
|
||||
dst: "/usr/share/zsh/site-functions/_gh"
|
||||
|
|
|
|||
21
Makefile
21
Makefile
|
|
@ -27,6 +27,13 @@ clean: script/build
|
|||
manpages: script/build
|
||||
@script/build $@
|
||||
|
||||
.PHONY: completions
|
||||
completions:
|
||||
mkdir -p ./share/bash-completion/completions ./share/fish/vendor_completions.d ./share/zsh/site-functions
|
||||
go run ./cmd/gh completion -s bash > ./share/bash-completion/completions/gh
|
||||
go run ./cmd/gh completion -s fish > ./share/fish/vendor_completions.d/gh.fish
|
||||
go run ./cmd/gh completion -s zsh > ./share/zsh/site-functions/_gh
|
||||
|
||||
# just a convenience task around `go test`
|
||||
.PHONY: test
|
||||
test:
|
||||
|
|
@ -60,15 +67,25 @@ endif
|
|||
DESTDIR :=
|
||||
prefix := /usr/local
|
||||
bindir := ${prefix}/bin
|
||||
mandir := ${prefix}/share/man
|
||||
datadir := ${prefix}/share
|
||||
mandir := ${datadir}/man
|
||||
|
||||
.PHONY: install
|
||||
install: bin/gh manpages
|
||||
install: bin/gh manpages completions
|
||||
install -d ${DESTDIR}${bindir}
|
||||
install -m755 bin/gh ${DESTDIR}${bindir}/
|
||||
install -d ${DESTDIR}${mandir}/man1
|
||||
install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/
|
||||
install -d ${DESTDIR}${datadir}/bash-completion/completions
|
||||
install -m644 ./share/bash-completion/completions/gh ${DESTDIR}${datadir}/bash-completion/completions/gh
|
||||
install -d ${DESTDIR}${datadir}/fish/vendor_completions.d
|
||||
install -m644 ./share/fish/vendor_completions.d/gh.fish ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish
|
||||
install -d ${DESTDIR}${datadir}/zsh/site-functions
|
||||
install -m644 ./share/zsh/site-functions/_gh ${DESTDIR}${datadir}/zsh/site-functions/_gh
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
rm -f ${DESTDIR}${bindir}/gh ${DESTDIR}${mandir}/man1/gh.1 ${DESTDIR}${mandir}/man1/gh-*.1
|
||||
rm -f ${DESTDIR}${datadir}/bash-completion/completions/gh
|
||||
rm -f ${DESTDIR}${datadir}/fish/vendor_completions.d/gh.fish
|
||||
rm -f ${DESTDIR}${datadir}/zsh/site-functions/_gh
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@ For more information, see [Linux & BSD installation](./docs/install_linux.md).
|
|||
| `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` |
|
||||
|
||||
> **Note**
|
||||
> The Windows installer modifes your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.)
|
||||
> The Windows installer modifies your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.)
|
||||
|
||||
#### scoop
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/go-gh"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
ghAPI "github.com/cli/go-gh/v2/pkg/api"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -40,11 +39,11 @@ func (c *Client) HTTP() *http.Client {
|
|||
}
|
||||
|
||||
type GraphQLError struct {
|
||||
ghAPI.GQLError
|
||||
*ghAPI.GraphQLError
|
||||
}
|
||||
|
||||
type HTTPError struct {
|
||||
ghAPI.HTTPError
|
||||
*ghAPI.HTTPError
|
||||
scopesSuggestion string
|
||||
}
|
||||
|
||||
|
|
@ -57,7 +56,7 @@ func (err HTTPError) ScopesSuggestion() string {
|
|||
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
|
||||
opts := clientOptions(hostname, c.http.Transport)
|
||||
opts.Headers[graphqlFeatures] = features
|
||||
gqlClient, err := gh.GQLClient(&opts)
|
||||
gqlClient, err := ghAPI.NewGraphQLClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -69,7 +68,7 @@ func (c Client) GraphQL(hostname string, query string, variables map[string]inte
|
|||
func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error {
|
||||
opts := clientOptions(hostname, c.http.Transport)
|
||||
opts.Headers[graphqlFeatures] = features
|
||||
gqlClient, err := gh.GQLClient(&opts)
|
||||
gqlClient, err := ghAPI.NewGraphQLClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -81,7 +80,7 @@ func (c Client) Mutate(hostname, name string, mutation interface{}, variables ma
|
|||
func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error {
|
||||
opts := clientOptions(hostname, c.http.Transport)
|
||||
opts.Headers[graphqlFeatures] = features
|
||||
gqlClient, err := gh.GQLClient(&opts)
|
||||
gqlClient, err := ghAPI.NewGraphQLClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -93,7 +92,7 @@ func (c Client) Query(hostname, name string, query interface{}, variables map[st
|
|||
func (c Client) QueryWithContext(ctx context.Context, hostname, name string, query interface{}, variables map[string]interface{}) error {
|
||||
opts := clientOptions(hostname, c.http.Transport)
|
||||
opts.Headers[graphqlFeatures] = features
|
||||
gqlClient, err := gh.GQLClient(&opts)
|
||||
gqlClient, err := ghAPI.NewGraphQLClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -103,7 +102,7 @@ func (c Client) QueryWithContext(ctx context.Context, hostname, name string, que
|
|||
// REST performs a REST request and parses the response.
|
||||
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
|
||||
opts := clientOptions(hostname, c.http.Transport)
|
||||
restClient, err := gh.RESTClient(&opts)
|
||||
restClient, err := ghAPI.NewRESTClient(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -112,7 +111,7 @@ func (c Client) REST(hostname string, method string, p string, body io.Reader, d
|
|||
|
||||
func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) {
|
||||
opts := clientOptions(hostname, c.http.Transport)
|
||||
restClient, err := gh.RESTClient(&opts)
|
||||
restClient, err := ghAPI.NewRESTClient(opts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -157,14 +156,14 @@ func HandleHTTPError(resp *http.Response) error {
|
|||
return handleResponse(ghAPI.HandleHTTPError(resp))
|
||||
}
|
||||
|
||||
// handleResponse takes a ghAPI.HTTPError or ghAPI.GQLError and converts it into an
|
||||
// handleResponse takes a ghAPI.HTTPError or ghAPI.GraphQLError and converts it into an
|
||||
// HTTPError or GraphQLError respectively.
|
||||
func handleResponse(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var restErr ghAPI.HTTPError
|
||||
var restErr *ghAPI.HTTPError
|
||||
if errors.As(err, &restErr) {
|
||||
return HTTPError{
|
||||
HTTPError: restErr,
|
||||
|
|
@ -175,10 +174,10 @@ func handleResponse(err error) error {
|
|||
}
|
||||
}
|
||||
|
||||
var gqlErr ghAPI.GQLError
|
||||
var gqlErr *ghAPI.GraphQLError
|
||||
if errors.As(err, &gqlErr) {
|
||||
return GraphQLError{
|
||||
GQLError: gqlErr,
|
||||
GraphQLError: gqlErr,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/utils"
|
||||
"github.com/cli/go-gh"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
ghAPI "github.com/cli/go-gh/v2/pkg/api"
|
||||
)
|
||||
|
||||
type tokenGetter interface {
|
||||
|
|
@ -55,7 +54,7 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
|
|||
clientOpts.CacheTTL = opts.CacheTTL
|
||||
}
|
||||
|
||||
client, err := gh.HTTPClient(&clientOpts)
|
||||
client, err := ghAPI.NewHTTPClient(clientOpts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -255,7 +255,7 @@ func CurrentUserProjectsV2(client *Client, hostname string) ([]ProjectV2, error)
|
|||
// When querying ProjectsV2 fields we generally dont want to show the user
|
||||
// scope errors and field does not exist errors. ProjectsV2IgnorableError
|
||||
// checks against known error strings to see if an error can be safely ignored.
|
||||
// Due to the fact that the GQLClient can return multiple types of errors
|
||||
// Due to the fact that the GraphQLClient can return multiple types of errors
|
||||
// this uses brittle string comparison to check against the known error strings.
|
||||
func ProjectsV2IgnorableError(err error) bool {
|
||||
msg := err.Error()
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import (
|
|||
"golang.org/x/sync/errgroup"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
ghAPI "github.com/cli/go-gh/pkg/api"
|
||||
ghAPI "github.com/cli/go-gh/v2/pkg/api"
|
||||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
||||
|
|
@ -263,8 +263,8 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R
|
|||
// guaranteed to happen when an authentication token with insufficient permissions is being used.
|
||||
if result.Repository == nil {
|
||||
return nil, GraphQLError{
|
||||
GQLError: ghAPI.GQLError{
|
||||
Errors: []ghAPI.GQLErrorItem{{
|
||||
GraphQLError: &ghAPI.GraphQLError{
|
||||
Errors: []ghAPI.GraphQLErrorItem{{
|
||||
Type: "NOT_FOUND",
|
||||
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
|
||||
}},
|
||||
|
|
@ -316,8 +316,8 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
|
|||
// guaranteed to happen when an authentication token with insufficient permissions is being used.
|
||||
if result.Repository == nil {
|
||||
return nil, GraphQLError{
|
||||
GQLError: ghAPI.GQLError{
|
||||
Errors: []ghAPI.GQLErrorItem{{
|
||||
GraphQLError: &ghAPI.GraphQLError{
|
||||
Errors: []ghAPI.GraphQLErrorItem{{
|
||||
Type: "NOT_FOUND",
|
||||
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
|
||||
}},
|
||||
|
|
|
|||
|
|
@ -372,6 +372,7 @@ var RepositoryFields = []string{
|
|||
"isInOrganization",
|
||||
"isMirror",
|
||||
"isPrivate",
|
||||
"visibility",
|
||||
"isTemplate",
|
||||
"isUserConfigurationRepository",
|
||||
"licenseInfo",
|
||||
|
|
|
|||
|
|
@ -47,9 +47,9 @@ type sanitizer struct {
|
|||
addEscape bool
|
||||
}
|
||||
|
||||
// Transform uses a sliding window alogorithm to detect C0 and C1
|
||||
// Transform uses a sliding window algorithm to detect C0 and C1
|
||||
// ASCII control sequences as they are read and replaces them
|
||||
// with equivelent inert characters. Characters that are not part
|
||||
// with equivalent inert characters. Characters that are not part
|
||||
// of a control sequence are not modified.
|
||||
func (t *sanitizer) Transform(dst, src []byte, atEOF bool) (nDst, nSrc int, err error) {
|
||||
lSrc := len(src)
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ sudo dnf update gh
|
|||
Install using our package repository for immediate access to latest releases:
|
||||
|
||||
```bash
|
||||
type -p yum-config-manager >/dev/null || sudo yum install yum-utils
|
||||
sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
|
||||
sudo yum install gh
|
||||
```
|
||||
|
|
|
|||
|
|
@ -354,6 +354,22 @@ func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool {
|
|||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Client) TrackingBranchNames(ctx context.Context, prefix string) []string {
|
||||
args := []string{"branch", "-r", "--format", "%(refname:strip=3)"}
|
||||
if prefix != "" {
|
||||
args = append(args, "--list", fmt.Sprintf("*/%s*", escapeGlob(prefix)))
|
||||
}
|
||||
cmd, err := c.Command(ctx, args...)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return strings.Split(string(output), "\n")
|
||||
}
|
||||
|
||||
// ToplevelDir returns the top-level directory path of the current repository.
|
||||
func (c *Client) ToplevelDir(ctx context.Context) (string, error) {
|
||||
out, err := c.revParse(ctx, "--show-toplevel")
|
||||
|
|
@ -623,3 +639,16 @@ func populateResolvedRemotes(remotes RemoteSet, resolved []string) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
var globReplacer = strings.NewReplacer(
|
||||
"*", `\*`,
|
||||
"?", `\?`,
|
||||
"[", `\[`,
|
||||
"]", `\]`,
|
||||
"{", `\{`,
|
||||
"}", `\}`,
|
||||
)
|
||||
|
||||
func escapeGlob(p string) string {
|
||||
return globReplacer.Replace(p)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,7 +303,7 @@ func TestClientCurrentBranch(t *testing.T) {
|
|||
wantBranch: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space",
|
||||
},
|
||||
{
|
||||
name: "detatched head",
|
||||
name: "detached head",
|
||||
cmdExitStatus: 1,
|
||||
wantCmdArgs: `path/to/git symbolic-ref --quiet HEAD`,
|
||||
wantErrorMsg: "failed to run git: not on any branch",
|
||||
|
|
@ -339,7 +339,7 @@ func TestClientShowRefs(t *testing.T) {
|
|||
wantErrorMsg string
|
||||
}{
|
||||
{
|
||||
name: "show refs with one vaid ref and one invalid ref",
|
||||
name: "show refs with one valid ref and one invalid ref",
|
||||
cmdExitStatus: 128,
|
||||
cmdStdout: "9ea76237a557015e73446d33268569a114c0649c refs/heads/valid",
|
||||
cmdStderr: "fatal: 'refs/heads/invalid' - not a valid ref",
|
||||
|
|
|
|||
6
go.mod
6
go.mod
|
|
@ -6,10 +6,10 @@ require (
|
|||
github.com/AlecAivazis/survey/v2 v2.3.6
|
||||
github.com/MakeNowJust/heredoc v1.0.0
|
||||
github.com/briandowns/spinner v1.18.1
|
||||
github.com/cenkalti/backoff/v4 v4.2.0
|
||||
github.com/cenkalti/backoff/v4 v4.2.1
|
||||
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
|
||||
github.com/charmbracelet/lipgloss v0.5.0
|
||||
github.com/cli/go-gh v1.2.1
|
||||
github.com/cli/go-gh/v2 v2.0.0
|
||||
github.com/cli/oauth v1.0.1
|
||||
github.com/cli/safeexec v1.0.1
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2
|
||||
|
|
@ -50,7 +50,7 @@ require (
|
|||
github.com/alessio/shellescape v1.4.1 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/cli/browser v1.1.0 // indirect
|
||||
github.com/cli/shurcooL-graphql v0.0.2 // indirect
|
||||
github.com/cli/shurcooL-graphql v0.0.3 // indirect
|
||||
github.com/danieljoos/wincred v1.1.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
||||
|
|
|
|||
12
go.sum
12
go.sum
|
|
@ -47,8 +47,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/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
|
||||
github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
|
||||
github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4=
|
||||
github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
|
||||
github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM=
|
||||
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
|
||||
|
|
@ -62,15 +62,15 @@ github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY=
|
|||
github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI=
|
||||
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8=
|
||||
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
github.com/cli/go-gh v1.2.1 h1:xFrjejSsgPiwXFP6VYynKWwxLQcNJy3Twbu82ZDlR/o=
|
||||
github.com/cli/go-gh v1.2.1/go.mod h1:Jxk8X+TCO4Ui/GarwY9tByWm/8zp4jJktzVZNlTW5VM=
|
||||
github.com/cli/go-gh/v2 v2.0.0 h1:JAgQY7VNHletsO0Eqr+/PzF7fF5QEjhY2t2+Tev3vmk=
|
||||
github.com/cli/go-gh/v2 v2.0.0/go.mod h1:2/ox3Dnc8wDBT5bnTAH1aKGy6Qt1ztlFBe10EufnvoA=
|
||||
github.com/cli/oauth v1.0.1 h1:pXnTFl/qUegXHK531Dv0LNjW4mLx626eS42gnzfXJPA=
|
||||
github.com/cli/oauth v1.0.1/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
|
||||
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
||||
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
|
||||
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
||||
github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk=
|
||||
github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA=
|
||||
github.com/cli/shurcooL-graphql v0.0.3 h1:CtpPxyGDs136/+ZeyAfUKYmcQBjDlq5aqnrDCW5Ghh8=
|
||||
github.com/cli/shurcooL-graphql v0.0.3/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package browser
|
|||
import (
|
||||
"io"
|
||||
|
||||
ghBrowser "github.com/cli/go-gh/pkg/browser"
|
||||
ghBrowser "github.com/cli/go-gh/v2/pkg/browser"
|
||||
)
|
||||
|
||||
type Browser interface {
|
||||
|
|
@ -12,5 +12,5 @@ type Browser interface {
|
|||
|
||||
func New(launcher string, stdout, stderr io.Writer) Browser {
|
||||
b := ghBrowser.New(launcher, stdout, stderr)
|
||||
return &b
|
||||
return b
|
||||
}
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ func (a *API) GetUser(ctx context.Context) (*User, error) {
|
|||
|
||||
var response User
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
|
@ -160,7 +160,7 @@ func (a *API) GetRepository(ctx context.Context, nwo string) (*Repository, error
|
|||
|
||||
var response Repository
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
|
@ -336,7 +336,7 @@ func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (c
|
|||
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
nextURL := findNextPage(resp.Header.Get("Link"))
|
||||
|
|
@ -398,7 +398,7 @@ func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userNam
|
|||
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
for _, cs := range response.Codespaces {
|
||||
|
|
@ -455,7 +455,7 @@ func (a *API) GetCodespace(ctx context.Context, codespaceName string, includeCon
|
|||
|
||||
var response Codespace
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
|
@ -563,7 +563,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
|
|||
Machines []*Machine `json:"machines"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return response.Machines, nil
|
||||
|
|
@ -636,7 +636,7 @@ func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch str
|
|||
Items []*Repository `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
repoNames := make([]string, len(response.Items))
|
||||
|
|
@ -683,7 +683,7 @@ func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User,
|
|||
}
|
||||
}
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
// While this response contains further helpful information ahead of codespace creation,
|
||||
|
|
@ -815,7 +815,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
|
|||
|
||||
var response Codespace
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return &response, errProvisioningInProgress // RPC finished before result of creation known
|
||||
|
|
@ -831,7 +831,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
|
|||
return nil, fmt.Errorf("error reading response body: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &ue); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
if ue.AllowPermissionsURL != "" {
|
||||
|
|
@ -853,7 +853,7 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
|
|||
|
||||
var response Codespace
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
|
@ -934,7 +934,7 @@ func (a *API) ListDevContainers(ctx context.Context, repoID int, branch string,
|
|||
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(&response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
nextURL := findNextPage(resp.Header.Get("Link"))
|
||||
|
|
@ -1007,7 +1007,7 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E
|
|||
|
||||
var response Codespace
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
return &response, nil
|
||||
|
|
@ -1055,7 +1055,7 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
|
|||
|
||||
var response getCodespaceRepositoryContentsResponse
|
||||
if err := json.Unmarshal(b, &response); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling response: %w", err)
|
||||
return nil, fmt.Errorf("error unmarshalling response: %w", err)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(response.Content)
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ func generateCodespaceList(start int, end int) []*Codespace {
|
|||
return codespacesList
|
||||
}
|
||||
|
||||
func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int) *httptest.Server {
|
||||
func createFakeListEndpointServer(t *testing.T, initialTotal 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")
|
||||
|
|
@ -48,7 +48,7 @@ func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int)
|
|||
switch page {
|
||||
case 1:
|
||||
response.Codespaces = generateCodespaceList(0, per_page)
|
||||
response.TotalCount = initalTotal
|
||||
response.TotalCount = initialTotal
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -88,9 +88,14 @@ func ConnectToLiveshare(ctx context.Context, progress progressIndicator, session
|
|||
})
|
||||
}
|
||||
|
||||
// ListenTCP starts a localhost tcp listener and returns the listener and bound port
|
||||
func ListenTCP(port int) (*net.TCPListener, int, error) {
|
||||
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("127.0.0.1:%d", port))
|
||||
// ListenTCP starts a localhost tcp listener on 127.0.0.1 (unless allInterfaces is true) and returns the listener and bound port
|
||||
func ListenTCP(port int, allInterfaces bool) (*net.TCPListener, int, error) {
|
||||
host := "127.0.0.1"
|
||||
if allInterfaces {
|
||||
host = ""
|
||||
}
|
||||
|
||||
addr, err := net.ResolveTCPAddr("tcp", fmt.Sprintf("%s:%d", host, port))
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("failed to build tcp address: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,7 @@ func (i *invoker) StartSSHServerWithOptions(ctx context.Context, options StartSS
|
|||
}
|
||||
|
||||
func listenTCP() (*net.TCPListener, error) {
|
||||
// We will end up using this same address to connect, so specify the IP also or the connect will fail
|
||||
addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build tcp address: %w", err)
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ func PollPostCreateStates(ctx context.Context, progress progressIndicator, apiCl
|
|||
}()
|
||||
|
||||
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
||||
listen, localPort, err := ListenTCP(0)
|
||||
listen, localPort, err := ListenTCP(0, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
|
||||
ghAuth "github.com/cli/go-gh/pkg/auth"
|
||||
ghConfig "github.com/cli/go-gh/pkg/config"
|
||||
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
|
|
@ -124,7 +124,7 @@ type AuthConfig struct {
|
|||
|
||||
// Token will retrieve the auth token for the given hostname,
|
||||
// searching environment variables, plain text config, and
|
||||
// lastly encypted storage.
|
||||
// lastly encrypted storage.
|
||||
func (c *AuthConfig) Token(hostname string) (string, string) {
|
||||
if c.tokenOverride != nil {
|
||||
return c.tokenOverride(hostname)
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
ghConfig "github.com/cli/go-gh/pkg/config"
|
||||
ghConfig "github.com/cli/go-gh/v2/pkg/config"
|
||||
)
|
||||
|
||||
func NewBlankConfig() *ConfigMock {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
ghAuth "github.com/cli/go-gh/pkg/auth"
|
||||
"github.com/cli/go-gh/pkg/repository"
|
||||
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
"github.com/cli/go-gh/v2/pkg/repository"
|
||||
)
|
||||
|
||||
// Interface describes an object that represents a GitHub repository
|
||||
|
|
@ -54,7 +54,7 @@ func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewWithHost(repo.Owner(), repo.Name(), repo.Host()), nil
|
||||
return NewWithHost(repo.Owner, repo.Name, repo.Host), nil
|
||||
}
|
||||
|
||||
// FromURL extracts the GitHub repository information from a git remote URL
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
)
|
||||
|
||||
|
|
@ -49,11 +50,24 @@ type surveyPrompter struct {
|
|||
stderr io.Writer
|
||||
}
|
||||
|
||||
// LatinMatchingFilter returns whether the value matches the input filter.
|
||||
// The strings are compared normalized in case.
|
||||
// The filter's diactritics are kept as-is, but the value's are normalized,
|
||||
// so that a missing diactritic in the filter still returns a result.
|
||||
func LatinMatchingFilter(filter, value string, index int) bool {
|
||||
filter = strings.ToLower(filter)
|
||||
value = strings.ToLower(value)
|
||||
|
||||
// include this option if it matches.
|
||||
return strings.Contains(value, filter) || strings.Contains(text.RemoveDiacritics(value), filter)
|
||||
}
|
||||
|
||||
func (p *surveyPrompter) Select(message, defaultValue string, options []string) (result int, err error) {
|
||||
q := &survey.Select{
|
||||
Message: message,
|
||||
Options: options,
|
||||
PageSize: 20,
|
||||
Filter: LatinMatchingFilter,
|
||||
}
|
||||
|
||||
if defaultValue != "" {
|
||||
|
|
@ -77,6 +91,7 @@ func (p *surveyPrompter) MultiSelect(message, defaultValue string, options []str
|
|||
Message: message,
|
||||
Options: options,
|
||||
PageSize: 20,
|
||||
Filter: LatinMatchingFilter,
|
||||
}
|
||||
|
||||
if defaultValue != "" {
|
||||
|
|
|
|||
78
internal/prompter/prompter_test.go
Normal file
78
internal/prompter/prompter_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package prompter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFilterDiacritics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filter string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "exact match no diacritics",
|
||||
filter: "Mikelis",
|
||||
value: "Mikelis",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match no diacritics",
|
||||
filter: "Mikelis",
|
||||
value: "Mikelis",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match diacritics",
|
||||
filter: "Miķelis",
|
||||
value: "Miķelis",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "partial match diacritics",
|
||||
filter: "Miķe",
|
||||
value: "Miķelis",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "exact match diacritics in value",
|
||||
filter: "Mikelis",
|
||||
value: "Miķelis",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "partial match diacritics in filter",
|
||||
filter: "Miķe",
|
||||
value: "Miķelis",
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "no match when removing diacritics in filter",
|
||||
filter: "Mielis",
|
||||
value: "Mikelis",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no match when removing diacritics in value",
|
||||
filter: "Mikelis",
|
||||
value: "Mielis",
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "no match diacritics in filter",
|
||||
filter: "Miķelis",
|
||||
value: "Mikelis",
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, LatinMatchingFilter(tt.filter, tt.value, 0), tt.want)
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/go-gh/pkg/tableprinter"
|
||||
"github.com/cli/go-gh/v2/pkg/tableprinter"
|
||||
)
|
||||
|
||||
type TablePrinter struct {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import (
|
|||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/cli/go-gh/pkg/text"
|
||||
"github.com/cli/go-gh/v2/pkg/text"
|
||||
"golang.org/x/text/cases"
|
||||
"golang.org/x/text/language"
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
var whitespaceRE = regexp.MustCompile(`\s+`)
|
||||
|
|
@ -72,3 +76,19 @@ func DisplayURL(urlStr string) string {
|
|||
}
|
||||
return u.Hostname() + u.Path
|
||||
}
|
||||
|
||||
// RemoveDiacritics returns the input value without "diacritics", or accent marks
|
||||
func RemoveDiacritics(value string) string {
|
||||
// Mn = "Mark, nonspacing" unicode character category
|
||||
removeMnTransfomer := runes.Remove(runes.In(unicode.Mn))
|
||||
|
||||
// 1/ Decompose the text into characters and diacritical marks,
|
||||
// 2/ Remove the diacriticals marks
|
||||
// 3/ Recompose the text
|
||||
t := transform.Chain(norm.NFD, removeMnTransfomer, norm.NFC)
|
||||
normalized, _, err := transform.String(t, value)
|
||||
if err != nil {
|
||||
return value
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,3 +54,57 @@ func TestFuzzyAgoAbbr(t *testing.T) {
|
|||
assert.Equal(t, expected, fuzzy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveDiacritics(t *testing.T) {
|
||||
tests := [][]string{
|
||||
// no diacritics
|
||||
{"e", "e"},
|
||||
{"و", "و"},
|
||||
{"И", "И"},
|
||||
{"ж", "ж"},
|
||||
{"私", "私"},
|
||||
{"万", "万"},
|
||||
|
||||
// diacritics test sets
|
||||
{"à", "a"},
|
||||
{"é", "e"},
|
||||
{"è", "e"},
|
||||
{"ô", "o"},
|
||||
{"ᾳ", "α"},
|
||||
{"εͅ", "ε"},
|
||||
{"ῃ", "η"},
|
||||
{"ιͅ", "ι"},
|
||||
|
||||
{"ؤ", "و"},
|
||||
|
||||
{"ā", "a"},
|
||||
{"č", "c"},
|
||||
{"ģ", "g"},
|
||||
{"ķ", "k"},
|
||||
{"ņ", "n"},
|
||||
{"š", "s"},
|
||||
{"ž", "z"},
|
||||
|
||||
{"ŵ", "w"},
|
||||
{"ŷ", "y"},
|
||||
{"ä", "a"},
|
||||
{"ÿ", "y"},
|
||||
{"á", "a"},
|
||||
{"ẁ", "w"},
|
||||
{"ỳ", "y"},
|
||||
{"ō", "o"},
|
||||
|
||||
// full words
|
||||
{"Miķelis", "Mikelis"},
|
||||
{"François", "Francois"},
|
||||
{"žluťoučký", "zlutoucky"},
|
||||
{"învățătorița", "invatatorita"},
|
||||
{"Kękę przy łóżku", "Keke przy łozku"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(RemoveDiacritics(tt[0]), func(t *testing.T) {
|
||||
assert.Equal(t, tt[1], RemoveDiacritics(tt[0]))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,8 +23,8 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/cli/v2/pkg/jsoncolor"
|
||||
"github.com/cli/go-gh/pkg/jq"
|
||||
"github.com/cli/go-gh/pkg/template"
|
||||
"github.com/cli/go-gh/v2/pkg/jq"
|
||||
"github.com/cli/go-gh/v2/pkg/template"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -328,7 +328,7 @@ func apiRun(opts *ApiOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, &tmpl)
|
||||
endCursor, err := processResponse(resp, opts, bodyWriter, headersWriter, tmpl)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -387,7 +387,11 @@ func processResponse(resp *http.Response, opts *ApiOptions, bodyWriter, headersW
|
|||
|
||||
if opts.FilterOutput != "" && serverError == "" {
|
||||
// TODO: reuse parsed query across pagination invocations
|
||||
err = jq.Evaluate(responseBody, bodyWriter, opts.FilterOutput)
|
||||
indent := ""
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
indent = " "
|
||||
}
|
||||
err = jq.EvaluateFormatted(responseBody, bodyWriter, opts.FilterOutput, indent, opts.IO.ColorEnabled())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/cli/go-gh/pkg/template"
|
||||
"github.com/cli/go-gh/v2/pkg/template"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
|
@ -378,6 +378,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err error
|
||||
stdout string
|
||||
stderr string
|
||||
isatty bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
|
|
@ -388,6 +389,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: `bam!`,
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "show response headers",
|
||||
|
|
@ -404,6 +406,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\nbody",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "success 204",
|
||||
|
|
@ -414,6 +417,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: ``,
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "REST error",
|
||||
|
|
@ -425,6 +429,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"message": "THIS IS FINE"}`,
|
||||
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "REST string errors",
|
||||
|
|
@ -436,6 +441,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"errors": ["ALSO", "FINE"]}`,
|
||||
stderr: "gh: ALSO\nFINE\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "GraphQL error",
|
||||
|
|
@ -450,6 +456,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"errors": [{"message":"AGAIN"}, {"message":"FINE"}]}`,
|
||||
stderr: "gh: AGAIN\nFINE\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "failure",
|
||||
|
|
@ -460,6 +467,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `gateway timeout`,
|
||||
stderr: "gh: HTTP 502\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "silent",
|
||||
|
|
@ -473,6 +481,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: ``,
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "show response headers even when silent",
|
||||
|
|
@ -490,6 +499,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "output template",
|
||||
|
|
@ -504,6 +514,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "not a cat",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "output template when REST error",
|
||||
|
|
@ -518,6 +529,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"message": "THIS IS FINE"}`,
|
||||
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "jq filter",
|
||||
|
|
@ -532,6 +544,7 @@ func Test_apiRun(t *testing.T) {
|
|||
err: nil,
|
||||
stdout: "Mona\nHubot\n",
|
||||
stderr: ``,
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "jq filter when REST error",
|
||||
|
|
@ -546,12 +559,29 @@ func Test_apiRun(t *testing.T) {
|
|||
err: cmdutil.SilentError,
|
||||
stdout: `{"message": "THIS IS FINE"}`,
|
||||
stderr: "gh: THIS IS FINE (HTTP 400)\n",
|
||||
isatty: false,
|
||||
},
|
||||
{
|
||||
name: "jq filter outputting JSON to a TTY",
|
||||
options: ApiOptions{
|
||||
FilterOutput: `.`,
|
||||
},
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(bytes.NewBufferString(`[{"name":"Mona"},{"name":"Hubot"}]`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
},
|
||||
err: nil,
|
||||
stdout: "[\n {\n \"name\": \"Mona\"\n },\n {\n \"name\": \"Hubot\"\n }\n]\n",
|
||||
stderr: ``,
|
||||
isatty: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(tt.isatty)
|
||||
|
||||
tt.options.IO = ios
|
||||
tt.options.Config = func() (config.Config, error) { return config.NewBlankConfig(), nil }
|
||||
|
|
@ -1206,7 +1236,7 @@ func Test_processResponse_template(t *testing.T) {
|
|||
tmpl := template.New(ios.Out, ios.TerminalWidth(), ios.ColorEnabled())
|
||||
err := tmpl.Parse(opts.Template)
|
||||
require.NoError(t, err)
|
||||
_, err = processResponse(&resp, &opts, ios.Out, io.Discard, &tmpl)
|
||||
_, err = processResponse(&resp, &opts, ios.Out, io.Discard, tmpl)
|
||||
require.NoError(t, err)
|
||||
err = tmpl.Flush()
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
ghAuth "github.com/cli/go-gh/pkg/auth"
|
||||
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -30,12 +30,12 @@ type LoginOptions struct {
|
|||
|
||||
Interactive bool
|
||||
|
||||
Hostname string
|
||||
Scopes []string
|
||||
Token string
|
||||
Web bool
|
||||
GitProtocol string
|
||||
SecureStorage bool
|
||||
Hostname string
|
||||
Scopes []string
|
||||
Token string
|
||||
Web bool
|
||||
GitProtocol string
|
||||
InsecureStorage bool
|
||||
}
|
||||
|
||||
func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
|
||||
|
|
@ -124,7 +124,13 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
|
|||
cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input")
|
||||
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.GitProtocol, "git-protocol", "p", "", []string{"ssh", "https"}, "The protocol to use for git operations")
|
||||
cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Save authentication credentials in secure credential store")
|
||||
|
||||
// secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility
|
||||
var secureStorage bool
|
||||
cmd.Flags().BoolVar(&secureStorage, "secure-storage", false, "Save authentication credentials in secure credential store")
|
||||
_ = cmd.Flags().MarkHidden("secure-storage")
|
||||
|
||||
cmd.Flags().BoolVar(&opts.InsecureStorage, "insecure-storage", false, "Save authentication credentials in plain text instead of credential store")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -136,11 +142,6 @@ func loginRun(opts *LoginOptions) error {
|
|||
}
|
||||
authCfg := cfg.Authentication()
|
||||
|
||||
if opts.SecureStorage {
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Using secure storage could break installed extensions\n", cs.WarningIcon())
|
||||
}
|
||||
|
||||
hostname := opts.Hostname
|
||||
if opts.Interactive && hostname == "" {
|
||||
var err error
|
||||
|
|
@ -150,6 +151,11 @@ func loginRun(opts *LoginOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
// The go-gh Config object currently does not support case-insensitive lookups for host names,
|
||||
// so normalize the host name case here before performing any lookups with it or persisting it.
|
||||
// https://github.com/cli/go-gh/pull/105
|
||||
hostname = strings.ToLower(hostname)
|
||||
|
||||
if src, writeable := shared.AuthTokenWriteable(authCfg, hostname); !writeable {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", src)
|
||||
fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n")
|
||||
|
|
@ -166,7 +172,7 @@ func loginRun(opts *LoginOptions) error {
|
|||
return fmt.Errorf("error validating token: %w", err)
|
||||
}
|
||||
// Adding a user key ensures that a nonempty host section gets written to the config file.
|
||||
return authCfg.Login(hostname, "x-access-token", opts.Token, opts.GitProtocol, opts.SecureStorage)
|
||||
return authCfg.Login(hostname, "x-access-token", opts.Token, opts.GitProtocol, !opts.InsecureStorage)
|
||||
}
|
||||
|
||||
existingToken, _ := authCfg.Token(hostname)
|
||||
|
|
@ -195,7 +201,7 @@ func loginRun(opts *LoginOptions) error {
|
|||
Prompter: opts.Prompter,
|
||||
GitClient: opts.GitClient,
|
||||
Browser: opts.Browser,
|
||||
SecureStorage: opts.SecureStorage,
|
||||
SecureStorage: !opts.InsecureStorage,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -178,16 +178,31 @@ func Test_NewCmdLogin(t *testing.T) {
|
|||
stdinTTY: true,
|
||||
cli: "--secure-storage",
|
||||
wants: LoginOptions{
|
||||
Interactive: true,
|
||||
SecureStorage: true,
|
||||
Interactive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nontty secure-storage",
|
||||
cli: "--secure-storage",
|
||||
wants: LoginOptions{
|
||||
Hostname: "github.com",
|
||||
SecureStorage: true,
|
||||
Hostname: "github.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tty insecure-storage",
|
||||
stdinTTY: true,
|
||||
cli: "--insecure-storage",
|
||||
wants: LoginOptions{
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nontty insecure-storage",
|
||||
cli: "--insecure-storage",
|
||||
wants: LoginOptions{
|
||||
Hostname: "github.com",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -251,10 +266,11 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
wantSecureToken string
|
||||
}{
|
||||
{
|
||||
name: "with token",
|
||||
name: "insecure with token",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
|
|
@ -262,11 +278,12 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
wantHosts: "github.com:\n oauth_token: abc123\n user: x-access-token\n",
|
||||
},
|
||||
{
|
||||
name: "with token and https git-protocol",
|
||||
name: "insecure with token and https git-protocol",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
GitProtocol: "https",
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
GitProtocol: "https",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
|
|
@ -276,8 +293,9 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
{
|
||||
name: "with token and non-default host",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "albert.wesker",
|
||||
Token: "abc123",
|
||||
Hostname: "albert.wesker",
|
||||
Token: "abc123",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||
|
|
@ -309,8 +327,9 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
{
|
||||
name: "has admin scope",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc456",
|
||||
Hostname: "github.com",
|
||||
Token: "abc456",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,admin:org"))
|
||||
|
|
@ -358,16 +377,14 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
{
|
||||
name: "with token and secure storage",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
SecureStorage: true,
|
||||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
},
|
||||
wantHosts: "github.com:\n user: x-access-token\n",
|
||||
wantSecureToken: "abc123",
|
||||
wantStderr: "! Using secure storage could break installed extensions\n",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -463,8 +480,9 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
{
|
||||
name: "hostname set",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "rebecca.chambers",
|
||||
Interactive: true,
|
||||
Hostname: "rebecca.chambers",
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
wantHosts: heredoc.Doc(`
|
||||
rebecca.chambers:
|
||||
|
|
@ -504,7 +522,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
git_protocol: https
|
||||
`),
|
||||
opts: &LoginOptions{
|
||||
Interactive: true,
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -543,7 +562,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
git_protocol: https
|
||||
`),
|
||||
opts: &LoginOptions{
|
||||
Interactive: true,
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -573,7 +593,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
git_protocol: ssh
|
||||
`),
|
||||
opts: &LoginOptions{
|
||||
Interactive: true,
|
||||
Interactive: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -593,9 +614,8 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
{
|
||||
name: "secure storage",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Interactive: true,
|
||||
SecureStorage: true,
|
||||
Hostname: "github.com",
|
||||
Interactive: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(prompt, _ string, opts []string) (int, error) {
|
||||
|
|
@ -617,7 +637,7 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
user: jillv
|
||||
git_protocol: https
|
||||
`),
|
||||
wantErrOut: regexp.MustCompile("! Using secure storage could break installed extensions"),
|
||||
wantErrOut: regexp.MustCompile("Logged in as jillv"),
|
||||
wantSecureToken: "def456",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,8 +28,8 @@ type RefreshOptions struct {
|
|||
Scopes []string
|
||||
AuthFlow func(*config.AuthConfig, *iostreams.IOStreams, string, []string, bool, bool) error
|
||||
|
||||
Interactive bool
|
||||
SecureStorage bool
|
||||
Interactive bool
|
||||
InsecureStorage bool
|
||||
}
|
||||
|
||||
func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command {
|
||||
|
|
@ -90,7 +90,12 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
|
|||
|
||||
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The GitHub host to use for authentication")
|
||||
cmd.Flags().StringSliceVarP(&opts.Scopes, "scopes", "s", nil, "Additional authentication scopes for gh to have")
|
||||
cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Save authentication credentials in secure credential store")
|
||||
// secure storage became the default on 2023/4/04; this flag is left as a no-op for backwards compatibility
|
||||
var secureStorage bool
|
||||
cmd.Flags().BoolVar(&secureStorage, "secure-storage", false, "Save authentication credentials in secure credential store")
|
||||
_ = cmd.Flags().MarkHidden("secure-storage")
|
||||
|
||||
cmd.Flags().BoolVarP(&opts.InsecureStorage, "insecure-storage", "", false, "Save authentication credentials in plain text instead of credential store")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -139,7 +144,7 @@ func refreshRun(opts *RefreshOptions) error {
|
|||
}
|
||||
|
||||
var additionalScopes []string
|
||||
if oldToken, source := authCfg.Token(hostname); oldToken != "" {
|
||||
if oldToken, _ := authCfg.Token(hostname); oldToken != "" {
|
||||
if oldScopes, err := shared.GetScopes(opts.HttpClient, hostname, oldToken); err == nil {
|
||||
for _, s := range strings.Split(oldScopes, ",") {
|
||||
s = strings.TrimSpace(s)
|
||||
|
|
@ -148,13 +153,6 @@ func refreshRun(opts *RefreshOptions) error {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If previous token was stored in secure storage assume
|
||||
// user wants to continue storing it there even if not
|
||||
// explicitly stated with the secure storage flag.
|
||||
if source == "keyring" {
|
||||
opts.SecureStorage = true
|
||||
}
|
||||
}
|
||||
|
||||
credentialFlow := &shared.GitCredentialFlow{
|
||||
|
|
@ -170,7 +168,7 @@ func refreshRun(opts *RefreshOptions) error {
|
|||
additionalScopes = append(additionalScopes, credentialFlow.Scopes()...)
|
||||
}
|
||||
|
||||
if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive, opts.SecureStorage); err != nil {
|
||||
if err := opts.AuthFlow(authCfg, opts.IO, hostname, append(opts.Scopes, additionalScopes...), opts.Interactive, !opts.InsecureStorage); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,11 +86,17 @@ func Test_NewCmdRefresh(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "secure storage",
|
||||
name: "secure storage",
|
||||
tty: true,
|
||||
cli: "--secure-storage",
|
||||
wants: RefreshOptions{},
|
||||
},
|
||||
{
|
||||
name: "insecure storage",
|
||||
tty: true,
|
||||
cli: "--secure-storage",
|
||||
cli: "--insecure-storage",
|
||||
wants: RefreshOptions{
|
||||
SecureStorage: true,
|
||||
InsecureStorage: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -178,8 +184,9 @@ func Test_refreshRun(t *testing.T) {
|
|||
Hostname: "obed.morton",
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -191,8 +198,9 @@ func Test_refreshRun(t *testing.T) {
|
|||
Hostname: "",
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -210,8 +218,9 @@ func Test_refreshRun(t *testing.T) {
|
|||
}
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
hostname: "github.com",
|
||||
scopes: nil,
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -223,12 +232,13 @@ func Test_refreshRun(t *testing.T) {
|
|||
Scopes: []string{"repo:invite", "public_key:read"},
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read"},
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read"},
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scopes provided",
|
||||
name: "more scopes provided",
|
||||
cfgHosts: []string{
|
||||
"github.com",
|
||||
},
|
||||
|
|
@ -237,37 +247,16 @@ func Test_refreshRun(t *testing.T) {
|
|||
Scopes: []string{"repo:invite", "public_key:read"},
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "explicit secure storage",
|
||||
cfgHosts: []string{
|
||||
"obed.morton",
|
||||
},
|
||||
opts: &RefreshOptions{
|
||||
Hostname: "obed.morton",
|
||||
SecureStorage: true,
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
hostname: "github.com",
|
||||
scopes: []string{"repo:invite", "public_key:read", "delete_repo", "codespace"},
|
||||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "implicit secure storage",
|
||||
config: func() config.Config {
|
||||
cfg := config.NewFromString("")
|
||||
authCfg := cfg.Authentication()
|
||||
authCfg.SetHosts([]string{"obed.morton"})
|
||||
authCfg.SetToken("abc123", "keyring")
|
||||
cfg.AuthenticationFunc = func() *config.AuthConfig {
|
||||
return authCfg
|
||||
}
|
||||
return cfg
|
||||
}(),
|
||||
name: "secure storage",
|
||||
cfgHosts: []string{
|
||||
"obed.morton",
|
||||
},
|
||||
opts: &RefreshOptions{
|
||||
Hostname: "obed.morton",
|
||||
},
|
||||
|
|
@ -277,6 +266,20 @@ func Test_refreshRun(t *testing.T) {
|
|||
secureStorage: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "insecure storage",
|
||||
cfgHosts: []string{
|
||||
"obed.morton",
|
||||
},
|
||||
opts: &RefreshOptions{
|
||||
Hostname: "obed.morton",
|
||||
InsecureStorage: true,
|
||||
},
|
||||
wantAuthArgs: authArgs{
|
||||
hostname: "obed.morton",
|
||||
scopes: nil,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -29,8 +30,26 @@ func NewCmdSetupGit(f *cmdutil.Factory, runF func(*SetupGitOptions) error) *cobr
|
|||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Short: "Configure git to use GitHub CLI as a credential helper",
|
||||
Use: "setup-git",
|
||||
Short: "Setup git with GitHub CLI",
|
||||
Long: heredoc.Docf(`
|
||||
This command configures git to use GitHub CLI as a credential helper.
|
||||
For more information on git credential helpers please reference:
|
||||
https://git-scm.com/docs/gitcredentials.
|
||||
|
||||
By default, GitHub CLI will be set as the credential helper for all authenticated hosts.
|
||||
If there is no authenticated hosts the command fails with an error.
|
||||
|
||||
Alternatively, use the %[1]s--hostname%[1]s flag to specify a single host to be configured.
|
||||
If the host is not authenticated with, the command fails with an error.
|
||||
`, "`"),
|
||||
Example: heredoc.Doc(`
|
||||
# Configure git to use GitHub CLI as the credential helper for all authenticated hosts
|
||||
$ gh auth setup-git
|
||||
|
||||
# Configure git to use GitHub CLI as the credential helper for enterprise.internal host
|
||||
$ gh auth setup-git --hostname enterprise.internal
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.gitConfigure = &shared.GitCredentialFlow{
|
||||
Executable: f.Executable(),
|
||||
|
|
|
|||
|
|
@ -200,11 +200,16 @@ func Login(opts *LoginOptions) error {
|
|||
}
|
||||
|
||||
if keyToUpload != "" {
|
||||
err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle)
|
||||
uploaded, err := sshKeyUpload(httpClient, hostname, keyToUpload, keyTitle)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
|
||||
|
||||
if uploaded {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Uploaded the SSH key to your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
|
||||
} else {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s SSH key already existed on your GitHub account: %s\n", cs.SuccessIcon(), cs.Bold(keyToUpload))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username))
|
||||
|
|
@ -223,10 +228,10 @@ func scopesSentence(scopes []string, isEnterprise bool) string {
|
|||
return strings.Join(quoted, ", ")
|
||||
}
|
||||
|
||||
func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) error {
|
||||
func sshKeyUpload(httpClient *http.Client, hostname, keyFile string, title string) (bool, error) {
|
||||
f, err := os.Open(keyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
return false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ func TestLogin_ssh(t *testing.T) {
|
|||
tr.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{ "login": "monalisa" }}}`))
|
||||
tr.Register(
|
||||
httpmock.REST("GET", "api/v3/user/keys"),
|
||||
httpmock.StringResponse(`[]`))
|
||||
tr.Register(
|
||||
httpmock.REST("POST", "api/v3/user/keys"),
|
||||
httpmock.StringResponse(`{}`))
|
||||
|
|
@ -78,7 +81,7 @@ func TestLogin_ssh(t *testing.T) {
|
|||
}
|
||||
assert.Equal(t, expected, args)
|
||||
// simulate that the public key file has been generated
|
||||
_ = os.WriteFile(keyFile+".pub", []byte("PUBKEY"), 0600)
|
||||
_ = os.WriteFile(keyFile+".pub", []byte("PUBKEY asdf"), 0600)
|
||||
})
|
||||
|
||||
cfg := tinyConfig{}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ func NewCmdToken(f *cmdutil.Factory, runF func(*TokenOptions) error) *cobra.Comm
|
|||
|
||||
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance authenticated with")
|
||||
cmd.Flags().BoolVarP(&opts.SecureStorage, "secure-storage", "", false, "Search only secure credential store for authentication token")
|
||||
_ = cmd.Flags().MarkHidden("secure-storeage")
|
||||
_ = cmd.Flags().MarkHidden("secure-storage")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
cmd := &cobra.Command{
|
||||
Long: "Open the GitHub repository in the web browser.",
|
||||
Short: "Open the repository in the browser",
|
||||
Use: "browse [<number> | <path>]",
|
||||
Use: "browse [<number> | <path> | <commit-SHA>]",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Example: heredoc.Doc(`
|
||||
$ gh browse
|
||||
|
|
@ -87,7 +87,8 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
"help:arguments": heredoc.Doc(`
|
||||
A browser location can be specified using arguments in the following format:
|
||||
- by number for issue or pull request, e.g. "123"; or
|
||||
- by path for opening folders and files, e.g. "cmd/gh/main.go"
|
||||
- by path for opening folders and files, e.g. "cmd/gh/main.go"; or
|
||||
- by commit SHA
|
||||
`),
|
||||
"help:environment": heredoc.Doc(`
|
||||
To configure a web browser other than the default, use the BROWSER environment variable.
|
||||
|
|
@ -124,6 +125,10 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
return err
|
||||
}
|
||||
|
||||
if (isNumber(opts.SelectorArg) || isCommit(opts.SelectorArg)) && (opts.Branch != "" || opts.Commit != "") {
|
||||
return cmdutil.FlagErrorf("%q is an invalid argument when using `--branch` or `--commit`", opts.SelectorArg)
|
||||
}
|
||||
|
||||
if cmd.Flags().Changed("repo") {
|
||||
opts.GitClient = &remoteGitClient{opts.BaseRepo, opts.HttpClient}
|
||||
}
|
||||
|
|
@ -144,6 +149,8 @@ func NewCmdBrowse(f *cmdutil.Factory, runF func(*BrowseOptions) error) *cobra.Co
|
|||
cmd.Flags().StringVarP(&opts.Commit, "commit", "c", "", "Select another commit by passing in the commit SHA, default is the last commit")
|
||||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Select another branch by passing in the branch name")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch")
|
||||
|
||||
// Preserve backwards compatibility for when commit flag used to be a boolean flag.
|
||||
cmd.Flags().Lookup("commit").NoOptDefVal = emptyCommitFlag
|
||||
|
||||
|
|
|
|||
|
|
@ -162,7 +162,27 @@ func TestNewCmdBrowse(t *testing.T) {
|
|||
},
|
||||
{
|
||||
name: "passed both branch and commit flags",
|
||||
cli: "main.go --branch main --comit=12a4",
|
||||
cli: "main.go --branch main --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both number arg and branch flag",
|
||||
cli: "1 --branch trunk",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both number arg and commit flag",
|
||||
cli: "1 --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both commit SHA arg and branch flag",
|
||||
cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --branch trunk",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "passed both commit SHA arg and commit flag",
|
||||
cli: "de07febc26e19000f8c9e821207f3bc34a3c8038 --commit=12a4",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
|
|
@ -349,7 +369,7 @@ func Test_runBrowse(t *testing.T) {
|
|||
opts: BrowseOptions{
|
||||
SelectorArg: "chocolate-pecan-pie.txt",
|
||||
},
|
||||
baseRepo: ghrepo.New("andrewhsu", "recipies"),
|
||||
baseRepo: ghrepo.New("andrewhsu", "recipes"),
|
||||
defaultBranch: "",
|
||||
wantsErr: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ type createOptions struct {
|
|||
idleTimeout time.Duration
|
||||
retentionPeriod NullableDuration
|
||||
displayName string
|
||||
useWeb bool
|
||||
}
|
||||
|
||||
func newCreateCmd(app *App) *cobra.Command {
|
||||
|
|
@ -78,11 +79,20 @@ func newCreateCmd(app *App) *cobra.Command {
|
|||
Use: "create",
|
||||
Short: "Create a codespace",
|
||||
Args: noArgsConstraint,
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmdutil.MutuallyExclusive(
|
||||
"using --web with --display-name, --idle-timeout, or --retention-period is not supported",
|
||||
opts.useWeb,
|
||||
opts.displayName != "" || opts.idleTimeout != 0 || opts.retentionPeriod.Duration != nil,
|
||||
)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return app.Create(cmd.Context(), opts)
|
||||
},
|
||||
}
|
||||
|
||||
createCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "create codespace from browser, cannot be used with --display-name, --idle-timeout, or --retention-period")
|
||||
|
||||
createCmd.Flags().StringVarP(&opts.repo, "repo", "R", "", "repository name with owner: user/repo")
|
||||
if err := addDeprecatedRepoShorthand(createCmd, &opts.repo); err != nil {
|
||||
fmt.Fprintf(app.io.ErrOut, "%v\n", err)
|
||||
|
|
@ -118,7 +128,11 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
|||
Location: opts.location,
|
||||
}
|
||||
|
||||
promptForRepoAndBranch := userInputs.Repository == ""
|
||||
if opts.useWeb && userInputs.Repository == "" {
|
||||
return a.browser.Browse("https://github.com/codespaces/new")
|
||||
}
|
||||
|
||||
promptForRepoAndBranch := userInputs.Repository == "" && !opts.useWeb
|
||||
if promptForRepoAndBranch {
|
||||
var defaultRepo string
|
||||
if remotes, _ := a.remotes(); remotes != nil {
|
||||
|
|
@ -249,12 +263,19 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
machine, err := getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting machine type: %w", err)
|
||||
}
|
||||
if machine == "" {
|
||||
return errors.New("there are no available machine types for this repository")
|
||||
machine := opts.machine
|
||||
// skip this if we have useWeb and no machine name provided,
|
||||
// because web UI will select default machine type if none is provided
|
||||
// web UI also provide a way to select machine type
|
||||
// therefore we let the user choose from the web UI instead of prompting from CLI
|
||||
if !(opts.useWeb && opts.machine == "") {
|
||||
machine, err = getMachineName(ctx, a.apiClient, repository.ID, opts.machine, branch, userInputs.Location, devContainerPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting machine type: %w", err)
|
||||
}
|
||||
if machine == "" {
|
||||
return errors.New("there are no available machine types for this repository")
|
||||
}
|
||||
}
|
||||
|
||||
createParams := &api.CreateCodespaceParams{
|
||||
|
|
@ -271,6 +292,10 @@ func (a *App) Create(ctx context.Context, opts createOptions) error {
|
|||
DisplayName: opts.displayName,
|
||||
}
|
||||
|
||||
if opts.useWeb {
|
||||
return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", createParams.RepositoryID, createParams.Branch, createParams.Machine, createParams.Location))
|
||||
}
|
||||
|
||||
var codespace *api.Codespace
|
||||
err = a.RunWithProgress("Creating codespace", func() (err error) {
|
||||
codespace, err = a.apiClient.CreateCodespace(ctx, createParams)
|
||||
|
|
|
|||
|
|
@ -1,17 +1,60 @@
|
|||
package codespace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateCmdFlagError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
wantsErr error
|
||||
}{
|
||||
{
|
||||
name: "return error when using web flag with display-name, idle-timeout, or retention-period flags",
|
||||
args: "--web --display-name foo --idle-timeout 30m",
|
||||
wantsErr: fmt.Errorf("using --web with --display-name, --idle-timeout, or --retention-period is not supported"),
|
||||
},
|
||||
{
|
||||
name: "return error when using web flag with one of display-name, idle-timeout or retention-period flags",
|
||||
args: "--web --idle-timeout 30m",
|
||||
wantsErr: fmt.Errorf("using --web with --display-name, --idle-timeout, or --retention-period is not supported"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
a := &App{
|
||||
io: ios,
|
||||
}
|
||||
cmd := newCreateCmd(a)
|
||||
|
||||
args, _ := shlex.Split(tt.args)
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err := cmd.ExecuteC()
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.EqualError(t, err, tt.wantsErr.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_Create(t *testing.T) {
|
||||
type fields struct {
|
||||
apiClient apiClient
|
||||
|
|
@ -23,6 +66,7 @@ func TestApp_Create(t *testing.T) {
|
|||
wantErr error
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantURL string
|
||||
isTTY bool
|
||||
}{
|
||||
{
|
||||
|
|
@ -115,6 +159,31 @@ func TestApp_Create(t *testing.T) {
|
|||
wantStdout: "monalisa-dotfiles-abcd1234\n",
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
},
|
||||
{
|
||||
name: "create codespace with nonexistent machine results in error",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetCodespacesMachinesFunc: func(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*api.Machine, error) {
|
||||
return []*api.Machine{
|
||||
{
|
||||
Name: "GIGA",
|
||||
DisplayName: "Gigabits of a machine",
|
||||
},
|
||||
{
|
||||
Name: "TERA",
|
||||
DisplayName: "Terabits of a machine",
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
machine: "MEGA",
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantErr: fmt.Errorf("error getting machine type: there is no such machine for the repository: %s\nAvailable machines: %v", "MEGA", []string{"GIGA", "TERA"}),
|
||||
},
|
||||
{
|
||||
name: "create codespace with devcontainer path results in selecting the correct machine type",
|
||||
fields: fields{
|
||||
|
|
@ -382,7 +451,95 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
|||
},
|
||||
wantStdout: "megacorp-private-abcd1234\n",
|
||||
},
|
||||
{
|
||||
name: "return default url when using web flag without other flags",
|
||||
opts: createOptions{
|
||||
useWeb: true,
|
||||
},
|
||||
wantURL: "https://github.com/codespaces/new",
|
||||
},
|
||||
{
|
||||
name: "skip machine check when using web flag and no machine provided",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
||||
return &api.Repository{
|
||||
ID: 123,
|
||||
DefaultBranch: "main",
|
||||
}, nil
|
||||
},
|
||||
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||
return &api.Codespace{
|
||||
Name: "monalisa-dotfiles-abcd1234",
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
useWeb: true,
|
||||
branch: "custom",
|
||||
location: "EastUS",
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "custom", "", "EastUS"),
|
||||
},
|
||||
{
|
||||
name: "return correct url with correct params when using web flag and repo flag",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
||||
return &api.Repository{
|
||||
ID: 123,
|
||||
DefaultBranch: "main",
|
||||
}, nil
|
||||
},
|
||||
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||
return &api.Codespace{
|
||||
Name: "monalisa-dotfiles-abcd1234",
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
useWeb: true,
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "main", "", ""),
|
||||
},
|
||||
{
|
||||
name: "return correct url with correct params when using web flag, repo, branch, location, machine flag",
|
||||
fields: fields{
|
||||
apiClient: apiCreateDefaults(&apiClientMock{
|
||||
GetRepositoryFunc: func(ctx context.Context, nwo string) (*api.Repository, error) {
|
||||
return &api.Repository{
|
||||
ID: 123,
|
||||
DefaultBranch: "main",
|
||||
}, nil
|
||||
},
|
||||
CreateCodespaceFunc: func(ctx context.Context, params *api.CreateCodespaceParams) (*api.Codespace, error) {
|
||||
return &api.Codespace{
|
||||
Name: "monalisa-dotfiles-abcd1234",
|
||||
Machine: api.CodespaceMachine{Name: "GIGA"},
|
||||
}, nil
|
||||
},
|
||||
}),
|
||||
},
|
||||
opts: createOptions{
|
||||
repo: "monalisa/dotfiles",
|
||||
machine: "GIGA",
|
||||
branch: "custom",
|
||||
location: "EastUS",
|
||||
useWeb: true,
|
||||
},
|
||||
wantStderr: " ✓ Codespaces usage for this repository is paid for by monalisa\n",
|
||||
wantURL: fmt.Sprintf("https://github.com/codespaces/new?repo=%d&ref=%s&machine=%s&location=%s", 123, "custom", "GIGA", "EastUS"),
|
||||
},
|
||||
}
|
||||
var a *App
|
||||
var b *browser.Stub
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
|
|
@ -390,9 +547,18 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
|||
ios.SetStdinTTY(tt.isTTY)
|
||||
ios.SetStderrTTY(tt.isTTY)
|
||||
|
||||
a := &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
if tt.opts.useWeb {
|
||||
b = &browser.Stub{}
|
||||
a = &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
browser: b,
|
||||
}
|
||||
} else {
|
||||
a = &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
}
|
||||
}
|
||||
|
||||
err := a.Create(context.Background(), tt.opts)
|
||||
|
|
@ -410,6 +576,10 @@ Alternatively, you can run "create" with the "--default-permissions" option to c
|
|||
t.Logf(t.Name())
|
||||
t.Errorf(" stderr = %v, want %v", got, tt.wantStderr)
|
||||
}
|
||||
|
||||
if tt.opts.useWeb {
|
||||
b.Verify(t, tt.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,23 +45,29 @@ func (a *App) Jupyter(ctx context.Context, selector *CodespaceSelector) (err err
|
|||
}
|
||||
defer safeClose(session, &err)
|
||||
|
||||
serverPort, serverUrl := 0, ""
|
||||
var (
|
||||
invoker rpc.Invoker
|
||||
serverPort int
|
||||
serverUrl string
|
||||
)
|
||||
err = a.RunWithProgress("Starting JupyterLab on codespace", func() (err error) {
|
||||
invoker, err := rpc.CreateInvoker(ctx, session)
|
||||
invoker, err = rpc.CreateInvoker(ctx, session)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer safeClose(invoker, &err)
|
||||
|
||||
serverPort, serverUrl, err = invoker.StartJupyterServer(ctx)
|
||||
return
|
||||
})
|
||||
if invoker != nil {
|
||||
defer safeClose(invoker, &err)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pass 0 to pick a random port
|
||||
listen, _, err := codespaces.ListenTCP(0)
|
||||
listen, _, err := codespaces.ListenTCP(0, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type listOptions struct {
|
|||
repo string
|
||||
orgName string
|
||||
userName string
|
||||
useWeb bool
|
||||
}
|
||||
|
||||
func newListCmd(app *App) *cobra.Command {
|
||||
|
|
@ -34,11 +35,29 @@ func newListCmd(app *App) *cobra.Command {
|
|||
`),
|
||||
Aliases: []string{"ls"},
|
||||
Args: noArgsConstraint,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
PreRunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"using `--org` or `--user` with `--repo` is not allowed",
|
||||
opts.repo != "",
|
||||
opts.orgName != "" || opts.userName != "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := cmdutil.MutuallyExclusive(
|
||||
"using `--web` with `--org` or `--user` is not supported, please use with `--repo` instead",
|
||||
opts.useWeb,
|
||||
opts.orgName != "" || opts.userName != "",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.limit < 1 {
|
||||
return cmdutil.FlagErrorf("invalid limit: %v", opts.limit)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return app.List(cmd.Context(), opts, exporter)
|
||||
},
|
||||
}
|
||||
|
|
@ -53,13 +72,14 @@ func newListCmd(app *App) *cobra.Command {
|
|||
listCmd.Flags().StringVarP(&opts.userName, "user", "u", "", "The `username` to list codespaces for (used with --org)")
|
||||
cmdutil.AddJSONFlags(listCmd, &exporter, api.CodespaceFields)
|
||||
|
||||
listCmd.Flags().BoolVarP(&opts.useWeb, "web", "w", false, "List codespaces in the web browser, cannot be used with --user or --org")
|
||||
|
||||
return listCmd
|
||||
}
|
||||
|
||||
func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Exporter) error {
|
||||
// if repo is provided, we don't accept orgName or userName
|
||||
if opts.repo != "" && (opts.orgName != "" || opts.userName != "") {
|
||||
return cmdutil.FlagErrorf("using `--org` or `--user` with `--repo` is not allowed")
|
||||
if opts.useWeb && opts.repo == "" {
|
||||
return a.browser.Browse("https://github.com/codespaces")
|
||||
}
|
||||
|
||||
var codespaces []*api.Codespace
|
||||
|
|
@ -92,6 +112,10 @@ func (a *App) List(ctx context.Context, opts *listOptions, exporter cmdutil.Expo
|
|||
return cmdutil.NewNoResultsError("no codespaces found")
|
||||
}
|
||||
|
||||
if opts.useWeb && codespaces[0].Repository.ID > 0 {
|
||||
return a.browser.Browse(fmt.Sprintf("https://github.com/codespaces?repository_id=%d", codespaces[0].Repository.ID))
|
||||
}
|
||||
|
||||
//nolint:staticcheck // SA1019: utils.NewTablePrinter is deprecated: use internal/tableprinter
|
||||
tp := utils.NewTablePrinter(a.io)
|
||||
if tp.IsTTY() {
|
||||
|
|
|
|||
|
|
@ -1,15 +1,68 @@
|
|||
package codespace
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/browser"
|
||||
"github.com/cli/cli/v2/internal/codespaces/api"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListCmdFlagError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
wantsErr error
|
||||
}{
|
||||
{
|
||||
name: "list codespaces,--repo, --org and --user flag",
|
||||
args: "--repo foo/bar --org github --user github",
|
||||
wantsErr: fmt.Errorf("using `--org` or `--user` with `--repo` is not allowed"),
|
||||
},
|
||||
{
|
||||
name: "list codespaces,--web, --org and --user flag",
|
||||
args: "--web --org github --user github",
|
||||
wantsErr: fmt.Errorf("using `--web` with `--org` or `--user` is not supported, please use with `--repo` instead"),
|
||||
},
|
||||
{
|
||||
name: "list codespaces, negative --limit flag",
|
||||
args: "--limit -1",
|
||||
wantsErr: fmt.Errorf("invalid limit: -1"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
a := &App{
|
||||
io: ios,
|
||||
}
|
||||
|
||||
cmd := newListCmd(a)
|
||||
|
||||
args, _ := shlex.Split(tt.args)
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err := cmd.ExecuteC()
|
||||
|
||||
if tt.wantsErr != nil {
|
||||
assert.Error(t, err)
|
||||
assert.EqualError(t, err, tt.wantsErr.Error())
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApp_List(t *testing.T) {
|
||||
type fields struct {
|
||||
apiClient apiClient
|
||||
|
|
@ -19,6 +72,7 @@ func TestApp_List(t *testing.T) {
|
|||
fields fields
|
||||
opts *listOptions
|
||||
wantError error
|
||||
wantURL string
|
||||
}{
|
||||
{
|
||||
name: "list codespaces, no flags",
|
||||
|
|
@ -115,22 +169,66 @@ func TestApp_List(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "list codespaces,--repo, --org and --user flag",
|
||||
name: "list codespaces,--web",
|
||||
opts: &listOptions{
|
||||
repo: "cli/cli",
|
||||
orgName: "TestOrg",
|
||||
userName: "jimmy",
|
||||
useWeb: true,
|
||||
},
|
||||
wantError: fmt.Errorf("using `--org` or `--user` with `--repo` is not allowed"),
|
||||
wantURL: "https://github.com/codespaces",
|
||||
},
|
||||
{
|
||||
name: "list codespaces,--web, --repo flag",
|
||||
fields: fields{
|
||||
apiClient: &apiClientMock{
|
||||
ListCodespacesFunc: func(ctx context.Context, opts api.ListCodespacesOptions) ([]*api.Codespace, error) {
|
||||
if opts.RepoName == "" {
|
||||
return nil, fmt.Errorf("Expected repository to not be nil")
|
||||
}
|
||||
if opts.RepoName != "cli/cli" {
|
||||
return nil, fmt.Errorf("Expected repository name to be cli/cli. Got %s", opts.RepoName)
|
||||
}
|
||||
if opts.OrgName != "" {
|
||||
return nil, fmt.Errorf("Expected orgName to be blank. Got %s", opts.OrgName)
|
||||
}
|
||||
if opts.UserName != "" {
|
||||
return nil, fmt.Errorf("Expected userName to be blank. Got %s", opts.UserName)
|
||||
}
|
||||
return []*api.Codespace{
|
||||
{
|
||||
DisplayName: "CS1",
|
||||
Repository: api.Repository{ID: 123},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
opts: &listOptions{
|
||||
useWeb: true,
|
||||
repo: "cli/cli",
|
||||
},
|
||||
wantURL: "https://github.com/codespaces?repository_id=123",
|
||||
},
|
||||
}
|
||||
|
||||
var b *browser.Stub
|
||||
var a *App
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
a := &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
if tt.opts.useWeb {
|
||||
b = &browser.Stub{}
|
||||
a = &App{
|
||||
browser: b,
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
}
|
||||
} else {
|
||||
a = &App{
|
||||
io: ios,
|
||||
apiClient: tt.fields.apiClient,
|
||||
}
|
||||
}
|
||||
|
||||
var exporter cmdutil.Exporter
|
||||
|
||||
err := a.List(context.Background(), tt.opts, exporter)
|
||||
|
|
@ -142,6 +240,10 @@ func TestApp_List(t *testing.T) {
|
|||
if err != nil && err.Error() != tt.wantError.Error() {
|
||||
t.Errorf("error = %v, wantErr %v", err, tt.wantError)
|
||||
}
|
||||
|
||||
if tt.opts.useWeb {
|
||||
b.Verify(t, tt.wantURL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func (a *App) Logs(ctx context.Context, selector *CodespaceSelector, follow bool
|
|||
defer safeClose(session, &err)
|
||||
|
||||
// Ensure local port is listening before client (getPostCreateOutput) connects.
|
||||
listen, localPort, err := codespaces.ListenTCP(0)
|
||||
listen, localPort, err := codespaces.ListenTCP(0, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ func getDevContainer(ctx context.Context, apiClient apiClient, codespace *api.Co
|
|||
|
||||
var container devContainer
|
||||
if err := json.Unmarshal(convertedJSON, &container); err != nil {
|
||||
ch <- devContainerResult{nil, fmt.Errorf("error unmarshaling: %w", err)}
|
||||
ch <- devContainerResult{nil, fmt.Errorf("error unmarshalling: %w", err)}
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -379,7 +379,7 @@ func (a *App) ForwardPorts(ctx context.Context, selector *CodespaceSelector, por
|
|||
for _, pair := range portPairs {
|
||||
pair := pair
|
||||
group.Go(func() error {
|
||||
listen, _, err := codespaces.ListenTCP(pair.local)
|
||||
listen, _, err := codespaces.ListenTCP(pair.local, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ func TestPendingOperationDisallowsListPorts(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestPendingOperationDisallowsUpdatePortVisability(t *testing.T) {
|
||||
func TestPendingOperationDisallowsUpdatePortVisibility(t *testing.T) {
|
||||
app := testingPortsApp()
|
||||
selector := &CodespaceSelector{api: app.apiClient, codespaceName: "disabledCodespace"}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,8 +56,14 @@ func newSSHCmd(app *App) *cobra.Command {
|
|||
The 'ssh' command is used to SSH into a codespace. In its simplest form, you can
|
||||
run 'gh cs ssh', select a codespace interactively, and connect.
|
||||
|
||||
By default, the 'ssh' command will create a public/private ssh key pair to
|
||||
authenticate with the codespace inside the ~/.ssh directory.
|
||||
The 'ssh' command will automatically create a public/private ssh key pair in the
|
||||
~/.ssh directory if you do not have an existing valid key pair. When selecting the
|
||||
key pair to use, the preferred order is:
|
||||
|
||||
1. Key specified by -i in <ssh-flags>
|
||||
2. Automatic key, if it already exists
|
||||
3. First valid key pair in ssh config (according to ssh -G)
|
||||
4. Automatic key, newly created
|
||||
|
||||
The 'ssh' command also supports deeper integration with OpenSSH using a '--config'
|
||||
option that generates per-codespace ssh configuration in OpenSSH format.
|
||||
|
|
@ -171,17 +177,23 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
|
|||
}
|
||||
defer safeClose(session, &err)
|
||||
|
||||
remoteSSHServerPort, sshUser := 0, ""
|
||||
var (
|
||||
invoker rpc.Invoker
|
||||
remoteSSHServerPort int
|
||||
sshUser string
|
||||
)
|
||||
err = a.RunWithProgress("Fetching SSH Details", func() (err error) {
|
||||
invoker, err := rpc.CreateInvoker(ctx, session)
|
||||
invoker, err = rpc.CreateInvoker(ctx, session)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer safeClose(invoker, &err)
|
||||
|
||||
remoteSSHServerPort, sshUser, err = invoker.StartSSHServerWithOptions(ctx, startSSHOptions)
|
||||
return
|
||||
})
|
||||
if invoker != nil {
|
||||
defer safeClose(invoker, &err)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting ssh server details: %w", err)
|
||||
}
|
||||
|
|
@ -199,7 +211,7 @@ func (a *App) SSH(ctx context.Context, sshArgs []string, opts sshOptions) (err e
|
|||
// Ensure local port is listening before client (Shell) connects.
|
||||
// Unless the user specifies a server port, localSSHServerPort is 0
|
||||
// and thus the client will pick a random port.
|
||||
listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort)
|
||||
listen, localSSHServerPort, err := codespaces.ListenTCP(localSSHServerPort, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ func (a *App) StopCodespace(ctx context.Context, opts *stopOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
err := a.RunWithProgress("Stoppping codespace", func() (err error) {
|
||||
err := a.RunWithProgress("Stopping codespace", func() (err error) {
|
||||
err = a.apiClient.StopCodespace(ctx, codespaceName, opts.orgName, ownerName)
|
||||
return
|
||||
})
|
||||
|
|
|
|||
|
|
@ -253,7 +253,7 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
|
|||
|
||||
// Qualifier flags
|
||||
cmd.Flags().StringSliceVar(&qualifiers.License, "license", nil, "Filter based on license type")
|
||||
cmd.Flags().StringVar(&qualifiers.User, "owner", "", "Filter on owner")
|
||||
cmd.Flags().StringSliceVar(&qualifiers.User, "owner", nil, "Filter on owner")
|
||||
|
||||
return cmd
|
||||
}(),
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cli/go-gh"
|
||||
"github.com/cli/go-gh/v2/pkg/api"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("hi world, this is the %s extension!")
|
||||
client, err := gh.RESTClient(nil)
|
||||
client, err := api.DefaultRESTClient()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -713,7 +713,7 @@ func TestManager_UpgradeExtension_BinaryExtension_Pinned(t *testing.T) {
|
|||
assert.Equal(t, err, pinnedExtensionUpgradeError)
|
||||
}
|
||||
|
||||
func TestManager_UpgradeExtenion_GitExtension_Pinned(t *testing.T) {
|
||||
func TestManager_UpgradeExtension_GitExtension_Pinned(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
extDir := filepath.Join(tempDir, "extensions", "gh-remote")
|
||||
assert.NoError(t, stubPinnedExtension(filepath.Join(extDir, "gh-remote"), "abcd1234"))
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/ghinstance"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/go-gh/pkg/ssh"
|
||||
"github.com/cli/go-gh/v2/pkg/ssh"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package create
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ type CreateOptions struct {
|
|||
Labels []string
|
||||
Projects []string
|
||||
Milestone string
|
||||
Template string
|
||||
}
|
||||
|
||||
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
|
||||
|
|
@ -91,6 +93,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
return cmdutil.FlagErrorf("`--recover` only supported when running interactively")
|
||||
}
|
||||
|
||||
if opts.Template != "" && bodyProvided {
|
||||
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
|
||||
}
|
||||
|
||||
opts.Interactive = !(titleProvided && bodyProvided)
|
||||
|
||||
if opts.Interactive && !opts.IO.CanPrompt() {
|
||||
|
|
@ -113,6 +119,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`")
|
||||
cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`")
|
||||
cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
|
||||
cmd.Flags().StringVarP(&opts.Template, "template", "T", "", "Template `name` to use as starting body text")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -216,9 +223,17 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
|
||||
if opts.RecoverFile == "" {
|
||||
var template shared.Template
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
if opts.Template != "" {
|
||||
template, err = tpl.Select(opts.Template)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if template != nil {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,38 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
Interactive: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from name tty",
|
||||
tty: true,
|
||||
cli: `-t mytitle --template "bug report"`,
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "mytitle",
|
||||
Body: "",
|
||||
RecoverFile: "",
|
||||
WebMode: false,
|
||||
Template: "bug report",
|
||||
Interactive: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from name non-tty",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template "bug report"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template "bug report" --body "issue body"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body file",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template "bug report" --body-file "body_file.md"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -134,6 +166,7 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
assert.Equal(t, tt.wantsOpts.RecoverFile, opts.RecoverFile)
|
||||
assert.Equal(t, tt.wantsOpts.WebMode, opts.WebMode)
|
||||
assert.Equal(t, tt.wantsOpts.Interactive, opts.Interactive)
|
||||
assert.Equal(t, tt.wantsOpts.Template, opts.Template)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@ package edit
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
shared "github.com/cli/cli/v2/pkg/cmd/issue/shared"
|
||||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
|
|
@ -24,8 +27,8 @@ type EditOptions struct {
|
|||
EditFieldsSurvey func(*prShared.Editable, string) error
|
||||
FetchOptions func(*api.Client, ghrepo.Interface, *prShared.Editable) error
|
||||
|
||||
SelectorArg string
|
||||
Interactive bool
|
||||
SelectorArgs []string
|
||||
Interactive bool
|
||||
|
||||
prShared.Editable
|
||||
}
|
||||
|
|
@ -43,12 +46,12 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
var bodyFile string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "edit {<number> | <url>}",
|
||||
Short: "Edit an issue",
|
||||
Use: "edit {<numbers> | <urls>}",
|
||||
Short: "Edit issues",
|
||||
Long: heredoc.Doc(`
|
||||
Edit an issue.
|
||||
Edit one or more issues within the same repository.
|
||||
|
||||
Editing an issue's projects requires authorization with the "project" scope.
|
||||
Editing issues' projects requires authorization with the "project" scope.
|
||||
To authorize, run "gh auth refresh -s project".
|
||||
`),
|
||||
Example: heredoc.Doc(`
|
||||
|
|
@ -58,13 +61,14 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
$ gh issue edit 23 --add-project "Roadmap" --remove-project v1,v2
|
||||
$ gh issue edit 23 --milestone "Version 1"
|
||||
$ gh issue edit 23 --body-file body.txt
|
||||
$ gh issue edit 23 34 --add-label "help wanted"
|
||||
`),
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
opts.SelectorArg = args[0]
|
||||
opts.SelectorArgs = args
|
||||
|
||||
flags := cmd.Flags()
|
||||
|
||||
|
|
@ -113,6 +117,10 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
return cmdutil.FlagErrorf("field to edit flag required when not running interactively")
|
||||
}
|
||||
|
||||
if opts.Interactive && len(opts.SelectorArgs) > 1 {
|
||||
return cmdutil.FlagErrorf("multiple issues cannot be edited interactively")
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
|
@ -141,41 +149,8 @@ func editRun(opts *EditOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Prompt the user which fields they'd like to edit.
|
||||
editable := opts.Editable
|
||||
lookupFields := []string{"id", "number", "title", "body", "url"}
|
||||
if opts.Interactive || editable.Assignees.Edited {
|
||||
lookupFields = append(lookupFields, "assignees")
|
||||
}
|
||||
if opts.Interactive || editable.Labels.Edited {
|
||||
lookupFields = append(lookupFields, "labels")
|
||||
}
|
||||
if opts.Interactive || editable.Projects.Edited {
|
||||
lookupFields = append(lookupFields, "projectCards")
|
||||
lookupFields = append(lookupFields, "projectItems")
|
||||
}
|
||||
if opts.Interactive || editable.Milestone.Edited {
|
||||
lookupFields = append(lookupFields, "milestone")
|
||||
}
|
||||
|
||||
issue, repo, err := shared.IssueFromArgWithFields(httpClient, opts.BaseRepo, opts.SelectorArg, lookupFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
editable.Title.Default = issue.Title
|
||||
editable.Body.Default = issue.Body
|
||||
editable.Assignees.Default = issue.Assignees.Logins()
|
||||
editable.Labels.Default = issue.Labels.Names()
|
||||
editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...)
|
||||
projectItems := map[string]string{}
|
||||
for _, n := range issue.ProjectItems.Nodes {
|
||||
projectItems[n.Project.ID] = n.ID
|
||||
}
|
||||
editable.Projects.ProjectItems = projectItems
|
||||
if issue.Milestone != nil {
|
||||
editable.Milestone.Default = issue.Milestone.Title
|
||||
}
|
||||
|
||||
if opts.Interactive {
|
||||
err = opts.FieldsToEditSurvey(&editable)
|
||||
if err != nil {
|
||||
|
|
@ -183,33 +158,122 @@ func editRun(opts *EditOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
lookupFields := []string{"id", "number", "title", "body", "url"}
|
||||
if editable.Assignees.Edited {
|
||||
lookupFields = append(lookupFields, "assignees")
|
||||
}
|
||||
if editable.Labels.Edited {
|
||||
lookupFields = append(lookupFields, "labels")
|
||||
}
|
||||
if editable.Projects.Edited {
|
||||
lookupFields = append(lookupFields, "projectCards")
|
||||
lookupFields = append(lookupFields, "projectItems")
|
||||
}
|
||||
if editable.Milestone.Edited {
|
||||
lookupFields = append(lookupFields, "milestone")
|
||||
}
|
||||
|
||||
// Get all specified issues and make sure they are within the same repo.
|
||||
issues, repo, err := shared.IssuesFromArgsWithFields(httpClient, opts.BaseRepo, opts.SelectorArgs, lookupFields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Fetch editable shared fields once for all issues.
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
opts.IO.StartProgressIndicator()
|
||||
opts.IO.StartProgressIndicatorWithLabel("Fetching repository information")
|
||||
err = opts.FetchOptions(apiClient, repo, &editable)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Interactive {
|
||||
editorCommand, err := opts.DetermineEditor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = opts.EditFieldsSurvey(&editable, editorCommand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Update all issues in parallel.
|
||||
editedIssueChan := make(chan string, len(issues))
|
||||
failedIssueChan := make(chan string, len(issues))
|
||||
g := sync.WaitGroup{}
|
||||
|
||||
// Only show progress if we will not prompt below or the survey will break up the progress indicator.
|
||||
if !opts.Interactive {
|
||||
opts.IO.StartProgressIndicatorWithLabel(fmt.Sprintf("Updating %d issues", len(issues)))
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
err = prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable)
|
||||
for _, issue := range issues {
|
||||
// Copy variables to capture in the go routine below.
|
||||
editable := editable.Clone()
|
||||
|
||||
editable.Title.Default = issue.Title
|
||||
editable.Body.Default = issue.Body
|
||||
editable.Assignees.Default = issue.Assignees.Logins()
|
||||
editable.Labels.Default = issue.Labels.Names()
|
||||
editable.Projects.Default = append(issue.ProjectCards.ProjectNames(), issue.ProjectItems.ProjectTitles()...)
|
||||
projectItems := map[string]string{}
|
||||
for _, n := range issue.ProjectItems.Nodes {
|
||||
projectItems[n.Project.ID] = n.ID
|
||||
}
|
||||
editable.Projects.ProjectItems = projectItems
|
||||
if issue.Milestone != nil {
|
||||
editable.Milestone.Default = issue.Milestone.Title
|
||||
}
|
||||
|
||||
// Allow interactive prompts for one issue; failed earlier if multiple issues specified.
|
||||
if opts.Interactive {
|
||||
editorCommand, err := opts.DetermineEditor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = opts.EditFieldsSurvey(&editable, editorCommand)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
g.Add(1)
|
||||
go func(issue *api.Issue) {
|
||||
defer g.Done()
|
||||
|
||||
err := prShared.UpdateIssue(httpClient, repo, issue.ID, issue.IsPullRequest(), editable)
|
||||
if err != nil {
|
||||
failedIssueChan <- fmt.Sprintf("failed to update %s: %s", issue.URL, err)
|
||||
return
|
||||
}
|
||||
|
||||
editedIssueChan <- issue.URL
|
||||
}(issue)
|
||||
}
|
||||
|
||||
g.Wait()
|
||||
close(editedIssueChan)
|
||||
close(failedIssueChan)
|
||||
|
||||
// Does nothing if progress was not started above.
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
// Print a sorted list of successfully edited issue URLs to stdout.
|
||||
editedIssueURLs := make([]string, 0, len(issues))
|
||||
for editedIssueURL := range editedIssueChan {
|
||||
editedIssueURLs = append(editedIssueURLs, editedIssueURL)
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.IO.Out, issue.URL)
|
||||
sort.Strings(editedIssueURLs)
|
||||
for _, editedIssueURL := range editedIssueURLs {
|
||||
fmt.Fprintln(opts.IO.Out, editedIssueURL)
|
||||
}
|
||||
|
||||
// Print a sorted list of failures to stderr.
|
||||
failedIssueErrors := make([]string, 0, len(issues))
|
||||
for failedIssueError := range failedIssueChan {
|
||||
failedIssueErrors = append(failedIssueErrors, failedIssueError)
|
||||
}
|
||||
|
||||
sort.Strings(failedIssueErrors)
|
||||
for _, failedIssueError := range failedIssueErrors {
|
||||
fmt.Fprintln(opts.IO.ErrOut, failedIssueError)
|
||||
}
|
||||
|
||||
if len(failedIssueErrors) > 0 {
|
||||
return fmt.Errorf("failed to update %s", text.Pluralize(len(failedIssueErrors), "issue"))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
prShared "github.com/cli/cli/v2/pkg/cmd/pr/shared"
|
||||
|
|
@ -41,8 +42,8 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "issue number argument",
|
||||
input: "23",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
Interactive: true,
|
||||
SelectorArgs: []string{"23"},
|
||||
Interactive: true,
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -50,7 +51,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "title flag",
|
||||
input: "23 --title test",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Title: prShared.EditableString{
|
||||
Value: "test",
|
||||
|
|
@ -64,7 +65,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "body flag",
|
||||
input: "23 --body test",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Body: prShared.EditableString{
|
||||
Value: "test",
|
||||
|
|
@ -79,7 +80,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
input: "23 --body-file -",
|
||||
stdin: "this is on standard input",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Body: prShared.EditableString{
|
||||
Value: "this is on standard input",
|
||||
|
|
@ -93,7 +94,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "body from file",
|
||||
input: fmt.Sprintf("23 --body-file '%s'", tmpFile),
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Body: prShared.EditableString{
|
||||
Value: "a body from file",
|
||||
|
|
@ -107,7 +108,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "add-assignee flag",
|
||||
input: "23 --add-assignee monalisa,hubot",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
|
|
@ -121,7 +122,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "remove-assignee flag",
|
||||
input: "23 --remove-assignee monalisa,hubot",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Remove: []string{"monalisa", "hubot"},
|
||||
|
|
@ -135,7 +136,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "add-label flag",
|
||||
input: "23 --add-label feature,TODO,bug",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"feature", "TODO", "bug"},
|
||||
|
|
@ -149,7 +150,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "remove-label flag",
|
||||
input: "23 --remove-label feature,TODO,bug",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Remove: []string{"feature", "TODO", "bug"},
|
||||
|
|
@ -163,7 +164,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "add-project flag",
|
||||
input: "23 --add-project Cleanup,Roadmap",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
|
|
@ -179,7 +180,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "remove-project flag",
|
||||
input: "23 --remove-project Cleanup,Roadmap",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
|
|
@ -195,7 +196,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
name: "milestone flag",
|
||||
input: "23 --milestone GA",
|
||||
output: EditOptions{
|
||||
SelectorArg: "23",
|
||||
SelectorArgs: []string{"23"},
|
||||
Editable: prShared.Editable{
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
|
|
@ -205,6 +206,34 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "add label to multiple issues",
|
||||
input: "23 34 --add-label bug",
|
||||
output: EditOptions{
|
||||
SelectorArgs: []string{"23", "34"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"bug"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "interactive multiple issues",
|
||||
input: "23 34",
|
||||
output: EditOptions{
|
||||
SelectorArgs: []string{"23", "34"},
|
||||
Editable: prShared.Editable{
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"bug"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -243,7 +272,7 @@ func TestNewCmdEdit(t *testing.T) {
|
|||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.output.SelectorArg, gotOpts.SelectorArg)
|
||||
assert.Equal(t, tt.output.SelectorArgs, gotOpts.SelectorArgs)
|
||||
assert.Equal(t, tt.output.Interactive, gotOpts.Interactive)
|
||||
assert.Equal(t, tt.output.Editable, gotOpts.Editable)
|
||||
})
|
||||
|
|
@ -257,12 +286,13 @@ func Test_editRun(t *testing.T) {
|
|||
httpStubs func(*testing.T, *httpmock.Registry)
|
||||
stdout string
|
||||
stderr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "non-interactive",
|
||||
input: &EditOptions{
|
||||
SelectorArg: "123",
|
||||
Interactive: false,
|
||||
SelectorArgs: []string{"123"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Title: prShared.EditableString{
|
||||
Value: "new title",
|
||||
|
|
@ -311,11 +341,176 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
stdout: "https://github.com/OWNER/REPO/issue/123\n",
|
||||
},
|
||||
{
|
||||
name: "non-interactive multiple issues",
|
||||
input: &EditOptions{
|
||||
SelectorArgs: []string{"456", "123"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
Remove: []string{"octocat"},
|
||||
Edited: true,
|
||||
},
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"feature", "TODO", "bug"},
|
||||
Remove: []string{"docs"},
|
||||
Edited: true,
|
||||
},
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "CleanupV2"},
|
||||
Remove: []string{"Roadmap", "RoadmapV2"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
FetchOptions: prShared.FetchOptions,
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// Should only be one fetch of metadata.
|
||||
mockRepoMetadata(t, reg)
|
||||
// All other queries and mutations should be doubled.
|
||||
mockIssueNumberGet(t, reg, 123)
|
||||
mockIssueNumberGet(t, reg, 456)
|
||||
mockIssueProjectItemsGet(t, reg)
|
||||
mockIssueProjectItemsGet(t, reg)
|
||||
mockIssueUpdate(t, reg)
|
||||
mockIssueUpdate(t, reg)
|
||||
mockIssueUpdateLabels(t, reg)
|
||||
mockIssueUpdateLabels(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
mockProjectV2ItemUpdate(t, reg)
|
||||
},
|
||||
stdout: heredoc.Doc(`
|
||||
https://github.com/OWNER/REPO/issue/123
|
||||
https://github.com/OWNER/REPO/issue/456
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "non-interactive multiple issues with fetch failures",
|
||||
input: &EditOptions{
|
||||
SelectorArgs: []string{"123", "9999"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
Remove: []string{"octocat"},
|
||||
Edited: true,
|
||||
},
|
||||
Labels: prShared.EditableSlice{
|
||||
Add: []string{"feature", "TODO", "bug"},
|
||||
Remove: []string{"docs"},
|
||||
Edited: true,
|
||||
},
|
||||
Projects: prShared.EditableProjects{
|
||||
EditableSlice: prShared.EditableSlice{
|
||||
Add: []string{"Cleanup", "CleanupV2"},
|
||||
Remove: []string{"Roadmap", "RoadmapV2"},
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
FetchOptions: prShared.FetchOptions,
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
mockIssueNumberGet(t, reg, 123)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "errors": [
|
||||
{
|
||||
"type": "NOT_FOUND",
|
||||
"message": "Could not resolve to an Issue with the number of 9999."
|
||||
}
|
||||
] }`),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "non-interactive multiple issues with update failures",
|
||||
input: &EditOptions{
|
||||
SelectorArgs: []string{"123", "456"},
|
||||
Interactive: false,
|
||||
Editable: prShared.Editable{
|
||||
Assignees: prShared.EditableSlice{
|
||||
Add: []string{"monalisa", "hubot"},
|
||||
Remove: []string{"octocat"},
|
||||
Edited: true,
|
||||
},
|
||||
Milestone: prShared.EditableString{
|
||||
Value: "GA",
|
||||
Edited: true,
|
||||
},
|
||||
},
|
||||
FetchOptions: prShared.FetchOptions,
|
||||
},
|
||||
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
|
||||
// Should only be one fetch of metadata.
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "assignableUsers": {
|
||||
"nodes": [
|
||||
{ "login": "hubot", "id": "HUBOTID" },
|
||||
{ "login": "MonaLisa", "id": "MONAID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryMilestoneList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "milestones": {
|
||||
"nodes": [
|
||||
{ "title": "GA", "id": "GAID" },
|
||||
{ "title": "Big One.oh", "id": "BIGONEID" }
|
||||
],
|
||||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }
|
||||
`))
|
||||
// All other queries should be doubled.
|
||||
mockIssueNumberGet(t, reg, 123)
|
||||
mockIssueNumberGet(t, reg, 456)
|
||||
// Updating 123 should succeed.
|
||||
reg.Register(
|
||||
httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool {
|
||||
return m["id"] == "123"
|
||||
}),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "data": { "updateIssue": { "__typename": "" } } }`,
|
||||
func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
// Updating 456 should fail.
|
||||
reg.Register(
|
||||
httpmock.GraphQLMutationMatcher(`mutation IssueUpdate\b`, func(m map[string]interface{}) bool {
|
||||
return m["id"] == "456"
|
||||
}),
|
||||
httpmock.GraphQLMutation(`
|
||||
{ "errors": [ { "message": "test error" } ] }`,
|
||||
func(inputs map[string]interface{}) {}),
|
||||
)
|
||||
},
|
||||
stdout: heredoc.Doc(`
|
||||
https://github.com/OWNER/REPO/issue/123
|
||||
`),
|
||||
stderr: `failed to update https://github.com/OWNER/REPO/issue/456:.*test error`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "interactive",
|
||||
input: &EditOptions{
|
||||
SelectorArg: "123",
|
||||
Interactive: true,
|
||||
SelectorArgs: []string{"123"},
|
||||
Interactive: true,
|
||||
FieldsToEditSurvey: func(eo *prShared.Editable) error {
|
||||
eo.Title.Edited = true
|
||||
eo.Body.Edited = true
|
||||
|
|
@ -351,38 +546,48 @@ func Test_editRun(t *testing.T) {
|
|||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
tt.httpStubs(t, reg)
|
||||
|
||||
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
|
||||
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
|
||||
|
||||
tt.input.IO = ios
|
||||
tt.input.HttpClient = httpClient
|
||||
tt.input.BaseRepo = baseRepo
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStdinTTY(true)
|
||||
ios.SetStderrTTY(true)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
tt.httpStubs(t, reg)
|
||||
|
||||
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
|
||||
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
|
||||
|
||||
tt.input.IO = ios
|
||||
tt.input.HttpClient = httpClient
|
||||
tt.input.BaseRepo = baseRepo
|
||||
|
||||
err := editRun(tt.input)
|
||||
assert.NoError(t, err)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.stdout, stdout.String())
|
||||
assert.Equal(t, tt.stderr, stderr.String())
|
||||
// Use regex match since mock errors and service errors will differ.
|
||||
assert.Regexp(t, tt.stderr, stderr.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mockIssueGet(_ *testing.T, reg *httpmock.Registry) {
|
||||
mockIssueNumberGet(nil, reg, 123)
|
||||
}
|
||||
|
||||
func mockIssueNumberGet(_ *testing.T, reg *httpmock.Registry, number int) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`
|
||||
httpmock.StringResponse(fmt.Sprintf(`
|
||||
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
|
||||
"number": 123,
|
||||
"url": "https://github.com/OWNER/REPO/issue/123",
|
||||
"id": "%[1]d",
|
||||
"number": %[1]d,
|
||||
"url": "https://github.com/OWNER/REPO/issue/%[1]d",
|
||||
"labels": {
|
||||
"nodes": [
|
||||
{ "id": "DOCSID", "name": "docs" }
|
||||
|
|
@ -393,7 +598,7 @@ func mockIssueGet(_ *testing.T, reg *httpmock.Registry) {
|
|||
{ "project": { "name": "Roadmap" } }
|
||||
], "totalCount": 1
|
||||
}
|
||||
} } } }`),
|
||||
} } } }`, number)),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,25 +14,20 @@ import (
|
|||
fd "github.com/cli/cli/v2/internal/featuredetection"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
// IssueFromArgWithFields loads an issue or pull request with the specified fields. If some of the fields
|
||||
// could not be fetched by GraphQL, this returns a non-nil issue and a *PartialLoadError.
|
||||
func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), arg string, fields []string) (*api.Issue, ghrepo.Interface, error) {
|
||||
issueNumber, baseRepo := issueMetadataFromURL(arg)
|
||||
|
||||
if issueNumber == 0 {
|
||||
var err error
|
||||
issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#"))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid issue format: %q", arg)
|
||||
}
|
||||
issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if baseRepo == nil {
|
||||
var err error
|
||||
baseRepo, err = baseRepoFn()
|
||||
if err != nil {
|
||||
if baseRepo, err = baseRepoFn(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +36,70 @@ func IssueFromArgWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.I
|
|||
return issue, baseRepo, err
|
||||
}
|
||||
|
||||
// IssuesFromArgWithFields loads 1 or more issues or pull requests with the specified fields. If some of the fields
|
||||
// could not be fetched by GraphQL, this returns non-nil issues and a *PartialLoadError.
|
||||
func IssuesFromArgsWithFields(httpClient *http.Client, baseRepoFn func() (ghrepo.Interface, error), args []string, fields []string) ([]*api.Issue, ghrepo.Interface, error) {
|
||||
var issuesRepo ghrepo.Interface
|
||||
issueNumbers := make([]int, 0, len(args))
|
||||
|
||||
for _, arg := range args {
|
||||
issueNumber, baseRepo, err := IssueNumberAndRepoFromArg(arg)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
issueNumbers = append(issueNumbers, issueNumber)
|
||||
if baseRepo == nil {
|
||||
var err error
|
||||
if baseRepo, err = baseRepoFn(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if issuesRepo == nil {
|
||||
issuesRepo = baseRepo
|
||||
continue
|
||||
}
|
||||
|
||||
if !ghrepo.IsSame(issuesRepo, baseRepo) {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"multiple issues must be in same repo: found %q, expected %q",
|
||||
ghrepo.FullName(baseRepo),
|
||||
ghrepo.FullName(issuesRepo),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
issuesChan := make(chan *api.Issue, len(args))
|
||||
g := errgroup.Group{}
|
||||
for _, num := range issueNumbers {
|
||||
issueNumber := num
|
||||
g.Go(func() error {
|
||||
issue, err := findIssueOrPR(httpClient, issuesRepo, issueNumber, fields)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
issuesChan <- issue
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
err := g.Wait()
|
||||
close(issuesChan)
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
issues := make([]*api.Issue, 0, len(args))
|
||||
for issue := range issuesChan {
|
||||
issues = append(issues, issue)
|
||||
}
|
||||
|
||||
return issues, issuesRepo, nil
|
||||
}
|
||||
|
||||
var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`)
|
||||
|
||||
func issueMetadataFromURL(s string) (int, ghrepo.Interface) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIssueFromArgWithFields(t *testing.T) {
|
||||
|
|
@ -195,3 +196,108 @@ func TestIssueFromArgWithFields(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssuesFromArgsWithFields(t *testing.T) {
|
||||
type args struct {
|
||||
baseRepoFn func() (ghrepo.Interface, error)
|
||||
selectors []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
httpStub func(*httpmock.Registry)
|
||||
wantIssues []int
|
||||
wantRepo string
|
||||
wantErr bool
|
||||
wantErrMsg string
|
||||
}{
|
||||
{
|
||||
name: "multiple repos",
|
||||
args: args{
|
||||
selectors: []string{"1", "https://github.com/OWNER/OTHERREPO/issues/2"},
|
||||
baseRepoFn: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
},
|
||||
httpStub: func(r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":1}
|
||||
}}}`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":2}
|
||||
}}}`))
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "multiple issues must be in same repo",
|
||||
},
|
||||
{
|
||||
name: "multiple issues",
|
||||
args: args{
|
||||
selectors: []string{"1", "2"},
|
||||
baseRepoFn: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
},
|
||||
httpStub: func(r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":1}
|
||||
}}}`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{
|
||||
"hasIssuesEnabled": true,
|
||||
"issue":{"number":2}
|
||||
}}}`))
|
||||
},
|
||||
wantIssues: []int{1, 2},
|
||||
wantRepo: "https://github.com/OWNER/REPO",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if !tt.wantErr && len(tt.args.selectors) != len(tt.wantIssues) {
|
||||
t.Fatal("number of selectors and issues not equal")
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
if tt.httpStub != nil {
|
||||
tt.httpStub(reg)
|
||||
}
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
issues, repo, err := IssuesFromArgsWithFields(httpClient, tt.args.baseRepoFn, tt.args.selectors, []string{"number"})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("IssuesFromArgsWithFields() error = %v, wantErr %v", err, tt.wantErr)
|
||||
if issues == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErrMsg)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
for i, issue := range issues {
|
||||
if issue.Number != tt.wantIssues[i] {
|
||||
t.Errorf("want issue #%d, got #%d", tt.wantIssues[i], issue.Number)
|
||||
}
|
||||
}
|
||||
if repo != nil {
|
||||
repoURL := ghrepo.GenerateRepoURL(repo, "")
|
||||
if repoURL != tt.wantRepo {
|
||||
t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL)
|
||||
}
|
||||
} else if tt.wantRepo != "" {
|
||||
t.Errorf("want repo %sw, got nil", tt.wantRepo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
98
pkg/cmd/org/list/http.go
Normal file
98
pkg/cmd/org/list/http.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cli/cli/v2/api"
|
||||
)
|
||||
|
||||
type OrganizationList struct {
|
||||
Organizations []Organization
|
||||
TotalCount int
|
||||
User string
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
Login string
|
||||
}
|
||||
|
||||
func listOrgs(httpClient *http.Client, hostname string, limit int) (*OrganizationList, error) {
|
||||
type response struct {
|
||||
User struct {
|
||||
Login string
|
||||
Organizations struct {
|
||||
TotalCount int
|
||||
Nodes []Organization
|
||||
PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
} `graphql:"organizations(first: $limit, after: $endCursor)"`
|
||||
}
|
||||
}
|
||||
|
||||
query := `query OrganizationList($user: String!, $limit: Int!, $endCursor: String) {
|
||||
user(login: $user) {
|
||||
login
|
||||
organizations(first: $limit, after: $endCursor) {
|
||||
totalCount
|
||||
nodes {
|
||||
login
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
user, err := api.CurrentLoginName(client, hostname)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listResult := OrganizationList{}
|
||||
listResult.User = user
|
||||
pageLimit := min(limit, 100)
|
||||
variables := map[string]interface{}{
|
||||
"user": user,
|
||||
}
|
||||
|
||||
loop:
|
||||
for {
|
||||
variables["limit"] = pageLimit
|
||||
var data response
|
||||
err := client.GraphQL(hostname, query, variables, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listResult.TotalCount = data.User.Organizations.TotalCount
|
||||
|
||||
for _, org := range data.User.Organizations.Nodes {
|
||||
listResult.Organizations = append(listResult.Organizations, org)
|
||||
if len(listResult.Organizations) == limit {
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
if data.User.Organizations.PageInfo.HasNextPage {
|
||||
variables["endCursor"] = data.User.Organizations.PageInfo.EndCursor
|
||||
pageLimit = min(pageLimit, limit-len(listResult.Organizations))
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return &listResult, nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
83
pkg/cmd/org/list/http_test.go
Normal file
83
pkg/cmd/org/list/http_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
)
|
||||
|
||||
func Test_listOrgs(t *testing.T) {
|
||||
type args struct {
|
||||
limit int
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
httpStub func(*testing.T, *httpmock.Registry)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "default",
|
||||
args: args{
|
||||
limit: 30,
|
||||
},
|
||||
httpStub: func(t *testing.T, reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) {
|
||||
want := map[string]interface{}{
|
||||
"user": "octocat",
|
||||
"limit": float64(30),
|
||||
}
|
||||
if !reflect.DeepEqual(vars, want) {
|
||||
t.Errorf("got GraphQL variables %#v, want %#v", vars, want)
|
||||
}
|
||||
}))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with limit",
|
||||
args: args{
|
||||
limit: 1,
|
||||
},
|
||||
httpStub: func(t *testing.T, r *httpmock.Registry) {
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.GraphQLQuery(`{"data":{}}`, func(query string, vars map[string]interface{}) {
|
||||
want := map[string]interface{}{
|
||||
"user": "octocat",
|
||||
"limit": float64(1),
|
||||
}
|
||||
if !reflect.DeepEqual(vars, want) {
|
||||
t.Errorf("got GraphQL variables %#v, want %#v", vars, want)
|
||||
}
|
||||
}))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
if tt.httpStub != nil {
|
||||
tt.httpStub(t, reg)
|
||||
}
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
_, err := listOrgs(httpClient, "octocat", tt.args.limit)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("listOrgs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
108
pkg/cmd/org/list/list.go
Normal file
108
pkg/cmd/org/list/list.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/tableprinter"
|
||||
"github.com/cli/cli/v2/internal/text"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type ListOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
Config func() (config.Config, error)
|
||||
HttpClient func() (*http.Client, error)
|
||||
|
||||
Limit int
|
||||
}
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
opts := ListOptions{
|
||||
IO: f.IOStreams,
|
||||
Config: f.Config,
|
||||
HttpClient: f.HttpClient,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Short: "List organizations for the authenticated user.",
|
||||
Example: heredoc.Doc(`
|
||||
# List the first 30 organizations
|
||||
$ gh org list
|
||||
|
||||
# List more organizations
|
||||
$ gh org list --limit 100
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if opts.Limit < 1 {
|
||||
return cmdutil.FlagErrorf("invalid limit: %v", opts.Limit)
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(&opts)
|
||||
}
|
||||
return listRun(&opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum number of organizations to list")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listRun(opts *ListOptions) error {
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg, err := opts.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
host, _ := cfg.Authentication().DefaultHost()
|
||||
|
||||
listResult, err := listOrgs(httpClient, host, opts.Limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := opts.IO.StartPager(); err != nil {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
|
||||
}
|
||||
defer opts.IO.StopPager()
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
header := listHeader(listResult.User, len(listResult.Organizations), listResult.TotalCount)
|
||||
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", header)
|
||||
}
|
||||
|
||||
table := tableprinter.New(opts.IO)
|
||||
|
||||
for _, org := range listResult.Organizations {
|
||||
table.AddField(org.Login)
|
||||
table.EndRow()
|
||||
}
|
||||
|
||||
err = table.Render()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func listHeader(user string, resultCount, totalCount int) string {
|
||||
if totalCount == 0 {
|
||||
return "There are no organizations associated with @" + user
|
||||
}
|
||||
|
||||
return fmt.Sprintf("Showing %d of %s", resultCount, text.Pluralize(totalCount, "organization"))
|
||||
}
|
||||
237
pkg/cmd/org/list/list_test.go
Normal file
237
pkg/cmd/org/list/list_test.go
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdList(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wants ListOptions
|
||||
wantsErr string
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
cli: "",
|
||||
wants: ListOptions{
|
||||
Limit: 30,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with limit",
|
||||
cli: "-L 101",
|
||||
wants: ListOptions{
|
||||
Limit: 101,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
cli: "123 456",
|
||||
wantsErr: "accepts at most 1 arg(s), received 2",
|
||||
},
|
||||
{
|
||||
name: "invalid limit",
|
||||
cli: "-L 0",
|
||||
wantsErr: "invalid limit: 0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *ListOptions
|
||||
cmd := NewCmdList(f, func(opts *ListOptions) 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.wantsErr != "" {
|
||||
assert.EqualError(t, err, tt.wantsErr)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wants.Limit, gotOpts.Limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts ListOptions
|
||||
isTTY bool
|
||||
wantOut string
|
||||
}{
|
||||
{
|
||||
name: "no organizations found",
|
||||
opts: ListOptions{HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": {
|
||||
"organizations": { "nodes": [], "totalCount": 0 }
|
||||
} } }`,
|
||||
),
|
||||
)
|
||||
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: true,
|
||||
wantOut: heredoc.Doc(`
|
||||
|
||||
There are no organizations associated with @octocat
|
||||
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "default behavior",
|
||||
opts: ListOptions{HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": { "organizations": {
|
||||
"totalCount": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"login": "github"
|
||||
},
|
||||
{
|
||||
"login": "cli"
|
||||
}
|
||||
] } } } }`,
|
||||
),
|
||||
)
|
||||
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: true,
|
||||
wantOut: heredoc.Doc(`
|
||||
|
||||
Showing 2 of 2 organizations
|
||||
|
||||
github
|
||||
cli
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "with limit",
|
||||
opts: ListOptions{Limit: 1, HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": { "organizations": {
|
||||
"totalCount": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"login": "github"
|
||||
},
|
||||
{
|
||||
"login": "cli"
|
||||
}
|
||||
] } } } }`,
|
||||
),
|
||||
)
|
||||
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: true,
|
||||
wantOut: heredoc.Doc(`
|
||||
|
||||
Showing 1 of 2 organizations
|
||||
|
||||
github
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "nontty output",
|
||||
opts: ListOptions{HttpClient: func() (*http.Client, error) {
|
||||
r := &httpmock.Registry{}
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data": {"viewer": {"login": "octocat"}}}`))
|
||||
|
||||
r.Register(
|
||||
httpmock.GraphQL(`query OrganizationList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "user": { "organizations": {
|
||||
"totalCount": 2,
|
||||
"nodes": [
|
||||
{
|
||||
"login": "github"
|
||||
},
|
||||
{
|
||||
"login": "cli"
|
||||
}
|
||||
] } } } }`,
|
||||
),
|
||||
)
|
||||
return &http.Client{Transport: r}, nil
|
||||
}},
|
||||
isTTY: false,
|
||||
wantOut: heredoc.Doc(`
|
||||
github
|
||||
cli
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, stdout, _ := iostreams.Test()
|
||||
|
||||
ios.SetStdoutTTY(tt.isTTY)
|
||||
ios.SetStdinTTY(tt.isTTY)
|
||||
ios.SetStderrTTY(tt.isTTY)
|
||||
|
||||
tt.opts.IO = ios
|
||||
tt.opts.Config = func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
}
|
||||
|
||||
err := listRun(&tt.opts)
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
24
pkg/cmd/org/org.go
Normal file
24
pkg/cmd/org/org.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package org
|
||||
|
||||
import (
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
orgListCmd "github.com/cli/cli/v2/pkg/cmd/org/list"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCmdOrg(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "org <command>",
|
||||
Short: "Manage organizations",
|
||||
Long: "Work with Github organizations.",
|
||||
Example: heredoc.Doc(`
|
||||
$ gh org list
|
||||
`),
|
||||
GroupID: "core",
|
||||
}
|
||||
|
||||
cmdutil.AddGroup(cmd, "General commands", orgListCmd.NewCmdList(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -30,6 +30,7 @@ type ChecksOptions struct {
|
|||
WebMode bool
|
||||
Interval time.Duration
|
||||
Watch bool
|
||||
FailFast bool
|
||||
Required bool
|
||||
}
|
||||
|
||||
|
|
@ -59,6 +60,10 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
|
|||
return cmdutil.FlagErrorf("argument required when using the `--repo` flag")
|
||||
}
|
||||
|
||||
if opts.FailFast && !opts.Watch {
|
||||
return cmdutil.FlagErrorf("cannot use `--fail-fast` flag without `--watch` flag")
|
||||
}
|
||||
|
||||
intervalChanged := cmd.Flags().Changed("interval")
|
||||
if !opts.Watch && intervalChanged {
|
||||
return cmdutil.FlagErrorf("cannot use `--interval` flag without `--watch` flag")
|
||||
|
|
@ -86,6 +91,7 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
|
|||
|
||||
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to show details about checks")
|
||||
cmd.Flags().BoolVarP(&opts.Watch, "watch", "", false, "Watch checks until they finish")
|
||||
cmd.Flags().BoolVarP(&opts.FailFast, "fail-fast", "", false, "Exit watch mode on first check failure")
|
||||
cmd.Flags().IntVarP(&interval, "interval", "i", 10, "Refresh interval in seconds when using `--watch` flag")
|
||||
cmd.Flags().BoolVar(&opts.Required, "required", false, "Only show checks that are required")
|
||||
|
||||
|
|
@ -171,6 +177,10 @@ func checksRun(opts *ChecksOptions) error {
|
|||
break
|
||||
}
|
||||
|
||||
if opts.FailFast && counts.Failed > 0 {
|
||||
break
|
||||
}
|
||||
|
||||
time.Sleep(opts.Interval)
|
||||
|
||||
checks, counts, err = populateStatusChecks(client, repo, pr, opts.Required)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,20 @@ func TestNewCmdChecks(t *testing.T) {
|
|||
cli: "--interval 5",
|
||||
wantsError: "cannot use `--interval` flag without `--watch` flag",
|
||||
},
|
||||
{
|
||||
name: "watch with fail-fast flag",
|
||||
cli: "--watch --fail-fast",
|
||||
wants: ChecksOptions{
|
||||
Watch: true,
|
||||
FailFast: true,
|
||||
Interval: time.Duration(10000000000),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "fail-fast flag without watch flag",
|
||||
cli: "--fail-fast",
|
||||
wantsError: "cannot use `--fail-fast` flag without `--watch` flag",
|
||||
},
|
||||
{
|
||||
name: "required flag",
|
||||
cli: "--required",
|
||||
|
|
@ -102,6 +116,7 @@ func TestNewCmdChecks(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.Watch, gotOpts.Watch)
|
||||
assert.Equal(t, tt.wants.Interval, gotOpts.Interval)
|
||||
assert.Equal(t, tt.wants.Required, gotOpts.Required)
|
||||
assert.Equal(t, tt.wants.FailFast, gotOpts.FailFast)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -111,6 +126,7 @@ func Test_checksRun(t *testing.T) {
|
|||
name string
|
||||
tty bool
|
||||
watch bool
|
||||
failFast bool
|
||||
required bool
|
||||
httpStubs func(*httpmock.Registry)
|
||||
wantOut string
|
||||
|
|
@ -189,6 +205,20 @@ func Test_checksRun(t *testing.T) {
|
|||
wantOut: "\x1b[?1049hAll checks were successful\n0 failing, 3 successful, 0 skipped, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n\x1b[?1049lAll checks were successful\n0 failing, 3 successful, 0 skipped, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "watch some failing with fail fast",
|
||||
tty: true,
|
||||
watch: true,
|
||||
failFast: true,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestStatusChecks\b`),
|
||||
httpmock.FileResponse("./fixtures/someFailing.json"),
|
||||
)
|
||||
},
|
||||
wantOut: "\x1b[?1049h\x1b[0;0H\x1b[JRefreshing checks status every 0 seconds. Press Ctrl+C to quit.\n\nSome checks were not successful\n1 failing, 1 successful, 0 skipped, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n* slow tests 1m26s sweet link\n\x1b[?1049lSome checks were not successful\n1 failing, 1 successful, 0 skipped, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n* slow tests 1m26s sweet link\n",
|
||||
wantErr: "SilentError",
|
||||
},
|
||||
{
|
||||
name: "with statuses",
|
||||
tty: true,
|
||||
|
|
@ -316,6 +346,7 @@ func Test_checksRun(t *testing.T) {
|
|||
SelectorArg: "123",
|
||||
Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")),
|
||||
Watch: tt.watch,
|
||||
FailFast: tt.failFast,
|
||||
Required: tt.required,
|
||||
}
|
||||
|
||||
|
|
@ -381,7 +412,7 @@ func TestChecksRun_web(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEliminateDupulicates(t *testing.T) {
|
||||
func TestEliminateDuplicates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
checkContexts []api.CheckContext
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ type CreateOptions struct {
|
|||
Milestone string
|
||||
|
||||
MaintainerCanModify bool
|
||||
Template string
|
||||
}
|
||||
|
||||
type CreateContext struct {
|
||||
|
|
@ -155,6 +156,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
opts.BodyProvided = true
|
||||
}
|
||||
|
||||
if opts.Template != "" && opts.BodyProvided {
|
||||
return errors.New("`--template` is not supported when using `--body` or `--body-file`")
|
||||
}
|
||||
|
||||
if !opts.IO.CanPrompt() && !opts.WebMode && !opts.Autofill && (!opts.TitleProvided || !opts.BodyProvided) {
|
||||
return cmdutil.FlagErrorf("must provide `--title` and `--body` (or `--fill`) when not running interactively")
|
||||
}
|
||||
|
|
@ -182,6 +187,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`")
|
||||
fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request")
|
||||
fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
|
||||
fl.StringVarP(&opts.Template, "template", "T", "", "Template `file` to use as starting body text")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head")
|
||||
|
||||
_ = cmd.RegisterFlagCompletionFunc("reviewer", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
results, err := requestableReviewersForCompletion(opts)
|
||||
|
|
@ -295,9 +303,17 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
if opts.RecoverFile == "" {
|
||||
tpl := shared.NewTemplateManager(client.HTTP(), ctx.BaseRepo, opts.Prompter, opts.RootDirOverride, opts.RepoOverride == "", true)
|
||||
var template shared.Template
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
if opts.Template != "" {
|
||||
template, err = tpl.Select(opts.Template)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
template, err = tpl.Choose()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if template != nil {
|
||||
|
|
|
|||
|
|
@ -130,6 +130,44 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
MaintainerCanModify: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from file name tty",
|
||||
tty: true,
|
||||
cli: "-t mytitle --template bug_fix.md",
|
||||
wantsErr: false,
|
||||
wantsOpts: CreateOptions{
|
||||
Title: "mytitle",
|
||||
TitleProvided: true,
|
||||
Body: "",
|
||||
BodyProvided: false,
|
||||
Autofill: false,
|
||||
RecoverFile: "",
|
||||
WebMode: false,
|
||||
IsDraft: false,
|
||||
BaseBranch: "",
|
||||
HeadBranch: "",
|
||||
MaintainerCanModify: true,
|
||||
Template: "bug_fix.md",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template from file name non-tty",
|
||||
tty: false,
|
||||
cli: "-t mytitle --template bug_fix.md",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body",
|
||||
tty: false,
|
||||
cli: `-t mytitle --template bug_fix.md --body "pr body"`,
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "template and body file",
|
||||
tty: false,
|
||||
cli: "-t mytitle --template bug_fix.md --body-file body_file.md",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
|
@ -178,6 +216,7 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
assert.Equal(t, tt.wantsOpts.MaintainerCanModify, opts.MaintainerCanModify)
|
||||
assert.Equal(t, tt.wantsOpts.BaseBranch, opts.BaseBranch)
|
||||
assert.Equal(t, tt.wantsOpts.HeadBranch, opts.HeadBranch)
|
||||
assert.Equal(t, tt.wantsOpts.Template, opts.Template)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringSliceVar(&opts.Editable.Projects.Remove, "remove-project", nil, "Remove the pull request from projects by `name`")
|
||||
cmd.Flags().StringVarP(&opts.Editable.Milestone.Value, "milestone", "m", "", "Edit the milestone the pull request belongs to by `name`")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base")
|
||||
|
||||
for _, flagName := range []string{"add-reviewer", "remove-reviewer"} {
|
||||
_ = cmd.RegisterFlagCompletionFunc(flagName, func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||
baseRepo, err := f.BaseRepo()
|
||||
|
|
|
|||
|
|
@ -109,9 +109,10 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringVarP(&opts.Assignee, "assignee", "a", "", "Filter by assignee")
|
||||
cmd.Flags().StringVarP(&opts.Search, "search", "S", "", "Search pull requests with `query`")
|
||||
cmdutil.NilBoolFlag(cmd, &opts.Draft, "draft", "d", "Filter by draft state")
|
||||
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.PullRequestFields)
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "base", "head")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -477,7 +477,7 @@ func (m *mergeContext) infof(format string, args ...interface{}) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// Creates a new MergeConext from MergeOptions.
|
||||
// Creates a new MergeContext from MergeOptions.
|
||||
func NewMergeContext(opts *MergeOptions) (*mergeContext, error) {
|
||||
findOptions := shared.FindOptions{
|
||||
Selector: opts.SelectorArg,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/set"
|
||||
"github.com/cli/cli/v2/pkg/surveyext"
|
||||
)
|
||||
|
|
@ -201,6 +202,59 @@ func (e Editable) MilestoneId() (*string, error) {
|
|||
return &m, err
|
||||
}
|
||||
|
||||
// Clone creates a mostly-shallow copy of Editable suitable for use in parallel
|
||||
// go routines. Fields that would be mutated will be copied.
|
||||
func (e *Editable) Clone() Editable {
|
||||
return Editable{
|
||||
Title: e.Title.clone(),
|
||||
Body: e.Body.clone(),
|
||||
Base: e.Base.clone(),
|
||||
Reviewers: e.Reviewers.clone(),
|
||||
Assignees: e.Assignees.clone(),
|
||||
Labels: e.Labels.clone(),
|
||||
Projects: e.Projects.clone(),
|
||||
Milestone: e.Milestone.clone(),
|
||||
// Shallow copy since no mutation.
|
||||
Metadata: e.Metadata,
|
||||
}
|
||||
}
|
||||
|
||||
func (es *EditableString) clone() EditableString {
|
||||
return EditableString{
|
||||
Value: es.Value,
|
||||
Default: es.Default,
|
||||
Edited: es.Edited,
|
||||
// Shallow copies since no mutation.
|
||||
Options: es.Options,
|
||||
}
|
||||
}
|
||||
|
||||
func (es *EditableSlice) clone() EditableSlice {
|
||||
cpy := EditableSlice{
|
||||
Edited: es.Edited,
|
||||
Allowed: es.Allowed,
|
||||
// Shallow copies since no mutation.
|
||||
Options: es.Options,
|
||||
// Copy mutable string slices.
|
||||
Add: make([]string, len(es.Add)),
|
||||
Remove: make([]string, len(es.Remove)),
|
||||
Value: make([]string, len(es.Value)),
|
||||
Default: make([]string, len(es.Default)),
|
||||
}
|
||||
copy(cpy.Add, es.Add)
|
||||
copy(cpy.Remove, es.Remove)
|
||||
copy(cpy.Value, es.Value)
|
||||
copy(cpy.Default, es.Default)
|
||||
return cpy
|
||||
}
|
||||
|
||||
func (ep *EditableProjects) clone() EditableProjects {
|
||||
return EditableProjects{
|
||||
EditableSlice: ep.EditableSlice.clone(),
|
||||
ProjectItems: ep.ProjectItems,
|
||||
}
|
||||
}
|
||||
|
||||
func EditFieldsSurvey(editable *Editable, editorCommand string) error {
|
||||
var err error
|
||||
if editable.Title.Edited {
|
||||
|
|
@ -395,6 +449,7 @@ func multiSelectSurvey(message string, defaults, options []string) ([]string, er
|
|||
Message: message,
|
||||
Options: options,
|
||||
Default: defaults,
|
||||
Filter: prompter.LatinMatchingFilter,
|
||||
}
|
||||
err := survey.AskOne(q, &results)
|
||||
return results, err
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func UpdateIssue(httpClient *http.Client, repo ghrepo.Interface, id string, isPR
|
|||
}
|
||||
}
|
||||
|
||||
// updateIssue mutation does not support ProjectsV2 so do them in a seperate request.
|
||||
// updateIssue mutation does not support ProjectsV2 so do them in a separate request.
|
||||
if options.Projects.Edited {
|
||||
wg.Go(func() error {
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package shared
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
|
@ -199,6 +200,24 @@ func (m *templateManager) Choose() (Template, error) {
|
|||
return m.templates[selectedOption], nil
|
||||
}
|
||||
|
||||
func (m *templateManager) Select(name string) (Template, error) {
|
||||
if err := m.memoizedFetch(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(m.templates) == 0 {
|
||||
return nil, errors.New("no templates found")
|
||||
}
|
||||
|
||||
for _, t := range m.templates {
|
||||
if t.Name() == name {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("template %q not found", name)
|
||||
}
|
||||
|
||||
func (m *templateManager) memoizedFetch() error {
|
||||
if m.didFetch {
|
||||
return m.fetchError
|
||||
|
|
|
|||
|
|
@ -113,3 +113,111 @@ func TestTemplateManager_hasAPI_PullRequest(t *testing.T) {
|
|||
assert.Equal(t, "", tpl.NameForSubmit())
|
||||
assert.Equal(t, "I fixed a problem", string(tpl.Body()))
|
||||
}
|
||||
|
||||
func TestTemplateManagerSelect(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
isPR bool
|
||||
templateName string
|
||||
wantTemplate Template
|
||||
wantErr bool
|
||||
errMsg string
|
||||
httpStubs func(*httpmock.Registry)
|
||||
}{
|
||||
{
|
||||
name: "no templates found",
|
||||
templateName: "Bug report",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`{"data":{"repository":{"issueTemplates":[]}}}`),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "no templates found",
|
||||
},
|
||||
{
|
||||
name: "no matching templates found",
|
||||
templateName: "Unknown report",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "issueTemplates": [
|
||||
{ "name": "Bug report", "body": "I found a problem" },
|
||||
{ "name": "Feature request", "body": "I need a feature" }
|
||||
] } } }`),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: `template "Unknown report" not found`,
|
||||
},
|
||||
{
|
||||
name: "matching issue template found",
|
||||
templateName: "Bug report",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query IssueTemplates\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "issueTemplates": [
|
||||
{ "name": "Bug report", "body": "I found a problem" },
|
||||
{ "name": "Feature request", "body": "I need a feature" }
|
||||
] } } }`),
|
||||
)
|
||||
},
|
||||
wantTemplate: &issueTemplate{
|
||||
Gname: "Bug report",
|
||||
Gbody: "I found a problem",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "matching pull request template found",
|
||||
isPR: true,
|
||||
templateName: "feature.md",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query PullRequestTemplates\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": { "PullRequestTemplates": [
|
||||
{ "filename": "bug.md", "body": "I fixed a problem" },
|
||||
{ "filename": "feature.md", "body": "I made a feature" }
|
||||
] } } }`),
|
||||
)
|
||||
},
|
||||
wantTemplate: &pullRequestTemplate{
|
||||
Gname: "feature.md",
|
||||
Gbody: "I made a feature",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
|
||||
m := templateManager{
|
||||
repo: ghrepo.NewWithHost("OWNER", "REPO", "example.com"),
|
||||
allowFS: false,
|
||||
isPR: tt.isPR,
|
||||
httpClient: &http.Client{Transport: reg},
|
||||
detector: &fd.EnabledDetectorMock{},
|
||||
}
|
||||
|
||||
tmpl, err := m.Select(tt.templateName)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
if tt.wantTemplate != nil {
|
||||
assert.Equal(t, tt.wantTemplate.Name(), tmpl.Name())
|
||||
assert.Equal(t, tt.wantTemplate.Body(), tmpl.Body())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -177,6 +177,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
cmdutil.NilBoolFlag(cmd, &opts.IsLatest, "latest", "", "Mark this release as \"Latest\" (default: automatic based on date and version)")
|
||||
cmd.Flags().BoolVarP(&opts.VerifyTag, "verify-tag", "", false, "Abort in case the git tag doesn't already exist in the remote repository")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringVar(&opts.TagName, "tag", "", "The name of the tag")
|
||||
cmd.Flags().StringVarP(¬esFile, "notes-file", "F", "", "Read release notes from `file` (use \"-\" to read from standard input)")
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "target")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,20 @@
|
|||
package create
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
|
|
@ -21,6 +25,10 @@ import (
|
|||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type errWithExitCode interface {
|
||||
ExitCode() int
|
||||
}
|
||||
|
||||
type iprompter interface {
|
||||
Input(string, string) (string, error)
|
||||
Select(string, string, []string) (int, error)
|
||||
|
|
@ -33,6 +41,7 @@ type CreateOptions struct {
|
|||
Config func() (config.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
Prompter iprompter
|
||||
BackOff backoff.BackOff
|
||||
|
||||
Name string
|
||||
Description string
|
||||
|
|
@ -387,17 +396,12 @@ func createFromScratch(opts *CreateOptions) error {
|
|||
|
||||
remoteURL := ghrepo.FormatRemoteURL(repo, protocol)
|
||||
|
||||
if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" {
|
||||
if opts.LicenseTemplate == "" && opts.GitIgnoreTemplate == "" && opts.Template == "" {
|
||||
// cloning empty repository or template
|
||||
checkoutBranch := ""
|
||||
if opts.Template != "" {
|
||||
// use the template's default branch
|
||||
checkoutBranch = templateRepoMainBranch
|
||||
}
|
||||
if err := localInit(opts.GitClient, remoteURL, repo.RepoName(), checkoutBranch); err != nil {
|
||||
if err := localInit(opts.GitClient, remoteURL, repo.RepoName()); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if _, err := opts.GitClient.Clone(context.Background(), remoteURL, []string{}); err != nil {
|
||||
} else if err := cloneWithRetry(opts, remoteURL, templateRepoMainBranch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
|
@ -563,6 +567,33 @@ func createFromLocal(opts *CreateOptions) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func cloneWithRetry(opts *CreateOptions, remoteURL, branch string) error {
|
||||
// Allow injecting alternative BackOff in tests.
|
||||
if opts.BackOff == nil {
|
||||
opts.BackOff = backoff.NewConstantBackOff(3 * time.Second)
|
||||
}
|
||||
|
||||
var args []string
|
||||
if branch != "" {
|
||||
args = append(args, "--branch", branch)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
return backoff.Retry(func() error {
|
||||
stderr := &bytes.Buffer{}
|
||||
_, err := opts.GitClient.Clone(ctx, remoteURL, args, git.WithStderr(stderr))
|
||||
|
||||
var execError errWithExitCode
|
||||
if errors.As(err, &execError) && execError.ExitCode() == 128 {
|
||||
return err
|
||||
} else {
|
||||
_, _ = io.Copy(opts.IO.ErrOut, stderr)
|
||||
}
|
||||
|
||||
return backoff.Permanent(err)
|
||||
}, backoff.WithContext(backoff.WithMaxRetries(opts.BackOff, 3), ctx))
|
||||
}
|
||||
|
||||
func sourceInit(gitClient *git.Client, io *iostreams.IOStreams, remoteURL, baseRemote string) error {
|
||||
cs := io.ColorScheme()
|
||||
remoteAdd, err := gitClient.Command(context.Background(), "remote", "add", baseRemote, remoteURL)
|
||||
|
|
@ -620,7 +651,7 @@ func isLocalRepo(gitClient *git.Client) (bool, error) {
|
|||
}
|
||||
|
||||
// clone the checkout branch to specified path
|
||||
func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) error {
|
||||
func localInit(gitClient *git.Client, remoteURL, path string) error {
|
||||
ctx := context.Background()
|
||||
gitInit, err := gitClient.Command(ctx, "init", path)
|
||||
if err != nil {
|
||||
|
|
@ -644,17 +675,7 @@ func localInit(gitClient *git.Client, remoteURL, path, checkoutBranch string) er
|
|||
return err
|
||||
}
|
||||
|
||||
if checkoutBranch == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
refspec := fmt.Sprintf("+refs/heads/%[1]s:refs/remotes/origin/%[1]s", checkoutBranch)
|
||||
err = gc.Fetch(ctx, "origin", refspec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return gc.CheckoutBranch(ctx, checkoutBranch)
|
||||
return nil
|
||||
}
|
||||
|
||||
func interactiveGitIgnore(client *http.Client, hostname string, prompter iprompter) (string, error) {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/cli/cli/v2/git"
|
||||
"github.com/cli/cli/v2/internal/config"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
|
|
@ -119,6 +120,18 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
IncludeAllBranches: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "template with .gitignore",
|
||||
cli: "template-repo --template mytemplate --gitignore ../.gitignore --public",
|
||||
wantsErr: true,
|
||||
errMsg: ".gitignore and license templates are not added when template is provided",
|
||||
},
|
||||
{
|
||||
name: "template with license",
|
||||
cli: "template-repo --template mytemplate --license ../.license --public",
|
||||
wantsErr: true,
|
||||
errMsg: ".gitignore and license templates are not added when template is provided",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -557,6 +570,98 @@ func Test_createRun(t *testing.T) {
|
|||
},
|
||||
wantStdout: "https://github.com/OWNER/REPO\n",
|
||||
},
|
||||
{
|
||||
name: "noninteractive clone from scratch",
|
||||
opts: &CreateOptions{
|
||||
Interactive: false,
|
||||
Name: "REPO",
|
||||
Visibility: "PRIVATE",
|
||||
Clone: true,
|
||||
},
|
||||
tty: false,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation RepositoryCreate\b`),
|
||||
httpmock.StringResponse(`
|
||||
{
|
||||
"data": {
|
||||
"createRepository": {
|
||||
"repository": {
|
||||
"id": "REPOID",
|
||||
"name": "REPO",
|
||||
"owner": {"login":"OWNER"},
|
||||
"url": "https://github.com/OWNER/REPO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`))
|
||||
},
|
||||
execStubs: func(cs *run.CommandStubber) {
|
||||
cs.Register(`git init REPO`, 0, "")
|
||||
cs.Register(`git -C REPO remote add origin https://github.com/OWNER/REPO`, 0, "")
|
||||
},
|
||||
wantStdout: "https://github.com/OWNER/REPO\n",
|
||||
},
|
||||
{
|
||||
name: "noninteractive create from template with retry",
|
||||
opts: &CreateOptions{
|
||||
Interactive: false,
|
||||
Name: "REPO",
|
||||
Visibility: "PRIVATE",
|
||||
Clone: true,
|
||||
Template: "mytemplate",
|
||||
BackOff: &backoff.ZeroBackOff{},
|
||||
},
|
||||
tty: false,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
// Test resolving repo owner from repo name only.
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"OWNER"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryInfo\b`),
|
||||
httpmock.GraphQLQuery(`{
|
||||
"data": {
|
||||
"repository": {
|
||||
"id": "REPOID",
|
||||
"defaultBranchRef": {
|
||||
"name": "main"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, func(s string, m map[string]interface{}) {
|
||||
assert.Equal(t, "OWNER", m["owner"])
|
||||
assert.Equal(t, "mytemplate", m["name"])
|
||||
}),
|
||||
)
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"id":"OWNERID"}}}`))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`mutation CloneTemplateRepository\b`),
|
||||
httpmock.GraphQLMutation(`
|
||||
{
|
||||
"data": {
|
||||
"cloneTemplateRepository": {
|
||||
"repository": {
|
||||
"id": "REPOID",
|
||||
"name": "REPO",
|
||||
"owner": {"login":"OWNER"},
|
||||
"url": "https://github.com/OWNER/REPO"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`, func(m map[string]interface{}) {
|
||||
assert.Equal(t, "REPOID", m["repositoryId"])
|
||||
}))
|
||||
},
|
||||
execStubs: func(cs *run.CommandStubber) {
|
||||
// fatal: Remote branch main not found in upstream origin
|
||||
cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 128, "")
|
||||
cs.Register(`git clone --branch main https://github.com/OWNER/REPO`, 0, "")
|
||||
},
|
||||
wantStdout: "https://github.com/OWNER/REPO\n",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
prompterMock := &prompter.PrompterMock{}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import (
|
|||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
ghAuth "github.com/cli/go-gh/pkg/auth"
|
||||
ghAuth "github.com/cli/go-gh/v2/pkg/auth"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -114,7 +114,7 @@ func deleteRun(opts *DeleteOptions) error {
|
|||
statusCode == http.StatusTemporaryRedirect ||
|
||||
statusCode == http.StatusPermanentRedirect {
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Failed to delete repository: %s has changed name or transfered ownership\n", cs.FailureIcon(), fullName)
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Failed to delete repository: %s has changed name or transferred ownership\n", cs.FailureIcon(), fullName)
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -157,11 +157,11 @@ func Test_deleteRun(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "repo transfered ownership",
|
||||
name: "repo transferred ownership",
|
||||
opts: &DeleteOptions{RepoArg: "OWNER/REPO", Confirmed: true},
|
||||
wantErr: true,
|
||||
errMsg: "SilentError",
|
||||
wantStderr: "X Failed to delete repository: OWNER/REPO has changed name or transfered ownership\n",
|
||||
wantStderr: "X Failed to delete repository: OWNER/REPO has changed name or transferred ownership\n",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", "repos/OWNER/REPO"),
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ func searchQuery(owner string, filter FilterOptions) string {
|
|||
Is: []string{filter.Visibility},
|
||||
Language: filter.Language,
|
||||
Topic: filter.Topic,
|
||||
User: owner,
|
||||
User: []string{owner},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -154,6 +154,10 @@ func listRun(opts *ListOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if opts.Owner != "" && listResult.Owner == "" && !listResult.FromSearch {
|
||||
return fmt.Errorf("the owner handle %q was not recognized as either a GitHub user or an organization", opts.Owner)
|
||||
}
|
||||
|
||||
if err := opts.IO.StartPager(); err != nil {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -452,3 +452,40 @@ func TestRepoList_noVisibilityField(t *testing.T) {
|
|||
assert.Equal(t, "", stderr.String())
|
||||
assert.Equal(t, "", stdout.String())
|
||||
}
|
||||
|
||||
func TestRepoList_invalidOwner(t *testing.T) {
|
||||
ios, _, stdout, stderr := iostreams.Test()
|
||||
ios.SetStdoutTTY(false)
|
||||
ios.SetStdinTTY(false)
|
||||
ios.SetStderrTTY(false)
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
defer reg.Verify(t)
|
||||
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query RepositoryList\b`),
|
||||
httpmock.StringResponse(`{ "data": { "repositoryOwner": null } }`),
|
||||
)
|
||||
|
||||
opts := ListOptions{
|
||||
Owner: "nonexist",
|
||||
IO: ios,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
Now: func() time.Time {
|
||||
t, _ := time.Parse(time.RFC822, "19 Feb 21 15:00 UTC")
|
||||
return t
|
||||
},
|
||||
Limit: 30,
|
||||
Detector: &fd.DisabledDetectorMock{},
|
||||
}
|
||||
|
||||
err := listRun(&opts)
|
||||
assert.EqualError(t, err, `the owner handle "nonexist" was not recognized as either a GitHub user or an organization`)
|
||||
assert.Equal(t, "", stderr.String())
|
||||
assert.Equal(t, "", stdout.String())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ func executeLocalRepoSync(srcRepo ghrepo.Interface, remote string, opts *SyncOpt
|
|||
}
|
||||
if currentBranch == branch {
|
||||
if isDirty, err := git.IsDirty(); err == nil && isDirty {
|
||||
return fmt.Errorf("can't sync because there are local changes; please stash them before trying again")
|
||||
return fmt.Errorf("refusing to sync due to uncommitted/untracked local changes\ntip: use `git stash --all` before retrying the sync and run `git stash pop` afterwards")
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ func Test_SyncRun(t *testing.T) {
|
|||
mgc.On("IsDirty").Return(true, nil).Once()
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "can't sync because there are local changes; please stash them before trying again",
|
||||
errMsg: "refusing to sync due to uncommitted/untracked local changes\ntip: use `git stash --all` before retrying the sync and run `git stash pop` afterwards",
|
||||
},
|
||||
{
|
||||
name: "sync local repo with parent - existing branch, non-current",
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ With '--branch', view a specific branch of the repository.`,
|
|||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository")
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -79,8 +79,11 @@ var HelpTopics = map[string]map[string]string{
|
|||
checks for new releases once every 24 hours and displays an upgrade notice on standard
|
||||
error if a newer version was found.
|
||||
|
||||
GH_CONFIG_DIR: the directory where gh will store configuration files. Default:
|
||||
"$XDG_CONFIG_HOME/gh" or "$HOME/.config/gh".
|
||||
GH_CONFIG_DIR: the directory where gh will store configuration files. If not specified,
|
||||
the default value will be one of the following paths (in order of precedence):
|
||||
- "$XDG_CONFIG_HOME/gh" (if $XDG_CONFIG_HOME is set),
|
||||
- "$AppData/GitHub CLI" (on Windows if $AppData is set), or
|
||||
- "$HOME/.config/gh".
|
||||
|
||||
GH_PROMPT_DISABLED: set to any value to disable interactive prompting in the terminal.
|
||||
|
||||
|
|
@ -108,8 +111,9 @@ var HelpTopics = map[string]map[string]string{
|
|||
The %[1]s--jq%[1]s flag requires a string argument in jq query syntax, and will only print
|
||||
those JSON values which match the query. jq queries can be used to select elements from an
|
||||
array, fields from an object, create a new array, and more. The jq utility does not need
|
||||
to be installed on the system to use this formatting directive.
|
||||
To learn about jq query syntax, see: <https://stedolan.github.io/jq/manual/v1.6/>
|
||||
to be installed on the system to use this formatting directive. When connected to a terminal,
|
||||
the output is automatically pretty-printed. To learn about jq query syntax, see:
|
||||
<https://stedolan.github.io/jq/manual/v1.6/>
|
||||
|
||||
The %[1]s--template%[1]s flag requires a string argument in Go template syntax, and will only print
|
||||
those JSON values which match the query.
|
||||
|
|
@ -171,7 +175,38 @@ var HelpTopics = map[string]map[string]string{
|
|||
codercat
|
||||
cli-maintainer
|
||||
|
||||
|
||||
# --jq can be used to implement more complex filtering and output changes:
|
||||
$ bin/gh issue list --json number,title,labels --jq \
|
||||
'map(select((.labels | length) > 0)) # must have labels
|
||||
| map(.labels = (.labels | map(.name))) # show only the label names
|
||||
| .[:3] # select the first 3 results'
|
||||
[
|
||||
{
|
||||
"labels": [
|
||||
"enhancement",
|
||||
"needs triage"
|
||||
],
|
||||
"number": 123,
|
||||
"title": "A helpful contribution"
|
||||
},
|
||||
{
|
||||
"labels": [
|
||||
"help wanted",
|
||||
"docs",
|
||||
"good first issue"
|
||||
],
|
||||
"number": 125,
|
||||
"title": "Improve the docs"
|
||||
},
|
||||
{
|
||||
"labels": [
|
||||
"enhancement",
|
||||
],
|
||||
"number": 7221,
|
||||
"title": "An exciting new feature"
|
||||
}
|
||||
]
|
||||
|
||||
# using the --template flag with the hyperlink helper
|
||||
gh issue list --json title,url --template '{{range .}}{{hyperlink .url .title}}{{"\n"}}{{end}}'
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import (
|
|||
gpgKeyCmd "github.com/cli/cli/v2/pkg/cmd/gpg-key"
|
||||
issueCmd "github.com/cli/cli/v2/pkg/cmd/issue"
|
||||
labelCmd "github.com/cli/cli/v2/pkg/cmd/label"
|
||||
orgCmd "github.com/cli/cli/v2/pkg/cmd/org"
|
||||
prCmd "github.com/cli/cli/v2/pkg/cmd/pr"
|
||||
releaseCmd "github.com/cli/cli/v2/pkg/cmd/release"
|
||||
repoCmd "github.com/cli/cli/v2/pkg/cmd/repo"
|
||||
|
|
@ -115,6 +116,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
|
|||
|
||||
cmd.AddCommand(browseCmd.NewCmdBrowse(&repoResolvingCmdFactory, nil))
|
||||
cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(orgCmd.NewCmdOrg(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(releaseCmd.NewCmdRelease(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory))
|
||||
|
|
|
|||
|
|
@ -95,7 +95,7 @@ func runCancel(opts *CancelOptions) error {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
run, err = shared.GetRun(client, repo, runID)
|
||||
run, err = shared.GetRun(client, repo, runID, 0)
|
||||
if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
|
|
|
|||
147
pkg/cmd/run/delete/delete.go
Normal file
147
pkg/cmd/run/delete/delete.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package delete
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/v2/api"
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/cmd/run/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultRunGetLimit = 10
|
||||
)
|
||||
|
||||
type DeleteOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Prompter prompter.Prompter
|
||||
Prompt bool
|
||||
RunID string
|
||||
}
|
||||
|
||||
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
|
||||
opts := &DeleteOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Prompter: f.Prompter,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete [<run-id>]",
|
||||
Short: "Delete a workflow run",
|
||||
Example: heredoc.Doc(`
|
||||
# Interactively select a run to delete
|
||||
$ gh run delete
|
||||
|
||||
# Delete a specific run
|
||||
$ gh run delete 12345
|
||||
`),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if len(args) > 0 {
|
||||
opts.RunID = args[0]
|
||||
} else if !opts.IO.CanPrompt() {
|
||||
return cmdutil.FlagErrorf("run ID required when not running interactively")
|
||||
} else {
|
||||
opts.Prompt = true
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return runDelete(opts)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(opts *DeleteOptions) error {
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
repo, err := opts.BaseRepo()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to determine base repo: %w", err)
|
||||
}
|
||||
|
||||
runID := opts.RunID
|
||||
var run *shared.Run
|
||||
|
||||
if opts.Prompt {
|
||||
payload, err := shared.GetRuns(client, repo, nil, defaultRunGetLimit)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get runs: %w", err)
|
||||
}
|
||||
|
||||
runs := payload.WorkflowRuns
|
||||
if len(runs) == 0 {
|
||||
return fmt.Errorf("found no runs to delete")
|
||||
}
|
||||
|
||||
runID, err = shared.SelectRun(opts.Prompter, cs, runs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range runs {
|
||||
if fmt.Sprintf("%d", r.ID) == runID {
|
||||
run = &r
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
run, err = shared.GetRun(client, repo, runID, 0)
|
||||
if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
if httpErr.StatusCode == http.StatusNotFound {
|
||||
err = fmt.Errorf("could not find any workflow run with ID %s", opts.RunID)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = deleteWorkflowRun(client, repo, fmt.Sprintf("%d", run.ID))
|
||||
if err != nil {
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) {
|
||||
if httpErr.StatusCode == http.StatusConflict {
|
||||
err = fmt.Errorf("cannot delete a workflow run that is completed")
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.Out, "%s Request to delete workflow submitted.\n", cs.SuccessIcon())
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteWorkflowRun(client *api.Client, repo ghrepo.Interface, runID string) error {
|
||||
path := fmt.Sprintf("repos/%s/actions/runs/%s", ghrepo.FullName(repo), runID)
|
||||
err := client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
200
pkg/cmd/run/delete/delete_test.go
Normal file
200
pkg/cmd/run/delete/delete_test.go
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
package delete
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/v2/internal/ghrepo"
|
||||
"github.com/cli/cli/v2/internal/prompter"
|
||||
"github.com/cli/cli/v2/pkg/cmd/run/shared"
|
||||
workflowShared "github.com/cli/cli/v2/pkg/cmd/workflow/shared"
|
||||
"github.com/cli/cli/v2/pkg/cmdutil"
|
||||
"github.com/cli/cli/v2/pkg/httpmock"
|
||||
"github.com/cli/cli/v2/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
tty bool
|
||||
wants DeleteOptions
|
||||
wantsErr bool
|
||||
prompterStubs func(*prompter.PrompterMock)
|
||||
}{
|
||||
{
|
||||
name: "blank tty",
|
||||
tty: true,
|
||||
wants: DeleteOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "blank nontty",
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "with arg",
|
||||
cli: "1234",
|
||||
wants: DeleteOptions{
|
||||
RunID: "1234",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ios, _, _, _ := iostreams.Test()
|
||||
ios.SetStdinTTY(tt.tty)
|
||||
ios.SetStdoutTTY(tt.tty)
|
||||
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: ios,
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *DeleteOptions
|
||||
cmd := NewCmdDelete(f, func(opts *DeleteOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wants.RunID, gotOpts.RunID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *DeleteOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
prompterStubs func(*prompter.PrompterMock)
|
||||
wantErr bool
|
||||
wantOut string
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "delete run",
|
||||
opts: &DeleteOptions{
|
||||
RunID: "1234",
|
||||
},
|
||||
wantErr: false,
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
|
||||
httpmock.JSONResponse(shared.SuccessfulRun))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows/123"),
|
||||
httpmock.JSONResponse(shared.TestWorkflow))
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)),
|
||||
httpmock.StatusStringResponse(204, ""))
|
||||
},
|
||||
wantOut: "✓ Request to delete workflow submitted.\n",
|
||||
},
|
||||
{
|
||||
name: "not found",
|
||||
opts: &DeleteOptions{
|
||||
RunID: "1234",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "could not find any workflow run with ID 1234",
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs/1234"),
|
||||
httpmock.StatusStringResponse(404, ""))
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "prompt",
|
||||
opts: &DeleteOptions{
|
||||
Prompt: true,
|
||||
},
|
||||
prompterStubs: func(pm *prompter.PrompterMock) {
|
||||
pm.SelectFunc = func(_, _ string, opts []string) (int, error) {
|
||||
return prompter.IndexFor(opts, "✓ cool commit, CI (trunk) Feb 23, 2021")
|
||||
}
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/runs"),
|
||||
httpmock.JSONResponse(shared.RunsPayload{
|
||||
TotalCount: 0,
|
||||
WorkflowRuns: []shared.Run{shared.SuccessfulRun},
|
||||
}))
|
||||
reg.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/actions/workflows"),
|
||||
httpmock.JSONResponse(
|
||||
workflowShared.WorkflowsPayload{
|
||||
Workflows: []workflowShared.Workflow{shared.TestWorkflow},
|
||||
},
|
||||
))
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", fmt.Sprintf("repos/OWNER/REPO/actions/runs/%d", shared.SuccessfulRun.ID)),
|
||||
httpmock.StatusStringResponse(204, ""))
|
||||
},
|
||||
wantOut: "✓ Request to delete workflow submitted.\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("OWNER/REPO")
|
||||
}
|
||||
|
||||
// mock http
|
||||
reg := &httpmock.Registry{}
|
||||
tt.httpStubs(reg)
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
|
||||
// mock IO
|
||||
ios, _, stdout, _ := iostreams.Test()
|
||||
ios.SetStdoutTTY(true)
|
||||
ios.SetStdinTTY(true)
|
||||
tt.opts.IO = ios
|
||||
|
||||
// mock prompter
|
||||
pm := &prompter.PrompterMock{}
|
||||
if tt.prompterStubs != nil {
|
||||
tt.prompterStubs(pm)
|
||||
}
|
||||
tt.opts.Prompter = pm
|
||||
|
||||
// execute test
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := runDelete(tt.opts)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.errMsg != "" {
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,9 @@ type ListOptions struct {
|
|||
WorkflowSelector string
|
||||
Branch string
|
||||
Actor string
|
||||
Status string
|
||||
Event string
|
||||
Created string
|
||||
|
||||
now time.Time
|
||||
}
|
||||
|
|
@ -67,8 +70,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd.Flags().StringVarP(&opts.WorkflowSelector, "workflow", "w", "", "Filter runs by workflow")
|
||||
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "Filter runs by branch")
|
||||
cmd.Flags().StringVarP(&opts.Actor, "user", "u", "", "Filter runs by user who triggered the run")
|
||||
cmd.Flags().StringVarP(&opts.Event, "event", "e", "", "Filter runs by which `event` triggered the run")
|
||||
cmd.Flags().StringVarP(&opts.Created, "created", "", "", "Filter runs by the `date` it was created")
|
||||
cmdutil.StringEnumFlag(cmd, &opts.Status, "status", "s", "", shared.AllStatuses, "Filter runs by status")
|
||||
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.RunFields)
|
||||
|
||||
_ = cmdutil.RegisterBranchCompletionFlags(f.GitClient, cmd, "branch")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
@ -85,8 +93,11 @@ func listRun(opts *ListOptions) error {
|
|||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
filters := &shared.FilterOptions{
|
||||
Branch: opts.Branch,
|
||||
Actor: opts.Actor,
|
||||
Branch: opts.Branch,
|
||||
Actor: opts.Actor,
|
||||
Status: opts.Status,
|
||||
Event: opts.Event,
|
||||
Created: opts.Created,
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
|
|
|
|||
|
|
@ -69,6 +69,30 @@ func TestNewCmdList(t *testing.T) {
|
|||
Actor: "bak1an",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
cli: "--status completed",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Status: "completed",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "event",
|
||||
cli: "--event push",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Event: "push",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "created",
|
||||
cli: "--created >=2023-04-24",
|
||||
wants: ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Created: ">=2023-04-24",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -104,6 +128,9 @@ func TestNewCmdList(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.WorkflowSelector, gotOpts.WorkflowSelector)
|
||||
assert.Equal(t, tt.wants.Branch, gotOpts.Branch)
|
||||
assert.Equal(t, tt.wants.Actor, gotOpts.Actor)
|
||||
assert.Equal(t, tt.wants.Status, gotOpts.Status)
|
||||
assert.Equal(t, tt.wants.Event, gotOpts.Event)
|
||||
assert.Equal(t, tt.wants.Created, gotOpts.Created)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -115,6 +142,7 @@ func TestListRun(t *testing.T) {
|
|||
wantErr bool
|
||||
wantOut string
|
||||
wantErrOut string
|
||||
wantErrMsg string
|
||||
stubs func(*httpmock.Registry)
|
||||
isTTY bool
|
||||
}{
|
||||
|
|
@ -335,7 +363,8 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "workflow selector",
|
||||
|
|
@ -376,7 +405,8 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "actor filter applied",
|
||||
|
|
@ -392,7 +422,62 @@ func TestListRun(t *testing.T) {
|
|||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "status filter applied",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Status: "queued",
|
||||
},
|
||||
isTTY: true,
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
|
||||
"status": []string{"queued"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "event filter applied",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Event: "push",
|
||||
},
|
||||
isTTY: true,
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
|
||||
"event": []string{"push"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
{
|
||||
name: "created filter applied",
|
||||
opts: &ListOptions{
|
||||
Limit: defaultLimit,
|
||||
Created: ">=2023-04-24",
|
||||
},
|
||||
isTTY: true,
|
||||
stubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(
|
||||
httpmock.QueryMatcher("GET", "repos/OWNER/REPO/actions/runs", url.Values{
|
||||
"created": []string{">=2023-04-24"},
|
||||
}),
|
||||
httpmock.JSONResponse(shared.RunsPayload{}),
|
||||
)
|
||||
},
|
||||
wantErr: true,
|
||||
wantErrMsg: "no runs found",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -416,6 +501,7 @@ func TestListRun(t *testing.T) {
|
|||
err := listRun(tt.opts)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.wantErrMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ func runRerun(opts *RerunOptions) error {
|
|||
}
|
||||
} else {
|
||||
opts.IO.StartProgressIndicator()
|
||||
run, err := shared.GetRun(client, repo, runID)
|
||||
run, err := shared.GetRun(client, repo, runID, 0)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get run: %w", err)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package run
|
|||
|
||||
import (
|
||||
cmdCancel "github.com/cli/cli/v2/pkg/cmd/run/cancel"
|
||||
cmdDelete "github.com/cli/cli/v2/pkg/cmd/run/delete"
|
||||
cmdDownload "github.com/cli/cli/v2/pkg/cmd/run/download"
|
||||
cmdList "github.com/cli/cli/v2/pkg/cmd/run/list"
|
||||
cmdRerun "github.com/cli/cli/v2/pkg/cmd/run/rerun"
|
||||
|
|
@ -26,6 +27,7 @@ func NewCmdRun(f *cmdutil.Factory) *cobra.Command {
|
|||
cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil))
|
||||
cmd.AddCommand(cmdWatch.NewCmdWatch(f, nil))
|
||||
cmd.AddCommand(cmdCancel.NewCmdCancel(f, nil))
|
||||
cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue