From 1235fa09a0aef566431a85e2a1800eee37d5c40b Mon Sep 17 00:00:00 2001 From: zzwong <6979793+zzwong@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:47:29 -0400 Subject: [PATCH 1/4] fix: handle output and cleanup errors --- internal/cli/auth.go | 12 ++++++------ internal/cli/bills.go | 12 ++++++------ internal/cli/bills_test.go | 6 +++--- internal/cli/diagnostics.go | 4 ++-- internal/cli/entities.go | 4 ++-- internal/cli/green_button.go | 14 +++++++------- internal/cli/opower.go | 8 ++++---- internal/coned/auth.go | 14 +++++++------- internal/coned/auth_test.go | 18 +++++++++--------- internal/coned/bills.go | 12 ++++++------ internal/coned/bills_test.go | 24 ++++++++++++------------ internal/coned/browser_import.go | 4 ++-- internal/coned/diagnostics.go | 2 +- internal/coned/green_button.go | 26 +++++++++++++------------- internal/coned/opower.go | 4 ++-- internal/coned/opower_history.go | 2 +- internal/config/config.go | 8 ++++---- 17 files changed, 87 insertions(+), 87 deletions(-) diff --git a/internal/cli/auth.go b/internal/cli/auth.go index 2e03d0b..f63cf38 100644 --- a/internal/cli/auth.go +++ b/internal/cli/auth.go @@ -268,11 +268,11 @@ func authStatus(cmd *cobra.Command, profile string, jsonOutput bool, deps Depend if err != nil { return err } - fmt.Fprintln(cmd.OutOrStdout(), string(data)) - return nil + _, err = fmt.Fprintln(cmd.OutOrStdout(), string(data)) + return err } - fmt.Fprintln(cmd.OutOrStdout(), statusName(state)) - return nil + _, err = fmt.Fprintln(cmd.OutOrStdout(), statusName(state)) + return err } func authLogout(cmd *cobra.Command, profile string, timeout time.Duration, deps Dependencies, forget bool) error { @@ -299,6 +299,6 @@ func authLogout(cmd *cobra.Command, profile string, timeout time.Duration, deps if len(errorsFound) > 0 { return errors.Join(errorsFound...) } - fmt.Fprintln(cmd.OutOrStdout(), "not authenticated") - return nil + _, err := fmt.Fprintln(cmd.OutOrStdout(), "not authenticated") + return err } diff --git a/internal/cli/bills.go b/internal/cli/bills.go index 5925773..1199ce3 100644 --- a/internal/cli/bills.go +++ b/internal/cli/bills.go @@ -116,8 +116,8 @@ func downloadBill(cmd *cobra.Command, options *Options, deps Dependencies, id, o keep := false defer func() { if !keep { - file.Close() - os.Remove(temp) + _ = file.Close() + _ = os.Remove(temp) } }() ctx, cancel := context.WithTimeout(cmd.Context(), options.Timeout) @@ -136,8 +136,8 @@ func downloadBill(cmd *cobra.Command, options *Options, deps Dependencies, id, o return coned.ErrProtocolChanged } keep = true - fmt.Fprintln(cmd.OutOrStdout(), output) - return nil + _, err = fmt.Fprintln(cmd.OutOrStdout(), output) + return err } func billDateFromID(id string) (string, bool) { @@ -172,8 +172,8 @@ func newOutputFile(output string, force bool) (*os.File, string, error) { return nil, "", coned.ErrProtocolChanged } if err := file.Chmod(0o600); err != nil { - file.Close() - os.Remove(file.Name()) + _ = file.Close() + _ = os.Remove(file.Name()) return nil, "", coned.ErrProtocolChanged } return file, file.Name(), nil diff --git a/internal/cli/bills_test.go b/internal/cli/bills_test.go index 7ad0627..2e63e1d 100644 --- a/internal/cli/bills_test.go +++ b/internal/cli/bills_test.go @@ -221,11 +221,11 @@ func TestConcreteDownloadFailuresCleanUpAtCLILayer(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/en/accounts-billing/billing-history": - io.WriteString(w, ``) + _, _ = io.WriteString(w, ``) case "/sitecore/api/ssc/ConEdWeb-Foundation-MyAccount-Areas-BillingHistory-BillingHistoryAPI/User/0/GetResidentialBillHistory": - io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-synthetic"}]`) + _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-synthetic"}]`) case "/sitecore/api/ssc/ConEdWeb-Foundation-MyAccount-Areas-BillingHistory-BillingHistoryAPI/User/0/BillInsertImage": - io.WriteString(w, tc.documentResponse) + _, _ = io.WriteString(w, tc.documentResponse) default: http.NotFound(w, r) } diff --git a/internal/cli/diagnostics.go b/internal/cli/diagnostics.go index 22bfe41..6d75cdb 100644 --- a/internal/cli/diagnostics.go +++ b/internal/cli/diagnostics.go @@ -70,11 +70,11 @@ func newDiagnosticsCommand(options *Options, deps Dependencies) *cobra.Command { err = closeErr } if err != nil { - os.Remove(temp) + _ = os.Remove(temp) return coned.ErrProtocolChanged } if err = publishOutput(temp, output, false); err != nil { - os.Remove(temp) + _ = os.Remove(temp) return coned.ErrProtocolChanged } return nil diff --git a/internal/cli/entities.go b/internal/cli/entities.go index 59ef25b..8590f36 100644 --- a/internal/cli/entities.go +++ b/internal/cli/entities.go @@ -208,8 +208,8 @@ func selectEntity(cmd *cobra.Command, options *Options, deps Dependencies, value if cfg.Save(deps.ConfigPath) != nil { return coned.ErrProtocolChanged } - fmt.Fprintln(cmd.OutOrStdout(), "selected "+strings.SplitN(value, "-", 2)[0]) - return nil + _, err = fmt.Fprintln(cmd.OutOrStdout(), "selected "+strings.SplitN(value, "-", 2)[0]) + return err } func resolveEntitySelection(ctx context.Context, options *Options, deps Dependencies, session auth.Session) (coned.EntitySelection, error) { account, meter := options.Account, options.Meter diff --git a/internal/cli/green_button.go b/internal/cli/green_button.go index 40dda5d..3c4eef6 100644 --- a/internal/cli/green_button.go +++ b/internal/cli/green_button.go @@ -106,7 +106,7 @@ func greenExport(cmd *cobra.Command, o *Options, d Dependencies, format, from, t if e != nil { return safeGreenError(e) } - defer os.Remove(spool) + defer func() { _ = os.Remove(spool) }() if download { if output == "" { output = "green-button-usage.zip" @@ -118,8 +118,8 @@ func greenExport(cmd *cobra.Command, o *Options, d Dependencies, format, from, t keep := false defer func() { if !keep { - f.Close() - os.Remove(tmp) + _ = f.Close() + _ = os.Remove(tmp) } }() in, e := os.Open(spool) @@ -127,16 +127,16 @@ func greenExport(cmd *cobra.Command, o *Options, d Dependencies, format, from, t return coned.ErrProtocolChanged } _, e = io.Copy(f, in) - in.Close() - if e != nil || f.Sync() != nil || f.Close() != nil { + closeErr := in.Close() + if e != nil || closeErr != nil || f.Sync() != nil || f.Close() != nil { return coned.ErrProtocolChanged } if publishOutput(tmp, output, force) != nil { return coned.ErrProtocolChanged } keep = true - fmt.Fprintln(cmd.OutOrStdout(), output) - return nil + _, e = fmt.Fprintln(cmd.OutOrStdout(), output) + return e } if format == "json" { e = coned.GreenButtonCSVJSON(spool, cmd.OutOrStdout()) diff --git a/internal/cli/opower.go b/internal/cli/opower.go index 6271b18..0a38a68 100644 --- a/internal/cli/opower.go +++ b/internal/cli/opower.go @@ -255,8 +255,8 @@ func runOpowerExport(cmd *cobra.Command, options *Options, deps Dependencies, fo keep := false defer func() { if !keep { - file.Close() - os.Remove(temp) + _ = file.Close() + _ = os.Remove(temp) } }() if _, err = file.Write(data); err != nil || file.Sync() != nil || file.Close() != nil { @@ -419,8 +419,8 @@ func newOutputFileWithPrefix(output string, force bool, prefix string) (*os.File return nil, "", err } if err := file.Chmod(0o600); err != nil { - file.Close() - os.Remove(file.Name()) + _ = file.Close() + _ = os.Remove(file.Name()) return nil, "", err } return file, file.Name(), nil diff --git a/internal/coned/auth.go b/internal/coned/auth.go index 9063b10..8b18feb 100644 --- a/internal/coned/auth.go +++ b/internal/coned/auth.go @@ -47,14 +47,14 @@ func (c *Client) Authenticate(ctx context.Context, credentials auth.Credentials) resetURL, _ := url.Parse("https://coned.okta.com/api/v1/sessions/me") if reset, resetErr := c.request(ctx, http.MethodDelete, resetURL, nil, false); resetErr == nil { c.debugf("auth Okta reset response status=%d", reset.StatusCode) - reset.Body.Close() + _ = reset.Body.Close() } c.debugf("auth login request started") response, err := c.request(ctx, http.MethodPost, c.endpoint(loginPath), bytes.NewReader(data), true) if err != nil { return auth.Session{}, transportError(ctx) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() c.debugf("auth login response status=%d", response.StatusCode) body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { @@ -163,7 +163,7 @@ func (c *Client) ResendMFA(ctx context.Context) error { if err != nil { return transportError(ctx) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() c.debugf("auth MFA resend response status=%d", response.StatusCode) _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20)) if response.StatusCode >= 200 && response.StatusCode < 300 { @@ -198,7 +198,7 @@ func (c *Client) VerifyMFA(ctx context.Context, code string) (auth.Session, erro if err != nil { return auth.Session{}, transportError(ctx) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() c.debugf("auth MFA verification response status=%d", response.StatusCode) body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { @@ -369,7 +369,7 @@ func (c *Client) follow(ctx context.Context, next *url.URL) error { if err != nil { return transportError(ctx) } - response.Body.Close() + _ = response.Body.Close() c.debugf("auth redirect response status=%d", response.StatusCode) for _, cookie := range response.Cookies() { c.debugf("auth redirect set-cookie name=%s domain=%s path=%s value_len=%d max_age=%d expires_set=%t expires_future=%t", cookie.Name, strings.TrimPrefix(cookie.Domain, "."), cookie.Path, len(cookie.Value), cookie.MaxAge, !cookie.Expires.IsZero(), cookie.Expires.After(time.Now())) @@ -404,7 +404,7 @@ func (c *Client) confirm(ctx context.Context) error { if err != nil { return transportError(ctx) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() c.debugf("auth confirmation response status=%d", response.StatusCode) if response.StatusCode >= 300 && response.StatusCode < 400 { location, err := response.Location() @@ -456,7 +456,7 @@ func (c *Client) Logout(ctx context.Context, session auth.Session) error { } return auth.ErrLogoutFailed } - response.Body.Close() + _ = response.Body.Close() if (response.StatusCode >= 200 && response.StatusCode < 300) || response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusNotFound { return nil } diff --git a/internal/coned/auth_test.go b/internal/coned/auth_test.go index a3e959c..da735bb 100644 --- a/internal/coned/auth_test.go +++ b/internal/coned/auth_test.go @@ -48,7 +48,7 @@ func TestAuthenticateSuccessAndExactPayload(t *testing.T) { t.Errorf("incorrect payload: %s", body) } fixture := readFixture(t, "login_success.json") - w.Write([]byte(strings.ReplaceAll(fixture, "https://www.coned.com", server.URL))) + _, _ = w.Write([]byte(strings.ReplaceAll(fixture, "https://www.coned.com", server.URL))) case "/authorize": if r.URL.Query().Get("state") != "synthetic" || r.URL.Query().Get("nonce") != "synthetic" || r.URL.Query().Get("sessionToken") != "synthetic-session-token" { t.Error("authorize state, nonce, or session token was not preserved") @@ -94,7 +94,7 @@ func TestVerifyMFAExactPayloadAndSession(t *testing.T) { t.Errorf("incorrect MFA payload: %s", body) } w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"code":true,"authRedirectUrl":"` + server.URL + `/complete"}`)) + _, _ = w.Write([]byte(`{"code":true,"authRedirectUrl":"` + server.URL + `/complete"}`)) case "/complete": http.SetCookie(w, &http.Cookie{Name: "CE_AUTH", Value: "synthetic", Path: "/", HttpOnly: true}) w.WriteHeader(http.StatusOK) @@ -123,7 +123,7 @@ func TestVerifyMFARejectsInvalidCodeAndUnsafeRedirect(t *testing.T) { {"unsafe", `{"code":true,"authRedirectUrl":"https://evil.example/"}`, ErrProtocolChanged}, } { t.Run(tc.name, func(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(tc.response)) })) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(tc.response)) })) defer server.Close() _, err := testClient(t, server).VerifyMFA(context.Background(), "123456") if !errors.Is(err, tc.want) { @@ -174,9 +174,9 @@ func TestAuthenticationResponseErrors(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(tc.status) if tc.name == "invalid" { - w.Write([]byte(readFixture(t, "login_failure.json"))) + _, _ = w.Write([]byte(readFixture(t, "login_failure.json"))) } else { - w.Write([]byte(tc.body)) + _, _ = w.Write([]byte(tc.body)) } })) defer server.Close() @@ -208,7 +208,7 @@ func TestUnsafeRedirectAndRedirectToLogin(t *testing.T) { if tc.authorize { body = `{"url":"` + server.URL + `/authorize"}` } - w.Write([]byte(body)) + _, _ = w.Write([]byte(body)) return } if r.URL.Path == "/authorize" { @@ -237,7 +237,7 @@ func TestTimeoutAndCookieFilteringAndRestore(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == loginPath { time.Sleep(100 * time.Millisecond) - w.Write([]byte(`{}`)) + _, _ = w.Write([]byte(`{}`)) return } w.WriteHeader(http.StatusOK) @@ -361,7 +361,7 @@ func TestLogoutClearsOnFailureAndHonorsCancellation(t *testing.T) { func TestProtocolErrorDiagnosticsAreSafe(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("x-ms-middleware-request-id", "safe-request_123") - w.Write([]byte(`{`)) + _, _ = w.Write([]byte(`{`)) })) defer server.Close() _, err := testClient(t, server).Authenticate(context.Background(), auth.Credentials{Email: "a@b", Password: "p"}) @@ -423,7 +423,7 @@ func TestConfirmationProtocolErrorDiagnostics(t *testing.T) { server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case loginPath: - w.Write([]byte(strings.ReplaceAll(readFixture(t, "login_success.json"), "https://www.coned.com", server.URL))) + _, _ = w.Write([]byte(strings.ReplaceAll(readFixture(t, "login_success.json"), "https://www.coned.com", server.URL))) case "/authorize": w.WriteHeader(http.StatusOK) case accountPath: diff --git a/internal/coned/bills.go b/internal/coned/bills.go index 3ccdcab..86f4a51 100644 --- a/internal/coned/bills.go +++ b/internal/coned/bills.go @@ -108,9 +108,9 @@ func (c *Client) DownloadBill(ctx context.Context, session auth.Session, id stri return billDownloadError(id, transportError(ctx)) } body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - response.Body.Close() - if readErr != nil || response.StatusCode < 200 || response.StatusCode >= 300 { - if readErr != nil { + closeErr := response.Body.Close() + if readErr != nil || closeErr != nil || response.StatusCode < 200 || response.StatusCode >= 300 { + if readErr != nil || closeErr != nil { return billDownloadError(id, ErrProtocolChanged) } if response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden || isLoginRedirect(response) { @@ -126,7 +126,7 @@ func (c *Client) DownloadBill(ctx context.Context, session auth.Session, id stri if err != nil { return billDownloadError(id, transportError(ctx)) } - defer pdf.Body.Close() + defer func() { _ = pdf.Body.Close() }() if pdf.StatusCode < 200 || pdf.StatusCode >= 300 { if pdf.StatusCode == http.StatusUnauthorized || pdf.StatusCode == http.StatusForbidden || isLoginRedirect(pdf) { return billDownloadError(id, ErrSessionExpired) @@ -151,7 +151,7 @@ func (c *Client) billRecords(ctx context.Context, session auth.Session) (billing if err != nil { return nil, nil, transportError(ctx) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) if err != nil { return nil, nil, ErrProtocolChanged @@ -178,7 +178,7 @@ func (c *Client) historyMetadata(ctx context.Context, session auth.Session) (bil if err != nil { return nil, transportError(ctx) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) if err != nil { return nil, ErrProtocolChanged diff --git a/internal/coned/bills_test.go b/internal/coned/bills_test.go index fd0d8e0..64d9a39 100644 --- a/internal/coned/bills_test.go +++ b/internal/coned/bills_test.go @@ -34,13 +34,13 @@ func TestListBillsUsesAuthenticatedHistoryAndReturnsPublicNewestFirst(t *testing } switch r.URL.Path { case billHistoryPath: - io.WriteString(w, readFixture(t, "bill_history.html")) + _, _ = io.WriteString(w, readFixture(t, "bill_history.html")) case residentialBillHistoryPath: body, _ := io.ReadAll(r.Body) if !strings.Contains(string(body), "synthetic-maid") { t.Error("metadata was not forwarded") } - io.WriteString(w, `{"data":[{"BillDate":"01/15/2026","Cycle":"January","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","Cycle":"February","DocumentId":"opaque-two","DocumentType":"bill"}]}`) + _, _ = io.WriteString(w, `{"data":[{"BillDate":"01/15/2026","Cycle":"January","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","Cycle":"February","DocumentId":"opaque-two","DocumentType":"bill"}]}`) default: http.NotFound(w, r) } @@ -76,15 +76,15 @@ func TestDownloadBillValidatesURLSizeAndPDF(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case billHistoryPath: - io.WriteString(w, readFixture(t, "bill_history.html")) + _, _ = io.WriteString(w, readFixture(t, "bill_history.html")) case residentialBillHistoryPath: - io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque","DocumentType":"bill"}]`) + _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque","DocumentType":"bill"}]`) case billInsertImagePath: if tc.status != 0 { w.WriteHeader(tc.status) return } - io.WriteString(w, `{"url":"`+tc.documentURL+`"}`) + _, _ = io.WriteString(w, `{"url":"`+tc.documentURL+`"}`) default: http.NotFound(w, r) } @@ -147,9 +147,9 @@ func TestBillSessionExpiryAndMissingBill(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case billHistoryPath: - io.WriteString(w, readFixture(t, "bill_history.html")) + _, _ = io.WriteString(w, readFixture(t, "bill_history.html")) case residentialBillHistoryPath: - io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`) + _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`) } })) defer server.Close() @@ -244,9 +244,9 @@ func TestBillingOperationsSerializeSessionRestore(t *testing.T) { } else { secondHistory <- cookie.Value } - io.WriteString(w, ``) + _, _ = io.WriteString(w, ``) case residentialBillHistoryPath: - io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`) + _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque"}]`) default: http.NotFound(w, r) } @@ -304,15 +304,15 @@ func TestSameDateIDsAreSafeAndAmbiguousDownloadsAreRejected(t *testing.T) { switch r.URL.Path { case billHistoryPath: history++ - io.WriteString(w, readFixture(t, "bill_history.html")) + _, _ = io.WriteString(w, readFixture(t, "bill_history.html")) case residentialBillHistoryPath: - io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","DocumentId":"opaque-two","DocumentType":"bill"}]`) + _, _ = io.WriteString(w, `[{"BillDate":"2026-02-15","DocumentId":"opaque-one","DocumentType":"bill"},{"BillDate":"2026-02-15","DocumentId":"opaque-two","DocumentType":"bill"}]`) case billInsertImagePath: insert++ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { t.Fatal(err) } - io.WriteString(w, `{"url":"https://synthetic.blob.core.windows.net/bill.pdf?sig=synthetic"}`) + _, _ = io.WriteString(w, `{"url":"https://synthetic.blob.core.windows.net/bill.pdf?sig=synthetic"}`) default: http.NotFound(w, r) } diff --git a/internal/coned/browser_import.go b/internal/coned/browser_import.go index ad7376a..6902355 100644 --- a/internal/coned/browser_import.go +++ b/internal/coned/browser_import.go @@ -57,7 +57,7 @@ func ImportBrowserSession(ctx context.Context, endpoint string) (auth.Session, e if err != nil { return auth.Session{}, errors.New("connect to browser endpoint") } - defer conn.Close() + defer func() { _ = conn.Close() }() if deadline, ok := ctx.Deadline(); ok { _ = conn.SetReadDeadline(deadline) _ = conn.SetWriteDeadline(deadline) @@ -193,7 +193,7 @@ func resolveBrowserWebSocket(ctx context.Context, endpoint string) (string, erro if err != nil { return "", errors.New("query browser endpoint") } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() if response.StatusCode != http.StatusOK { return "", fmt.Errorf("query browser endpoint: status %d", response.StatusCode) } diff --git a/internal/coned/diagnostics.go b/internal/coned/diagnostics.go index 0a850c2..6e15b84 100644 --- a/internal/coned/diagnostics.go +++ b/internal/coned/diagnostics.go @@ -31,7 +31,7 @@ func (c *Client) SchemaDiagnostics(ctx context.Context, session auth.Session) ([ if err != nil { return nil, transportError(ctx) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, protocolError(resp) } diff --git a/internal/coned/green_button.go b/internal/coned/green_button.go index c6d5c2c..c35bb96 100644 --- a/internal/coned/green_button.go +++ b/internal/coned/green_button.go @@ -227,7 +227,7 @@ func (c *Client) GreenButtonExport(ctx context.Context, s auth.Session, o GreenB if err != nil { return "", transportError(ctx) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { return "", protocolError(resp) } @@ -236,22 +236,22 @@ func (c *Client) GreenButtonExport(ctx context.Context, s auth.Session, o GreenB return "", ErrProtocolChanged } if err = file.Chmod(0600); err != nil { - file.Close() - os.Remove(file.Name()) + _ = file.Close() + _ = os.Remove(file.Name()) return "", ErrProtocolChanged } n, err := io.Copy(file, io.LimitReader(resp.Body, maxExportZIP+1)) if err != nil || n > maxExportZIP { - file.Close() - os.Remove(file.Name()) + _ = file.Close() + _ = os.Remove(file.Name()) return "", ErrProtocolChanged } if err = file.Close(); err != nil { - os.Remove(file.Name()) + _ = os.Remove(file.Name()) return "", ErrProtocolChanged } if err = validateExportZIP(file.Name()); err != nil { - os.Remove(file.Name()) + _ = os.Remove(file.Name()) return "", err } return file.Name(), nil @@ -268,7 +268,7 @@ func validateExportZIP(name string) error { if err != nil { return ErrProtocolChanged } - defer z.Close() + defer func() { _ = z.Close() }() if len(z.File) == 0 || len(z.File) > maxExportEntries { return ErrProtocolChanged } @@ -288,7 +288,7 @@ func ExtractGreenButton(name, format string, out io.Writer) error { if err != nil { return ErrProtocolChanged } - defer z.Close() + defer func() { _ = z.Close() }() ext := "." + strings.ToLower(format) for _, f := range z.File { if strings.EqualFold(filepath.Ext(f.Name), ext) { @@ -297,8 +297,8 @@ func ExtractGreenButton(name, format string, out io.Writer) error { return ErrProtocolChanged } _, e = io.Copy(out, io.LimitReader(r, maxExportEntry+1)) - r.Close() - if e != nil { + closeErr := r.Close() + if e != nil || closeErr != nil { return ErrProtocolChanged } return nil @@ -311,14 +311,14 @@ func GreenButtonCSVJSON(name string, out io.Writer) error { if err != nil { return ErrProtocolChanged } - defer z.Close() + defer func() { _ = z.Close() }() for _, f := range z.File { if strings.EqualFold(filepath.Ext(f.Name), ".csv") { r, e := f.Open() if e != nil { return ErrProtocolChanged } - defer r.Close() + defer func() { _ = r.Close() }() cr := csv.NewReader(io.LimitReader(r, maxExportEntry+1)) cr.FieldsPerRecord = -1 var heads []string diff --git a/internal/coned/opower.go b/internal/coned/opower.go index 75b088f..3b7799c 100644 --- a/internal/coned/opower.go +++ b/internal/coned/opower.go @@ -70,7 +70,7 @@ func (c *Client) mintOpowerToken(ctx context.Context) (string, error) { if err != nil { return "", transportError(ctx) } - defer response.Body.Close() + defer func() { _ = response.Body.Close() }() body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if err != nil { return "", ErrProtocolChanged @@ -112,7 +112,7 @@ func (c *Client) opower(ctx context.Context, session auth.Session, operation, qu if err != nil { return transportError(ctx) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 16<<20)) if err != nil { return ErrProtocolChanged diff --git a/internal/coned/opower_history.go b/internal/coned/opower_history.go index 040e777..78cbdb2 100644 --- a/internal/coned/opower_history.go +++ b/internal/coned/opower_history.go @@ -305,7 +305,7 @@ func (c *Client) opowerGET(ctx context.Context, session auth.Session, token, end if err != nil { return transportError(ctx) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) if err != nil { return ErrProtocolChanged diff --git a/internal/config/config.go b/internal/config/config.go index 4963694..bf0554f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,18 +129,18 @@ func (c Config) Save(path string) error { return fmt.Errorf("create temporary config: %w", err) } tmpName := tmp.Name() - defer os.Remove(tmpName) + defer func() { _ = os.Remove(tmpName) }() if err := tmp.Chmod(0600); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("set config permissions: %w", err) } if _, err := tmp.Write(data); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("write config: %w", err) } if err := tmp.Sync(); err != nil { - tmp.Close() + _ = tmp.Close() return fmt.Errorf("sync config: %w", err) } if err := tmp.Close(); err != nil { From b612b02f0beca9d98057ec4ea8d9f23deec2bdb5 Mon Sep 17 00:00:00 2001 From: zzwong <6979793+zzwong@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:47:29 -0400 Subject: [PATCH 2/4] ci: enforce golangci-lint --- .github/pull_request_template.md | 5 ++--- .github/workflows/ci.yml | 19 ++++++++++++++++++- .golangci.yml | 9 +++++++++ CONTRIBUTING.md | 13 ++++++++----- Makefile | 15 +++++++++------ README.md | 6 ++---- 6 files changed, 48 insertions(+), 19 deletions(-) create mode 100644 .golangci.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e3c36f3..d9637c1 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -2,9 +2,8 @@ ## Testing -- [ ] `go test -race ./...` -- [ ] `go vet ./...` -- [ ] `git diff --check` +- [ ] `make check` +- [ ] `govulncheck ./...` - [ ] Provider-contract changes include updated provenance/version/`last_verified` metadata ## Security and privacy diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b40f58..d4e7fc7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,21 @@ jobs: - run: go test -race -shuffle=on ./... - run: go vet ./... - run: git diff --check + lint: + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version-file: go.mod + - uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 + with: + version: v2.12.2 security: needs: changes if: needs.changes.outputs.code == 'true' @@ -99,7 +114,7 @@ jobs: GOARCH: amd64 run: go build ./cmd/coned required: - needs: [changes, test, security] + needs: [changes, test, lint, security] if: always() runs-on: ubuntu-latest timeout-minutes: 5 @@ -110,10 +125,12 @@ jobs: CHANGE_RESULT: ${{ needs.changes.result }} CODE_CHANGED: ${{ needs.changes.outputs.code }} TEST_RESULT: ${{ needs.test.result }} + LINT_RESULT: ${{ needs.lint.result }} SECURITY_RESULT: ${{ needs.security.result }} run: | test "$CHANGE_RESULT" = "success" if [[ "$CODE_CHANGED" == "true" ]]; then test "$TEST_RESULT" = "success" + test "$LINT_RESULT" = "success" test "$SECURITY_RESULT" = "success" fi diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..e8bffd5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,9 @@ +version: "2" + +run: + timeout: 5m + tests: true + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9173d3..c4b9eae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,14 +13,17 @@ For security vulnerabilities, follow [SECURITY.md](SECURITY.md) instead of openi ## Development -Go 1.25.12 or newer is required. +Go 1.25.12 or newer is required. Install the pinned lint runner once: + +```bash +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 +``` + +Then run: ```bash go mod download -make fmt-check -make test -make vet -make diff-check +make check make build ``` diff --git a/Makefile b/Makefile index d599a6a..4a2b644 100644 --- a/Makefile +++ b/Makefile @@ -7,17 +7,20 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) DIRTY ?= $(shell test -z "$$(git status --porcelain 2>/dev/null)" && echo false || echo true) LDFLAGS := -X $(PKG).Version=$(VERSION) -X $(PKG).Commit=$(COMMIT) -X $(PKG).Date=$(DATE) -X $(PKG).Dirty=$(DIRTY) +GO ?= go -.PHONY: build install test vet security fmt-check diff-check release-check check clean +.PHONY: build install test vet lint security fmt-check diff-check release-check check clean build: mkdir -p $(BIN_DIR) - go build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(CMD) + GOTOOLCHAIN=auto $(GO) build -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/$(BINARY) $(CMD) install: - go install -ldflags "$(LDFLAGS)" $(CMD) + GOTOOLCHAIN=auto $(GO) install -ldflags "$(LDFLAGS)" $(CMD) test: - go test -race ./... + GOTOOLCHAIN=auto $(GO) test -race -shuffle=on ./... vet: - go vet ./... + GOTOOLCHAIN=auto $(GO) vet ./... +lint: + GOTOOLCHAIN=auto golangci-lint run ./... fmt-check: test -z "$$(gofmt -l $$(find . -name '*.go' -type f))" diff-check: @@ -27,6 +30,6 @@ security: release-check: goreleaser check goreleaser release --snapshot --clean -check: fmt-check test vet diff-check +check: fmt-check test vet lint diff-check clean: rm -rf $(BIN_DIR) diff --git a/README.md b/README.md index 2cdfba1..729a167 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # coned-cli [![CI](https://github.com/zzwong/coned-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/zzwong/coned-cli/actions/workflows/ci.yml) +[![golangci-lint](https://img.shields.io/github/actions/workflow/status/zzwong/coned-cli/ci.yml?branch=main&label=golangci-lint&logo=go)](https://github.com/zzwong/coned-cli/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) A headless CLI for accessing Con Edison account, billing, and energy-usage data. @@ -212,10 +213,7 @@ CLI flags override the configured profile and timeout. The production client acc ## Development ```bash -make fmt-check -make test -make vet -make diff-check +make check make build ./bin/coned version ``` From 8ec9e8004e6ec76e0ee7e056516347982e9d61d1 Mon Sep 17 00:00:00 2001 From: zzwong <6979793+zzwong@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:58:03 -0400 Subject: [PATCH 3/4] test: cover usage and Green Button flows --- internal/cli/green_button_test.go | 112 ++++++++++++++++++++++++++++++ internal/coned/discovery_test.go | 61 ++++++++++++++++ internal/coned/opower_test.go | 50 +++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 internal/cli/green_button_test.go create mode 100644 internal/coned/discovery_test.go diff --git a/internal/cli/green_button_test.go b/internal/cli/green_button_test.go new file mode 100644 index 0000000..9946107 --- /dev/null +++ b/internal/cli/green_button_test.go @@ -0,0 +1,112 @@ +package cli + +import ( + "archive/zip" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/zzwong/coned-cli/internal/auth" + "github.com/zzwong/coned-cli/internal/coned" +) + +type fakeGreenButton struct { + *fakeOpower + t *testing.T + dir string + options coned.GreenButtonOptions + err error +} + +func (f *fakeGreenButton) GreenButtonInspect(context.Context, auth.Session) (coned.GreenButtonMetadata, error) { + return coned.GreenButtonMetadata{CustomerClass: "RESIDENTIAL", TimeZone: "America/New_York", BillIntervals: []string{"2026-01"}, AMIIntervals: []string{"2026-01", "2026-02"}}, f.err +} + +func (f *fakeGreenButton) GreenButtonExport(_ context.Context, _ auth.Session, options coned.GreenButtonOptions) (string, error) { + f.options = options + if f.err != nil { + return "", f.err + } + path := filepath.Join(f.dir, "spool-"+options.Format+".zip") + file, err := os.Create(path) + if err != nil { + f.t.Fatal(err) + } + archive := zip.NewWriter(file) + name, body := "usage.csv", "TYPE,DATE,USAGE\nElectric usage,2026-01-01,3\n" + if options.Format == "xml" { + name, body = "usage.xml", "synthetic" + } + member, err := archive.Create(name) + if err != nil { + f.t.Fatal(err) + } + if _, err := member.Write([]byte(body)); err != nil { + f.t.Fatal(err) + } + if err := archive.Close(); err != nil { + f.t.Fatal(err) + } + if err := file.Close(); err != nil { + f.t.Fatal(err) + } + return path, nil +} + +func greenButtonDeps(t *testing.T) (*fakeGreenButton, Dependencies) { + fake := &fakeGreenButton{fakeOpower: &fakeOpower{}, t: t, dir: t.TempDir()} + deps := opowerDeps(t, fake.fakeOpower) + deps.Opower = fake + return fake, deps +} + +func TestGreenButtonInspectExportAndDownload(t *testing.T) { + fake, deps := greenButtonDeps(t) + output, err := runWithDependencies(t, deps, "", "green-button inspect") + if err != nil || !strings.Contains(output, "bill intervals: 1") { + t.Fatalf("output=%q err=%v", output, err) + } + output, err = runWithDependencies(t, deps, "", "--json green-button inspect") + if err != nil || !strings.Contains(output, `"customer_class":"RESIDENTIAL"`) { + t.Fatalf("output=%q err=%v", output, err) + } + output, err = runWithDependencies(t, deps, "", "green-button export --format json --from 2026-01-01 --to 2026-01-31") + if err != nil || fake.options.Format != "csv" || !strings.Contains(output, `"USAGE":"3"`) { + t.Fatalf("output=%q options=%#v err=%v", output, fake.options, err) + } + output, err = runWithDependencies(t, deps, "", "green-button export --format xml") + if err != nil || output != "synthetic" { + t.Fatalf("output=%q err=%v", output, err) + } + destination := filepath.Join(t.TempDir(), "usage.zip") + output, err = runWithDependencies(t, deps, "", "green-button download --format csv --output "+destination) + if err != nil || strings.TrimSpace(output) != destination { + t.Fatalf("output=%q err=%v", output, err) + } + if info, err := os.Stat(destination); err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("stat=%v err=%v", info, err) + } +} + +func TestGreenButtonValidationAndSafeErrors(t *testing.T) { + _, deps := greenButtonDeps(t) + for _, command := range []string{ + "green-button export --format zip", + "green-button download --format json", + "green-button export --from 2026-01-01", + "green-button export --from bad --to 2026-01-01", + "green-button export --from 2026-02-01 --to 2026-01-01", + } { + if _, err := runWithDependencies(t, deps, "", command); !errors.Is(err, coned.ErrProtocolChanged) { + t.Fatalf("%q: %v", command, err) + } + } + fake, deps := greenButtonDeps(t) + fake.err = errors.New("private detail: " + coned.ErrGreenButtonUnavailable.Error()) + if _, err := runWithDependencies(t, deps, "", "green-button inspect"); !errors.Is(err, coned.ErrGreenButtonUnavailable) { + t.Fatalf("error=%v", err) + } +} diff --git a/internal/coned/discovery_test.go b/internal/coned/discovery_test.go new file mode 100644 index 0000000..c5af7c7 --- /dev/null +++ b/internal/coned/discovery_test.go @@ -0,0 +1,61 @@ +package coned + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" +) + +func TestDiscoverBuildsDeduplicatedHierarchy(t *testing.T) { + client, err := NewClient(Options{Transport: opowerRT(func(request *http.Request) (*http.Response, error) { + var query struct { + OperationName string `json:"operationName"` + } + if err := json.NewDecoder(request.Body).Decode(&query); err != nil { + t.Fatal(err) + } + switch query.OperationName { + case "WBAS_BillingAccounts": + return opowerResponse(`{"data":{"billingAccountsConnection":{"edges":[{"node":{"urn":"account-1"}}]}}}`), nil + case "WRTAMI_GetMetadata": + return opowerResponse(`{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"uuid":"sa-1","serviceType":"ELECTRIC","servicePointsConnection":{"edges":[{"node":{"uuid":"meter-1","premise":{"uuid":"premise-1"},"registers":[{"serviceQuantityIdentifier":"register-1","readResolution":"HOUR","unitOfMeasure":"kWh"}]}},{"node":{"uuid":"meter-2","premise":{"uuid":"premise-1"},"registers":[{"readResolution":"DAY","unitOfMeasure":"kWh"}]}}]}}}]}}}}`), nil + default: + t.Fatalf("unexpected operation %q", query.OperationName) + return nil, errors.New("unexpected operation") + } + })}) + if err != nil { + t.Fatal(err) + } + entities, err := client.Discover(context.Background(), opowerSession(true)) + if err != nil { + t.Fatal(err) + } + if len(entities) != 6 { + t.Fatalf("entities=%#v", entities) + } + counts := map[string]int{} + for _, entity := range entities { + counts[entity.Type]++ + if entity.ContractVersion != 1 || entity.LastVerified == "" { + t.Fatalf("missing contract metadata: %#v", entity) + } + } + if counts["account"] != 1 || counts["premise"] != 1 || counts["meter"] != 2 || counts["register"] != 2 { + t.Fatalf("counts=%v", counts) + } +} + +func TestDiscoverRejectsEmptyAccounts(t *testing.T) { + client, err := NewClient(Options{Transport: opowerRT(func(*http.Request) (*http.Response, error) { + return opowerResponse(`{"data":{"billingAccountsConnection":{"edges":[]}}}`), nil + })}) + if err != nil { + t.Fatal(err) + } + if _, err := client.Discover(context.Background(), opowerSession(true)); !errors.Is(err, ErrProtocolChanged) { + t.Fatalf("error=%v", err) + } +} diff --git a/internal/coned/opower_test.go b/internal/coned/opower_test.go index bd2753e..b838f0e 100644 --- a/internal/coned/opower_test.go +++ b/internal/coned/opower_test.go @@ -57,6 +57,56 @@ func TestOpowerAccountsMaskIDsAndRejectPartialErrors(t *testing.T) { t.Fatalf("missing token=%v", err) } } +func TestOpowerUsageResourcesEndToEnd(t *testing.T) { + responses := map[string]string{ + "WBAS_BillingAccounts": `{"data":{"billingAccountsConnection":{"edges":[{"node":{"urn":"urn:synthetic:account","name":"Home","utilityCode":"SYN","customerClass":"RES","serviceAgreementsConnection":{"totalCount":1}}}]}}}`, + "WRTAMI_GetMetadata": `{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"uuid":"sa-1","serviceType":"ELECTRIC","servicePointsConnection":{"edges":[{"node":{"uuid":"sp-1","premise":{"uuid":"premise-1"},"registers":[{"readResolution":"HOUR","unitOfMeasure":"kWh","availableReadsTimeInterval":"2026-01-01/2026-02-01"}]}}]}}}]}}}}`, + "WDB_GetCostUsageReadsForBills": `{"data":{"billingAccountByAuthContext":{"bills":[{"timeInterval":"2026-01-01/2026-02-01","segments":[{"usageInterval":"2026-01-01/2026-02-01","estimated":true,"serviceQuantities":[{"unit":"kWh","serviceQuantity":{"value":10}}],"usageCharges":{"value":3.5}}]}]}}}`, + "WDB_GetWeather": `{"data":{"billingAccountByAuthContext":{"premisesConnection":{"edges":[{"node":{"weather":[{"timeInterval":"2026-01-01/2026-01-02","minTemperature":{"value":20},"meanTemperature":{"value":30},"maxTemperature":{"value":40}}]}}]}}}}`, + "WDB_GetNeighborComparisons": `{"data":{"billingAccountByAuthContext":{"premisesConnection":{"edges":[{"elec":[{"meterType":"ELEC","timeInterval":"2026-01-01/2026-02-01","youUsage":{"unit":"kWh","value":10},"efficientNeighborsThresholdUsage":{"unit":"kWh","value":8},"averageOfAllNeighborsUsage":{"unit":"kWh","value":12},"numberOfNeighbors":100}]}]}}}}`, + "WRTAMI_GetRegisters": `{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"servicePointsConnection":{"edges":[{"node":{"intervalReads":[{"registerId":"register-1"}]}}]}}}]}}}}`, + "WRTAMI_GetRegisterUsage": `{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[{"node":{"servicePointsConnection":{"edges":[{"node":{"intervalReads":[{"unit":"kWh","reads":[{"timeInterval":"2026-01-01T00:00:00Z/2026-01-01T01:00:00Z","measuredAmount":{"value":1.25}}]}]}}]}}}]}}}}`, + } + client, err := NewClient(Options{Transport: opowerRT(func(request *http.Request) (*http.Response, error) { + var query struct { + OperationName string `json:"operationName"` + } + if err := json.NewDecoder(request.Body).Decode(&query); err != nil { + t.Fatal(err) + } + body, ok := responses[query.OperationName] + if !ok { + t.Fatalf("unexpected operation %q", query.OperationName) + } + return opowerResponse(body), nil + })}) + if err != nil { + t.Fatal(err) + } + session := opowerSession(true) + selection := EntitySelection{Account: "urn:synthetic:account", Meter: "sp-1"} + for _, resource := range []string{"accounts", "usage-bills", "weather", "neighbors", "meters", "realtime", "summary", "export"} { + value, err := client.FetchSelected(context.Background(), session, resource, selection) + if err != nil { + t.Fatalf("%s: %v", resource, err) + } + if value == nil { + t.Fatalf("%s returned nil", resource) + } + } + reads, err := client.UsageRealtime(context.Background(), session, UsageOptions{Account: selection.Account}) + if err != nil || len(reads) != 1 || reads[0].Value != 1.25 { + t.Fatalf("reads=%#v err=%v", reads, err) + } + summary, err := client.UsageSummary(context.Background(), session, UsageOptions{Account: selection.Account}) + if err != nil || summary.Bills != 1 || summary.Usage.Value != 10 || summary.Charges != 3.5 { + t.Fatalf("summary=%#v err=%v", summary, err) + } + if _, err := client.FetchSelected(context.Background(), session, "unknown", selection); !errors.Is(err, ErrProtocolChanged) { + t.Fatalf("unknown resource: %v", err) + } +} + func TestRealtimeEmptyIsUnavailable(t *testing.T) { client, err := NewClient(Options{Transport: opowerRT(func(*http.Request) (*http.Response, error) { return opowerResponse(`{"data":{"billingAccountByAuthContext":{"serviceAgreementsConnection":{"edges":[]}}}}`), nil From 0a0b41bf48e4bc4370932a69d0e15562e92bd444 Mon Sep 17 00:00:00 2001 From: zzwong <6979793+zzwong@users.noreply.github.com> Date: Mon, 13 Jul 2026 04:58:03 -0400 Subject: [PATCH 4/4] ci: enforce meaningful coverage floor --- .github/workflows/ci.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4e7fc7..2aba5d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,19 @@ jobs: run: | unformatted="$(find . -name '*.go' -type f -exec gofmt -l {} +)" test -z "$unformatted" - - run: go test -race -shuffle=on ./... + - name: Test with coverage + if: runner.os == 'Linux' + run: go test -race -shuffle=on -coverprofile=coverage.out ./... + - name: Enforce coverage floor + if: runner.os == 'Linux' + shell: bash + run: | + coverage="$(go tool cover -func=coverage.out | awk '/^total:/ {gsub(/%/, "", $3); print $3}')" + echo "total statement coverage: ${coverage}%" + awk -v coverage="$coverage" 'BEGIN { exit !(coverage + 0 >= 70) }' + - name: Test + if: runner.os != 'Linux' + run: go test -race -shuffle=on ./... - run: go vet ./... - run: git diff --check lint: