diff --git a/command/root.go b/command/root.go index 92a25e67f..5b9923fa7 100644 --- a/command/root.go +++ b/command/root.go @@ -76,8 +76,10 @@ func init() { HttpClient: func() (*http.Client, error) { token := os.Getenv("GITHUB_TOKEN") if len(token) == 0 { + // TODO: decouple from `context` ctx := context.New() var err error + // TODO: pass IOStreams to this so that the auth flow knows if it's interactive or not token, err = ctx.AuthToken() if err != nil { return nil, err @@ -85,6 +87,11 @@ func init() { } return httpClient(token), nil }, + BaseRepo: func() (ghrepo.Interface, error) { + // TODO: decouple from `context` + ctx := context.New() + return ctx.BaseRepo() + }, } RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil)) } diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index b4a8dbdff..a5540cf2d 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -13,6 +13,8 @@ import ( "strconv" "strings" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/pkg/jsoncolor" @@ -32,12 +34,14 @@ type ApiOptions struct { ShowResponseHeaders bool HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) } func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command { opts := ApiOptions{ IO: f.IOStreams, HttpClient: f.HttpClient, + BaseRepo: f.BaseRepo, } cmd := &cobra.Command{ @@ -45,13 +49,16 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command Short: "Make an authenticated GitHub API request", Long: `Makes an authenticated HTTP request to the GitHub API and prints the response. -The argument should either be a path of a GitHub API v3 endpoint, or +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" and ":repo" in the endpoint argument will get replaced +with values from the repository of the current directory. + The default HTTP request method is "GET" normally and "POST" if any parameters were added. Override the method with '--method'. -Pass one or more '--raw-field' values in "=" format to add +Pass one or more '--raw-field' values in "key=value" format to add JSON-encoded string parameters to the POST body. The '--field' flag behaves like '--raw-field' with magic type conversion based @@ -59,6 +66,8 @@ on the format of the value: - literal values "true", "false", "null", and integer numbers get converted to appropriate JSON types; +- placeholder values ":owner" and ":repo" 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. @@ -66,6 +75,19 @@ Raw request body may be passed from the outside via a file specified by '--input Pass "-" to read from standard input. In this mode, parameters specified via '--field' flags are serialized into URL query parameters. `, + Example: heredoc.Doc(` + $ gh api repos/:owner/:repo/releases + + $ gh api graphql -F owner=':owner' -F name=':repo' -f query=' + query($name: String!, $owner: String!) { + repository(owner: $owner, name: $name) { + releases(last: 3) { + nodes { tagName } + } + } + } + ' + `), Args: cobra.ExactArgs(1), RunE: func(c *cobra.Command, args []string) error { opts.RequestPath = args[0] @@ -93,8 +115,11 @@ func apiRun(opts *ApiOptions) error { return err } + requestPath, err := fillPlaceholders(opts.RequestPath, opts) + if err != nil { + return fmt.Errorf("unable to expand placeholder in path: %w", err) + } method := opts.RequestMethod - requestPath := opts.RequestPath requestHeaders := opts.RequestHeaders var requestBody interface{} = params @@ -170,6 +195,33 @@ func apiRun(opts *ApiOptions) error { return nil } +var placeholderRE = regexp.MustCompile(`\:(owner|repo)\b`) + +// fillPlaceholders populates `:owner` and `:repo` placeholders with values from the current repository +func fillPlaceholders(value string, opts *ApiOptions) (string, error) { + if !placeholderRE.MatchString(value) { + return value, nil + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return value, err + } + + value = placeholderRE.ReplaceAllStringFunc(value, func(m string) string { + switch m { + case ":owner": + return baseRepo.RepoOwner() + case ":repo": + return baseRepo.RepoName() + default: + panic(fmt.Sprintf("invalid placeholder: %q", m)) + } + }) + + return value, nil +} + func printHeaders(w io.Writer, headers http.Header, colorize bool) { var names []string for name := range headers { @@ -204,7 +256,7 @@ func parseFields(opts *ApiOptions) (map[string]interface{}, error) { if err != nil { return params, err } - value, err := magicFieldValue(strValue, opts.IO.In) + value, err := magicFieldValue(strValue, opts) if err != nil { return params, fmt.Errorf("error parsing %q value: %w", key, err) } @@ -221,9 +273,9 @@ func parseField(f string) (string, string, error) { return f[0:idx], f[idx+1:], nil } -func magicFieldValue(v string, stdin io.ReadCloser) (interface{}, error) { +func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) { if strings.HasPrefix(v, "@") { - return readUserFile(v[1:], stdin) + return readUserFile(v[1:], opts.IO.In) } if n, err := strconv.Atoi(v); err == nil { @@ -238,7 +290,7 @@ func magicFieldValue(v string, stdin io.ReadCloser) (interface{}, error) { case "null": return nil, nil default: - return v, nil + return fillPlaceholders(v, opts) } } diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go index 100c1257e..605e4bf9e 100644 --- a/pkg/cmd/api/api_test.go +++ b/pkg/cmd/api/api_test.go @@ -3,12 +3,12 @@ package api import ( "bytes" "fmt" - "io" "io/ioutil" "net/http" "os" "testing" + "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/google/shlex" @@ -366,9 +366,11 @@ func Test_magicFieldValue(t *testing.T) { f.Close() t.Cleanup(func() { os.Remove(f.Name()) }) + io, _, _, _ := iostreams.Test() + type args struct { - v string - stdin io.ReadCloser + v string + opts *ApiOptions } tests := []struct { name string @@ -401,21 +403,41 @@ func Test_magicFieldValue(t *testing.T) { wantErr: false, }, { - name: "file", - args: args{v: "@" + f.Name()}, + name: "placeholder", + 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{ + v: "@" + f.Name(), + opts: &ApiOptions{IO: io}, + }, want: []byte("file contents"), wantErr: false, }, { - name: "file error", - args: args{v: "@"}, + name: "file error", + args: args{ + v: "@", + opts: &ApiOptions{IO: io}, + }, want: nil, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := magicFieldValue(tt.args.v, tt.args.stdin) + got, err := magicFieldValue(tt.args.v, tt.args.opts) if (err != nil) != tt.wantErr { t.Errorf("magicFieldValue() error = %v, wantErr %v", err, tt.wantErr) return @@ -451,3 +473,64 @@ func Test_openUserFile(t *testing.T) { assert.Equal(t, int64(13), length) assert.Equal(t, "file contents", string(fb)) } + +func Test_fillPlaceholders(t *testing.T) { + type args struct { + value string + opts *ApiOptions + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "no changes", + args: args{ + value: "repos/owner/repo/releases", + opts: &ApiOptions{ + BaseRepo: nil, + }, + }, + want: "repos/owner/repo/releases", + wantErr: false, + }, + { + 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: "no greedy substitutes", + 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) { + got, err := fillPlaceholders(tt.args.value, tt.args.opts) + if (err != nil) != tt.wantErr { + t.Errorf("fillPlaceholders() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got != tt.want { + t.Errorf("fillPlaceholders() got = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cmdutil/factory.go b/pkg/cmdutil/factory.go index 578b29561..ad7162415 100644 --- a/pkg/cmdutil/factory.go +++ b/pkg/cmdutil/factory.go @@ -3,10 +3,12 @@ package cmdutil import ( "net/http" + "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/pkg/iostreams" ) type Factory struct { IOStreams *iostreams.IOStreams HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) }