Merge remote-tracking branch 'origin/api-template' into api-jq
This commit is contained in:
commit
4c26d617d3
22 changed files with 373 additions and 60 deletions
|
|
@ -13,6 +13,7 @@ import (
|
|||
"time"
|
||||
|
||||
surveyCore "github.com/AlecAivazis/survey/v2/core"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/build"
|
||||
"github.com/cli/cli/internal/config"
|
||||
|
|
@ -32,7 +33,21 @@ import (
|
|||
|
||||
var updaterEnabled = ""
|
||||
|
||||
type exitCode int
|
||||
|
||||
const (
|
||||
exitOK exitCode = 0
|
||||
exitError exitCode = 1
|
||||
exitCancel exitCode = 2
|
||||
exitAuth exitCode = 4
|
||||
)
|
||||
|
||||
func main() {
|
||||
code := mainRun()
|
||||
os.Exit(int(code))
|
||||
}
|
||||
|
||||
func mainRun() exitCode {
|
||||
buildDate := build.Date
|
||||
buildVersion := build.Version
|
||||
|
||||
|
|
@ -78,7 +93,7 @@ func main() {
|
|||
cfg, err := cmdFactory.Config()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "failed to read configuration: %s\n", err)
|
||||
os.Exit(2)
|
||||
return exitError
|
||||
}
|
||||
|
||||
if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" {
|
||||
|
|
@ -102,7 +117,7 @@ func main() {
|
|||
expandedArgs, isShell, err = expand.ExpandAlias(cfg, os.Args, nil)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "failed to process aliases: %s\n", err)
|
||||
os.Exit(2)
|
||||
return exitError
|
||||
}
|
||||
|
||||
if hasDebug {
|
||||
|
|
@ -113,7 +128,7 @@ func main() {
|
|||
exe, err := safeexec.LookPath(expandedArgs[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "failed to run external command: %s", err)
|
||||
os.Exit(3)
|
||||
return exitError
|
||||
}
|
||||
|
||||
externalCmd := exec.Command(exe, expandedArgs[1:]...)
|
||||
|
|
@ -125,14 +140,14 @@ func main() {
|
|||
err = preparedCmd.Run()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
os.Exit(ee.ExitCode())
|
||||
return exitCode(ee.ExitCode())
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "failed to run external command: %s", err)
|
||||
os.Exit(3)
|
||||
return exitError
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
return exitOK
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,34 +157,41 @@ func main() {
|
|||
fmt.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!"))
|
||||
fmt.Fprintln(stderr)
|
||||
fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.")
|
||||
os.Exit(4)
|
||||
return exitAuth
|
||||
}
|
||||
|
||||
rootCmd.SetArgs(expandedArgs)
|
||||
|
||||
if cmd, err := rootCmd.ExecuteC(); err != nil {
|
||||
if err == cmdutil.SilentError {
|
||||
return exitError
|
||||
} else if cmdutil.IsUserCancellation(err) {
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
// ensure the next shell prompt will start on its own line
|
||||
fmt.Fprint(stderr, "\n")
|
||||
}
|
||||
return exitCancel
|
||||
}
|
||||
|
||||
printError(stderr, err, cmd, hasDebug)
|
||||
|
||||
var httpErr api.HTTPError
|
||||
if errors.As(err, &httpErr) && httpErr.StatusCode == 401 {
|
||||
fmt.Println("hint: try authenticating with `gh auth login`")
|
||||
fmt.Fprintln(stderr, "hint: try authenticating with `gh auth login`")
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
return exitError
|
||||
}
|
||||
if root.HasFailed() {
|
||||
os.Exit(1)
|
||||
return exitError
|
||||
}
|
||||
|
||||
newRelease := <-updateMessageChan
|
||||
if newRelease != nil {
|
||||
isHomebrew := false
|
||||
if ghExe, err := os.Executable(); err == nil {
|
||||
isHomebrew = isUnderHomebrew(ghExe)
|
||||
}
|
||||
isHomebrew := isUnderHomebrew(cmdFactory.Executable)
|
||||
if isHomebrew && isRecentRelease(newRelease.PublishedAt) {
|
||||
// do not notify Homebrew users before the version bump had a chance to get merged into homebrew-core
|
||||
return
|
||||
return exitOK
|
||||
}
|
||||
fmt.Fprintf(stderr, "\n\n%s %s → %s\n",
|
||||
ansi.Color("A new release of gh is available:", "yellow"),
|
||||
|
|
@ -181,13 +203,11 @@ func main() {
|
|||
fmt.Fprintf(stderr, "%s\n\n",
|
||||
ansi.Color(newRelease.URL, "yellow"))
|
||||
}
|
||||
|
||||
return exitOK
|
||||
}
|
||||
|
||||
func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
|
||||
if err == cmdutil.SilentError {
|
||||
return
|
||||
}
|
||||
|
||||
var dnsError *net.DNSError
|
||||
if errors.As(err, &dnsError) {
|
||||
fmt.Fprintf(out, "error connecting to %s\n", dnsError.Name)
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
|
|
@ -38,6 +37,7 @@ type ApiOptions struct {
|
|||
MagicFields []string
|
||||
RawFields []string
|
||||
RequestHeaders []string
|
||||
Previews []string
|
||||
ShowResponseHeaders bool
|
||||
Paginate bool
|
||||
Silent bool
|
||||
|
|
@ -124,7 +124,10 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
$ gh api -X GET search/issues -f q='repo:cli/cli is:open remote'
|
||||
|
||||
# set a custom HTTP header
|
||||
$ gh api -H 'Accept: application/vnd.github.XYZ-preview+json' ...
|
||||
$ gh api -H 'Accept: application/vnd.github.v3.raw+json' ...
|
||||
|
||||
# opt into GitHub API previews
|
||||
$ gh api --preview baptiste,nebula ...
|
||||
|
||||
# print only specific fields from the response
|
||||
$ gh api repos/:owner/:repo/issues --filter '.[].title'
|
||||
|
|
@ -200,6 +203,7 @@ func NewCmdApi(f *cmdutil.Factory, runF func(*ApiOptions) error) *cobra.Command
|
|||
cmd.Flags().StringArrayVarP(&opts.MagicFields, "field", "F", nil, "Add a typed parameter in `key=value` format")
|
||||
cmd.Flags().StringArrayVarP(&opts.RawFields, "raw-field", "f", nil, "Add a string parameter in `key=value` format")
|
||||
cmd.Flags().StringArrayVarP(&opts.RequestHeaders, "header", "H", nil, "Add a HTTP request header in `key:value` format")
|
||||
cmd.Flags().StringSliceVarP(&opts.Previews, "preview", "p", nil, "Opt into GitHub API previews")
|
||||
cmd.Flags().BoolVarP(&opts.ShowResponseHeaders, "include", "i", false, "Include HTTP response headers in the output")
|
||||
cmd.Flags().BoolVar(&opts.Paginate, "paginate", false, "Make additional HTTP requests to fetch all pages of results")
|
||||
cmd.Flags().StringVar(&opts.RequestInputFile, "input", "", "The `file` to use as body for the HTTP request")
|
||||
|
|
@ -246,6 +250,10 @@ func apiRun(opts *ApiOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
if len(opts.Previews) > 0 {
|
||||
requestHeaders = append(requestHeaders, "Accept: "+previewNamesToMIMETypes(opts.Previews))
|
||||
}
|
||||
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -341,25 +349,7 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream
|
|||
}
|
||||
} else if opts.Template != "" {
|
||||
// TODO: reuse parsed template across pagination invocations
|
||||
var t *template.Template
|
||||
t, err = parseTemplate(opts.Template, opts.IO.ColorEnabled())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var jsonData []byte
|
||||
jsonData, err = ioutil.ReadAll(responseBody)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var m interface{}
|
||||
err = json.Unmarshal(jsonData, &m)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = t.Execute(opts.IO.Out, m)
|
||||
err = executeTemplate(opts.IO.Out, responseBody, opts.Template, opts.IO.ColorEnabled())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
|
@ -570,3 +560,11 @@ func parseErrorResponse(r io.Reader, statusCode int) (io.Reader, string, error)
|
|||
|
||||
return bodyCopy, "", nil
|
||||
}
|
||||
|
||||
func previewNamesToMIMETypes(names []string) string {
|
||||
types := []string{fmt.Sprintf("application/vnd.github.%s-preview+json", names[0])}
|
||||
for _, p := range names[1:] {
|
||||
types = append(types, fmt.Sprintf("application/vnd.github.%s-preview", p))
|
||||
}
|
||||
return strings.Join(types, ", ")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -66,6 +67,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -85,6 +87,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -104,6 +107,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -123,6 +127,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: true,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -142,6 +147,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: true,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -166,6 +172,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: true,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -190,6 +197,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -214,6 +222,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -233,6 +242,27 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: time.Minute * 5,
|
||||
Template: "",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "with template",
|
||||
cli: "user -t 'hello {{.name}}'",
|
||||
wants: ApiOptions{
|
||||
Hostname: "",
|
||||
RequestMethod: "GET",
|
||||
RequestMethodPassed: false,
|
||||
RequestPath: "user",
|
||||
RequestInputFile: "",
|
||||
RawFields: []string(nil),
|
||||
MagicFields: []string(nil),
|
||||
RequestHeaders: []string(nil),
|
||||
ShowResponseHeaders: false,
|
||||
Paginate: false,
|
||||
Silent: false,
|
||||
CacheTTL: 0,
|
||||
Template: "hello {{.name}}",
|
||||
},
|
||||
wantsErr: false,
|
||||
},
|
||||
|
|
@ -270,6 +300,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.Paginate, opts.Paginate)
|
||||
assert.Equal(t, tt.wants.Silent, opts.Silent)
|
||||
assert.Equal(t, tt.wants.CacheTTL, opts.CacheTTL)
|
||||
assert.Equal(t, tt.wants.Template, opts.Template)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -395,6 +426,20 @@ func Test_apiRun(t *testing.T) {
|
|||
stdout: "HTTP/1.1 200 Okey-dokey\nContent-Type: text/plain\r\n\r\n",
|
||||
stderr: ``,
|
||||
},
|
||||
{
|
||||
name: "output template",
|
||||
options: ApiOptions{
|
||||
Template: `{{.status}}`,
|
||||
},
|
||||
httpResponse: &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(`{"status":"not a cat"}`)),
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
},
|
||||
err: nil,
|
||||
stdout: "not a cat",
|
||||
stderr: ``,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -913,6 +958,32 @@ func Test_fillPlaceholders(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_previewNamesToMIMETypes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
previews []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single",
|
||||
previews: []string{"nebula"},
|
||||
want: "application/vnd.github.nebula-preview+json",
|
||||
},
|
||||
{
|
||||
name: "multiple",
|
||||
previews: []string{"nebula", "baptiste", "squirrel-girl"},
|
||||
want: "application/vnd.github.nebula-preview+json, application/vnd.github.baptiste-preview, application/vnd.github.squirrel-girl-preview",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := previewNamesToMIMETypes(tt.previews); got != tt.want {
|
||||
t.Errorf("previewNamesToMIMETypes() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_processResponse_template(t *testing.T) {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -31,7 +34,7 @@ func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
|
|||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return utils.FuzzyAgoAbbr(now, t), nil
|
||||
return timeAgo(now.Sub(t)), nil
|
||||
},
|
||||
|
||||
"pluck": templatePluck,
|
||||
|
|
@ -47,6 +50,25 @@ func parseTemplate(tpl string, colorEnabled bool) (*template.Template, error) {
|
|||
return template.New("").Funcs(templateFuncs).Parse(tpl)
|
||||
}
|
||||
|
||||
func executeTemplate(w io.Writer, input io.Reader, templateStr string, colorEnabled bool) error {
|
||||
t, err := parseTemplate(templateStr, colorEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
jsonData, err := ioutil.ReadAll(input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var data interface{}
|
||||
if err := json.Unmarshal(jsonData, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return t.Execute(w, data)
|
||||
}
|
||||
|
||||
func jsonScalarToString(input interface{}) (string, error) {
|
||||
switch tt := input.(type) {
|
||||
case string:
|
||||
|
|
@ -94,3 +116,22 @@ func templateJoin(sep string, input []interface{}) (string, error) {
|
|||
}
|
||||
return strings.Join(results, sep), nil
|
||||
}
|
||||
|
||||
func timeAgo(ago time.Duration) string {
|
||||
if ago < time.Minute {
|
||||
return "just now"
|
||||
}
|
||||
if ago < time.Hour {
|
||||
return utils.Pluralize(int(ago.Minutes()), "minute") + " ago"
|
||||
}
|
||||
if ago < 24*time.Hour {
|
||||
return utils.Pluralize(int(ago.Hours()), "hour") + " ago"
|
||||
}
|
||||
if ago < 30*24*time.Hour {
|
||||
return utils.Pluralize(int(ago.Hours())/24, "day") + " ago"
|
||||
}
|
||||
if ago < 365*24*time.Hour {
|
||||
return utils.Pluralize(int(ago.Hours())/24/30, "month") + " ago"
|
||||
}
|
||||
return utils.Pluralize(int(ago.Hours()/24/365), "year") + " ago"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
package api
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
)
|
||||
|
||||
func Test_jsonScalarToString(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -58,3 +67,97 @@ func Test_jsonScalarToString(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_executeTemplate(t *testing.T) {
|
||||
type args struct {
|
||||
json io.Reader
|
||||
template string
|
||||
colorize bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantW string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "color",
|
||||
args: args{
|
||||
json: strings.NewReader(`{}`),
|
||||
template: `{{color "blue+h" "songs are like tattoos"}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "\x1b[0;94msongs are like tattoos\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "autocolor enabled",
|
||||
args: args{
|
||||
json: strings.NewReader(`{}`),
|
||||
template: `{{autocolor "red" "stop"}}`,
|
||||
colorize: true,
|
||||
},
|
||||
wantW: "\x1b[0;31mstop\x1b[0m",
|
||||
},
|
||||
{
|
||||
name: "autocolor disabled",
|
||||
args: args{
|
||||
json: strings.NewReader(`{}`),
|
||||
template: `{{autocolor "red" "go"}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "go",
|
||||
},
|
||||
{
|
||||
name: "timefmt",
|
||||
args: args{
|
||||
json: strings.NewReader(`{"created_at":"2008-02-25T20:18:33Z"}`),
|
||||
template: `{{.created_at | timefmt "Mon Jan 2, 2006"}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "Mon Feb 25, 2008",
|
||||
},
|
||||
{
|
||||
name: "timeago",
|
||||
args: args{
|
||||
json: strings.NewReader(fmt.Sprintf(`{"created_at":"%s"}`, time.Now().Add(-5*time.Minute).Format(time.RFC3339))),
|
||||
template: `{{.created_at | timeago}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "5 minutes ago",
|
||||
},
|
||||
{
|
||||
name: "pluck",
|
||||
args: args{
|
||||
json: strings.NewReader(heredoc.Doc(`[
|
||||
{"name": "bug"},
|
||||
{"name": "feature request"},
|
||||
{"name": "chore"}
|
||||
]`)),
|
||||
template: `{{range(pluck "name" .)}}{{. | printf "%s\n"}}{{end}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "bug\nfeature request\nchore\n",
|
||||
},
|
||||
{
|
||||
name: "join",
|
||||
args: args{
|
||||
json: strings.NewReader(`[ "bug", "feature request", "chore" ]`),
|
||||
template: `{{join "\t" .}}`,
|
||||
colorize: false,
|
||||
},
|
||||
wantW: "bug\tfeature request\tchore",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
w := &bytes.Buffer{}
|
||||
if err := executeTemplate(w, tt.args.json, tt.args.template, tt.args.colorize); (err != nil) != tt.wantErr {
|
||||
t.Errorf("executeTemplate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if gotW := w.String(); gotW != tt.wantW {
|
||||
t.Errorf("executeTemplate() = %q, want %q", gotW, tt.wantW)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ type LoginOptions struct {
|
|||
Config func() (config.Config, error)
|
||||
HttpClient func() (*http.Client, error)
|
||||
|
||||
MainExecutable string
|
||||
|
||||
Interactive bool
|
||||
|
||||
Hostname string
|
||||
|
|
@ -36,6 +38,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm
|
|||
IO: f.IOStreams,
|
||||
Config: f.Config,
|
||||
HttpClient: f.HttpClient,
|
||||
|
||||
MainExecutable: f.Executable,
|
||||
}
|
||||
|
||||
var tokenStdin bool
|
||||
|
|
@ -189,6 +193,7 @@ func loginRun(opts *LoginOptions) error {
|
|||
Interactive: opts.Interactive,
|
||||
Web: opts.Web,
|
||||
Scopes: opts.Scopes,
|
||||
Executable: opts.MainExecutable,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ type RefreshOptions struct {
|
|||
IO *iostreams.IOStreams
|
||||
Config func() (config.Config, error)
|
||||
|
||||
MainExecutable string
|
||||
|
||||
Hostname string
|
||||
Scopes []string
|
||||
AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error
|
||||
|
|
@ -34,6 +36,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.
|
|||
_, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes)
|
||||
return err
|
||||
},
|
||||
MainExecutable: f.Executable,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ import (
|
|||
)
|
||||
|
||||
type GitCredentialFlow struct {
|
||||
Executable string
|
||||
|
||||
shouldSetup bool
|
||||
helper string
|
||||
scopes []string
|
||||
|
|
@ -50,13 +52,26 @@ func (flow *GitCredentialFlow) ShouldSetup() bool {
|
|||
}
|
||||
|
||||
func (flow *GitCredentialFlow) Setup(hostname, username, authToken string) error {
|
||||
return GitCredentialSetup(hostname, username, authToken, flow.helper)
|
||||
return flow.gitCredentialSetup(hostname, username, authToken)
|
||||
}
|
||||
|
||||
func GitCredentialSetup(hostname, username, password, helper string) error {
|
||||
if helper == "" {
|
||||
func (flow *GitCredentialFlow) gitCredentialSetup(hostname, username, password string) error {
|
||||
if flow.helper == "" {
|
||||
// first use a blank value to indicate to git we want to sever the chain of credential helpers
|
||||
preConfigureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = run.PrepareCmd(preConfigureCmd).Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// use GitHub CLI as a credential helper (for this host only)
|
||||
configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential")
|
||||
configureCmd, err := git.GitCommand(
|
||||
"config", "--global", "--add",
|
||||
gitCredentialHelperKey(hostname),
|
||||
fmt.Sprintf("!%s auth git-credential", shellQuote(flow.Executable)),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
@ -124,3 +139,10 @@ func isOurCredentialHelper(cmd string) bool {
|
|||
|
||||
return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh"
|
||||
}
|
||||
|
||||
func shellQuote(s string) string {
|
||||
if strings.ContainsAny(s, " $") {
|
||||
return "'" + s + "'"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,12 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) {
|
|||
cs.Register(`git credential reject`, 0, "")
|
||||
cs.Register(`git credential approve`, 0, "")
|
||||
|
||||
if err := GitCredentialSetup("example.com", "monalisa", "PASSWD", "osxkeychain"); err != nil {
|
||||
f := GitCredentialFlow{
|
||||
Executable: "gh",
|
||||
helper: "osxkeychain",
|
||||
}
|
||||
|
||||
if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil {
|
||||
t.Errorf("GitCredentialSetup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -20,13 +25,29 @@ func TestGitCredentialSetup_configureExisting(t *testing.T) {
|
|||
func TestGitCredentialSetup_setOurs(t *testing.T) {
|
||||
cs, restoreRun := run.Stub()
|
||||
defer restoreRun(t)
|
||||
cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) {
|
||||
if val := args[len(args)-1]; val != "!gh auth git-credential" {
|
||||
cs.Register(`git config --global credential\.`, 0, "", func(args []string) {
|
||||
if key := args[len(args)-2]; key != "credential.https://example.com.helper" {
|
||||
t.Errorf("git config key was %q", key)
|
||||
}
|
||||
if val := args[len(args)-1]; val != "" {
|
||||
t.Errorf("global credential helper configured to %q", val)
|
||||
}
|
||||
})
|
||||
cs.Register(`git config --global --add credential\.`, 0, "", func(args []string) {
|
||||
if key := args[len(args)-2]; key != "credential.https://example.com.helper" {
|
||||
t.Errorf("git config key was %q", key)
|
||||
}
|
||||
if val := args[len(args)-1]; val != "!/path/to/gh auth git-credential" {
|
||||
t.Errorf("global credential helper configured to %q", val)
|
||||
}
|
||||
})
|
||||
|
||||
if err := GitCredentialSetup("example.com", "monalisa", "PASSWD", ""); err != nil {
|
||||
f := GitCredentialFlow{
|
||||
Executable: "/path/to/gh",
|
||||
helper: "",
|
||||
}
|
||||
|
||||
if err := f.gitCredentialSetup("example.com", "monalisa", "PASSWD"); err != nil {
|
||||
t.Errorf("GitCredentialSetup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ type LoginOptions struct {
|
|||
Interactive bool
|
||||
Web bool
|
||||
Scopes []string
|
||||
Executable string
|
||||
|
||||
sshContext sshContext
|
||||
}
|
||||
|
|
@ -56,7 +57,7 @@ func Login(opts *LoginOptions) error {
|
|||
|
||||
var additionalScopes []string
|
||||
|
||||
credentialFlow := &GitCredentialFlow{}
|
||||
credentialFlow := &GitCredentialFlow{Executable: opts.Executable}
|
||||
if opts.Interactive && gitProtocol == "https" {
|
||||
if err := credentialFlow.Prompt(hostname); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -44,6 +44,11 @@ func New(appVersion string) *cmdutil.Factory {
|
|||
}
|
||||
remotesFunc := rr.Resolver(hostOverride)
|
||||
|
||||
ghExecutable := "gh"
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
ghExecutable = exe
|
||||
}
|
||||
|
||||
return &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
Config: configFunc,
|
||||
|
|
@ -70,5 +75,6 @@ func New(appVersion string) *cmdutil.Factory {
|
|||
}
|
||||
return currentBranch, nil
|
||||
},
|
||||
Executable: ghExecutable,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ func editRun(opts *EditOptions) error {
|
|||
case "Submit":
|
||||
stop = true
|
||||
case "Cancel":
|
||||
return cmdutil.SilentError
|
||||
return cmdutil.CancelError
|
||||
}
|
||||
|
||||
if stop {
|
||||
|
|
|
|||
|
|
@ -194,7 +194,7 @@ func Test_editRun(t *testing.T) {
|
|||
as.StubOne("unix.md")
|
||||
as.StubOne("Cancel")
|
||||
},
|
||||
wantErr: "SilentError",
|
||||
wantErr: "CancelError",
|
||||
gist: &shared.Gist{
|
||||
ID: "1234",
|
||||
Files: map[string]*shared.GistFile{
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
|
||||
if action == prShared.CancelAction {
|
||||
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
|
||||
err = cmdutil.CancelError
|
||||
return
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -311,7 +311,8 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
|
||||
if action == shared.CancelAction {
|
||||
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
|
||||
return nil
|
||||
err = cmdutil.CancelError
|
||||
return
|
||||
}
|
||||
|
||||
err = handlePush(*opts, *ctx)
|
||||
|
|
@ -553,7 +554,7 @@ func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
|
|||
} else if pushOptions[selectedOption] == "Skip pushing the branch" {
|
||||
isPushEnabled = false
|
||||
} else if pushOptions[selectedOption] == "Cancel" {
|
||||
return nil, cmdutil.SilentError
|
||||
return nil, cmdutil.CancelError
|
||||
} else {
|
||||
// "Create a fork of ..."
|
||||
if baseRepo.IsPrivate {
|
||||
|
|
|
|||
|
|
@ -227,7 +227,7 @@ func mergeRun(opts *MergeOptions) error {
|
|||
}
|
||||
if action == shared.CancelAction {
|
||||
fmt.Fprintln(opts.IO.ErrOut, "Cancelled.")
|
||||
return cmdutil.SilentError
|
||||
return cmdutil.CancelError
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -895,7 +895,7 @@ func TestPRMerge_interactiveCancelled(t *testing.T) {
|
|||
as.StubOne("Cancel") // Confirm submit survey
|
||||
|
||||
output, err := runCommand(http, "blueberries", true, "")
|
||||
if !errors.Is(err, cmdutil.SilentError) {
|
||||
if !errors.Is(err, cmdutil.CancelError) {
|
||||
t.Fatalf("got error %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
)
|
||||
|
||||
|
|
@ -18,6 +19,11 @@ func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr
|
|||
return
|
||||
}
|
||||
|
||||
if cmdutil.IsUserCancellation(*createErr) {
|
||||
// these errors are user-initiated cancellations
|
||||
return
|
||||
}
|
||||
|
||||
out := io.ErrOut
|
||||
|
||||
// this extra newline guards against appending to the end of a survey line
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ func createRun(opts *CreateOptions) error {
|
|||
case "Save as draft":
|
||||
opts.Draft = true
|
||||
case "Cancel":
|
||||
return cmdutil.SilentError
|
||||
return cmdutil.CancelError
|
||||
default:
|
||||
return fmt.Errorf("invalid action: %v", opts.SubmitAction)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -78,7 +78,7 @@ func deleteRun(opts *DeleteOptions) error {
|
|||
}
|
||||
|
||||
if !confirmed {
|
||||
return cmdutil.SilentError
|
||||
return cmdutil.CancelError
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package cmdutil
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
)
|
||||
|
||||
// FlagError is the kind of error raised in flag processing
|
||||
type FlagError struct {
|
||||
|
|
@ -17,3 +21,10 @@ func (fe FlagError) Unwrap() error {
|
|||
|
||||
// SilentError is an error that triggers exit code 1 without any error messaging
|
||||
var SilentError = errors.New("SilentError")
|
||||
|
||||
// CancelError signals user-initiated cancellation
|
||||
var CancelError = errors.New("CancelError")
|
||||
|
||||
func IsUserCancellation(err error) bool {
|
||||
return errors.Is(err, CancelError) || errors.Is(err, terminal.InterruptErr)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,4 +16,7 @@ type Factory struct {
|
|||
Remotes func() (context.Remotes, error)
|
||||
Config func() (config.Config, error)
|
||||
Branch func() (string, error)
|
||||
|
||||
// Executable is the path to the currently invoked gh binary
|
||||
Executable string
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue