diff --git a/api/client.go b/api/client.go index bfd526fbc..9fd176006 100644 --- a/api/client.go +++ b/api/client.go @@ -316,41 +316,55 @@ func graphQLClient(h *http.Client, hostname string) *graphql.Client { // REST performs a REST request and parses the response. func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error { + _, err := c.RESTWithNext(hostname, method, p, body, data) + return err +} + +func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) { req, err := http.NewRequest(method, restURL(hostname, p), body) if err != nil { - return err + return "", err } req.Header.Set("Content-Type", "application/json; charset=utf-8") resp, err := c.http.Do(req) if err != nil { - return err + return "", err } defer resp.Body.Close() success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { - return HandleHTTPError(resp) + return "", HandleHTTPError(resp) } if resp.StatusCode == http.StatusNoContent { - return nil + return "", nil } b, err := ioutil.ReadAll(resp.Body) if err != nil { - return err + return "", err } err = json.Unmarshal(b, &data) if err != nil { - return err + return "", err } - return nil + var next string + for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) { + if len(m) > 2 && m[2] == "next" { + next = m[1] + } + } + + return next, nil } +var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`) + func restURL(hostname string, pathOrURL string) string { if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") { return pathOrURL diff --git a/cmd/gh/main.go b/cmd/gh/main.go index a8f1ed141..9a2501f49 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -58,7 +58,7 @@ func mainRun() exitCode { updateMessageChan <- rel }() - hasDebug := os.Getenv("DEBUG") != "" + hasDebug, _ := utils.IsDebugEnabled() cmdFactory := factory.New(buildVersion) stderr := cmdFactory.IOStreams.ErrOut @@ -327,8 +327,10 @@ func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) { // does not depend on user configuration func basicClient(currentVersion string) (*api.Client, error) { var opts []api.ClientOption - if verbose := os.Getenv("DEBUG"); verbose != "" { - opts = append(opts, apiVerboseLog()) + if isVerbose, debugValue := utils.IsDebugEnabled(); isVerbose { + colorize := utils.IsTerminal(os.Stderr) + logTraffic := strings.Contains(debugValue, "api") + opts = append(opts, api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize)) } opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", currentVersion))) @@ -344,12 +346,6 @@ func basicClient(currentVersion string) (*api.Client, error) { return api.NewClient(opts...), nil } -func apiVerboseLog() api.ClientOption { - logTraffic := strings.Contains(os.Getenv("DEBUG"), "api") - colorize := utils.IsTerminal(os.Stderr) - return api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize) -} - func isRecentRelease(publishedAt time.Time) bool { return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24 } diff --git a/go.mod b/go.mod index e04ff252e..e16a661d9 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,13 @@ require ( github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.18.1 github.com/charmbracelet/glamour v0.4.0 + github.com/charmbracelet/lipgloss v0.5.0 github.com/cli/browser v1.1.0 github.com/cli/oauth v0.9.0 github.com/cli/safeexec v1.0.0 github.com/cli/shurcooL-graphql v0.0.1 github.com/cpuguy83/go-md2man/v2 v2.0.1 - github.com/creack/pty v1.1.17 + github.com/creack/pty v1.1.18 github.com/gabriel-vasile/mimetype v1.4.0 github.com/google/go-cmp v0.5.7 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 @@ -27,7 +28,7 @@ require ( github.com/mattn/go-isatty v0.0.14 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/muesli/reflow v0.3.0 - github.com/muesli/termenv v0.9.0 + github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 github.com/opentracing/opentracing-go v1.1.0 github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b diff --git a/go.sum b/go.sum index 3c76c15a1..10e63ce28 100644 --- a/go.sum +++ b/go.sum @@ -48,6 +48,8 @@ github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/pp github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k= github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -66,8 +68,9 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cpuguy83/go-md2man/v2 v2.0.1 h1:r/myEWzV9lfsM1tFLgDyu0atFtJ1fXn261LKYj/3DxU= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/creack/pty v1.1.17 h1:QeVUsEDNrLBW4tMgZHvxy18sKtr6VI492kBhUfhDJNI= github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -181,6 +184,7 @@ github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -189,10 +193,12 @@ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQ github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y= github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.9.0 h1:wnbOaGz+LUR3jNT0zOzinPnyDaCZUQRZj9GxK8eRVl8= github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo= github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go index fbf0a9e34..c809e455a 100644 --- a/internal/authflow/flow.go +++ b/internal/authflow/flow.go @@ -12,6 +12,7 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/pkg/cmdutil" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/utils" "github.com/cli/oauth" ) @@ -53,8 +54,9 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition cs := IO.ColorScheme() httpClient := http.DefaultClient - if envDebug := os.Getenv("DEBUG"); envDebug != "" { - logTraffic := strings.Contains(envDebug, "api") || strings.Contains(envDebug, "oauth") + debugEnabled, debugValue := utils.IsDebugEnabled() + if debugEnabled { + logTraffic := strings.Contains(debugValue, "api") httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport) } diff --git a/internal/run/run.go b/internal/run/run.go index 58fb189e3..d482e04cc 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -8,6 +8,8 @@ import ( "os/exec" "path/filepath" "strings" + + "github.com/cli/cli/v2/utils" ) // Runnable is typically an exec.Cmd or its stub in tests @@ -28,7 +30,7 @@ type cmdWithStderr struct { } func (c cmdWithStderr) Output() ([]byte, error) { - if os.Getenv("DEBUG") != "" { + if isVerbose, _ := utils.IsDebugEnabled(); isVerbose { _ = printArgs(os.Stderr, c.Cmd.Args) } if c.Cmd.Stderr != nil { @@ -44,7 +46,7 @@ func (c cmdWithStderr) Output() ([]byte, error) { } func (c cmdWithStderr) Run() error { - if os.Getenv("DEBUG") != "" { + if isVerbose, _ := utils.IsDebugEnabled(); isVerbose { _ = printArgs(os.Stderr, c.Cmd.Args) } if c.Cmd.Stderr != nil { diff --git a/pkg/cmd/codespace/ssh.go b/pkg/cmd/codespace/ssh.go index 97fc1974e..ebf871a1a 100644 --- a/pkg/cmd/codespace/ssh.go +++ b/pkg/cmd/codespace/ssh.go @@ -59,7 +59,7 @@ func newSSHCmd(app *App) *cobra.Command { $ gh codespace ssh $ gh codespace ssh --config > ~/.ssh/codespaces - $ echo 'include ~/.ssh/codespaces' >> ~/.ssh/config + $ printf 'Match all\nInclude ~/.ssh/codespaces\n' >> ~/.ssh/config `), PreRunE: func(c *cobra.Command, args []string) error { if opts.stdio { diff --git a/pkg/cmd/extension/command.go b/pkg/cmd/extension/command.go index 1c929482f..785ac0d88 100644 --- a/pkg/cmd/extension/command.go +++ b/pkg/cmd/extension/command.go @@ -65,7 +65,13 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { if !c.IsBinary() && len(version) > 8 { version = version[:8] } - t.AddField(version, nil, nil) + + if c.IsPinned() { + t.AddField(version, nil, cs.Cyan) + } else { + t.AddField(version, nil, nil) + } + var updateAvailable string if c.UpdateAvailable() { updateAvailable = "Upgrade available" @@ -76,10 +82,12 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { return t.Render() }, }, - &cobra.Command{ - Use: "install ", - Short: "Install a gh extension from a repository", - Long: heredoc.Doc(` + func() *cobra.Command { + var pinFlag string + cmd := &cobra.Command{ + Use: "install ", + Short: "Install a gh extension from a repository", + Long: heredoc.Doc(` Install a GitHub repository locally as a GitHub CLI extension. The repository argument can be specified in "owner/repo" format as well as a full URL. @@ -90,41 +98,57 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { See the list of available extensions at . `), - Example: heredoc.Doc(` + Example: heredoc.Doc(` $ gh extension install owner/gh-extension $ gh extension install https://git.example.com/owner/gh-extension $ gh extension install . `), - Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), - RunE: func(cmd *cobra.Command, args []string) error { - if args[0] == "." { - wd, err := os.Getwd() + Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"), + RunE: func(cmd *cobra.Command, args []string) error { + if args[0] == "." { + if pinFlag != "" { + return fmt.Errorf("local extensions cannot be pinned") + } + wd, err := os.Getwd() + if err != nil { + return err + } + return m.InstallLocal(wd) + } + + repo, err := ghrepo.FromFullName(args[0]) if err != nil { return err } - return m.InstallLocal(wd) - } - repo, err := ghrepo.FromFullName(args[0]) - if err != nil { - return err - } + if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { + return err + } - if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil { - return err - } - - if err := m.Install(repo); err != nil { - return err - } - - if io.IsStdoutTTY() { cs := io.ColorScheme() - fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) - } - return nil - }, - }, + if err := m.Install(repo, pinFlag); err != nil { + if errors.Is(err, releaseNotFoundErr) { + return fmt.Errorf("%s Could not find a release of %s for %s", + cs.FailureIcon(), args[0], cs.Cyan(pinFlag)) + } else if errors.Is(err, commitNotFoundErr) { + return fmt.Errorf("%s %s does not exist in %s", + cs.FailureIcon(), cs.Cyan(pinFlag), args[0]) + } + return err + } + + if io.IsStdoutTTY() { + fmt.Fprintf(io.Out, "%s Installed extension %s\n", cs.SuccessIcon(), args[0]) + if pinFlag != "" { + fmt.Fprintf(io.Out, "%s Pinned extension at %s\n", cs.SuccessIcon(), cs.Cyan(pinFlag)) + } + } + return nil + }, + } + cmd.Flags().StringVar(&pinFlag, "pin", "", "pin extension to a release tag or commit ref") + return cmd + }(), func() *cobra.Command { var flagAll bool var flagForce bool @@ -153,6 +177,9 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command { if err != nil && !errors.Is(err, upToDateError) { if name != "" { fmt.Fprintf(io.ErrOut, "%s Failed upgrading extension %s: %s\n", cs.FailureIcon(), name, err) + } else if errors.Is(err, noExtensionsInstalledError) { + fmt.Fprintf(io.ErrOut, "%s No installed extensions found\n", cs.WarningIcon()) + return nil } else { fmt.Fprintf(io.ErrOut, "%s Failed upgrading extensions\n", cs.FailureIcon()) } diff --git a/pkg/cmd/extension/command_test.go b/pkg/cmd/extension/command_test.go index 7e5520fa4..0a3c4f626 100644 --- a/pkg/cmd/extension/command_test.go +++ b/pkg/cmd/extension/command_test.go @@ -44,7 +44,7 @@ func TestNewCmdExtension(t *testing.T) { em.ListFunc = func(bool) []extensions.Extension { return []extensions.Extension{} } - em.InstallFunc = func(_ ghrepo.Interface) error { + em.InstallFunc = func(_ ghrepo.Interface, _ string) error { return nil } return func(t *testing.T) { @@ -86,6 +86,13 @@ func TestNewCmdExtension(t *testing.T) { } }, }, + { + name: "install local extension with pin", + args: []string{"install", ".", "--pin", "v1.0.0"}, + wantErr: true, + errMsg: "local extensions cannot be pinned", + isTTY: true, + }, { name: "upgrade argument error", args: []string{"upgrade"}, @@ -207,6 +214,22 @@ func TestNewCmdExtension(t *testing.T) { isTTY: true, wantStdout: "✓ Successfully upgraded extensions\n", }, + { + name: "upgrade all none installed", + args: []string{"upgrade", "--all"}, + managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) { + em.UpgradeFunc = func(name string, force bool) error { + return noExtensionsInstalledError + } + return func(t *testing.T) { + calls := em.UpgradeCalls() + assert.Equal(t, 1, len(calls)) + assert.Equal(t, "", calls[0].Name) + } + }, + isTTY: true, + wantStderr: "! No installed extensions found\n", + }, { name: "upgrade all notty", args: []string{"upgrade", "--all"}, diff --git a/pkg/cmd/extension/extension.go b/pkg/cmd/extension/extension.go index d1b814c57..e4f109d83 100644 --- a/pkg/cmd/extension/extension.go +++ b/pkg/cmd/extension/extension.go @@ -18,6 +18,7 @@ type Extension struct { path string url string isLocal bool + isPinned bool currentVersion string latestVersion string kind ExtensionKind @@ -43,8 +44,13 @@ func (e *Extension) CurrentVersion() string { return e.currentVersion } +func (e *Extension) IsPinned() bool { + return e.isPinned +} + func (e *Extension) UpdateAvailable() bool { - if e.isLocal || + if e.isPinned || + e.isLocal || e.currentVersion == "" || e.latestVersion == "" || e.currentVersion == e.latestVersion { diff --git a/pkg/cmd/extension/http.go b/pkg/cmd/extension/http.go index cfae2b738..7208b1569 100644 --- a/pkg/cmd/extension/http.go +++ b/pkg/cmd/extension/http.go @@ -2,6 +2,7 @@ package extension import ( "encoding/json" + "errors" "fmt" "io" "io/ioutil" @@ -80,6 +81,9 @@ func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string) return err } +var releaseNotFoundErr = errors.New("release not found") +var commitNotFoundErr = errors.New("commit not found") + // fetchLatestRelease finds the latest published release for a repository. func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) { path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName()) @@ -112,3 +116,71 @@ func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*re return &r, nil } + +// fetchReleaseFromTag finds release by tag name for a repository +func fetchReleaseFromTag(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*release, error) { + fullRepoName := fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), baseRepo.RepoName()) + path := fmt.Sprintf("repos/%s/releases/tags/%s", fullRepoName, tagName) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + if resp.StatusCode == 404 { + return nil, releaseNotFoundErr + } + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var r release + err = json.Unmarshal(b, &r) + if err != nil { + return nil, err + } + + return &r, nil +} + +// fetchCommitSHA finds full commit SHA from a target ref in a repo +func fetchCommitSHA(httpClient *http.Client, baseRepo ghrepo.Interface, targetRef string) (string, error) { + path := fmt.Sprintf("repos/%s/%s/commits/%s", baseRepo.RepoOwner(), baseRepo.RepoName(), targetRef) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return "", err + } + + req.Header.Set("Accept", "application/vnd.github.VERSION.sha") + resp, err := httpClient.Do(req) + if err != nil { + return "", err + } + + defer resp.Body.Close() + if resp.StatusCode == 422 { + return "", commitNotFoundErr + } + if resp.StatusCode > 299 { + return "", api.HandleHTTPError(resp) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return "", err + } + + return string(body), nil +} diff --git a/pkg/cmd/extension/manager.go b/pkg/cmd/extension/manager.go index 3449092cc..156212909 100644 --- a/pkg/cmd/extension/manager.go +++ b/pkg/cmd/extension/manager.go @@ -198,6 +198,7 @@ func (m *Manager) parseBinaryExtensionDir(fi fs.FileInfo) (Extension, error) { remoteURL := ghrepo.GenerateRepoURL(repo, "") ext.url = remoteURL ext.currentVersion = bm.Tag + ext.isPinned = bm.IsPinned return ext, nil } @@ -206,12 +207,20 @@ func (m *Manager) parseGitExtensionDir(fi fs.FileInfo) (Extension, error) { exePath := filepath.Join(id, fi.Name(), fi.Name()) remoteUrl := m.getRemoteUrl(fi.Name()) currentVersion := m.getCurrentVersion(fi.Name()) + + var isPinned bool + pinPath := filepath.Join(id, fi.Name(), fmt.Sprintf(".pin-%s", currentVersion)) + if _, err := os.Stat(pinPath); err == nil { + isPinned = true + } + return Extension{ path: exePath, url: remoteUrl, isLocal: false, currentVersion: currentVersion, kind: GitKind, + isPinned: isPinned, }, nil } @@ -224,6 +233,7 @@ func (m *Manager) getCurrentVersion(extension string) string { dir := m.installDir() gitDir := "--git-dir=" + filepath.Join(dir, extension, ".git") cmd := m.newCommand(gitExe, gitDir, "rev-parse", "HEAD") + localSha, err := cmd.Output() if err != nil { return "" @@ -312,21 +322,23 @@ func (m *Manager) InstallLocal(dir string) error { } type binManifest struct { - Owner string - Name string - Host string - Tag string + Owner string + Name string + Host string + Tag string + IsPinned bool // TODO I may end up not using this; just thinking ahead to local installs Path string } -func (m *Manager) Install(repo ghrepo.Interface) error { +// Install installs an extension from repo, and pins to commitish if provided +func (m *Manager) Install(repo ghrepo.Interface, target string) error { isBin, err := isBinExtension(m.client, repo) if err != nil { return fmt.Errorf("could not check for binary extension: %w", err) } if isBin { - return m.installBin(repo) + return m.installBin(repo, target) } hs, err := hasScript(m.client, repo) @@ -337,13 +349,18 @@ func (m *Manager) Install(repo ghrepo.Interface) error { return errors.New("extension is not installable: missing executable") } - protocol, _ := m.config.GetOrDefault(repo.RepoHost(), "git_protocol") - return m.installGit(ghrepo.FormatRemoteURL(repo, protocol), m.io.Out, m.io.ErrOut) + return m.installGit(repo, target, m.io.Out, m.io.ErrOut) } -func (m *Manager) installBin(repo ghrepo.Interface) error { +func (m *Manager) installBin(repo ghrepo.Interface, target string) error { var r *release - r, err := fetchLatestRelease(m.client, repo) + var err error + isPinned := target != "" + if isPinned { + r, err = fetchReleaseFromTag(m.client, repo, target) + } else { + r, err = fetchLatestRelease(m.client, repo) + } if err != nil { return err } @@ -365,6 +382,7 @@ func (m *Manager) installBin(repo ghrepo.Interface) error { name := repo.RepoName() targetDir := filepath.Join(m.installDir(), name) + // TODO clean this up if function errs? err = os.MkdirAll(targetDir, 0755) if err != nil { @@ -380,11 +398,12 @@ func (m *Manager) installBin(repo ghrepo.Interface) error { } manifest := binManifest{ - Name: name, - Owner: repo.RepoOwner(), - Host: repo.RepoHost(), - Path: binPath, - Tag: r.Tag, + Name: name, + Owner: repo.RepoOwner(), + Host: repo.RepoHost(), + Path: binPath, + Tag: r.Tag, + IsPinned: isPinned, } bs, err := yaml.Marshal(manifest) @@ -408,21 +427,52 @@ func (m *Manager) installBin(repo ghrepo.Interface) error { return nil } -func (m *Manager) installGit(cloneURL string, stdout, stderr io.Writer) error { +func (m *Manager) installGit(repo ghrepo.Interface, target string, stdout, stderr io.Writer) error { + protocol, _ := m.config.GetOrDefault(repo.RepoHost(), "git_protocol") + cloneURL := ghrepo.FormatRemoteURL(repo, protocol) + exe, err := m.lookPath("git") if err != nil { return err } + var commitSHA string + if target != "" { + commitSHA, err = fetchCommitSHA(m.client, repo, target) + if err != nil { + return err + } + } + name := strings.TrimSuffix(path.Base(cloneURL), ".git") targetDir := filepath.Join(m.installDir(), name) externalCmd := m.newCommand(exe, "clone", cloneURL, targetDir) externalCmd.Stdout = stdout externalCmd.Stderr = stderr - return externalCmd.Run() + if err := externalCmd.Run(); err != nil { + return err + } + if commitSHA == "" { + return nil + } + + checkoutCmd := m.newCommand(exe, "-C", targetDir, "checkout", commitSHA) + checkoutCmd.Stdout = stdout + checkoutCmd.Stderr = stderr + if err := checkoutCmd.Run(); err != nil { + return err + } + + pinPath := filepath.Join(targetDir, fmt.Sprintf(".pin-%s", commitSHA)) + f, err := os.OpenFile(pinPath, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return fmt.Errorf("failed to create pin file in directory: %w", err) + } + return f.Close() } +var pinnedExtensionUpgradeError = errors.New("pinned extensions can not be upgraded") var localExtensionUpgradeError = errors.New("local extensions can not be upgraded") var upToDateError = errors.New("already up to date") var noExtensionsInstalledError = errors.New("no extensions installed") @@ -462,7 +512,8 @@ func (m *Manager) upgradeExtensions(exts []Extension, force bool) error { err := m.upgradeExtension(f, force) if err != nil { if !errors.Is(err, localExtensionUpgradeError) && - !errors.Is(err, upToDateError) { + !errors.Is(err, upToDateError) && + !errors.Is(err, pinnedExtensionUpgradeError) { failed = true } fmt.Fprintf(m.io.Out, "%s\n", err) @@ -480,6 +531,9 @@ func (m *Manager) upgradeExtension(ext Extension, force bool) error { if ext.isLocal { return localExtensionUpgradeError } + if ext.IsPinned() { + return pinnedExtensionUpgradeError + } if !ext.UpdateAvailable() { return upToDateError } @@ -498,7 +552,7 @@ func (m *Manager) upgradeExtension(ext Extension, force bool) error { if err != nil { return fmt.Errorf("failed to migrate to new precompiled extension format: %w", err) } - return m.installBin(repo) + return m.installBin(repo, "") } err = m.upgradeGitExtension(ext, force) } @@ -525,7 +579,7 @@ func (m *Manager) upgradeBinExtension(ext Extension) error { if err != nil { return fmt.Errorf("failed to parse URL %s: %w", ext.url, err) } - return m.installBin(repo) + return m.installBin(repo, "") } func (m *Manager) Remove(name string) error { diff --git a/pkg/cmd/extension/manager_test.go b/pkg/cmd/extension/manager_test.go index 47f11d348..717a0b906 100644 --- a/pkg/cmd/extension/manager_test.go +++ b/pkg/cmd/extension/manager_test.go @@ -445,6 +445,50 @@ func TestManager_UpgradeExtension_BinaryExtension(t *testing.T) { assert.Equal(t, "", stderr.String()) } +func TestManager_UpgradeExtension_BinaryExtension_Pinned(t *testing.T) { + tempDir := t.TempDir() + + assert.NoError(t, stubBinaryExtension( + filepath.Join(tempDir, "extensions", "gh-bin-ext"), + binManifest{ + Owner: "owner", + Name: "gh-bin-ext", + Host: "example.com", + Tag: "v1.6.3", + IsPinned: true, + })) + + io, _, _, _ := iostreams.Test() + m := newTestManager(tempDir, nil, io) + exts, err := m.list(false) + assert.Nil(t, err) + assert.Equal(t, 1, len(exts)) + ext := exts[0] + + err = m.upgradeExtension(ext, false) + assert.NotNil(t, err) + assert.Equal(t, err, pinnedExtensionUpgradeError) +} + +func TestManager_UpgradeExtenion_GitExtension_Pinned(t *testing.T) { + tempDir := t.TempDir() + extDir := filepath.Join(tempDir, "extensions", "gh-remote") + assert.NoError(t, stubPinnedExtension(filepath.Join(extDir, "gh-remote"), "abcd1234")) + + io, _, _, _ := iostreams.Test() + m := newTestManager(tempDir, nil, io) + exts, err := m.list(false) + assert.NoError(t, err) + assert.Equal(t, 1, len(exts)) + ext := exts[0] + ext.isPinned = true + ext.latestVersion = "new version" + + err = m.upgradeExtension(ext, false) + assert.NotNil(t, err) + assert.Equal(t, err, pinnedExtensionUpgradeError) +} + func TestManager_Install_git(t *testing.T) { tempDir := t.TempDir() @@ -472,12 +516,113 @@ func TestManager_Install_git(t *testing.T) { repo := ghrepo.New("owner", "gh-some-ext") - err := m.Install(repo) + err := m.Install(repo, "") assert.NoError(t, err) assert.Equal(t, fmt.Sprintf("[git clone https://github.com/owner/gh-some-ext.git %s]\n", filepath.Join(tempDir, "extensions", "gh-some-ext")), stdout.String()) assert.Equal(t, "", stderr.String()) } +func TestManager_Install_git_pinned(t *testing.T) { + tempDir := t.TempDir() + + reg := httpmock.Registry{} + defer reg.Verify(t) + client := http.Client{Transport: ®} + + io, _, _, stderr := iostreams.Test() + m := newTestManager(tempDir, &client, io) + + reg.Register( + httpmock.REST("GET", "repos/owner/gh-cool-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "not-a-binary", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "repos/owner/gh-cool-ext/commits/some-ref"), + httpmock.StringResponse("abcd1234")) + reg.Register( + httpmock.REST("GET", "repos/owner/gh-cool-ext/contents/gh-cool-ext"), + httpmock.StringResponse("script")) + + _ = os.MkdirAll(filepath.Join(m.installDir(), "gh-cool-ext"), 0700) + repo := ghrepo.New("owner", "gh-cool-ext") + err := m.Install(repo, "some-ref") + assert.NoError(t, err) + assert.Equal(t, "", stderr.String()) +} + +func TestManager_Install_binary_pinned(t *testing.T) { + repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") + + reg := httpmock.Registry{} + defer reg.Verify(t) + + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/latest"), + httpmock.JSONResponse( + release{ + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64.exe", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "api/v3/repos/owner/gh-bin-ext/releases/tags/v1.6.3-pre"), + httpmock.JSONResponse( + release{ + Tag: "v1.6.3-pre", + Assets: []releaseAsset{ + { + Name: "gh-bin-ext-windows-amd64.exe", + APIURL: "https://example.com/release/cool", + }, + }, + })) + reg.Register( + httpmock.REST("GET", "release/cool"), + httpmock.StringResponse("FAKE BINARY")) + + io, _, stdout, stderr := iostreams.Test() + tempDir := t.TempDir() + + m := newTestManager(tempDir, &http.Client{Transport: ®}, io) + + err := m.Install(repo, "v1.6.3-pre") + assert.NoError(t, err) + + manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) + assert.NoError(t, err) + + var bm binManifest + err = yaml.Unmarshal(manifest, &bm) + assert.NoError(t, err) + + assert.Equal(t, binManifest{ + Name: "gh-bin-ext", + Owner: "owner", + Host: "example.com", + Tag: "v1.6.3-pre", + IsPinned: true, + Path: filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe"), + }, bm) + + fakeBin, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext/gh-bin-ext.exe")) + assert.NoError(t, err) + assert.Equal(t, "FAKE BINARY", string(fakeBin)) + + assert.Equal(t, "", stdout.String()) + assert.Equal(t, "", stderr.String()) + +} + func TestManager_Install_binary_unsupported(t *testing.T) { repo := ghrepo.NewWithHost("owner", "gh-bin-ext", "example.com") @@ -514,7 +659,7 @@ func TestManager_Install_binary_unsupported(t *testing.T) { m := newTestManager(tempDir, &client, io) - err := m.Install(repo) + err := m.Install(repo, "") assert.EqualError(t, err, "gh-bin-ext unsupported for windows-amd64. Open an issue: `gh issue create -R owner/gh-bin-ext -t'Support windows-amd64'`") assert.Equal(t, "", stdout.String()) @@ -559,7 +704,7 @@ func TestManager_Install_binary(t *testing.T) { m := newTestManager(tempDir, &http.Client{Transport: ®}, io) - err := m.Install(repo) + err := m.Install(repo, "") assert.NoError(t, err) manifest, err := os.ReadFile(filepath.Join(tempDir, "extensions/gh-bin-ext", manifestName)) @@ -702,6 +847,24 @@ func stubExtension(path string) error { return f.Close() } +func stubPinnedExtension(path string, pinnedVersion string) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE, 0755) + if err != nil { + return err + } + f.Close() + + pinPath := filepath.Join(filepath.Dir(path), fmt.Sprintf(".pin-%s", pinnedVersion)) + f, err = os.OpenFile(pinPath, os.O_WRONLY|os.O_CREATE, 0600) + if err != nil { + return err + } + return f.Close() +} + func stubLocalExtension(tempDir, path string) error { extDir, err := ioutil.TempDir(tempDir, "local-ext") if err != nil { diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index 7037b1558..f5c30582f 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -3,7 +3,6 @@ package factory import ( "fmt" "net/http" - "os" "regexp" "strings" "time" @@ -12,6 +11,7 @@ import ( "github.com/cli/cli/v2/internal/ghinstance" "github.com/cli/cli/v2/internal/httpunix" "github.com/cli/cli/v2/pkg/iostreams" + "github.com/cli/cli/v2/utils" ) var timezoneNames = map[int]string{ @@ -84,8 +84,8 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string, })) } - if verbose := os.Getenv("DEBUG"); verbose != "" { - logTraffic := strings.Contains(verbose, "api") + if isVerbose, debugValue := utils.IsDebugEnabled(); isVerbose { + logTraffic := strings.Contains(debugValue, "api") opts = append(opts, api.VerboseLog(io.ErrOut, logTraffic, io.IsStderrTTY())) } diff --git a/pkg/cmd/factory/http_test.go b/pkg/cmd/factory/http_test.go index 0cb5ac15c..0686b855d 100644 --- a/pkg/cmd/factory/http_test.go +++ b/pkg/cmd/factory/http_test.go @@ -24,6 +24,8 @@ func TestNewHTTPClient(t *testing.T) { name string args args envDebug string + setGhDebug bool + envGhDebug string host string sso string wantHeader map[string]string @@ -82,8 +84,39 @@ func TestNewHTTPClient(t *testing.T) { appVersion: "v1.2.3", setAccept: true, }, - host: "github.com", - envDebug: "api", + host: "github.com", + envDebug: "api", + setGhDebug: false, + wantHeader: map[string]string{ + "authorization": "token MYTOKEN", + "user-agent": "GitHub CLI v1.2.3", + "accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview", + }, + wantStderr: heredoc.Doc(` + * Request at