X Tutup
Skip to content

Commit eeca998

Browse files
author
vilmibm
committed
binary extension support in gh extension install
1 parent e13398f commit eeca998

File tree

7 files changed

+425
-29
lines changed

7 files changed

+425
-29
lines changed

pkg/cmd/extension/command.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@ package extension
33
import (
44
"errors"
55
"fmt"
6+
"net/http"
67
"os"
78
"strings"
9+
"time"
810

911
"github.com/MakeNowJust/heredoc"
12+
"github.com/cli/cli/v2/api"
1013
"github.com/cli/cli/v2/git"
1114
"github.com/cli/cli/v2/internal/ghrepo"
1215
"github.com/cli/cli/v2/pkg/cmdutil"
@@ -102,15 +105,38 @@ func NewCmdExtension(f *cmdutil.Factory) *cobra.Command {
102105
return err
103106
}
104107
if err := checkValidExtension(cmd.Root(), m, repo.RepoName()); err != nil {
108+
// TODO i feel like this should check for a gh-foo script
105109
return err
106110
}
107111

112+
client, err := f.HttpClient()
113+
if err != nil {
114+
return fmt.Errorf("could not make http client: %w", err)
115+
}
116+
client = api.NewCachedClient(client, time.Second*30)
117+
118+
isBin, err := isBinExtension(client, repo)
119+
if err != nil {
120+
return fmt.Errorf("could not check for binary extension: %w", err)
121+
}
122+
if isBin {
123+
return m.InstallBin(client, repo)
124+
}
125+
126+
hs, err := hasScript(client, repo)
127+
if err != nil {
128+
return err
129+
}
130+
if !hs {
131+
return errors.New("extension is uninstallable: missing executable")
132+
}
133+
108134
cfg, err := f.Config()
109135
if err != nil {
110136
return err
111137
}
112138
protocol, _ := cfg.Get(repo.RepoHost(), "git_protocol")
113-
return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut)
139+
return m.InstallGit(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut)
114140
},
115141
},
116142
func() *cobra.Command {
@@ -220,6 +246,26 @@ func checkValidExtension(rootCmd *cobra.Command, m extensions.ExtensionManager,
220246
return nil
221247
}
222248

249+
func isBinExtension(client *http.Client, repo ghrepo.Interface) (isBin bool, err error) {
250+
hs, err := hasScript(client, repo)
251+
if err != nil || hs {
252+
return
253+
}
254+
255+
_, err = fetchLatestRelease(client, repo)
256+
if err != nil {
257+
httpErr, ok := err.(api.HTTPError)
258+
if ok && httpErr.StatusCode == 404 {
259+
err = nil
260+
return
261+
}
262+
return
263+
}
264+
265+
isBin = true
266+
return
267+
}
268+
223269
func normalizeExtensionSelector(n string) string {
224270
if idx := strings.IndexRune(n, '/'); idx >= 0 {
225271
n = n[idx+1:]

pkg/cmd/extension/command_test.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ package extension
33
import (
44
"io"
55
"io/ioutil"
6+
"net/http"
67
"os"
78
"strings"
89
"testing"
910

1011
"github.com/MakeNowJust/heredoc"
1112
"github.com/cli/cli/v2/internal/config"
13+
"github.com/cli/cli/v2/internal/ghrepo"
1214
"github.com/cli/cli/v2/pkg/cmdutil"
1315
"github.com/cli/cli/v2/pkg/extensions"
16+
"github.com/cli/cli/v2/pkg/httpmock"
1417
"github.com/cli/cli/v2/pkg/iostreams"
1518
"github.com/spf13/cobra"
1619
"github.com/stretchr/testify/assert"
@@ -25,6 +28,7 @@ func TestNewCmdExtension(t *testing.T) {
2528
tests := []struct {
2629
name string
2730
args []string
31+
httpStubs func(*httpmock.Registry)
2832
managerStubs func(em *extensions.ExtensionManagerMock) func(*testing.T)
2933
isTTY bool
3034
wantErr bool
@@ -33,24 +37,56 @@ func TestNewCmdExtension(t *testing.T) {
3337
wantStderr string
3438
}{
3539
{
36-
name: "install an extension",
40+
name: "install a git extension",
3741
args: []string{"install", "owner/gh-some-ext"},
42+
httpStubs: func(reg *httpmock.Registry) {
43+
reg.Register(
44+
httpmock.REST("GET", "repos/owner/gh-some-ext/contents/gh-some-ext"),
45+
httpmock.StringResponse("a script"))
46+
},
3847
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
3948
em.ListFunc = func(bool) []extensions.Extension {
4049
return []extensions.Extension{}
4150
}
42-
em.InstallFunc = func(s string, out, errOut io.Writer) error {
51+
em.InstallGitFunc = func(s string, out, errOut io.Writer) error {
4352
return nil
4453
}
4554
return func(t *testing.T) {
46-
installCalls := em.InstallCalls()
55+
installCalls := em.InstallGitCalls()
4756
assert.Equal(t, 1, len(installCalls))
4857
assert.Equal(t, "https://github.com/owner/gh-some-ext.git", installCalls[0].URL)
4958
listCalls := em.ListCalls()
5059
assert.Equal(t, 1, len(listCalls))
5160
}
5261
},
5362
},
63+
{
64+
name: "install a binary extension",
65+
args: []string{"install", "owner/gh-bin-ext"},
66+
httpStubs: func(reg *httpmock.Registry) {
67+
reg.Register(
68+
httpmock.REST("GET", "repos/owner/gh-bin-ext/contents/gh-bin-ext"),
69+
httpmock.StatusStringResponse(404, "no"))
70+
reg.Register(
71+
httpmock.REST("GET", "repos/owner/gh-bin-ext/releases/latest"),
72+
httpmock.StringResponse("{}"))
73+
},
74+
managerStubs: func(em *extensions.ExtensionManagerMock) func(*testing.T) {
75+
em.ListFunc = func(bool) []extensions.Extension {
76+
return []extensions.Extension{}
77+
}
78+
em.InstallBinFunc = func(_ *http.Client, _ ghrepo.Interface) error {
79+
return nil
80+
}
81+
return func(t *testing.T) {
82+
installCalls := em.InstallBinCalls()
83+
assert.Equal(t, 1, len(installCalls))
84+
assert.Equal(t, "gh-bin-ext", installCalls[0].Repo.RepoName())
85+
listCalls := em.ListCalls()
86+
assert.Equal(t, 1, len(listCalls))
87+
}
88+
},
89+
},
5490
{
5591
name: "install an extension with same name as existing extension",
5692
args: []string{"install", "owner/gh-existing-ext"},
@@ -281,12 +317,23 @@ func TestNewCmdExtension(t *testing.T) {
281317
assertFunc = tt.managerStubs(em)
282318
}
283319

320+
reg := httpmock.Registry{}
321+
defer reg.Verify(t)
322+
client := http.Client{Transport: &reg}
323+
324+
if tt.httpStubs != nil {
325+
tt.httpStubs(&reg)
326+
}
327+
284328
f := cmdutil.Factory{
285329
Config: func() (config.Config, error) {
286330
return config.NewBlankConfig(), nil
287331
},
288332
IOStreams: ios,
289333
ExtensionManager: em,
334+
HttpClient: func() (*http.Client, error) {
335+
return &client, nil
336+
},
290337
}
291338

292339
cmd := NewCmdExtension(&f)

pkg/cmd/extension/http.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package extension
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"io/ioutil"
8+
"net/http"
9+
"os"
10+
11+
"github.com/cli/cli/v2/api"
12+
"github.com/cli/cli/v2/internal/ghinstance"
13+
"github.com/cli/cli/v2/internal/ghrepo"
14+
)
15+
16+
func hasScript(httpClient *http.Client, repo ghrepo.Interface) (hs bool, err error) {
17+
path := fmt.Sprintf("repos/%s/%s/contents/%s",
18+
repo.RepoOwner(), repo.RepoName(), repo.RepoName())
19+
url := ghinstance.RESTPrefix(repo.RepoHost()) + path
20+
req, err := http.NewRequest("GET", url, nil)
21+
if err != nil {
22+
return
23+
}
24+
25+
resp, err := httpClient.Do(req)
26+
if err != nil {
27+
return
28+
}
29+
defer resp.Body.Close()
30+
31+
if resp.StatusCode == 404 {
32+
return
33+
}
34+
35+
if resp.StatusCode > 299 {
36+
err = api.HandleHTTPError(resp)
37+
return
38+
}
39+
40+
hs = true
41+
return
42+
}
43+
44+
type releaseAsset struct {
45+
Name string
46+
APIURL string `json:"url"`
47+
}
48+
49+
type release struct {
50+
Assets []releaseAsset
51+
}
52+
53+
// downloadAsset downloads a single asset to the given file path.
54+
func downloadAsset(httpClient *http.Client, asset releaseAsset, destPath string) error {
55+
req, err := http.NewRequest("GET", asset.APIURL, nil)
56+
if err != nil {
57+
return err
58+
}
59+
60+
req.Header.Set("Accept", "application/octet-stream")
61+
62+
resp, err := httpClient.Do(req)
63+
if err != nil {
64+
return err
65+
}
66+
defer resp.Body.Close()
67+
68+
if resp.StatusCode > 299 {
69+
return api.HandleHTTPError(resp)
70+
}
71+
72+
f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0755)
73+
if err != nil {
74+
return err
75+
}
76+
defer f.Close()
77+
78+
_, err = io.Copy(f, resp.Body)
79+
return err
80+
}
81+
82+
// fetchLatestRelease finds the latest published release for a repository.
83+
func fetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*release, error) {
84+
path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName())
85+
url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path
86+
req, err := http.NewRequest("GET", url, nil)
87+
if err != nil {
88+
return nil, err
89+
}
90+
91+
resp, err := httpClient.Do(req)
92+
if err != nil {
93+
return nil, err
94+
}
95+
defer resp.Body.Close()
96+
97+
if resp.StatusCode > 299 {
98+
return nil, api.HandleHTTPError(resp)
99+
}
100+
101+
b, err := ioutil.ReadAll(resp.Body)
102+
if err != nil {
103+
return nil, err
104+
}
105+
106+
var r release
107+
err = json.Unmarshal(b, &r)
108+
if err != nil {
109+
return nil, err
110+
}
111+
112+
return &r, nil
113+
}

0 commit comments

Comments
 (0)
X Tutup