Merge remote-tracking branch 'origin' into pr-lookup-refactor

This commit is contained in:
Mislav Marohnić 2021-05-17 17:41:38 +02:00
commit bc3bb97c43
52 changed files with 1128 additions and 385 deletions

View file

@ -48,26 +48,26 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
GitHub CLI integrates with Actions to help you manage runs and workflows.
%s
gh run list: List recent workflow runs
gh run view: View details for a workflow run or one of its jobs
gh run watch: Watch a workflow run while it executes
gh run rerun: Rerun a failed workflow run
%s
gh run list: List recent workflow runs
gh run view: View details for a workflow run or one of its jobs
gh run watch: Watch a workflow run while it executes
gh run rerun: Rerun a failed workflow run
gh run download: Download artifacts generated by runs
To see more help, run 'gh help run <subcommand>'
%s
gh workflow list: List all the workflow files in your repository
gh workflow view: View details for a workflow file
gh workflow enable: Enable a workflow file
gh workflow disable: Disable a workflow file
%s
gh workflow list: List all the workflow files in your repository
gh workflow view: View details for a workflow file
gh workflow enable: Enable a workflow file
gh workflow disable: Disable a workflow file
gh workflow run: Trigger a workflow_dispatch run for a workflow file
To see more help, run 'gh help workflow <subcommand>'
For more in depth help including examples, see online documentation at:
https://docs.github.com/en/actions/guides/managing-github-actions-with-github-cli
<https://docs.github.com/en/actions/guides/managing-github-actions-with-github-cli>
`, header, runHeader, workflowHeader)
}

View file

@ -71,8 +71,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
The endpoint argument should either be a path of a GitHub API v3 endpoint, or
"graphql" to access the GitHub API v4.
Placeholder values ":owner", ":repo", and ":branch" in the endpoint argument will
get replaced with values from the repository of the current directory.
Placeholder values "{owner}", "{repo}", and "{branch}" in the endpoint argument will
get replaced with values from the repository of the current directory. Note that in
some shells, for example PowerShell, you may need to enclose any value that contains
"{...}" in quotes to prevent the shell from applying special meaning to curly braces.
The default HTTP request method is "GET" normally and "POST" if any parameters
were added. Override the method with %[1]s--method%[1]s.
@ -87,7 +89,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
- literal values "true", "false", "null", and integer numbers get converted to
appropriate JSON types;
- placeholder values ":owner", ":repo", and ":branch" get populated with values
- placeholder values "{owner}", "{repo}", and "{branch}" get populated with values
from the repository of the current directory;
- if the value starts with "@", the rest of the value is interpreted as a
filename to read the value from. Pass "-" to read from standard input.
@ -106,10 +108,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
`, "`"),
Example: heredoc.Doc(`
# list releases in the current repository
$ gh api repos/:owner/:repo/releases
$ gh api repos/{owner}/{repo}/releases
# post an issue comment
$ gh api repos/:owner/:repo/issues/123/comments -f body='Hi from CLI'
$ gh api repos/{owner}/{repo}/issues/123/comments -f body='Hi from CLI'
# add parameters to a GET request
$ gh api -X GET search/issues -f q='repo:cli/cli is:open remote'
@ -121,14 +123,14 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
$ gh api --preview baptiste,nebula ...
# print only specific fields from the response
$ gh api repos/:owner/:repo/issues --jq '.[].title'
$ gh api repos/{owner}/{repo}/issues --jq '.[].title'
# use a template for the output
$ gh api repos/:owner/:repo/issues --template \
$ gh api repos/{owner}/{repo}/issues --template \
'{{range .}}{{.title}} ({{.labels | pluck "name" | join ", " | color "yellow"}}){{"\n"}}{{end}}'
# list releases with GraphQL
$ gh api graphql -F owner=':owner' -F name=':repo' -f query='
$ gh api graphql -F owner='{owner}' -F name='{repo}' -f query='
query($name: String!, $owner: String!) {
repository(owner: $owner, name: $name) {
releases(last: 3) {
@ -397,41 +399,41 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
return
}
var placeholderRE = regexp.MustCompile(`\:(owner|repo|branch)\b`)
var placeholderRE = regexp.MustCompile(`(\:(owner|repo|branch)\b|\{[a-z]+\})`)
// fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository
// fillPlaceholders replaces placeholders with values from the current repository
func fillPlaceholders(value string, opts *ApiOptions) (string, error) {
if !placeholderRE.MatchString(value) {
return value, nil
}
var err error
return placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
var name string
if m[0] == ':' {
name = m[1:]
} else {
name = m[1 : len(m)-1]
}
baseRepo, err := opts.BaseRepo()
if err != nil {
return value, err
}
filled := placeholderRE.ReplaceAllStringFunc(value, func(m string) string {
switch m {
case ":owner":
return baseRepo.RepoOwner()
case ":repo":
return baseRepo.RepoName()
case ":branch":
branch, e := opts.Branch()
if e != nil {
switch name {
case "owner":
if baseRepo, e := opts.BaseRepo(); e == nil {
return baseRepo.RepoOwner()
} else {
err = e
}
case "repo":
if baseRepo, e := opts.BaseRepo(); e == nil {
return baseRepo.RepoName()
} else {
err = e
}
case "branch":
if branch, e := opts.Branch(); e == nil {
return branch
} else {
err = e
}
return branch
default:
panic(fmt.Sprintf("invalid placeholder: %q", m))
}
})
if err != nil {
return value, err
}
return filled, nil
return m
}), err
}
func printHeaders(w io.Writer, headers http.Header, colorize bool) {

View file

@ -693,6 +693,9 @@ func Test_apiRun_inputFile(t *testing.T) {
contentLength: 10,
},
}
tempDir := t.TempDir()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
@ -702,13 +705,12 @@ func Test_apiRun_inputFile(t *testing.T) {
if tt.inputFile == "-" {
_, _ = stdin.Write(tt.inputContents)
} else {
f, err := ioutil.TempFile("", tt.inputFile)
f, err := ioutil.TempFile(tempDir, tt.inputFile)
if err != nil {
t.Fatal(err)
}
_, _ = f.Write(tt.inputContents)
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
defer f.Close()
inputFile = f.Name()
}
@ -825,13 +827,13 @@ func Test_parseFields(t *testing.T) {
}
func Test_magicFieldValue(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
io, _, _, _ := iostreams.Test()
@ -870,7 +872,7 @@ func Test_magicFieldValue(t *testing.T) {
wantErr: false,
},
{
name: "placeholder",
name: "placeholder colon",
args: args{
v: ":owner",
opts: &ApiOptions{
@ -883,6 +885,20 @@ func Test_magicFieldValue(t *testing.T) {
want: "hubot",
wantErr: false,
},
{
name: "placeholder braces",
args: args{
v: "{owner}",
opts: &ApiOptions{
IO: io,
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "hubot",
wantErr: false,
},
{
name: "file",
args: args{
@ -918,13 +934,13 @@ func Test_magicFieldValue(t *testing.T) {
}
func Test_openUserFile(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
file, length, err := openUserFile(f.Name(), nil)
if err != nil {
@ -964,7 +980,7 @@ func Test_fillPlaceholders(t *testing.T) {
wantErr: false,
},
{
name: "has substitutes",
name: "has substitutes (colon)",
args: args{
value: "repos/:owner/:repo/releases",
opts: &ApiOptions{
@ -977,39 +993,96 @@ func Test_fillPlaceholders(t *testing.T) {
wantErr: false,
},
{
name: "has branch placeholder",
name: "has branch placeholder (colon)",
args: args{
value: "repos/cli/cli/branches/:branch/protection/required_status_checks",
value: "repos/owner/repo/branches/:branch/protection/required_status_checks",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("cli", "cli"), nil
},
BaseRepo: nil,
Branch: func() (string, error) {
return "trunk", nil
},
},
},
want: "repos/cli/cli/branches/trunk/protection/required_status_checks",
want: "repos/owner/repo/branches/trunk/protection/required_status_checks",
wantErr: false,
},
{
name: "has branch placeholder and git is in detached head",
name: "has branch placeholder and git is in detached head (colon)",
args: args{
value: "repos/:owner/:repo/branches/:branch",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("cli", "cli"), nil
return ghrepo.New("hubot", "robot-uprising"), nil
},
Branch: func() (string, error) {
return "", git.ErrNotOnAnyBranch
},
},
},
want: "repos/:owner/:repo/branches/:branch",
want: "repos/hubot/robot-uprising/branches/:branch",
wantErr: true,
},
{
name: "no greedy substitutes",
name: "has substitutes",
args: args{
value: "repos/{owner}/{repo}/releases",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
},
},
want: "repos/hubot/robot-uprising/releases",
wantErr: false,
},
{
name: "has branch placeholder",
args: args{
value: "repos/owner/repo/branches/{branch}/protection/required_status_checks",
opts: &ApiOptions{
BaseRepo: nil,
Branch: func() (string, error) {
return "trunk", nil
},
},
},
want: "repos/owner/repo/branches/trunk/protection/required_status_checks",
wantErr: false,
},
{
name: "has branch placeholder and git is in detached head",
args: args{
value: "repos/{owner}/{repo}/branches/{branch}",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
Branch: func() (string, error) {
return "", git.ErrNotOnAnyBranch
},
},
},
want: "repos/hubot/robot-uprising/branches/{branch}",
wantErr: true,
},
{
name: "surfaces errors in earlier placeholders",
args: args{
value: "{branch}-{owner}",
opts: &ApiOptions{
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("hubot", "robot-uprising"), nil
},
Branch: func() (string, error) {
return "", git.ErrNotOnAnyBranch
},
},
},
want: "{branch}-hubot",
wantErr: true,
},
{
name: "no greedy substitutes (colon)",
args: args{
value: ":ownership/:repository",
opts: &ApiOptions{
@ -1019,6 +1092,17 @@ func Test_fillPlaceholders(t *testing.T) {
want: ":ownership/:repository",
wantErr: false,
},
{
name: "non-placeholders are left intact",
args: args{
value: "{}{ownership}/{repository}",
opts: &ApiOptions{
BaseRepo: nil,
},
},
want: "{}{ownership}/{repository}",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View file

@ -21,7 +21,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
When installing GitHub CLI through a package manager, it's possible that
no additional shell configuration is necessary to gain completion support. For
Homebrew, see https://docs.brew.sh/Shell-Completion
Homebrew, see <https://docs.brew.sh/Shell-Completion>
If you need to set up completions manually, follow the instructions below. The exact
config file locations might vary based on your system. Make sure to restart your

View file

@ -151,7 +151,13 @@ func createRun(opts *CreateOptions) error {
var httpError api.HTTPError
if errors.As(err, &httpError) {
if httpError.OAuthScopes != "" && !strings.Contains(httpError.OAuthScopes, "gist") {
return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate by doing `gh config set -h github.com oauth_token ''` and running the command again.")
return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h %s -s gist", host)
}
if httpError.StatusCode == http.StatusUnprocessableEntity {
if detectEmptyFiles(files) {
fmt.Fprintf(errOut, "%s Failed to create gist: %s\n", cs.FailureIcon(), "a gist file cannot be blank")
return cmdutil.SilentError
}
}
}
return fmt.Errorf("%s Failed to create gist: %w", cs.Red("X"), err)
@ -266,3 +272,12 @@ func createGist(client *http.Client, hostname, description string, public bool,
return &result, nil
}
func detectEmptyFiles(files map[string]*shared.GistFile) bool {
for _, file := range files {
if strings.TrimSpace(file.Content) == "" {
return true
}
}
return false
}

View file

@ -5,9 +5,11 @@ import (
"encoding/json"
"io/ioutil"
"net/http"
"path"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/gist/shared"
@ -18,10 +20,6 @@ import (
"github.com/stretchr/testify/assert"
)
const (
fixtureFile = "../fixture.txt"
)
func Test_processFiles(t *testing.T) {
fakeStdin := strings.NewReader("hey cool how is it going")
files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"})
@ -164,15 +162,22 @@ func TestNewCmdCreate(t *testing.T) {
}
func Test_createRun(t *testing.T) {
tempDir := t.TempDir()
fixtureFile := path.Join(tempDir, "fixture.txt")
assert.NoError(t, ioutil.WriteFile(fixtureFile, []byte("{}"), 0644))
emptyFile := path.Join(tempDir, "empty.txt")
assert.NoError(t, ioutil.WriteFile(emptyFile, []byte(" \t\n"), 0644))
tests := []struct {
name string
opts *CreateOptions
stdin string
wantOut string
wantStderr string
wantParams map[string]interface{}
wantErr bool
wantBrowse string
name string
opts *CreateOptions
stdin string
wantOut string
wantStderr string
wantParams map[string]interface{}
wantErr bool
wantBrowse string
responseStatus int
}{
{
name: "public",
@ -193,6 +198,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "with description",
@ -213,6 +219,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "multiple files",
@ -236,6 +243,28 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "file with empty content",
opts: &CreateOptions{
Filenames: []string{emptyFile},
},
wantOut: "",
wantStderr: heredoc.Doc(`
- Creating gist empty.txt
X Failed to create gist: a gist file cannot be blank
`),
wantErr: true,
wantParams: map[string]interface{}{
"description": "",
"updated_at": "0001-01-01T00:00:00Z",
"public": false,
"files": map[string]interface{}{
"empty.txt": map[string]interface{}{"content": " \t\n"},
},
},
responseStatus: http.StatusUnprocessableEntity,
},
{
name: "stdin arg",
@ -256,6 +285,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "web arg",
@ -277,14 +307,22 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"),
httpmock.JSONResponse(struct {
Html_url string
}{"https://gist.github.com/aa5a315d61ae9438b18d"}))
if tt.responseStatus == http.StatusOK {
reg.Register(
httpmock.REST("POST", "gists"),
httpmock.StringResponse(`{
"html_url": "https://gist.github.com/aa5a315d61ae9438b18d"
}`))
} else {
reg.Register(
httpmock.REST("POST", "gists"),
httpmock.StatusStringResponse(tt.responseStatus, "{}"))
}
mockClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
@ -325,6 +363,32 @@ func Test_createRun(t *testing.T) {
}
}
func Test_detectEmptyFiles(t *testing.T) {
tests := []struct {
content string
isEmptyFile bool
}{
{
content: "{}",
isEmptyFile: false,
},
{
content: "\n\t",
isEmptyFile: true,
},
}
for _, tt := range tests {
files := map[string]*shared.GistFile{}
files["file"] = &shared.GistFile{
Content: tt.content,
}
isEmptyFile := detectEmptyFiles(files)
assert.Equal(t, tt.isEmptyFile, isEmptyFile)
}
}
func Test_CreateRun_reauth(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"), func(req *http.Request) (*http.Response, error) {
@ -332,33 +396,24 @@ func Test_CreateRun_reauth(t *testing.T) {
StatusCode: 404,
Request: req,
Header: map[string][]string{
"X-Oauth-Scopes": {"coolScope"},
"X-Oauth-Scopes": {"repo, read:org"},
},
Body: ioutil.NopCloser(bytes.NewBufferString("oh no")),
}, nil
})
mockClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, _, _ := iostreams.Test()
opts := &CreateOptions{
IO: io,
HttpClient: mockClient,
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
Filenames: []string{fixtureFile},
}
err := createRun(opts)
if err == nil {
t.Fatalf("expected oauth error")
}
if !strings.Contains(err.Error(), "Please re-authenticate") {
t.Errorf("got unexpected error: %s", err)
}
assert.EqualError(t, err, "This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h github.com -s gist")
}

View file

@ -1 +0,0 @@
{}

View file

@ -20,7 +20,7 @@ type GistFile struct {
Filename string `json:"filename,omitempty"`
Type string `json:"type,omitempty"`
Language string `json:"language,omitempty"`
Content string `json:"content,omitempty"`
Content string `json:"content"`
}
type GistOwner struct {

View file

@ -65,7 +65,7 @@ func closeRun(opts *CloseOptions) error {
return err
}
if issue.Closed {
if issue.State == "CLOSED" {
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", cs.Yellow("!"), issue.Number, issue.Title)
return nil
}

View file

@ -96,7 +96,7 @@ func TestIssueClose_alreadyClosed(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
"issue": { "number": 13, "title": "The title of the issue", "state": "CLOSED"}
} } }`),
)

View file

@ -6,7 +6,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
@ -422,8 +421,9 @@ func TestIssueCreate_recover(t *testing.T) {
},
})
tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*")
assert.NoError(t, err)
defer tmpfile.Close()
state := prShared.IssueMetadataState{
Title: "recovered title",

View file

@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
data := api.ExportIssues(listResult.Issues, opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, listResult.Issues, opts.IO.ColorEnabled())
}
if isTerminal {

View file

@ -65,7 +65,7 @@ func reopenRun(opts *ReopenOptions) error {
return err
}
if !issue.Closed {
if issue.State == "OPEN" {
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", cs.Yellow("!"), issue.Number, issue.Title)
return nil
}

View file

@ -64,7 +64,7 @@ func TestIssueReopen(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 2, "closed": true, "title": "The title of the issue"}
"issue": { "id": "THE-ID", "number": 2, "state": "CLOSED", "title": "The title of the issue"}
} } }`),
)
http.Register(
@ -96,7 +96,7 @@ func TestIssueReopen_alreadyOpen(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 2, "closed": false, "title": "The title of the issue"}
"issue": { "number": 2, "state": "OPEN", "title": "The title of the issue"}
} } }`),
)

View file

@ -96,11 +96,11 @@ func statusRun(opts *StatusOptions) error {
if opts.Exporter != nil {
data := map[string]interface{}{
"createdBy": api.ExportIssues(issuePayload.Authored.Issues, opts.Exporter.Fields()),
"assigned": api.ExportIssues(issuePayload.Assigned.Issues, opts.Exporter.Fields()),
"mentioned": api.ExportIssues(issuePayload.Mentioned.Issues, opts.Exporter.Fields()),
"createdBy": issuePayload.Authored.Issues,
"assigned": issuePayload.Assigned.Issues,
"mentioned": issuePayload.Mentioned.Issues,
}
return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
}
out := opts.IO.Out

View file

@ -118,7 +118,7 @@ func Test_transferRunSuccessfulIssueTransfer(t *testing.T) {
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 1234, "closed": true, "title": "The title of the issue"}
"issue": { "id": "THE-ID", "number": 1234, "title": "The title of the issue"}
} } }`))
http.Register(

View file

@ -116,8 +116,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
exportIssue := issue.ExportData(opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, exportIssue, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, issue, opts.IO.ColorEnabled())
}
if opts.IO.IsStdoutTTY() {

View file

@ -681,7 +681,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
// one by forking the base repository
if headRepo == nil && ctx.IsPushEnabled {
opts.IO.StartProgressIndicator()
headRepo, err = api.ForkRepo(client, ctx.BaseRepo)
headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "")
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error forking repo: %w", err)

View file

@ -6,7 +6,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"testing"
@ -305,8 +304,9 @@ func TestPRCreate_recover(t *testing.T) {
},
})
tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*")
assert.NoError(t, err)
defer tmpfile.Close()
state := prShared.IssueMetadataState{
Title: "recovered title",

View file

@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
data := api.ExportPRs(listResult.PullRequests, opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, listResult.PullRequests, opts.IO.ColorEnabled())
}
if opts.IO.IsStdoutTTY() {

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"io/ioutil"
"os"
"testing"
"github.com/cli/cli/pkg/iostreams"
@ -70,6 +69,8 @@ func Test_PreserveInput(t *testing.T) {
},
}
tempDir := t.TempDir()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.state == nil {
@ -78,9 +79,9 @@ func Test_PreserveInput(t *testing.T) {
io, _, _, errOut := iostreams.Test()
tf, tferr := tmpfile()
tf, tferr := ioutil.TempFile(tempDir, "testfile*")
assert.NoError(t, tferr)
defer os.Remove(tf.Name())
defer tf.Close()
io.TempFileOverride = tf
@ -111,13 +112,3 @@ func Test_PreserveInput(t *testing.T) {
})
}
}
func tmpfile() (*os.File, error) {
dir := os.TempDir()
tmpfile, err := ioutil.TempFile(dir, "testfile*")
if err != nil {
return nil, err
}
return tmpfile, nil
}

View file

@ -113,13 +113,13 @@ func statusRun(opts *StatusOptions) error {
if opts.Exporter != nil {
data := map[string]interface{}{
"currentBranch": nil,
"createdBy": api.ExportPRs(prPayload.ViewerCreated.PullRequests, opts.Exporter.Fields()),
"needsReview": api.ExportPRs(prPayload.ReviewRequested.PullRequests, opts.Exporter.Fields()),
"createdBy": prPayload.ViewerCreated.PullRequests,
"needsReview": prPayload.ReviewRequested.PullRequests,
}
if prPayload.CurrentPR != nil {
data["currentBranch"] = prPayload.CurrentPR.ExportData(opts.Exporter.Fields())
data["currentBranch"] = prPayload.CurrentPR
}
return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
}
out := opts.IO.Out

View file

@ -119,8 +119,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
exportPR := pr.ExportData(opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, pr, opts.IO.ColorEnabled())
}
if connectedToTerminal {

View file

@ -36,6 +36,7 @@ type ForkOptions struct {
PromptClone bool
PromptRemote bool
RemoteName string
Organization string
Rename bool
}
@ -110,6 +111,7 @@ Additional 'git clone' flags can be passed in by listing them after '--'.`,
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.")
cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization")
return cmd
}
@ -169,7 +171,7 @@ func forkRun(opts *ForkOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicator()
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
forkedRepo, err := api.ForkRepo(apiClient, repoToFork, opts.Organization)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to fork: %w", err)

View file

@ -2,12 +2,14 @@ package fork
import (
"bytes"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
@ -72,8 +74,9 @@ func TestNewCmdFork(t *testing.T) {
name: "blank nontty",
cli: "",
wants: ForkOptions{
RemoteName: "origin",
Rename: true,
RemoteName: "origin",
Rename: true,
Organization: "",
},
},
{
@ -85,6 +88,7 @@ func TestNewCmdFork(t *testing.T) {
PromptClone: true,
PromptRemote: true,
Rename: true,
Organization: "",
},
},
{
@ -104,6 +108,16 @@ func TestNewCmdFork(t *testing.T) {
Rename: true,
},
},
{
name: "to org",
cli: "--org batmanshome",
wants: ForkOptions{
RemoteName: "origin",
Remote: false,
Rename: false,
Organization: "batmanshome",
},
},
}
for _, tt := range tests {
@ -141,6 +155,7 @@ func TestNewCmdFork(t *testing.T) {
assert.Equal(t, tt.wants.Remote, gotOpts.Remote)
assert.Equal(t, tt.wants.PromptRemote, gotOpts.PromptRemote)
assert.Equal(t, tt.wants.PromptClone, gotOpts.PromptClone)
assert.Equal(t, tt.wants.Organization, gotOpts.Organization)
})
}
}
@ -289,6 +304,7 @@ func TestRepoFork_in_parent_tty(t *testing.T) {
assert.Equal(t, "✓ Created fork someone/REPO\n✓ Added remote origin\n", output.Stderr())
reg.Verify(t)
}
func TestRepoFork_in_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
@ -409,37 +425,65 @@ func TestRepoFork_in_parent(t *testing.T) {
func TestRepoFork_outside(t *testing.T) {
tests := []struct {
name string
args string
name string
args string
postBody string
responseBody string
wantStderr string
}{
{
name: "url arg",
args: "--clone=false http://github.com/OWNER/REPO.git",
name: "url arg",
args: "--clone=false http://github.com/OWNER/REPO.git",
postBody: "{}\n",
responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`,
wantStderr: heredoc.Doc(`
Created fork monalisa/REPO
`),
},
{
name: "full name arg",
args: "--clone=false OWNER/REPO",
name: "full name arg",
args: "--clone=false OWNER/REPO",
postBody: "{}\n",
responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`,
wantStderr: heredoc.Doc(`
Created fork monalisa/REPO
`),
},
{
name: "fork to org without clone",
args: "--clone=false OWNER/REPO --org batmanshome",
postBody: "{\"organization\":\"batmanshome\"}\n",
responseBody: `{"name":"REPO", "owner":{"login":"BatmansHome"}}`,
wantStderr: heredoc.Doc(`
Created fork BatmansHome/REPO
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/forks"),
func(req *http.Request) (*http.Response, error) {
bb, err := ioutil.ReadAll(req.Body)
if err != nil {
return nil, err
}
assert.Equal(t, tt.postBody, string(bb))
return &http.Response{
Request: req,
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(tt.responseBody)),
}, nil
})
httpClient := &http.Client{Transport: reg}
output, err := runCommand(httpClient, nil, true, tt.args)
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.NoError(t, err)
assert.Equal(t, "", output.String())
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
assert.Equal(t, tt.wantStderr, output.Stderr())
reg.Verify(t)
})
}

View file

@ -1,48 +1,18 @@
package list
import (
"context"
"fmt"
"net/http"
"reflect"
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/githubsearch"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type Repository struct {
NameWithOwner string
Description string
IsFork bool
IsPrivate bool
IsArchived bool
PushedAt time.Time
}
func (r Repository) Info() string {
var tags []string
if r.IsPrivate {
tags = append(tags, "private")
} else {
tags = append(tags, "public")
}
if r.IsFork {
tags = append(tags, "fork")
}
if r.IsArchived {
tags = append(tags, "archived")
}
return strings.Join(tags, ", ")
}
type RepositoryList struct {
Owner string
Repositories []Repository
Repositories []api.Repository
TotalCount int
FromSearch bool
}
@ -54,6 +24,7 @@ type FilterOptions struct {
Language string
Archived bool
NonArchived bool
Fields []string
}
func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
@ -67,62 +38,65 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi
}
variables := map[string]interface{}{
"perPage": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"perPage": githubv4.Int(perPage),
}
if filter.Visibility != "" {
variables["privacy"] = githubv4.RepositoryPrivacy(strings.ToUpper(filter.Visibility))
} else {
variables["privacy"] = (*githubv4.RepositoryPrivacy)(nil)
}
if filter.Fork {
variables["fork"] = githubv4.Boolean(true)
} else if filter.Source {
variables["fork"] = githubv4.Boolean(false)
} else {
variables["fork"] = (*githubv4.Boolean)(nil)
}
inputs := []string{"$perPage:Int!", "$endCursor:String", "$privacy:RepositoryPrivacy", "$fork:Boolean"}
var ownerConnection string
if owner == "" {
ownerConnection = `graphql:"repositoryOwner: viewer"`
ownerConnection = "repositoryOwner: viewer"
} else {
ownerConnection = `graphql:"repositoryOwner(login: $owner)"`
ownerConnection = "repositoryOwner(login: $owner)"
variables["owner"] = githubv4.String(owner)
inputs = append(inputs, "$owner:String!")
}
type repositoryOwner struct {
Login string
Repositories struct {
Nodes []Repository
TotalCount int
PageInfo struct {
HasNextPage bool
EndCursor string
type result struct {
RepositoryOwner struct {
Login string
Repositories struct {
Nodes []api.Repository
TotalCount int
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
} `graphql:"repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC })"`
}
}
query := reflect.StructOf([]reflect.StructField{
{
Name: "RepositoryOwner",
Type: reflect.TypeOf(repositoryOwner{}),
Tag: reflect.StructTag(ownerConnection),
},
})
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
query := fmt.Sprintf(`query RepositoryList(%s) {
%s {
login
repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC }) {
nodes{%s}
totalCount
pageInfo{hasNextPage,endCursor}
}
}
}`, strings.Join(inputs, ","), ownerConnection, api.RepositoryGraphQL(filter.Fields))
apiClient := api.NewClientFromHTTP(client)
listResult := RepositoryList{}
pagination:
for {
result := reflect.New(query)
err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables)
var res result
err := apiClient.GraphQL(hostname, query, variables, &res)
if err != nil {
return nil, err
}
owner := result.Elem().FieldByName("RepositoryOwner").Interface().(repositoryOwner)
owner := res.RepositoryOwner
listResult.TotalCount = owner.Repositories.TotalCount
listResult.Owner = owner.Login
@ -143,47 +117,52 @@ pagination:
}
func searchRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
type query struct {
type result struct {
Search struct {
RepositoryCount int
Nodes []struct {
Repository Repository `graphql:"...on Repository"`
}
PageInfo struct {
Nodes []api.Repository
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor)"`
}
}
query := fmt.Sprintf(`query RepositoryListSearch($query:String!,$perPage:Int!,$endCursor:String) {
search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor) {
repositoryCount
nodes{...on Repository{%s}}
pageInfo{hasNextPage,endCursor}
}
}`, api.RepositoryGraphQL(filter.Fields))
perPage := limit
if perPage > 100 {
perPage = 100
}
variables := map[string]interface{}{
"query": githubv4.String(searchQuery(owner, filter)),
"perPage": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"query": githubv4.String(searchQuery(owner, filter)),
"perPage": githubv4.Int(perPage),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
apiClient := api.NewClientFromHTTP(client)
listResult := RepositoryList{FromSearch: true}
pagination:
for {
var result query
err := gql.QueryNamed(context.Background(), "RepositoryListSearch", &result, variables)
var result result
err := apiClient.GraphQL(hostname, query, variables, &result)
if err != nil {
return nil, err
}
listResult.TotalCount = result.Search.RepositoryCount
for _, node := range result.Search.Nodes {
if listResult.Owner == "" {
idx := strings.IndexRune(node.Repository.NameWithOwner, '/')
listResult.Owner = node.Repository.NameWithOwner[:idx]
for _, repo := range result.Search.Nodes {
if listResult.Owner == "" && repo.NameWithOwner != "" {
idx := strings.IndexRune(repo.NameWithOwner, '/')
listResult.Owner = repo.NameWithOwner[:idx]
}
listResult.Repositories = append(listResult.Repositories, node.Repository)
listResult.Repositories = append(listResult.Repositories, repo)
if len(listResult.Repositories) >= limit {
break pagination
}

View file

@ -3,8 +3,10 @@ package list
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -17,6 +19,7 @@ type ListOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
Exporter cmdutil.Exporter
Limit int
Owner string
@ -88,10 +91,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language")
cmd.Flags().BoolVar(&opts.Archived, "archived", false, "Show only archived repositories")
cmd.Flags().BoolVar(&opts.NonArchived, "no-archived", false, "Omit archived repositories")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
return cmd
}
var defaultFields = []string{"nameWithOwner", "description", "isPrivate", "isFork", "isArchived", "createdAt", "pushedAt"}
func listRun(opts *ListOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
@ -105,6 +111,10 @@ func listRun(opts *ListOptions) error {
Language: opts.Language,
Archived: opts.Archived,
NonArchived: opts.NonArchived,
Fields: defaultFields,
}
if opts.Exporter != nil {
filter.Fields = opts.Exporter.Fields()
}
cfg, err := opts.Config()
@ -127,27 +137,31 @@ func listRun(opts *ListOptions) error {
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.Repositories, opts.IO.ColorEnabled())
}
cs := opts.IO.ColorScheme()
tp := utils.NewTablePrinter(opts.IO)
now := opts.Now()
for _, repo := range listResult.Repositories {
info := repo.Info()
info := repoInfo(repo)
infoColor := cs.Gray
if repo.IsPrivate {
infoColor = cs.Yellow
}
t := repo.PushedAt
// if listResult.FromSearch {
// t = repo.UpdatedAt
// }
if repo.PushedAt == nil {
t = &repo.CreatedAt
}
tp.AddField(repo.NameWithOwner, nil, cs.Bold)
tp.AddField(text.ReplaceExcessiveWhitespace(repo.Description), nil, nil)
tp.AddField(info, nil, infoColor)
if tp.IsTTY() {
tp.AddField(utils.FuzzyAgoAbbr(now, t), nil, cs.Gray)
tp.AddField(utils.FuzzyAgoAbbr(now, *t), nil, cs.Gray)
} else {
tp.AddField(t.Format(time.RFC3339), nil, nil)
}
@ -179,3 +193,21 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool)
}
return fmt.Sprintf("Showing %d of %d repositories in @%s%s", matchCount, totalMatchCount, owner, matchStr)
}
func repoInfo(r api.Repository) string {
var tags []string
if r.IsPrivate {
tags = append(tags, "private")
} else {
tags = append(tags, "public")
}
if r.IsFork {
tags = append(tags, "fork")
}
if r.IsArchived {
tags = append(tags, "archived")
}
return strings.Join(tags, ", ")
}

View file

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

View file

@ -29,6 +29,7 @@ type ViewOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Browser browser
Exporter cmdutil.Exporter
RepoArg string
Web bool
@ -67,10 +68,13 @@ With '--branch', view a specific branch of the repository.`,
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a repository in the browser")
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
return cmd
}
var defaultFields = []string{"name", "owner", "description"}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
@ -101,11 +105,24 @@ func viewRun(opts *ViewOptions) error {
}
}
repo, err := api.GitHubRepo(apiClient, toView)
var readme *RepoReadme
fields := defaultFields
if opts.Exporter != nil {
fields = opts.Exporter.Fields()
}
repo, err := fetchRepository(apiClient, toView, fields)
if err != nil {
return err
}
if !opts.Web && opts.Exporter == nil {
readme, err = RepositoryReadme(httpClient, toView, opts.Branch)
if err != nil && !errors.Is(err, NotFoundError) {
return err
}
}
openURL := generateBranchURL(toView, opts.Branch)
if opts.Web {
if opts.IO.IsStdoutTTY() {
@ -114,21 +131,17 @@ func viewRun(opts *ViewOptions) error {
return opts.Browser.Browse(openURL)
}
fullName := ghrepo.FullName(toView)
readme, err := RepositoryReadme(httpClient, toView, opts.Branch)
if err != nil && err != NotFoundError {
return err
}
opts.IO.DetectTerminalTheme()
err = opts.IO.StartPager()
if err != nil {
if err := opts.IO.StartPager(); err != nil {
return err
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, repo, opts.IO.ColorEnabled())
}
fullName := ghrepo.FullName(toView)
stdout := opts.IO.Out
if !opts.IO.IsStdoutTTY() {

View file

@ -3,10 +3,12 @@ package view
import (
"bytes"
"fmt"
"io"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
@ -625,3 +627,51 @@ func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) {
})
}
}
func Test_viewRun_json(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(false)
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.StubRepoInfoResponse("OWNER", "REPO", "main")
opts := &ViewOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Exporter: &testExporter{
fields: []string{"name", "defaultBranchRef"},
},
}
_, teardown := run.Stub()
defer teardown(t)
err := viewRun(opts)
assert.NoError(t, err)
assert.Equal(t, heredoc.Doc(`
name: REPO
defaultBranchRef: main
`), stdout.String())
assert.Equal(t, "", stderr.String())
}
type testExporter struct {
fields []string
}
func (e *testExporter) Fields() []string {
return e.fields
}
func (e *testExporter) Write(w io.Writer, data interface{}, colorize bool) error {
r := data.(*api.Repository)
fmt.Fprintf(w, "name: %s\n", r.Name)
fmt.Fprintf(w, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
return nil
}

View file

@ -7,7 +7,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"testing"
"github.com/cli/cli/api"
@ -149,13 +148,13 @@ func TestNewCmdRun(t *testing.T) {
}
func Test_magicFieldValue(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
io, _, _, _ := iostreams.Test()

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"reflect"
"sort"
"strings"
@ -26,6 +27,21 @@ func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`")
f.StringP("template", "t", "", "Format JSON output using a Go template")
_ = cmd.RegisterFlagCompletionFunc("json", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string
if idx := strings.IndexRune(toComplete, ','); idx >= 0 {
toComplete = toComplete[idx+1:]
}
toComplete = strings.ToLower(toComplete)
for _, f := range fields {
if strings.HasPrefix(strings.ToLower(f), toComplete) {
results = append(results, f)
}
}
sort.Strings(results)
return results, cobra.ShellCompDirectiveNoSpace
})
oldPreRun := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if oldPreRun != nil {
@ -102,11 +118,14 @@ func (e *exportFormat) Fields() []string {
return e.fields
}
// Write serializes data into JSON output written to w. If the object passed as data implements exportable,
// or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain
// raw data for serialization.
func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error {
buf := bytes.Buffer{}
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
if err := encoder.Encode(e.exportData(reflect.ValueOf(data))); err != nil {
return err
}
@ -121,3 +140,44 @@ func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) e
_, err := io.Copy(w, &buf)
return err
}
func (e *exportFormat) exportData(v reflect.Value) interface{} {
switch v.Kind() {
case reflect.Ptr, reflect.Interface:
if !v.IsNil() {
return e.exportData(v.Elem())
}
case reflect.Slice:
a := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
a[i] = e.exportData(v.Index(i))
}
return a
case reflect.Map:
t := reflect.MapOf(v.Type().Key(), emptyInterfaceType)
m := reflect.MakeMapWithSize(t, v.Len())
iter := v.MapRange()
for iter.Next() {
ve := reflect.ValueOf(e.exportData(iter.Value()))
m.SetMapIndex(iter.Key(), ve)
}
return m.Interface()
case reflect.Struct:
if v.CanAddr() && reflect.PtrTo(v.Type()).Implements(exportableType) {
ve := v.Addr().Interface().(exportable)
return ve.ExportData(e.fields)
} else if v.Type().Implements(exportableType) {
ve := v.Interface().(exportable)
return ve.ExportData(e.fields)
}
}
return v.Interface()
}
type exportable interface {
ExportData([]string) *map[string]interface{}
}
var exportableType = reflect.TypeOf((*exportable)(nil)).Elem()
var sliceOfEmptyInterface []interface{}
var emptyInterfaceType = reflect.TypeOf(sliceOfEmptyInterface).Elem()

View file

@ -2,6 +2,7 @@ package cmdutil
import (
"bytes"
"fmt"
"io/ioutil"
"testing"
@ -137,6 +138,29 @@ func Test_exportFormat_Write(t *testing.T) {
wantW: "{\"name\":\"hubot\"}\n",
wantErr: false,
},
{
name: "call ExportData",
exporter: exportFormat{fields: []string{"field1", "field2"}},
args: args{
data: &exportableItem{"item1"},
colorEnabled: false,
},
wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n",
wantErr: false,
},
{
name: "recursively call ExportData",
exporter: exportFormat{fields: []string{"f1", "f2"}},
args: args{
data: map[string]interface{}{
"s1": []exportableItem{{"i1"}, {"i2"}},
"s2": []exportableItem{{"i3"}},
},
colorEnabled: false,
},
wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n",
wantErr: false,
},
{
name: "with jq filter",
exporter: exportFormat{filter: ".name"},
@ -166,8 +190,20 @@ func Test_exportFormat_Write(t *testing.T) {
return
}
if gotW := w.String(); gotW != tt.wantW {
t.Errorf("exportFormat.Write() = %v, want %v", gotW, tt.wantW)
t.Errorf("exportFormat.Write() = %q, want %q", gotW, tt.wantW)
}
})
}
}
type exportableItem struct {
Name string
}
func (e *exportableItem) ExportData(fields []string) *map[string]interface{} {
m := map[string]interface{}{}
for _, f := range fields {
m[f] = fmt.Sprintf("%s:%s", e.Name, f)
}
return &m
}

View file

@ -261,12 +261,11 @@ func TestFindLegacy(t *testing.T) {
}
func TestExtractName(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "gh-cli")
tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli")
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
type args struct {
filePath string
@ -322,12 +321,11 @@ about: This is how you report bugs
}
func TestExtractContents(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "gh-cli")
tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli")
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
type args struct {
filePath string

View file

@ -10,7 +10,7 @@ import (
const (
colorDelim = "1;38" // bright white
colorKey = "1;34" // bright blue
colorNull = "1;30" // gray
colorNull = "36" // cyan
colorString = "32" // green
colorBool = "33" // yellow
)