Merge pull request #1406 from cli/migrate-gist

migrate gist create to new command format
This commit is contained in:
Nate Smith 2020-07-22 10:47:03 -05:00 committed by GitHub
commit 41376a3626
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 545 additions and 280 deletions

View file

@ -29,7 +29,7 @@ jobs:
go mod verify
go mod download
LINT_VERSION=1.27.0
LINT_VERSION=1.29.0
curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \
tar xz --strip-components 1 --wildcards \*/golangci-lint
mkdir -p bin && mv golangci-lint bin/

View file

@ -33,6 +33,12 @@ func NewClient(opts ...ClientOption) *Client {
return client
}
// NewClientFromHTTP takes in an http.Client instance
func NewClientFromHTTP(httpClient *http.Client) *Client {
client := &Client{http: httpClient}
return client
}
// AddHeader turns a RoundTripper into one that adds a request header
func AddHeader(name, value string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
@ -179,9 +185,10 @@ func (gr GraphQLErrorResponse) Error() string {
// HTTPError is an error returned by a failed API call
type HTTPError struct {
StatusCode int
RequestURL *url.URL
Message string
StatusCode int
RequestURL *url.URL
Message string
OAuthScopes string
}
func (err HTTPError) Error() string {
@ -322,8 +329,9 @@ func handleResponse(resp *http.Response, data interface{}) error {
func handleHTTPError(resp *http.Response) error {
httpError := HTTPError{
StatusCode: resp.StatusCode,
RequestURL: resp.Request.URL,
StatusCode: resp.StatusCode,
RequestURL: resp.Request.URL,
OAuthScopes: resp.Header.Get("X-Oauth-Scopes"),
}
body, err := ioutil.ReadAll(resp.Body)

View file

@ -1,175 +0,0 @@
package command
import (
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(gistCmd)
gistCmd.AddCommand(gistCreateCmd)
gistCreateCmd.Flags().StringP("desc", "d", "", "A description for this gist")
gistCreateCmd.Flags().BoolP("public", "p", false, "List the gist publicly (default: private)")
}
var gistCmd = &cobra.Command{
Use: "gist",
Short: "Create gists",
Long: `Work with GitHub gists.`,
}
var gistCreateCmd = &cobra.Command{
Use: `create [<filename>... | -]`,
Short: "Create a new gist",
Long: `Create a new GitHub gist with given contents.
Gists can be created from one or multiple files. Alternatively, pass "-" as
file name to read from standard input.
By default, gists are private; use '--public' to make publicly listed ones.`,
Example: heredoc.Doc(`
# publish file 'hello.py' as a public gist
$ gh gist create --public hello.py
# create a gist with a description
$ gh gist create hello.py -d "my Hello-World program in Python"
# create a gist containing several files
$ gh gist create hello.py world.py cool.txt
# read from standard input to create a gist
$ gh gist create -
# create a gist from output piped from another command
$ cat cool.txt | gh gist create
`),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return nil
}
info, err := os.Stdin.Stat()
if err != nil {
return fmt.Errorf("failed to check STDIN: %w", err)
}
stdinIsTTY := (info.Mode() & os.ModeCharDevice) == os.ModeCharDevice
if stdinIsTTY {
return &cmdutil.FlagError{Err: errors.New("no filenames passed and nothing on STDIN")}
}
return nil
},
RunE: gistCreate,
}
type Opts struct {
Description string
Public bool
}
func gistCreate(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
client, err := apiClientForContext(ctx)
if err != nil {
return err
}
// This performs a dummy query, checks what scopes we have, and then asks for a user to reauth
// with expanded scopes. it introduces latency whenever this command is run: a trade-off to avoid
// having every single user reauth as a result of this feature even if they never once use gists.
//
// In the future we'd rather have the ability to detect a "reauth needed" scenario and replay
// failed requests but some short spikes indicated that that would be a fair bit of work.
client, err = ensureScopes(ctx, client, "gist")
if err != nil {
return err
}
opts, err := processOpts(cmd)
if err != nil {
return fmt.Errorf("did not understand arguments: %w", err)
}
fileArgs := args
if len(args) == 0 {
fileArgs = []string{"-"}
}
files, err := processFiles(os.Stdin, fileArgs)
if err != nil {
return fmt.Errorf("failed to collect files for posting: %w", err)
}
errOut := colorableErr(cmd)
fmt.Fprintf(errOut, "%s Creating gist...\n", utils.Gray("-"))
gist, err := api.GistCreate(client, opts.Description, opts.Public, files)
if err != nil {
return fmt.Errorf("%s Failed to create gist: %w", utils.Red("X"), err)
}
fmt.Fprintf(errOut, "%s Created gist\n", utils.Green("✓"))
fmt.Fprintln(cmd.OutOrStdout(), gist.HTMLURL)
return nil
}
func processOpts(cmd *cobra.Command) (*Opts, error) {
description, err := cmd.Flags().GetString("desc")
if err != nil {
return nil, err
}
public, err := cmd.Flags().GetBool("public")
if err != nil {
return nil, err
}
return &Opts{
Description: description,
Public: public,
}, err
}
func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, error) {
fs := map[string]string{}
if len(filenames) == 0 {
return nil, errors.New("no files passed")
}
for i, f := range filenames {
var filename string
var content []byte
var err error
if f == "-" {
filename = fmt.Sprintf("gistfile%d.txt", i)
content, err = ioutil.ReadAll(stdin)
if err != nil {
return fs, fmt.Errorf("failed to read from stdin: %w", err)
}
stdin.Close()
} else {
content, err = ioutil.ReadFile(f)
if err != nil {
return fs, fmt.Errorf("failed to read file %s: %w", f, err)
}
filename = path.Base(f)
}
fs[filename] = string(content)
}
return fs, nil
}

View file

@ -1,56 +0,0 @@
package command
import (
"encoding/json"
"io/ioutil"
"strings"
"testing"
"github.com/cli/cli/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func TestGistCreate(t *testing.T) {
initBlankContext("", "OWNER/REPO", "trunk")
http := initFakeHTTP()
http.Register(httpmock.REST("POST", "gists"), httpmock.StringResponse(`
{
"html_url": "https://gist.github.com/aa5a315d61ae9438b18d"
}
`))
output, err := RunCommand(`gist create "../test/fixtures/gistCreate.json" -d "Gist description" --public`)
assert.NoError(t, err)
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := make(map[string]interface{})
err = json.Unmarshal(bodyBytes, &reqBody)
if err != nil {
t.Fatalf("error decoding JSON: %v", err)
}
expectParams := map[string]interface{}{
"description": "Gist description",
"files": map[string]interface{}{
"gistCreate.json": map[string]interface{}{
"content": "{}",
},
},
"public": true,
}
assert.Equal(t, expectParams, reqBody)
assert.Equal(t, "https://gist.github.com/aa5a315d61ae9438b18d\n", output.String())
}
func TestGistCreate_stdin(t *testing.T) {
fakeStdin := strings.NewReader("hey cool how is it going")
files, err := processFiles(ioutil.NopCloser(fakeStdin), []string{"-"})
if err != nil {
t.Fatalf("unexpected error processing files: %s", err)
}
assert.Equal(t, 1, len(files))
assert.Equal(t, "hey cool how is it going", files["gistfile0.txt"])
}

View file

@ -20,6 +20,7 @@ import (
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
apiCmd "github.com/cli/cli/pkg/cmd/api"
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
@ -95,6 +96,14 @@ func init() {
},
}
RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil))
gistCmd := &cobra.Command{
Use: "gist",
Short: "Create gists",
Long: `Work with GitHub gists.`,
}
RootCmd.AddCommand(gistCmd)
gistCmd.AddCommand(gistCreateCmd.NewCmdCreate(cmdFactory, nil))
}
// RootCmd is the entry point of command-line execution
@ -252,46 +261,6 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
return api.NewClient(opts...), nil
}
var ensureScopes = func(ctx context.Context, client *api.Client, wantedScopes ...string) (*api.Client, error) {
hasScopes, appID, err := client.HasScopes(wantedScopes...)
if err != nil {
return client, err
}
if hasScopes {
return client, nil
}
tokenFromEnv := len(os.Getenv("GITHUB_TOKEN")) > 0
if config.IsGitHubApp(appID) && !tokenFromEnv && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
cfg, err := ctx.Config()
if err != nil {
return nil, err
}
_, err = config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: additional authorization required")
if err != nil {
return nil, err
}
reloadedClient, err := apiClientForContext(ctx)
if err != nil {
return client, err
}
return reloadedClient, nil
} else {
fmt.Fprintf(os.Stderr, "Warning: gh now requires %s OAuth scopes.\n", wantedScopes)
fmt.Fprintf(os.Stderr, "Visit https://github.com/settings/tokens and edit your token to enable %s\n", wantedScopes)
if tokenFromEnv {
fmt.Fprintln(os.Stderr, "or generate a new token for the GITHUB_TOKEN environment variable")
} else {
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
}
return client, errors.New("Unable to reauthenticate")
}
}
func apiVerboseLog() api.ClientOption {
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
colorize := utils.IsTerminal(os.Stderr)

View file

@ -97,9 +97,6 @@ func initBlankContext(cfg, repo, branch string) {
func initFakeHTTP() *httpmock.Registry {
http := &httpmock.Registry{}
ensureScopes = func(ctx context.Context, client *api.Client, wantedScopes ...string) (*api.Client, error) {
return client, nil
}
apiClientForContext = func(context.Context) (*api.Client, error) {
return api.NewClient(api.ReplaceTripper(http)), nil
}

View file

@ -0,0 +1,156 @@
package create
import (
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"path"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type CreateOptions struct {
IO *iostreams.IOStreams
Description string
Public bool
Filenames []string
HttpClient func() (*http.Client, error)
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
opts := CreateOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "create [<filename>... | -]",
Short: "Create a new gist",
Long: heredoc.Doc(`
Create a new GitHub gist with given contents.
Gists can be created from one or multiple files. Alternatively, pass "-" as
file name to read from standard input.
By default, gists are private; use '--public' to make publicly listed ones.
`),
Example: heredoc.Doc(`
# publish file 'hello.py' as a public gist
$ gh gist create --public hello.py
# create a gist with a description
$ gh gist create hello.py -d "my Hello-World program in Python"
# create a gist containing several files
$ gh gist create hello.py world.py cool.txt
# read from standard input to create a gist
$ gh gist create -
# create a gist from output piped from another command
$ cat cool.txt | gh gist create
`),
Args: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return nil
}
if opts.IO.IsStdinTTY() {
return &cmdutil.FlagError{Err: errors.New("no filenames passed and nothing on STDIN")}
}
return nil
},
RunE: func(c *cobra.Command, args []string) error {
opts.HttpClient = f.HttpClient
opts.Filenames = args
if runF != nil {
return runF(&opts)
}
return createRun(&opts)
},
}
cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "A description for this gist")
cmd.Flags().BoolVarP(&opts.Public, "public", "p", false, "List the gist publicly (default: private)")
return cmd
}
func createRun(opts *CreateOptions) error {
fileArgs := opts.Filenames
if len(fileArgs) == 0 {
fileArgs = []string{"-"}
}
files, err := processFiles(opts.IO.In, fileArgs)
if err != nil {
return fmt.Errorf("failed to collect files for posting: %w", err)
}
errOut := opts.IO.ErrOut
fmt.Fprintf(errOut, "%s Creating gist...\n", utils.Gray("-"))
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
gist, err := apiCreate(httpClient, opts.Description, opts.Public, files)
if err != nil {
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("%s Failed to create gist: %w", utils.Red("X"), err)
}
fmt.Fprintf(errOut, "%s Created gist\n", utils.Green("✓"))
fmt.Fprintln(opts.IO.Out, gist.HTMLURL)
return nil
}
func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, error) {
fs := map[string]string{}
if len(filenames) == 0 {
return nil, errors.New("no files passed")
}
for i, f := range filenames {
var filename string
var content []byte
var err error
if f == "-" {
filename = fmt.Sprintf("gistfile%d.txt", i)
content, err = ioutil.ReadAll(stdin)
if err != nil {
return fs, fmt.Errorf("failed to read from stdin: %w", err)
}
stdin.Close()
} else {
content, err = ioutil.ReadFile(f)
if err != nil {
return fs, fmt.Errorf("failed to read file %s: %w", f, err)
}
filename = path.Base(f)
}
fs[filename] = string(content)
}
return fs, nil
}

View file

@ -0,0 +1,295 @@
package create
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"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{"-"})
if err != nil {
t.Fatalf("unexpected error processing files: %s", err)
}
assert.Equal(t, 1, len(files))
assert.Equal(t, "hey cool how is it going", files["gistfile0.txt"])
}
func TestNewCmdCreate(t *testing.T) {
tests := []struct {
name string
cli string
factory func(*cmdutil.Factory) *cmdutil.Factory
wants CreateOptions
wantsErr bool
}{
{
name: "no arguments",
cli: "",
wants: CreateOptions{
Description: "",
Public: false,
Filenames: []string{""},
},
wantsErr: false,
},
{
name: "no arguments with TTY stdin",
factory: func(f *cmdutil.Factory) *cmdutil.Factory {
f.IOStreams.SetStdinTTY(true)
return f
},
cli: "",
wants: CreateOptions{
Description: "",
Public: false,
Filenames: []string{""},
},
wantsErr: true,
},
{
name: "stdin argument",
cli: "-",
wants: CreateOptions{
Description: "",
Public: false,
Filenames: []string{"-"},
},
wantsErr: false,
},
{
name: "with description",
cli: `-d "my new gist" -`,
wants: CreateOptions{
Description: "my new gist",
Public: false,
Filenames: []string{"-"},
},
wantsErr: false,
},
{
name: "public",
cli: `--public -`,
wants: CreateOptions{
Description: "",
Public: true,
Filenames: []string{"-"},
},
wantsErr: false,
},
{
name: "list of files",
cli: "file1.txt file2.txt",
wants: CreateOptions{
Description: "",
Public: false,
Filenames: []string{"file1.txt", "file2.txt"},
},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: io,
}
if tt.factory != nil {
f = tt.factory(f)
}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *CreateOptions
cmd := NewCmdCreate(f, func(opts *CreateOptions) 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.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wants.Description, gotOpts.Description)
assert.Equal(t, tt.wants.Public, gotOpts.Public)
})
}
}
func Test_createRun(t *testing.T) {
tests := []struct {
name string
opts *CreateOptions
stdin string
wantOut string
wantStderr string
wantParams map[string]interface{}
wantErr bool
}{
{
name: "public",
opts: &CreateOptions{
Public: true,
Filenames: []string{fixtureFile},
},
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
wantStderr: "- Creating gist...\n✓ Created gist\n",
wantErr: false,
wantParams: map[string]interface{}{
"public": true,
"files": map[string]interface{}{
"fixture.txt": map[string]interface{}{
"content": "{}",
},
},
},
},
{
name: "with description",
opts: &CreateOptions{
Description: "an incredibly interesting gist",
Filenames: []string{fixtureFile},
},
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
wantStderr: "- Creating gist...\n✓ Created gist\n",
wantErr: false,
wantParams: map[string]interface{}{
"description": "an incredibly interesting gist",
"files": map[string]interface{}{
"fixture.txt": map[string]interface{}{
"content": "{}",
},
},
},
},
{
name: "multiple files",
opts: &CreateOptions{
Filenames: []string{fixtureFile, "-"},
},
stdin: "cool stdin content",
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
wantStderr: "- Creating gist...\n✓ Created gist\n",
wantErr: false,
wantParams: map[string]interface{}{
"files": map[string]interface{}{
"fixture.txt": map[string]interface{}{
"content": "{}",
},
"gistfile1.txt": map[string]interface{}{
"content": "cool stdin content",
},
},
},
},
{
name: "stdin arg",
opts: &CreateOptions{
Filenames: []string{"-"},
},
stdin: "cool stdin content",
wantOut: "https://gist.github.com/aa5a315d61ae9438b18d\n",
wantStderr: "- Creating gist...\n✓ Created gist\n",
wantErr: false,
wantParams: map[string]interface{}{
"files": map[string]interface{}{
"gistfile0.txt": map[string]interface{}{
"content": "cool stdin content",
},
},
},
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"),
httpmock.JSONResponse(struct {
Html_url string
}{"https://gist.github.com/aa5a315d61ae9438b18d"}))
mockClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
tt.opts.HttpClient = mockClient
io, stdin, stdout, stderr := iostreams.Test()
tt.opts.IO = io
t.Run(tt.name, func(t *testing.T) {
stdin.WriteString(tt.stdin)
if err := createRun(tt.opts); (err != nil) != tt.wantErr {
t.Errorf("createRun() error = %v, wantErr %v", err, tt.wantErr)
}
bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body)
reqBody := make(map[string]interface{})
err := json.Unmarshal(bodyBytes, &reqBody)
if err != nil {
t.Fatalf("error decoding JSON: %v", err)
}
assert.Equal(t, tt.wantOut, stdout.String())
assert.Equal(t, tt.wantStderr, stderr.String())
assert.Equal(t, tt.wantParams, reqBody)
reg.Verify(t)
})
}
}
func Test_CreateRun_reauth(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"), func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: 404,
Request: req,
Header: map[string][]string{
"X-Oauth-Scopes": {"coolScope"},
},
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,
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)
}
}

View file

@ -0,0 +1,53 @@
package create
import (
"bytes"
"encoding/json"
"net/http"
"github.com/cli/cli/api"
)
// Gist represents a GitHub's gist.
type Gist struct {
Description string `json:"description,omitempty"`
Public bool `json:"public,omitempty"`
Files map[GistFilename]GistFile `json:"files,omitempty"`
HTMLURL string `json:"html_url,omitempty"`
}
type GistFilename string
type GistFile struct {
Content string `json:"content,omitempty"`
}
func apiCreate(httpClient *http.Client, description string, public bool, files map[string]string) (*Gist, error) {
gistFiles := map[GistFilename]GistFile{}
for filename, content := range files {
gistFiles[GistFilename(filename)] = GistFile{content}
}
path := "gists"
body := &Gist{
Description: description,
Public: public,
Files: gistFiles,
}
result := Gist{}
requestByte, err := json.Marshal(body)
if err != nil {
return nil, err
}
requestBody := bytes.NewReader(requestByte)
apiClient := api.NewClientFromHTTP(httpClient)
err = apiClient.REST("POST", path, requestBody, &result)
if err != nil {
return nil, err
}
return &result, nil
}

View file

@ -16,12 +16,30 @@ type IOStreams struct {
ErrOut io.Writer
colorEnabled bool
stdinTTYOverride bool
stdinIsTTY bool
}
func (s *IOStreams) ColorEnabled() bool {
return s.colorEnabled
}
func (s *IOStreams) SetStdinTTY(isTTY bool) {
s.stdinTTYOverride = true
s.stdinIsTTY = isTTY
}
func (s *IOStreams) IsStdinTTY() bool {
if s.stdinTTYOverride {
return s.stdinIsTTY
}
if stdin, ok := s.In.(*os.File); ok {
return isTerminal(stdin)
}
return false
}
func System() *IOStreams {
var out io.Writer = os.Stdout
var colorEnabled bool