diff options
Diffstat (limited to 'internal/release')
| -rw-r--r-- | internal/release/release.go | 43 | ||||
| -rw-r--r-- | internal/release/release_prompt_test.go | 74 |
2 files changed, 103 insertions, 14 deletions
diff --git a/internal/release/release.go b/internal/release/release.go index fc414c9..5608f33 100644 --- a/internal/release/release.go +++ b/internal/release/release.go @@ -38,6 +38,15 @@ type Manager struct { aiTool string } +// closeResponseBody closes an HTTP response body. By the time this runs the +// body has already been fully read (or the caller is bailing out on an +// earlier error), so a close failure here cannot change the outcome that was +// already determined - the error is intentionally discarded rather than +// treated as actionable. +func closeResponseBody(resp *http.Response) { + _ = resp.Body.Close() +} + // NewManager creates a new release manager func NewManager(workDir string) *Manager { return &Manager{ @@ -79,7 +88,7 @@ func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error { if err != nil { return err } - defer resp.Body.Close() + defer closeResponseBody(resp) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("failed to get repo info: %s - %s", resp.Status, string(body)) @@ -112,7 +121,7 @@ func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error { if err != nil { return err } - defer patchResp.Body.Close() + defer closeResponseBody(patchResp) if patchResp.StatusCode != 200 { pbody, _ := io.ReadAll(patchResp.Body) return fmt.Errorf("failed to enable releases: %s - %s", patchResp.Status, string(pbody)) @@ -544,7 +553,7 @@ func (m *Manager) GetGitHubReleases(owner, repo string) ([]string, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer closeResponseBody(resp) if resp.StatusCode == 404 { // Repository might not exist on GitHub @@ -588,7 +597,7 @@ func (m *Manager) GetCodebergReleases(owner, repo string) ([]string, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer closeResponseBody(resp) if resp.StatusCode == 404 { // Repository might not exist on Codeberg @@ -669,7 +678,7 @@ func (m *Manager) CreateGitHubRelease(owner, repo, tag, releaseNotes string) err if err != nil { return err } - defer resp.Body.Close() + defer closeResponseBody(resp) if resp.StatusCode != 201 { body, _ := io.ReadAll(resp.Body) @@ -722,7 +731,7 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e if err != nil { return err } - defer resp.Body.Close() + defer closeResponseBody(resp) if resp.StatusCode != 201 { body, _ := io.ReadAll(resp.Body) @@ -739,7 +748,7 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e probeReq.Header.Set("Authorization", "token "+m.codebergToken) } if probeResp, perr2 := httpclient.Do(probeReq); perr2 == nil { - defer probeResp.Body.Close() + defer closeResponseBody(probeResp) if probeResp.StatusCode == 200 { // Try to detect if releases are disabled var repoInfo struct { @@ -768,7 +777,7 @@ func (m *Manager) CreateCodebergRelease(owner, repo, tag, releaseNotes string) e if rerr != nil { return rerr } - defer retryResp.Body.Close() + defer closeResponseBody(retryResp) if retryResp.StatusCode != 201 { rbody, _ := io.ReadAll(retryResp.Body) return fmt.Errorf("failed to create Codeberg release after enabling releases: %s - %s", retryResp.Status, string(rbody)) @@ -812,7 +821,11 @@ func PromptConfirmation(message string) bool { fmt.Printf("%s [y/N]: ", message) var response string - fmt.Scanln(&response) + // Scanln can return an error for empty input (e.g. a bare newline) or if + // stdin is closed; either way response stays "" and the check below + // already treats that as a decline, so there is nothing extra to do + // with the error here. + _, _ = fmt.Scanln(&response) response = strings.ToLower(strings.TrimSpace(response)) return response == "y" || response == "yes" @@ -828,7 +841,9 @@ func PromptConfirmationWithNotes(message, releaseNotes string) bool { fmt.Printf("%s [y/N]: ", message) var response string - fmt.Scanln(&response) + // See PromptConfirmation: a Scanln error still leaves response == "", + // which the check below already treats as a decline. + _, _ = fmt.Scanln(&response) response = strings.ToLower(strings.TrimSpace(response)) return response == "y" || response == "yes" @@ -856,7 +871,7 @@ func (m *Manager) UpdateGitHubRelease(owner, repo, tag, releaseNotes string) err if err != nil { return err } - defer resp.Body.Close() + defer closeResponseBody(resp) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -898,7 +913,7 @@ func (m *Manager) UpdateGitHubRelease(owner, repo, tag, releaseNotes string) err if err != nil { return err } - defer updateResp.Body.Close() + defer closeResponseBody(updateResp) if updateResp.StatusCode != 200 { body, _ := io.ReadAll(updateResp.Body) @@ -929,7 +944,7 @@ func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) e if err != nil { return err } - defer resp.Body.Close() + defer closeResponseBody(resp) if resp.StatusCode != 200 { body, _ := io.ReadAll(resp.Body) @@ -970,7 +985,7 @@ func (m *Manager) UpdateCodebergRelease(owner, repo, tag, releaseNotes string) e if err != nil { return err } - defer updateResp.Body.Close() + defer closeResponseBody(updateResp) if updateResp.StatusCode != 200 { body, _ := io.ReadAll(updateResp.Body) diff --git a/internal/release/release_prompt_test.go b/internal/release/release_prompt_test.go new file mode 100644 index 0000000..fdb1d27 --- /dev/null +++ b/internal/release/release_prompt_test.go @@ -0,0 +1,74 @@ +package release + +import ( + "os" + "testing" +) + +// withStdin temporarily replaces os.Stdin with r for the duration of fn, then +// restores the original value. Not run in parallel with other tests since +// os.Stdin is a shared global. +func withStdin(t *testing.T, r *os.File, fn func()) { + t.Helper() + + original := os.Stdin + os.Stdin = r + defer func() { os.Stdin = original }() + + fn() +} + +// TestPromptConfirmation_EmptyInputDeclines verifies that when Scanln +// returns an error (e.g. a bare newline, which fmt.Scanln reports as +// "unexpected newline"), PromptConfirmation still safely defaults to +// declining rather than panicking or hanging - this is the discarded-error +// behavior that PromptConfirmation's fmt.Scanln call relies on. +func TestPromptConfirmation_EmptyInputDeclines(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + defer func() { _ = r.Close() }() + + if _, err := w.WriteString("\n"); err != nil { + t.Fatalf("failed to write to pipe: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close pipe writer: %v", err) + } + + var got bool + withStdin(t, r, func() { + got = PromptConfirmation("Proceed?") + }) + + if got { + t.Fatal("PromptConfirmation() = true for empty input, want false") + } +} + +// TestPromptConfirmation_YesInputConfirms is a regression check that the +// happy path (explicit "y") still works after touching the Scanln call. +func TestPromptConfirmation_YesInputConfirms(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("failed to create pipe: %v", err) + } + defer func() { _ = r.Close() }() + + if _, err := w.WriteString("y\n"); err != nil { + t.Fatalf("failed to write to pipe: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("failed to close pipe writer: %v", err) + } + + var got bool + withStdin(t, r, func() { + got = PromptConfirmation("Proceed?") + }) + + if !got { + t.Fatal("PromptConfirmation() = false for \"y\" input, want true") + } +} |
