Merge pull request #1699 from cli/more-gists

bunch of gist stuff
This commit is contained in:
Nate Smith 2020-09-16 11:36:54 -05:00 committed by GitHub
commit bdadb3058b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 1268 additions and 13 deletions

View file

@ -18,6 +18,7 @@ We'd love to hear your feedback about `gh`. If you spot bugs or have features th
- `gh pr [status, list, view, checkout, create]`
- `gh issue [status, list, view, create]`
- `gh repo [view, create, clone, fork]`
- `gh gist [create, list, view, edit]`
- `gh auth [login, logout, refresh, status]`
- `gh config [get, set]`
- `gh help`

View file

@ -55,3 +55,10 @@ func RESTPrefix(hostname string) string {
}
return "https://api.github.com/"
}
func GistPrefix(hostname string) string {
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/gist/", hostname)
}
return fmt.Sprintf("https://gist.%s/", hostname)
}

View file

@ -69,12 +69,7 @@ func listRun(opts *ListOptions) error {
sort.Strings(keys)
for _, alias := range keys {
if tp.IsTTY() {
// ensure that screen readers pause
tp.AddField(alias+":", nil, nil)
} else {
tp.AddField(alias, nil, nil)
}
tp.AddField(alias+":", nil, nil)
tp.AddField(aliasMap[alias], nil, nil)
tp.EndRow()
}

View file

@ -23,9 +23,10 @@ import (
type CreateOptions struct {
IO *iostreams.IOStreams
Description string
Public bool
Filenames []string
Description string
Public bool
Filenames []string
FilenameOverride string
HttpClient func() (*http.Client, error)
}
@ -84,6 +85,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
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)")
cmd.Flags().StringVarP(&opts.FilenameOverride, "filename", "f", "", "Provide a filename to be used when reading from STDIN")
return cmd
}
@ -93,7 +95,7 @@ func createRun(opts *CreateOptions) error {
fileArgs = []string{"-"}
}
files, err := processFiles(opts.IO.In, fileArgs)
files, err := processFiles(opts.IO.In, opts.FilenameOverride, fileArgs)
if err != nil {
return fmt.Errorf("failed to collect files for posting: %w", err)
}
@ -137,7 +139,7 @@ func createRun(opts *CreateOptions) error {
return nil
}
func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, error) {
func processFiles(stdin io.ReadCloser, filenameOverride string, filenames []string) (map[string]string, error) {
fs := map[string]string{}
if len(filenames) == 0 {
@ -149,7 +151,11 @@ func processFiles(stdin io.ReadCloser, filenames []string) (map[string]string, e
var content []byte
var err error
if f == "-" {
filename = fmt.Sprintf("gistfile%d.txt", i)
if filenameOverride != "" {
filename = filenameOverride
} else {
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)

View file

@ -21,7 +21,7 @@ const (
func Test_processFiles(t *testing.T) {
fakeStdin := strings.NewReader("hey cool how is it going")
files, err := processFiles(ioutil.NopCloser(fakeStdin), []string{"-"})
files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"})
if err != nil {
t.Fatalf("unexpected error processing files: %s", err)
}

210
pkg/cmd/gist/edit/edit.go Normal file
View file

@ -0,0 +1,210 @@
package edit
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/surveyext"
"github.com/spf13/cobra"
)
type EditOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
Edit func(string, string, string, *iostreams.IOStreams) (string, error)
Selector string
Filename string
}
func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Command {
opts := EditOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Edit: func(editorCmd, filename, defaultContent string, io *iostreams.IOStreams) (string, error) {
return surveyext.Edit(
editorCmd,
"*."+filename,
defaultContent,
io.In, io.Out, io.ErrOut, nil)
},
}
cmd := &cobra.Command{
Use: "edit {<gist ID> | <gist URL>}",
Short: "Edit one of your gists",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Selector = args[0]
if runF != nil {
return runF(&opts)
}
return editRun(&opts)
},
}
cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "a specific file to edit")
return cmd
}
func editRun(opts *EditOptions) error {
gistID := opts.Selector
u, err := url.Parse(opts.Selector)
if err == nil {
if strings.HasPrefix(u.Path, "/") {
gistID = u.Path[1:]
}
}
client, err := opts.HttpClient()
if err != nil {
return err
}
gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID)
if err != nil {
return err
}
filesToUpdate := map[string]string{}
for {
filename := opts.Filename
candidates := []string{}
for filename := range gist.Files {
candidates = append(candidates, filename)
}
sort.Strings(candidates)
if filename == "" {
if len(candidates) == 1 {
filename = candidates[0]
} else {
if !opts.IO.CanPrompt() {
return errors.New("unsure what file to edit; either specify --filename or run interactively")
}
err = prompt.SurveyAskOne(&survey.Select{
Message: "Edit which file?",
Options: candidates,
}, &filename)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
}
}
if _, ok := gist.Files[filename]; !ok {
return fmt.Errorf("gist has no file %q", filename)
}
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
if err != nil {
return err
}
text, err := opts.Edit(editorCommand, filename, gist.Files[filename].Content, opts.IO)
if err != nil {
return err
}
if text != gist.Files[filename].Content {
gistFile := gist.Files[filename]
gistFile.Content = text // so it appears if they re-edit
filesToUpdate[filename] = text
}
if !opts.IO.CanPrompt() {
break
}
if len(candidates) == 1 {
break
}
choice := ""
err = prompt.SurveyAskOne(&survey.Select{
Message: "What next?",
Options: []string{
"Edit another file",
"Submit",
"Cancel",
},
}, &choice)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
stop := false
switch choice {
case "Edit another file":
continue
case "Submit":
stop = true
case "Cancel":
return cmdutil.SilentError
}
if stop {
break
}
}
err = updateGist(client, ghinstance.OverridableDefault(), gist)
if err != nil {
return err
}
return nil
}
func updateGist(client *http.Client, hostname string, gist *shared.Gist) error {
body := shared.Gist{
Description: gist.Description,
Files: gist.Files,
}
path := "gists/" + gist.ID
requestByte, err := json.Marshal(body)
if err != nil {
return err
}
requestBody := bytes.NewReader(requestByte)
result := shared.Gist{}
apiClient := api.NewClientFromHTTP(client)
err = apiClient.REST(hostname, "POST", path, requestBody, &result)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,244 @@
package edit
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdEdit(t *testing.T) {
tests := []struct {
name string
cli string
wants EditOptions
}{
{
name: "no flags",
cli: "123",
wants: EditOptions{
Selector: "123",
},
},
{
name: "filename",
cli: "123 --filename cool.md",
wants: EditOptions{
Selector: "123",
Filename: "cool.md",
},
},
}
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 *EditOptions
cmd := NewCmdEdit(f, func(opts *EditOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
assert.NoError(t, err)
assert.Equal(t, tt.wants.Filename, gotOpts.Filename)
assert.Equal(t, tt.wants.Selector, gotOpts.Selector)
})
}
}
func Test_editRun(t *testing.T) {
tests := []struct {
name string
opts *EditOptions
gist *shared.Gist
httpStubs func(*httpmock.Registry)
askStubs func(*prompt.AskStubber)
nontty bool
wantErr bool
wantParams map[string]interface{}
}{
{
name: "no such gist",
wantErr: true,
},
{
name: "one file",
gist: &shared.Gist{
ID: "1234",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Filename: "cicada.txt",
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "gists/1234"),
httpmock.StatusStringResponse(201, "{}"))
},
wantParams: map[string]interface{}{
"description": "",
"updated_at": "0001-01-01T00:00:00Z",
"public": false,
"files": map[string]interface{}{
"cicada.txt": map[string]interface{}{
"content": "new file content",
"filename": "cicada.txt",
"type": "text/plain",
},
},
},
},
{
name: "multiple files, submit",
askStubs: func(as *prompt.AskStubber) {
as.StubOne("unix.md")
as.StubOne("Submit")
},
gist: &shared.Gist{
ID: "1234",
Description: "catbug",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Filename: "cicada.txt",
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"unix.md": {
Filename: "unix.md",
Content: "meow",
Type: "application/markdown",
},
},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("POST", "gists/1234"),
httpmock.StatusStringResponse(201, "{}"))
},
wantParams: map[string]interface{}{
"description": "catbug",
"updated_at": "0001-01-01T00:00:00Z",
"public": false,
"files": map[string]interface{}{
"cicada.txt": map[string]interface{}{
"content": "bwhiizzzbwhuiiizzzz",
"filename": "cicada.txt",
"type": "text/plain",
},
"unix.md": map[string]interface{}{
"content": "new file content",
"filename": "unix.md",
"type": "application/markdown",
},
},
},
},
{
name: "multiple files, cancel",
askStubs: func(as *prompt.AskStubber) {
as.StubOne("unix.md")
as.StubOne("Cancel")
},
wantErr: true,
gist: &shared.Gist{
ID: "1234",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Filename: "cicada.txt",
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"unix.md": {
Filename: "unix.md",
Content: "meow",
Type: "application/markdown",
},
},
},
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.gist == nil {
reg.Register(httpmock.REST("GET", "gists/1234"),
httpmock.StatusStringResponse(404, "Not Found"))
} else {
reg.Register(httpmock.REST("GET", "gists/1234"),
httpmock.JSONResponse(tt.gist))
}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
as, teardown := prompt.InitAskStubber()
defer teardown()
if tt.askStubs != nil {
tt.askStubs(as)
}
if tt.opts == nil {
tt.opts = &EditOptions{}
}
tt.opts.Edit = func(_, _, _ string, _ *iostreams.IOStreams) (string, error) {
return "new file content", nil
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(!tt.nontty)
io.SetStdinTTY(!tt.nontty)
tt.opts.IO = io
tt.opts.Selector = "1234"
tt.opts.Config = func() (config.Config, error) {
return config.NewBlankConfig(), nil
}
t.Run(tt.name, func(t *testing.T) {
err := editRun(tt.opts)
reg.Verify(t)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
if tt.wantParams != nil {
bodyBytes, _ := ioutil.ReadAll(reg.Requests[1].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.wantParams, reqBody)
}
})
}
}

View file

@ -1,7 +1,11 @@
package gist
import (
"github.com/MakeNowJust/heredoc"
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit"
gistListCmd "github.com/cli/cli/pkg/cmd/gist/list"
gistViewCmd "github.com/cli/cli/pkg/cmd/gist/view"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
@ -11,9 +15,20 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command {
Use: "gist",
Short: "Create gists",
Long: `Work with GitHub gists.`,
Annotations: map[string]string{
"IsCore": "true",
"help:arguments": heredoc.Doc(`
A gist can be supplied as argument in either of the following formats:
- by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f
- by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f"
`),
},
}
cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil))
cmd.AddCommand(gistListCmd.NewCmdList(f, nil))
cmd.AddCommand(gistViewCmd.NewCmdView(f, nil))
cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil))
return cmd
}

46
pkg/cmd/gist/list/http.go Normal file
View file

@ -0,0 +1,46 @@
package list
import (
"fmt"
"net/http"
"net/url"
"sort"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/cmd/gist/shared"
)
func listGists(client *http.Client, hostname string, limit int, visibility string) ([]shared.Gist, error) {
result := []shared.Gist{}
query := url.Values{}
if visibility == "all" {
query.Add("per_page", fmt.Sprintf("%d", limit))
} else {
query.Add("per_page", "100")
}
// TODO switch to graphql
apiClient := api.NewClientFromHTTP(client)
err := apiClient.REST(hostname, "GET", "gists?"+query.Encode(), nil, &result)
if err != nil {
return nil, err
}
gists := []shared.Gist{}
for _, gist := range result {
if len(gists) == limit {
break
}
if visibility == "all" || (visibility == "secret" && !gist.Public) || (visibility == "public" && gist.Public) {
gists = append(gists, gist)
}
}
sort.SliceStable(gists, func(i, j int) bool {
return gists[i].UpdatedAt.After(gists[j].UpdatedAt)
})
return gists, nil
}

116
pkg/cmd/gist/list/list.go Normal file
View file

@ -0,0 +1,116 @@
package list
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ListOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
Limit int
Visibility string // all, secret, public
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
opts := &ListOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "list",
Short: "List your gists",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if opts.Limit < 1 {
return &cmdutil.FlagError{Err: fmt.Errorf("invalid limit: %v", opts.Limit)}
}
pub := cmd.Flags().Changed("public")
secret := cmd.Flags().Changed("secret")
opts.Visibility = "all"
if pub && !secret {
opts.Visibility = "public"
} else if secret && !pub {
opts.Visibility = "secret"
}
if runF != nil {
return runF(opts)
}
return listRun(opts)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 10, "Maximum number of gists to fetch")
cmd.Flags().Bool("public", false, "Show only public gists")
cmd.Flags().Bool("secret", false, "Show only secret gists")
return cmd
}
func listRun(opts *ListOptions) error {
client, err := opts.HttpClient()
if err != nil {
return err
}
gists, err := listGists(client, ghinstance.OverridableDefault(), opts.Limit, opts.Visibility)
if err != nil {
return err
}
cs := opts.IO.ColorScheme()
tp := utils.NewTablePrinter(opts.IO)
for _, gist := range gists {
fileCount := 0
for range gist.Files {
fileCount++
}
visibility := "public"
visColor := cs.Green
if !gist.Public {
visibility = "secret"
visColor = cs.Red
}
description := gist.Description
if description == "" {
for filename := range gist.Files {
if !strings.HasPrefix(filename, "gistfile") {
description = filename
break
}
}
}
tp.AddField(gist.ID, nil, nil)
tp.AddField(description, nil, cs.Bold)
tp.AddField(utils.Pluralize(fileCount, "file"), nil, nil)
tp.AddField(visibility, nil, visColor)
if tp.IsTTY() {
updatedAt := utils.FuzzyAgo(time.Since(gist.UpdatedAt))
tp.AddField(updatedAt, nil, cs.Gray)
} else {
tp.AddField(gist.UpdatedAt.String(), nil, nil)
}
tp.EndRow()
}
return tp.Render()
}

View file

@ -0,0 +1,224 @@
package list
import (
"bytes"
"net/http"
"testing"
"time"
"github.com/cli/cli/pkg/cmd/gist/shared"
"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"
)
func TestNewCmdList(t *testing.T) {
tests := []struct {
name string
cli string
wants ListOptions
}{
{
name: "no arguments",
wants: ListOptions{
Limit: 10,
Visibility: "all",
},
},
{
name: "public",
cli: "--public",
wants: ListOptions{
Limit: 10,
Visibility: "public",
},
},
{
name: "secret",
cli: "--secret",
wants: ListOptions{
Limit: 10,
Visibility: "secret",
},
},
{
name: "public and secret",
cli: "--secret --public",
wants: ListOptions{
Limit: 10,
Visibility: "all",
},
},
{
name: "limit",
cli: "--limit 30",
wants: ListOptions{
Limit: 30,
Visibility: "all",
},
},
}
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()
assert.NoError(t, err)
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
assert.Equal(t, tt.wants.Limit, gotOpts.Limit)
})
}
}
func Test_listRun(t *testing.T) {
tests := []struct {
name string
opts *ListOptions
wantOut string
stubs func(*httpmock.Registry)
nontty bool
updatedAt *time.Time
}{
{
name: "no gists",
opts: &ListOptions{},
stubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("GET", "gists"),
httpmock.JSONResponse([]shared.Gist{}))
},
wantOut: "",
},
{
name: "default behavior",
opts: &ListOptions{},
wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n4567890123 1 file public about 6 hours ago\n2345678901 tea leaves thwart... 2 files secret about 6 hours ago\n3456789012 short desc 11 files secret about 6 hours ago\n",
},
{
name: "with public filter",
opts: &ListOptions{Visibility: "public"},
wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n4567890123 1 file public about 6 hours ago\n",
},
{
name: "with secret filter",
opts: &ListOptions{Visibility: "secret"},
wantOut: "2345678901 tea leaves thwart... 2 files secret about 6 hours ago\n3456789012 short desc 11 files secret about 6 hours ago\n",
},
{
name: "with limit",
opts: &ListOptions{Limit: 1},
wantOut: "1234567890 cool.txt 1 file public about 6 hours ago\n",
},
{
name: "nontty output",
opts: &ListOptions{},
updatedAt: &time.Time{},
wantOut: "1234567890\tcool.txt\t1 file\tpublic\t0001-01-01 00:00:00 +0000 UTC\n4567890123\t\t1 file\tpublic\t0001-01-01 00:00:00 +0000 UTC\n2345678901\ttea leaves thwart those who court catastrophe\t2 files\tsecret\t0001-01-01 00:00:00 +0000 UTC\n3456789012\tshort desc\t11 files\tsecret\t0001-01-01 00:00:00 +0000 UTC\n",
nontty: true,
},
}
for _, tt := range tests {
sixHoursAgo, _ := time.ParseDuration("-6h")
updatedAt := time.Now().Add(sixHoursAgo)
if tt.updatedAt != nil {
updatedAt = *tt.updatedAt
}
reg := &httpmock.Registry{}
if tt.stubs == nil {
reg.Register(httpmock.REST("GET", "gists"),
httpmock.JSONResponse([]shared.Gist{
{
ID: "1234567890",
UpdatedAt: updatedAt,
Description: "",
Files: map[string]*shared.GistFile{
"cool.txt": {},
},
Public: true,
},
{
ID: "4567890123",
UpdatedAt: updatedAt,
Description: "",
Files: map[string]*shared.GistFile{
"gistfile0.txt": {},
},
Public: true,
},
{
ID: "2345678901",
UpdatedAt: updatedAt,
Description: "tea leaves thwart those who court catastrophe",
Files: map[string]*shared.GistFile{
"gistfile0.txt": {},
"gistfile1.txt": {},
},
Public: false,
},
{
ID: "3456789012",
UpdatedAt: updatedAt,
Description: "short desc",
Files: map[string]*shared.GistFile{
"gistfile0.txt": {},
"gistfile1.txt": {},
"gistfile2.txt": {},
"gistfile3.txt": {},
"gistfile4.txt": {},
"gistfile5.txt": {},
"gistfile6.txt": {},
"gistfile7.txt": {},
"gistfile8.txt": {},
"gistfile9.txt": {},
"gistfile10.txt": {},
},
Public: false,
},
}))
} else {
tt.stubs(reg)
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, stdout, _ := iostreams.Test()
io.SetStdoutTTY(!tt.nontty)
tt.opts.IO = io
if tt.opts.Limit == 0 {
tt.opts.Limit = 10
}
if tt.opts.Visibility == "" {
tt.opts.Visibility = "all"
}
t.Run(tt.name, func(t *testing.T) {
err := listRun(tt.opts)
assert.NoError(t, err)
assert.Equal(t, tt.wantOut, stdout.String())
reg.Verify(t)
})
}
}

View file

@ -0,0 +1,39 @@
package shared
import (
"fmt"
"net/http"
"time"
"github.com/cli/cli/api"
)
// TODO make gist create use this file
type GistFile struct {
Filename string `json:"filename"`
Type string `json:"type,omitempty"`
Language string `json:"language,omitempty"`
Content string `json:"content"`
}
type Gist struct {
ID string `json:"id,omitempty"`
Description string `json:"description"`
Files map[string]*GistFile `json:"files"`
UpdatedAt time.Time `json:"updated_at"`
Public bool `json:"public"`
}
func GetGist(client *http.Client, hostname, gistID string) (*Gist, error) {
gist := Gist{}
path := fmt.Sprintf("gists/%s", gistID)
apiClient := api.NewClientFromHTTP(client)
err := apiClient.REST(hostname, "GET", path, nil, &gist)
if err != nil {
return nil, err
}
return &gist, nil
}

135
pkg/cmd/gist/view/view.go Normal file
View file

@ -0,0 +1,135 @@
package view
import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type ViewOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
Selector string
Filename string
Raw bool
Web bool
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "view {<gist id> | <gist url>}",
Short: "View a gist",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Selector = args[0]
if !opts.IO.IsStdoutTTY() {
opts.Raw = true
}
if runF != nil {
return runF(opts)
}
return viewRun(opts)
},
}
cmd.Flags().BoolVarP(&opts.Raw, "raw", "r", false, "do not try and render markdown")
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "open gist in browser")
cmd.Flags().StringVarP(&opts.Filename, "filename", "f", "", "display a single file of the gist")
return cmd
}
func viewRun(opts *ViewOptions) error {
gistID := opts.Selector
if opts.Web {
gistURL := gistID
if !strings.Contains(gistURL, "/") {
hostname := ghinstance.OverridableDefault()
gistURL = ghinstance.GistPrefix(hostname) + gistID
}
if opts.IO.IsStderrTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(gistURL))
}
return utils.OpenInBrowser(gistURL)
}
u, err := url.Parse(opts.Selector)
if err == nil {
if strings.HasPrefix(u.Path, "/") {
gistID = u.Path[1:]
}
}
client, err := opts.HttpClient()
if err != nil {
return err
}
gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID)
if err != nil {
return err
}
cs := opts.IO.ColorScheme()
if gist.Description != "" {
fmt.Fprintf(opts.IO.Out, "%s\n", cs.Bold(gist.Description))
}
if opts.Filename != "" {
gistFile, ok := gist.Files[opts.Filename]
if !ok {
return fmt.Errorf("gist has no such file %q", opts.Filename)
}
gist.Files = map[string]*shared.GistFile{
opts.Filename: gistFile,
}
}
showFilenames := len(gist.Files) > 1
outs := []string{} // to ensure consistent ordering
for filename, gistFile := range gist.Files {
out := ""
if showFilenames {
out += fmt.Sprintf("%s\n\n", cs.Gray(filename))
}
content := gistFile.Content
if strings.Contains(gistFile.Type, "markdown") && !opts.Raw {
rendered, err := utils.RenderMarkdown(gistFile.Content)
if err == nil {
content = rendered
}
}
out += fmt.Sprintf("%s\n\n", content)
outs = append(outs, out)
}
sort.Strings(outs)
for _, out := range outs {
fmt.Fprint(opts.IO.Out, out)
}
return nil
}

View file

@ -0,0 +1,217 @@
package view
import (
"bytes"
"net/http"
"testing"
"github.com/cli/cli/pkg/cmd/gist/shared"
"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"
)
func TestNewCmdView(t *testing.T) {
tests := []struct {
name string
cli string
wants ViewOptions
tty bool
}{
{
name: "tty no arguments",
tty: true,
cli: "123",
wants: ViewOptions{
Raw: false,
Selector: "123",
},
},
{
name: "nontty no arguments",
cli: "123",
wants: ViewOptions{
Raw: true,
Selector: "123",
},
},
{
name: "filename passed",
cli: "-fcool.txt 123",
tty: true,
wants: ViewOptions{
Raw: false,
Selector: "123",
Filename: "cool.txt",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(tt.tty)
f := &cmdutil.Factory{
IOStreams: io,
}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *ViewOptions
cmd := NewCmdView(f, func(opts *ViewOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
assert.NoError(t, err)
assert.Equal(t, tt.wants.Raw, gotOpts.Raw)
assert.Equal(t, tt.wants.Selector, gotOpts.Selector)
assert.Equal(t, tt.wants.Filename, gotOpts.Filename)
})
}
}
func Test_viewRun(t *testing.T) {
tests := []struct {
name string
opts *ViewOptions
wantOut string
gist *shared.Gist
wantErr bool
}{
{
name: "no such gist",
wantErr: true,
},
{
name: "one file",
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
},
},
wantOut: "bwhiizzzbwhuiiizzzz\n\n",
},
{
name: "filename selected",
opts: &ViewOptions{
Filename: "cicada.txt",
},
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"foo.md": {
Content: "# foo",
Type: "application/markdown",
},
},
},
wantOut: "bwhiizzzbwhuiiizzzz\n\n",
},
{
name: "multiple files, no description",
gist: &shared.Gist{
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"foo.md": {
Content: "# foo",
Type: "application/markdown",
},
},
},
wantOut: "cicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n # foo \n\n\n\n",
},
{
name: "multiple files, description",
gist: &shared.Gist{
Description: "some files",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"foo.md": {
Content: "- foo",
Type: "application/markdown",
},
},
},
wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n\n \n • foo \n\n\n\n",
},
{
name: "raw",
opts: &ViewOptions{
Raw: true,
},
gist: &shared.Gist{
Description: "some files",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
"foo.md": {
Content: "- foo",
Type: "application/markdown",
},
},
},
wantOut: "some files\ncicada.txt\n\nbwhiizzzbwhuiiizzzz\n\nfoo.md\n\n- foo\n\n",
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.gist == nil {
reg.Register(httpmock.REST("GET", "gists/1234"),
httpmock.StatusStringResponse(404, "Not Found"))
} else {
reg.Register(httpmock.REST("GET", "gists/1234"),
httpmock.JSONResponse(tt.gist))
}
if tt.opts == nil {
tt.opts = &ViewOptions{}
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, stdout, _ := iostreams.Test()
io.SetStdoutTTY(true)
tt.opts.IO = io
tt.opts.Selector = "1234"
t.Run(tt.name, func(t *testing.T) {
err := viewRun(tt.opts)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantOut, stdout.String())
reg.Verify(t)
})
}
}