diff --git a/api/queries_repo.go b/api/queries_repo.go index dd9027ad4..7edf26f85 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -701,7 +701,7 @@ func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) { for _, assigneeLogin := range names { found := false for _, u := range m.AssignableUsers { - if strings.EqualFold(assigneeLogin, (u.Login())) { + if strings.EqualFold(assigneeLogin, u.Login()) { ids = append(ids, u.ID()) found = true break diff --git a/internal/prompter/accessible_prompter_test.go b/internal/prompter/accessible_prompter_test.go index ed4da8de8..ec19a43e0 100644 --- a/internal/prompter/accessible_prompter_test.go +++ b/internal/prompter/accessible_prompter_test.go @@ -5,6 +5,7 @@ package prompter_test import ( "fmt" "io" + "slices" "strings" "testing" "time" @@ -54,6 +55,73 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, 0, selectValue) }) + t.Run("Select - blank input returns default value", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "12345abcdefg" + options := []string{"1", "2", dummyDefaultValue} + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Input a number between 1 and 3:") + require.NoError(t, err) + + // Just press enter to accept the default + _, err = console.SendLine("") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", dummyDefaultValue, options) + require.NoError(t, err) + + expectedIndex := slices.Index(options, dummyDefaultValue) + assert.Equal(t, expectedIndex, selectValue) + }) + + t.Run("Select - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "12345abcdefg" + options := []string{"1", "2", dummyDefaultValue} + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number (default: 12345abcdefg)") + require.NoError(t, err) + + // Just press enter to accept the default + _, err = console.SendLine("") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", dummyDefaultValue, options) + require.NoError(t, err) + + expectedIndex := slices.Index(options, dummyDefaultValue) + assert.Equal(t, expectedIndex, selectValue) + }) + + t.Run("Select - invalid defaults are excluded from prompt", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "foo" + options := []string{"1", "2"} + + go func() { + // Wait for prompt to appear without the invalid default value + _, err := console.ExpectString("Select a number \r\n") + require.NoError(t, err) + + // Select option 2 + _, err = console.SendLine("2") + require.NoError(t, err) + }() + + selectValue, err := p.Select("Select a number", dummyDefaultValue, options) + require.NoError(t, err) + assert.Equal(t, 1, selectValue) + }) + t.Run("MultiSelect", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -100,6 +168,62 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, []int{1}, multiSelectValue) }) + t.Run("MultiSelect - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValues := []string{"foo", "bar"} + options := []string{"1", "2"} + options = append(options, dummyDefaultValues...) + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Select a number (defaults: foo, bar)") + require.NoError(t, err) + + // Don't select anything because the defaults should be selected. + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValues, err := p.MultiSelect("Select a number", dummyDefaultValues, options) + require.NoError(t, err) + var expectedIndices []int + + // Get the indices of the default values within the options slice + // as that's what we expect the prompter to return when no selections are made. + for _, defaultValue := range dummyDefaultValues { + expectedIndices = append(expectedIndices, slices.Index(options, defaultValue)) + } + assert.Equal(t, expectedIndices, multiSelectValues) + }) + + t.Run("MultiSelect - invalid defaults are excluded from prompt", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValues := []string{"foo", "bar"} + options := []string{"1", "2"} + + go func() { + // Wait for prompt to appear without the invalid default values + _, err := console.ExpectString("Select a number \r\n") + require.NoError(t, err) + + // Not selecting anything will fail because there are no defaults. + _, err = console.SendLine("2") + require.NoError(t, err) + + // This confirms selections + _, err = console.SendLine("0") + require.NoError(t, err) + }() + + multiSelectValues, err := p.MultiSelect("Select a number", dummyDefaultValues, options) + require.NoError(t, err) + assert.Equal(t, []int{1}, multiSelectValues) + }) + t.Run("Input", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -140,6 +264,26 @@ func TestAccessiblePrompter(t *testing.T) { assert.Equal(t, dummyDefaultValue, inputValue) }) + t.Run("Input - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + dummyDefaultValue := "12345abcdefg" + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Enter some characters (default: 12345abcdefg)") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + }() + + inputValue, err := p.Input("Enter some characters", dummyDefaultValue) + require.NoError(t, err) + assert.Equal(t, dummyDefaultValue, inputValue) + }) + t.Run("Password", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -164,7 +308,12 @@ func TestAccessiblePrompter(t *testing.T) { // Ensure the dummy password is not printed to the screen, // asserting that echo mode is disabled. - _, err = console.ExpectString(" \r\n\r\n") + // + // Note that since console.ExpectString returns successful if the + // expected string matches any part of the stream, we have to use an + // anchored regexp (i.e., with ^ and $) to make sure the password/token + // is not printed at all. + _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) require.NoError(t, err) }) @@ -206,6 +355,26 @@ func TestAccessiblePrompter(t *testing.T) { require.Equal(t, false, confirmValue) }) + t.Run("Confirm - default value is in prompt and in readable format", func(t *testing.T) { + console := newTestVirtualTerminal(t) + p := newTestAccessiblePrompter(t, console) + defaultValue := true + + go func() { + // Wait for prompt to appear + _, err := console.ExpectString("Are you sure (default: yes)") + require.NoError(t, err) + + // Enter nothing + _, err = console.SendLine("") + require.NoError(t, err) + }() + + confirmValue, err := p.Confirm("Are you sure", defaultValue) + require.NoError(t, err) + require.Equal(t, defaultValue, confirmValue) + }) + t.Run("AuthToken", func(t *testing.T) { console := newTestVirtualTerminal(t) p := newTestAccessiblePrompter(t, console) @@ -230,7 +399,12 @@ func TestAccessiblePrompter(t *testing.T) { // Ensure the dummy password is not printed to the screen, // asserting that echo mode is disabled. - _, err = console.ExpectString(" \r\n\r\n") + // + // Note that since console.ExpectString returns successful if the + // expected string matches any part of the stream, we have to use an + // anchored regexp (i.e., with ^ and $) to make sure the password/token + // is not printed at all. + _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) require.NoError(t, err) }) @@ -252,6 +426,10 @@ func TestAccessiblePrompter(t *testing.T) { _, err = console.ExpectString("token is required") require.NoError(t, err) + // Wait for the retry prompt + _, err = console.ExpectString("Paste your authentication token:") + require.NoError(t, err) + // Wait to ensure huh has time to set the echo mode time.Sleep(beforePasswordSendTimeout) @@ -266,7 +444,12 @@ func TestAccessiblePrompter(t *testing.T) { // Ensure the dummy password is not printed to the screen, // asserting that echo mode is disabled. - _, err = console.ExpectString(" \r\n\r\n") + // + // Note that since console.ExpectString returns successful if the + // expected string matches any part of the stream, we have to use an + // anchored regexp (i.e., with ^ and $) to make sure the password/token + // is not printed at all. + _, err = console.Expect(expect.RegexpPattern("^ \r\n\r\n$")) require.NoError(t, err) }) diff --git a/internal/prompter/prompter.go b/internal/prompter/prompter.go index 1e4f5592a..c2233fd92 100644 --- a/internal/prompter/prompter.go +++ b/internal/prompter/prompter.go @@ -77,10 +77,40 @@ func (p *accessiblePrompter) newForm(groups ...*huh.Group) *huh.Form { WithOutput(p.stdout) } -func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, error) { +// addDefaultsToPrompt adds default values to the prompt string. +func (p *accessiblePrompter) addDefaultsToPrompt(prompt string, defaultValues []string) string { + // Removing empty defaults from the slice. + defaultValues = slices.DeleteFunc(defaultValues, func(s string) bool { + return s == "" + }) + + // Pluralizing the prompt if there are multiple default values. + if len(defaultValues) == 1 { + prompt = fmt.Sprintf("%s (default: %s)", prompt, defaultValues[0]) + } else if len(defaultValues) > 1 { + prompt = fmt.Sprintf("%s (defaults: %s)", prompt, strings.Join(defaultValues, ", ")) + } + + // Zero-length defaultValues means return prompt unchanged. + return prompt +} + +func (p *accessiblePrompter) Select(prompt, defaultValue string, options []string) (int, error) { var result int + + // Remove invalid default values from the defaults slice. + if !slices.Contains(options, defaultValue) { + defaultValue = "" + } + + prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue}) formOptions := []huh.Option[int]{} for i, o := range options { + // If this option is the default value, assign its index + // to the result variable. huh will treat it as a default selection. + if defaultValue == o { + result = i + } formOptions = append(formOptions, huh.NewOption(o, i)) } @@ -99,12 +129,18 @@ func (p *accessiblePrompter) Select(prompt, _ string, options []string) (int, er func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, options []string) ([]int, error) { var result []int + + // Remove invalid default values from the defaults slice. + defaults = slices.DeleteFunc(defaults, func(s string) bool { + return !slices.Contains(options, s) + }) + + prompt = p.addDefaultsToPrompt(prompt, defaults) formOptions := make([]huh.Option[int], len(options)) for i, o := range options { // If this option is in the defaults slice, // let's add its index to the result slice and huh // will treat it as a default selection. - // TODO: does an invalid default value constitute a panic? if slices.Contains(defaults, o) { result = append(result, i) } @@ -131,7 +167,7 @@ func (p *accessiblePrompter) MultiSelect(prompt string, defaults []string, optio func (p *accessiblePrompter) Input(prompt, defaultValue string) (string, error) { result := defaultValue - prompt = fmt.Sprintf("%s (%s)", prompt, defaultValue) + prompt = p.addDefaultsToPrompt(prompt, []string{defaultValue}) form := p.newForm( huh.NewGroup( huh.NewInput(). @@ -167,6 +203,13 @@ func (p *accessiblePrompter) Password(prompt string) (string, error) { func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, error) { result := defaultValue + + if defaultValue { + prompt = p.addDefaultsToPrompt(prompt, []string{"yes"}) + } else { + prompt = p.addDefaultsToPrompt(prompt, []string{"no"}) + } + form := p.newForm( huh.NewGroup( huh.NewConfirm(). @@ -174,6 +217,7 @@ func (p *accessiblePrompter) Confirm(prompt string, defaultValue bool) (bool, er Value(&result), ), ) + if err := form.Run(); err != nil { return false, err } diff --git a/pkg/cmd/pr/edit/edit.go b/pkg/cmd/pr/edit/edit.go index 3002c8dbe..155f08897 100644 --- a/pkg/cmd/pr/edit/edit.go +++ b/pkg/cmd/pr/edit/edit.go @@ -198,6 +198,7 @@ func editRun(opts *EditOptions) error { findOptions := shared.FindOptions{ Selector: opts.SelectorArg, Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "labels", "projectCards", "projectItems", "milestone"}, + Detector: opts.Detector, } if opts.Detector == nil { diff --git a/pkg/cmd/pr/edit/edit_test.go b/pkg/cmd/pr/edit/edit_test.go index 5c2e65eba..0f15d6bd1 100644 --- a/pkg/cmd/pr/edit/edit_test.go +++ b/pkg/cmd/pr/edit/edit_test.go @@ -758,3 +758,73 @@ func (s testSurveyor) EditFields(e *shared.Editable, _ string) error { func (t testEditorRetriever) Retrieve() (string, error) { return "vim", nil } + +// TODO projectsV1Deprecation +// Remove this test. +func TestProjectsV1Deprecation(t *testing.T) { + t.Run("when projects v1 is supported, is included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`projectCards`), + // Simulate a GraphQL error to early exit the test. + httpmock.StatusStringResponse(500, ""), + ) + + f := &cmdutil.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Detector: &fd.EnabledDetectorMock{}, + + Finder: shared.NewFinder(f), + + SelectorArg: "https://github.com/cli/cli/pull/123", + }) + + // Verify that our request contained projectCards + reg.Verify(t) + }) + + t.Run("when projects v1 is not supported, is not included in query", func(t *testing.T) { + ios, _, _, _ := iostreams.Test() + + reg := &httpmock.Registry{} + reg.Exclude(t, httpmock.GraphQL(`projectCards`)) + + f := &cmdutil.Factory{ + IOStreams: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + } + + // Ignore the error because we have no way to really stub it without + // fully stubbing a GQL error structure in the request body. + _ = editRun(&EditOptions{ + IO: ios, + HttpClient: func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + }, + Detector: &fd.DisabledDetectorMock{}, + + Finder: shared.NewFinder(f), + + SelectorArg: "https://github.com/cli/cli/pull/123", + }) + + // Verify that our request did not contain projectCards + reg.Verify(t) + }) +} diff --git a/pkg/cmd/pr/shared/editable_http.go b/pkg/cmd/pr/shared/editable_http.go index cd183e565..8cd51c349 100644 --- a/pkg/cmd/pr/shared/editable_http.go +++ b/pkg/cmd/pr/shared/editable_http.go @@ -103,7 +103,7 @@ func replaceActorAssigneesForEditable(apiClient *api.Client, repo ghrepo.Interfa var mutation struct { ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` + TypeName string `graphql:"__typename"` } `graphql:"replaceActorsForAssignable(input: $input)"` } diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go index cdf6135b6..b90721412 100644 --- a/pkg/cmd/release/download/download.go +++ b/pkg/cmd/release/download/download.go @@ -165,10 +165,24 @@ func downloadRun(opts *DownloadOptions) error { var toDownload []shared.ReleaseAsset isArchive := false if opts.ArchiveType != "" { - var archiveURL = release.ZipballURL + var archiveURL string if opts.ArchiveType == "tar.gz" { archiveURL = release.TarballURL + } else { + archiveURL = release.ZipballURL } + + if archiveURL == "" { + errMessage := fmt.Sprintf( + "release %q with tag %q, does not have a %q archive asset.", + release.Name, release.TagName, opts.ArchiveType, + ) + if release.IsDraft { + errMessage += " Most likely, this is because it is a draft." + } + return errors.New(errMessage) + } + // create pseudo-Asset with no name and pointing to ZipBallURL or TarBallURL toDownload = append(toDownload, shared.ReleaseAsset{APIURL: archiveURL}) isArchive = true diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go index 78709dd57..9337c9b65 100644 --- a/pkg/cmd/release/download/download_test.go +++ b/pkg/cmd/release/download/download_test.go @@ -183,6 +183,7 @@ func Test_downloadRun(t *testing.T) { name string isTTY bool opts DownloadOptions + httpStubs func(*httpmock.Registry) wantErr string wantStdout string wantStderr string @@ -196,6 +197,24 @@ func Test_downloadRun(t *testing.T) { Destination: ".", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + reg.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`)) + reg.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`)) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -213,6 +232,23 @@ func Test_downloadRun(t *testing.T) { Destination: "tmp/assets", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + reg.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`)) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -229,6 +265,20 @@ func Test_downloadRun(t *testing.T) { Destination: ".", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + }, wantStdout: ``, wantStderr: ``, wantErr: "no assets match the file pattern", @@ -242,6 +292,30 @@ func Test_downloadRun(t *testing.T) { Destination: "tmp/packages", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register( + httpmock.REST( + "GET", + "repos/OWNER/REPO/zipball/v1.2.3", + ), + httpmock.WithHeader( + httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=zipball.zip", + ), + ) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -257,6 +331,30 @@ func Test_downloadRun(t *testing.T) { Destination: "tmp/packages", Concurrency: 2, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register( + httpmock.REST( + "GET", + "repos/OWNER/REPO/tarball/v1.2.3", + ), + httpmock.WithHeader( + httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz", + ), + ) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -273,6 +371,30 @@ func Test_downloadRun(t *testing.T) { Concurrency: 2, ArchiveType: "tar.gz", }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register( + httpmock.REST( + "GET", + "repos/OWNER/REPO/tarball/v1.2.3", + ), + httpmock.WithHeader( + httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz", + ), + ) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -289,6 +411,22 @@ func Test_downloadRun(t *testing.T) { Concurrency: 2, FilePatterns: []string{"*windows-32bit.zip"}, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + }, wantStdout: ``, wantStderr: ``, wantFiles: []string{ @@ -305,9 +443,85 @@ func Test_downloadRun(t *testing.T) { Concurrency: 2, FilePatterns: []string{"*windows-32bit.zip"}, }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", + "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" + }`) + + reg.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + }, wantStdout: `1234`, wantStderr: ``, }, + { + name: "draft release with null tarball_url and zipball_url", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + ArchiveType: "tar.gz", + Destination: "tmp/packages", + Concurrency: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "tag_name": "v1.2.3", + "name": "patch-36", + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": null, + "zipball_url": null, + "draft": true + }`) + }, + wantStdout: ``, + wantStderr: ``, + wantErr: "release \"patch-36\" with tag \"v1.2.3\", does not have a \"tar.gz\" archive asset. Most likely, this is because it is a draft.", + }, + { + name: "non-draft release with null tarball_url and zipball_url", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + ArchiveType: "tar.gz", + Destination: "tmp/packages", + Concurrency: 2, + }, + httpStubs: func(reg *httpmock.Registry) { + shared.StubFetchRelease(t, reg, "OWNER", "REPO", "v1.2.3", `{ + "tag_name": "v1.2.3", + "name": "patch-36", + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ], + "tarball_url": null, + "zipball_url": null, + "draft": false + }`) + }, + wantStdout: ``, + wantStderr: ``, + wantErr: "release \"patch-36\" with tag \"v1.2.3\", does not have a \"tar.gz\" archive asset.", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -324,41 +538,11 @@ func Test_downloadRun(t *testing.T) { ios.SetStderrTTY(tt.isTTY) fakeHTTP := &httpmock.Registry{} - shared.StubFetchRelease(t, fakeHTTP, "OWNER", "REPO", tt.opts.TagName, `{ - "assets": [ - { "name": "windows-32bit.zip", "size": 12, - "url": "https://api.github.com/assets/1234" }, - { "name": "windows-64bit.zip", "size": 34, - "url": "https://api.github.com/assets/3456" }, - { "name": "linux.tgz", "size": 56, - "url": "https://api.github.com/assets/5678" } - ], - "tarball_url": "https://api.github.com/repos/OWNER/REPO/tarball/v1.2.3", - "zipball_url": "https://api.github.com/repos/OWNER/REPO/zipball/v1.2.3" - }`) - fakeHTTP.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) - fakeHTTP.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`)) - fakeHTTP.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`)) + defer fakeHTTP.Verify(t) - fakeHTTP.Register( - httpmock.REST( - "GET", - "repos/OWNER/REPO/tarball/v1.2.3", - ), - httpmock.WithHeader( - httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=tarball.tgz", - ), - ) - - fakeHTTP.Register( - httpmock.REST( - "GET", - "repos/OWNER/REPO/zipball/v1.2.3", - ), - httpmock.WithHeader( - httpmock.StringResponse("somedata"), "content-disposition", "attachment; filename=zipball.zip", - ), - ) + if tt.httpStubs != nil { + tt.httpStubs(fakeHTTP) + } tt.opts.IO = ios tt.opts.HttpClient = func() (*http.Client, error) {