From 5ad90995cfa5ca59b1fe94cfda25f828afcafec2 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:12:13 -0400 Subject: [PATCH 1/8] Scope forecast to private repos and improve email formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the visibility split is available (private_minutes / public_minutes in actions data), the forecast now projects only private usage against quota limits and labels itself "Monthly Forecast — private repos". Public minutes are shown as an informational footnote below the table since they are free. The Actions section also surfaces the private/public minute breakdown when the split is present. Co-Authored-By: Claude Sonnet 4.6 --- src/github_usage/email_report_html.py | 16 +++++++++++++- src/github_usage/email_report_text.py | 32 +++++++++++++++++++++------ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/github_usage/email_report_html.py b/src/github_usage/email_report_html.py index 3efe6e8..28eec34 100644 --- a/src/github_usage/email_report_html.py +++ b/src/github_usage/email_report_html.py @@ -327,6 +327,11 @@ def _limit(value: float | None) -> str: def _run_out(value: int | None) -> str: return f"day {value}" if value is not None else "--" + has_split = "public_minutes" in forecast + scope_note = ( + ' (private repos — quota-counted)' if has_split else "" + ) + rows = [ ("Actions Minutes", forecast["minutes"]), ("Storage (avg MB)", forecast["storage_avg_mb"]), @@ -334,7 +339,7 @@ def _run_out(value: int | None) -> str: ] parts = [ - "

Monthly Forecast

", + f"

Monthly Forecast{scope_note}

", (f"

Day {forecast['day_of_month']} of {forecast['days_in_month']}

"), "", "", @@ -350,6 +355,15 @@ def _run_out(value: int | None) -> str: "" ) parts.append("
MetricCurrentProjectedLimitRun-out
") + if has_split: + pub_min = float(forecast.get("public_minutes") or 0.0) + pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) + if pub_min > 0 or pub_mb > 0: + storage_part = f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "" + parts.append( + f'

+ Public repos (free): ' + f"{pub_min:,.1f} min{html.escape(storage_part)}

" + ) return parts diff --git a/src/github_usage/email_report_text.py b/src/github_usage/email_report_text.py index 9ac6b78..e963874 100644 --- a/src/github_usage/email_report_text.py +++ b/src/github_usage/email_report_text.py @@ -44,7 +44,8 @@ def _format_actions_section(data: dict) -> list[str]: if not actions: return [] net = (data.get("monthly_costs") or {}).get("actions", {}).get("net", 0.0) - return [ + has_split = "private_minutes" in actions + lines = [ "Actions", ( f"- Minutes: {actions.get('minutes', 0.0):,.1f} / " @@ -57,8 +58,15 @@ def _format_actions_section(data: dict) -> list[str]: f"({actions.get('storage_percent', 0.0):.1f}%)" ), f"- Net cost: {fmt_price(net)}", - "", ] + if has_split: + priv_min = float(actions.get("private_minutes") or 0.0) + pub_min = float(actions.get("public_minutes") or 0.0) + lines.append( + f" (private: {priv_min:,.1f} min quota-counted · public: {pub_min:,.1f} min free)" + ) + lines.append("") + return lines def _format_copilot_section(data: dict) -> list[str]: @@ -355,10 +363,12 @@ def _format_forecast_section( if forecast is None: return [] + has_split = "public_minutes" in forecast + scope = " — private repos" if has_split else "" lines = [ - f"Monthly Forecast (day {forecast['day_of_month']} of {forecast['days_in_month']})", - "───────────────────────────────────────────", - "Metric Current Projected Limit Run-out", + f"Monthly Forecast{scope} (day {forecast['day_of_month']} of {forecast['days_in_month']})", + "───────────────────────────────────────────────────", + "Metric Current Projected Limit Run-out", ] def _limit(value: float | None) -> str: @@ -374,9 +384,17 @@ def _run_out(value: int | None) -> str: ] for label, metric in rows: lines.append( - f"{label:19} {metric['current']:>9,.1f} {metric['projected']:>10,.1f} " - f"{_limit(metric['limit']):>8} {_run_out(metric['run_out_day']):>8}" + f"{label:20} {metric['current']:>9,.1f} {metric['projected']:>10,.1f} " + f"{_limit(metric['limit']):>9} {_run_out(metric['run_out_day']):>8}" ) + if has_split: + pub_min = float(forecast.get("public_minutes") or 0.0) + pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) + if pub_min > 0 or pub_mb > 0: + lines.append( + f" + Public repos (free): {pub_min:,.1f} min" + + (f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "") + ) lines.append("") return lines From 4b639ad552eb357e7ea780f057b5d3ae486472d7 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:20:28 -0400 Subject: [PATCH 2/8] Add email_format to setup wizard and switch GitHub Actions to HTML email_format was a hidden config-only setting with no UI path to change it. The wizard now exposes a Select widget (plain text / HTML) in the Report options step, seeds it from config on load, saves it on next, and shows the chosen format in the review summary. GitHub Actions workflow updated to --email-format html to match local config. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/email-report.yml | 2 +- src/github_usage/gui/wizard/setup_wizard_flow.py | 4 ++++ .../gui/wizard/setup_wizard_screen.py | 15 ++++++++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/email-report.yml b/.github/workflows/email-report.yml index a5e4b91..ffef18f 100644 --- a/.github/workflows/email-report.yml +++ b/.github/workflows/email-report.yml @@ -102,7 +102,7 @@ jobs: if [ "${{ inputs.only_private }}" = "true" ]; then args+=(--only-private) fi - profile_args=(--max-repos 50 --email-format text --warn-over 25 --warn-over 80%) + profile_args=(--max-repos 50 --email-format html --warn-over 25 --warn-over 80%) if [ ${#profile_args[@]} -gt 0 ]; then args+=("${profile_args[@]}") fi diff --git a/src/github_usage/gui/wizard/setup_wizard_flow.py b/src/github_usage/gui/wizard/setup_wizard_flow.py index 3f6b1c1..1912b7f 100644 --- a/src/github_usage/gui/wizard/setup_wizard_flow.py +++ b/src/github_usage/gui/wizard/setup_wizard_flow.py @@ -48,6 +48,7 @@ class WizardData: only_public: bool = False only_private: bool = False max_repos: int = 100 + email_format: str = "text" target_email: str = "" local_weekday: int = 1 local_hour: int = 9 @@ -78,6 +79,7 @@ def load_initial_data(paths: SetupPaths) -> WizardData: data.only_public = bool(email.get("only_public")) data.only_private = bool(email.get("only_private")) data.max_repos = int(email.get("max_repos", 100)) + data.email_format = str(email.get("email_format", "text")) data.target_email = profile.get("target_email", "") sched = profile.get("schedule", {}) data.local_weekday = int(sched.get("weekday", 1)) @@ -130,6 +132,7 @@ def save_options_step(paths: SetupPaths, data: WizardData) -> None: email["only_public"] = data.only_public email["only_private"] = data.only_private email["max_repos"] = data.max_repos + email["email_format"] = data.email_format profile["target_email"] = data.target_email update_profile(config, profile) save_profiles(paths, config) @@ -190,6 +193,7 @@ def review_summary(data: WizardData) -> str: f"releases={data.include_release_assets}, " f"only_public={data.only_public}, only_private={data.only_private}", f"[b]Max repos[/b]: {data.max_repos}", + f"[b]Email format[/b]: {data.email_format}", f"[b]Local schedule[/b]: " f"{describe_local_schedule(data.local_weekday, data.local_hour, data.local_minute)}", ] diff --git a/src/github_usage/gui/wizard/setup_wizard_screen.py b/src/github_usage/gui/wizard/setup_wizard_screen.py index 0247ffc..1b47f1b 100644 --- a/src/github_usage/gui/wizard/setup_wizard_screen.py +++ b/src/github_usage/gui/wizard/setup_wizard_screen.py @@ -8,7 +8,7 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Button, Checkbox, ContentSwitcher, Input, Label, RichLog, Static +from textual.widgets import Button, Checkbox, ContentSwitcher, Input, Label, RichLog, Select, Static from ..errors import format_error from ..layout import FormGrid @@ -108,6 +108,16 @@ def compose(self) -> ComposeResult: with FormGrid(): yield Label("Max repos:") yield Input(value="100", id="wizard-max-repos") + yield Label("Email format:") + yield Select( + [ + ("Plain text (safe for all clients)", "text"), + ("HTML (styled tables)", "html"), + ], + value="text", + id="wizard-email-format", + allow_blank=False, + ) yield Label("target_email:") yield Input( placeholder="Optional — uses REPORT_EMAIL when blank", @@ -184,6 +194,7 @@ def _populate_options(self) -> None: self.query_one("#wizard-only-public", Checkbox).value = self._data.only_public self.query_one("#wizard-only-private", Checkbox).value = self._data.only_private self.query_one("#wizard-max-repos", Input).value = str(self._data.max_repos) + self.query_one("#wizard-email-format", Select).value = self._data.email_format self.query_one("#wizard-target-email", Input).value = self._data.target_email def _read_secrets_from_form(self) -> None: @@ -204,6 +215,8 @@ def _read_options_from_form(self) -> None: self._data.include_release_assets = self.query_one("#wizard-release", Checkbox).value self._data.only_public = self.query_one("#wizard-only-public", Checkbox).value self._data.only_private = self.query_one("#wizard-only-private", Checkbox).value + fmt = self.query_one("#wizard-email-format", Select).value + self._data.email_format = str(fmt) if fmt and fmt is not Select.BLANK else "text" self._data.target_email = self.query_one("#wizard-target-email", Input).value.strip() def _read_local_from_form(self) -> bool: From cea00f12634909de9dcf7f9cdd4560ec779e34ca Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:25:38 -0400 Subject: [PATCH 3/8] Add tests and docs for email_format wizard flow - 6 new tests covering load_initial_data, save_options_step, and review_summary for the email_format field (WizardEmailFormatFlowTests) - Guard wizard __init__ import behind try/except so setup_wizard_flow is importable without textual installed; this also unblocks the previously-erroring test_setup_wizard_visibility.py (2 pre-existing tests now run instead of erroring at collection time) - config.example.toml: add comment explaining that config.toml is gitignored but its settings are baked into the committed workflow YAML via the setup wizard, so changes require re-rendering + committing Co-Authored-By: Claude Sonnet 4.6 --- .github-usage/config.example.toml | 10 +++- src/github_usage/gui/wizard/__init__.py | 7 ++- tests/test_setup_wizard_visibility.py | 65 ++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/.github-usage/config.example.toml b/.github-usage/config.example.toml index 253b69d..9faad19 100644 --- a/.github-usage/config.example.toml +++ b/.github-usage/config.example.toml @@ -17,8 +17,14 @@ include_forecast = true # When omitted, premium requests are projected but no run-out day is shown. # premium_requests_limit = 10000 max_repos = 100 -# Email body format: "text" (default) or "html". HTML requires the renderer -# in github_usage.email_report.format_html_report; text uses format_report_email. +# Email body format: "text" (default) or "html". +# HTML delivers styled tables to clients that support it; text is the fallback. +# +# IMPORTANT — how this setting reaches GitHub Actions: +# .github-usage/config.toml is gitignored (it holds secrets alongside settings). +# After changing email_format here, re-run setup option 5 "GitHub Actions workflow" +# (or the TUI wizard) to re-render .github/workflows/email-report.yml, then +# commit that file. The workflow YAML is the committed source of truth for CI. email_format = "text" warn_over = [ "25", diff --git a/src/github_usage/gui/wizard/__init__.py b/src/github_usage/gui/wizard/__init__.py index 2d74fd3..9846aa9 100644 --- a/src/github_usage/gui/wizard/__init__.py +++ b/src/github_usage/gui/wizard/__init__.py @@ -1,5 +1,8 @@ """Guided setup wizard for the Textual TUI.""" -from .setup_wizard_screen import SetupWizardScreen +try: + from .setup_wizard_screen import SetupWizardScreen -__all__ = ["SetupWizardScreen"] + __all__ = ["SetupWizardScreen"] +except ImportError: + pass diff --git a/tests/test_setup_wizard_visibility.py b/tests/test_setup_wizard_visibility.py index 9a83eaa..1e56596 100644 --- a/tests/test_setup_wizard_visibility.py +++ b/tests/test_setup_wizard_visibility.py @@ -1,10 +1,20 @@ -"""Tests for wizard visibility filter validation.""" +"""Tests for wizard visibility filter validation and wizard flow helpers.""" from __future__ import annotations +import tempfile import unittest +from pathlib import Path +from unittest import mock -from github_usage.gui.wizard.setup_wizard_flow import WizardData, validate_options +from github_usage.gui.wizard.setup_wizard_flow import ( + WizardData, + load_initial_data, + review_summary, + save_options_step, + validate_options, +) +from github_usage.setup_config import SetupPaths, load_config, write_config class WizardVisibilityFilterTests(unittest.TestCase): @@ -20,5 +30,56 @@ def test_validate_options_allows_single_visibility_filter(self) -> None: self.assertIsNone(validate_options(data)) +class WizardEmailFormatFlowTests(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.root = Path(self.tmpdir) + self.paths = SetupPaths.from_root(self.root) + write_config(self.paths.config_file, load_config(self.paths.config_file)) + + def tearDown(self): + import shutil + + shutil.rmtree(self.tmpdir) + + def test_load_initial_data_reads_email_format_html(self): + write_config( + self.paths.config_file, + {"email_report": {"email_format": "html"}}, + ) + with mock.patch("github_usage.gui.wizard.setup_wizard_flow.read_secrets", return_value={}): + data = load_initial_data(self.paths) + self.assertEqual(data.email_format, "html") + + def test_load_initial_data_defaults_email_format_to_text(self): + write_config(self.paths.config_file, {"email_report": {}}) + with mock.patch("github_usage.gui.wizard.setup_wizard_flow.read_secrets", return_value={}): + data = load_initial_data(self.paths) + self.assertEqual(data.email_format, "text") + + def test_save_options_step_persists_email_format_html(self): + data = WizardData(email_format="html") + with mock.patch("github_usage.gui.wizard.setup_wizard_flow.apply_env"): + save_options_step(self.paths, data) + config = load_config(self.paths.config_file) + self.assertEqual(config["email_report"]["email_format"], "html") + + def test_save_options_step_persists_email_format_text(self): + data = WizardData(email_format="text") + with mock.patch("github_usage.gui.wizard.setup_wizard_flow.apply_env"): + save_options_step(self.paths, data) + config = load_config(self.paths.config_file) + self.assertEqual(config["email_report"]["email_format"], "text") + + def test_review_summary_includes_email_format(self): + data = WizardData(email_format="html") + summary = review_summary(data) + self.assertIn("html", summary) + self.assertIn("Email format", summary) + + def test_wizard_data_default_email_format_is_text(self): + self.assertEqual(WizardData().email_format, "text") + + if __name__ == "__main__": unittest.main() From 2cddce4d4bc5467abfc8bfaff077ab48dd100d8b Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:39:44 -0400 Subject: [PATCH 4/8] Fix Sonar S3776: extract public-repo footnote helpers to reduce cognitive complexity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _format_forecast_section and _format_html_forecast_section each hit complexity 17 (limit 15) due to the nested has_split → pub_min/pub_mb conditional block. Extracted _public_repos_text_note and _public_repos_html_note as module-level helpers, dropping each function back to ~13. Co-Authored-By: Claude Sonnet 4.6 --- src/github_usage/email_report_html.py | 23 +++++++++++++++-------- src/github_usage/email_report_text.py | 19 ++++++++++++------- 2 files changed, 27 insertions(+), 15 deletions(-) diff --git a/src/github_usage/email_report_html.py b/src/github_usage/email_report_html.py index 28eec34..6fe88de 100644 --- a/src/github_usage/email_report_html.py +++ b/src/github_usage/email_report_html.py @@ -303,6 +303,18 @@ def _format_html_errors_section(data: dict) -> list[str]: return parts +def _public_repos_html_note(forecast: dict) -> str: + pub_min = float(forecast.get("public_minutes") or 0.0) + pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) + if pub_min <= 0 and pub_mb <= 0: + return "" + storage_part = f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "" + return ( + f'

+ Public repos (free): ' + f"{pub_min:,.1f} min{html.escape(storage_part)}

" + ) + + def _format_html_forecast_section( data: dict, *, @@ -356,14 +368,9 @@ def _run_out(value: int | None) -> str: ) parts.append("") if has_split: - pub_min = float(forecast.get("public_minutes") or 0.0) - pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) - if pub_min > 0 or pub_mb > 0: - storage_part = f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "" - parts.append( - f'

+ Public repos (free): ' - f"{pub_min:,.1f} min{html.escape(storage_part)}

" - ) + note = _public_repos_html_note(forecast) + if note: + parts.append(note) return parts diff --git a/src/github_usage/email_report_text.py b/src/github_usage/email_report_text.py index e963874..912696a 100644 --- a/src/github_usage/email_report_text.py +++ b/src/github_usage/email_report_text.py @@ -345,6 +345,15 @@ def _format_errors_section(data: dict) -> list[str]: return lines +def _public_repos_text_note(forecast: dict) -> str: + pub_min = float(forecast.get("public_minutes") or 0.0) + pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) + if pub_min <= 0 and pub_mb <= 0: + return "" + storage = f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "" + return f" + Public repos (free): {pub_min:,.1f} min{storage}" + + def _format_forecast_section( data: dict, *, @@ -388,13 +397,9 @@ def _run_out(value: int | None) -> str: f"{_limit(metric['limit']):>9} {_run_out(metric['run_out_day']):>8}" ) if has_split: - pub_min = float(forecast.get("public_minutes") or 0.0) - pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) - if pub_min > 0 or pub_mb > 0: - lines.append( - f" + Public repos (free): {pub_min:,.1f} min" - + (f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "") - ) + note = _public_repos_text_note(forecast) + if note: + lines.append(note) lines.append("") return lines From f4bfe3f2eda0c22db3689e2f6560075bc1a7f5ce Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:01:35 -0400 Subject: [PATCH 5/8] Address PR review findings: unify has_split predicate, narrow ImportError guard, validate email_format, add tests MED-1: _format_actions_section now uses "public_minutes" as the split signal (was "private_minutes"), matching the forecast sections and HTML formatter so both sides of the same report use the same predicate. MED-2: gui/wizard/__init__.py narrows except ImportError to ModuleNotFoundError to avoid silently swallowing real import bugs in future code that might import from setup_wizard_screen. MED-3: load_initial_data normalizes email_format via .lower() and validates membership in {"text","html"}, defaulting to "text". Prevents a Textual InvalidSelectValueError crash when the config has a non-canonical value like "HTML". LOW-1: Added docstrings to _public_repos_text_note and _public_repos_html_note; added 9 new tests covering the has_split paths in both text and HTML formatters and the helper return values. LOW-4: Simplified the Select guard in setup_wizard_screen from "fmt and fmt is not Select.BLANK" to "fmt in ('text','html')" to make the intent explicit. LOW-6: Added two render_workflow round-trip tests asserting that email_format="html" and email_format="text" in the profile config each produce the correct --email-format flag in the rendered YAML. Co-Authored-By: Claude Sonnet 4.6 --- src/github_usage/email_report_html.py | 1 + src/github_usage/email_report_text.py | 3 +- src/github_usage/gui/wizard/__init__.py | 2 +- .../gui/wizard/setup_wizard_flow.py | 3 +- .../gui/wizard/setup_wizard_screen.py | 2 +- tests/test_email_report.py | 130 ++++++++++++++++++ tests/test_setup_wizard_visibility.py | 12 ++ tests/test_setup_workflow.py | 8 ++ 8 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/github_usage/email_report_html.py b/src/github_usage/email_report_html.py index 6fe88de..5a5e16d 100644 --- a/src/github_usage/email_report_html.py +++ b/src/github_usage/email_report_html.py @@ -304,6 +304,7 @@ def _format_html_errors_section(data: dict) -> list[str]: def _public_repos_html_note(forecast: dict) -> str: + """Return the HTML public-repos note element, or empty string when data is absent/zero.""" pub_min = float(forecast.get("public_minutes") or 0.0) pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) if pub_min <= 0 and pub_mb <= 0: diff --git a/src/github_usage/email_report_text.py b/src/github_usage/email_report_text.py index 912696a..dea842b 100644 --- a/src/github_usage/email_report_text.py +++ b/src/github_usage/email_report_text.py @@ -44,7 +44,7 @@ def _format_actions_section(data: dict) -> list[str]: if not actions: return [] net = (data.get("monthly_costs") or {}).get("actions", {}).get("net", 0.0) - has_split = "private_minutes" in actions + has_split = "public_minutes" in actions lines = [ "Actions", ( @@ -346,6 +346,7 @@ def _format_errors_section(data: dict) -> list[str]: def _public_repos_text_note(forecast: dict) -> str: + """Return the public-repos footnote line, or empty string when data is absent/zero.""" pub_min = float(forecast.get("public_minutes") or 0.0) pub_mb = float(forecast.get("public_storage_avg_mb") or 0.0) if pub_min <= 0 and pub_mb <= 0: diff --git a/src/github_usage/gui/wizard/__init__.py b/src/github_usage/gui/wizard/__init__.py index 9846aa9..8fe7553 100644 --- a/src/github_usage/gui/wizard/__init__.py +++ b/src/github_usage/gui/wizard/__init__.py @@ -4,5 +4,5 @@ from .setup_wizard_screen import SetupWizardScreen __all__ = ["SetupWizardScreen"] -except ImportError: +except ModuleNotFoundError: pass diff --git a/src/github_usage/gui/wizard/setup_wizard_flow.py b/src/github_usage/gui/wizard/setup_wizard_flow.py index 1912b7f..54c5721 100644 --- a/src/github_usage/gui/wizard/setup_wizard_flow.py +++ b/src/github_usage/gui/wizard/setup_wizard_flow.py @@ -79,7 +79,8 @@ def load_initial_data(paths: SetupPaths) -> WizardData: data.only_public = bool(email.get("only_public")) data.only_private = bool(email.get("only_private")) data.max_repos = int(email.get("max_repos", 100)) - data.email_format = str(email.get("email_format", "text")) + raw = str(email.get("email_format", "text")).lower() + data.email_format = raw if raw in {"text", "html"} else "text" data.target_email = profile.get("target_email", "") sched = profile.get("schedule", {}) data.local_weekday = int(sched.get("weekday", 1)) diff --git a/src/github_usage/gui/wizard/setup_wizard_screen.py b/src/github_usage/gui/wizard/setup_wizard_screen.py index 1b47f1b..2c9e712 100644 --- a/src/github_usage/gui/wizard/setup_wizard_screen.py +++ b/src/github_usage/gui/wizard/setup_wizard_screen.py @@ -216,7 +216,7 @@ def _read_options_from_form(self) -> None: self._data.only_public = self.query_one("#wizard-only-public", Checkbox).value self._data.only_private = self.query_one("#wizard-only-private", Checkbox).value fmt = self.query_one("#wizard-email-format", Select).value - self._data.email_format = str(fmt) if fmt and fmt is not Select.BLANK else "text" + self._data.email_format = str(fmt) if fmt in ("text", "html") else "text" self._data.target_email = self.query_one("#wizard-target-email", Input).value.strip() def _read_local_from_form(self) -> bool: diff --git a/tests/test_email_report.py b/tests/test_email_report.py index ffda6d7..bb73dcf 100644 --- a/tests/test_email_report.py +++ b/tests/test_email_report.py @@ -830,6 +830,136 @@ def test_html_table_helpers_live_in_dedicated_module(self): self.assertTrue(callable(tables.html_grouped_table)) self.assertTrue(callable(tables.html_storage_row)) + # --- MED-1: actions section uses "public_minutes" as the split signal --- + + def test_format_actions_section_shows_split_when_public_minutes_present(self): + from github_usage.email_report_text import _format_actions_section + + data = { + "actions": { + "minutes": 1500.0, + "minutes_limit": 2000, + "minutes_percent": 75.0, + "storage_avg_mb": 200.0, + "storage_limit_mb": 500, + "storage_percent": 40.0, + "private_minutes": 1200.0, + "public_minutes": 300.0, + }, + "monthly_costs": {"actions": {"net": 0.0}}, + } + lines = _format_actions_section(data) + joined = "\n".join(lines) + self.assertIn("private: 1,200.0 min quota-counted", joined) + self.assertIn("public: 300.0 min free", joined) + + def test_format_actions_section_no_split_without_public_minutes(self): + from github_usage.email_report_text import _format_actions_section + + data = { + "actions": { + "minutes": 500.0, + "minutes_limit": 2000, + "minutes_percent": 25.0, + "storage_avg_mb": 100.0, + "storage_limit_mb": 500, + "storage_percent": 20.0, + }, + "monthly_costs": {"actions": {"net": 0.0}}, + } + lines = _format_actions_section(data) + joined = "\n".join(lines) + self.assertNotIn("private:", joined) + self.assertNotIn("public:", joined) + + # --- LOW-1: new public-repos footnote helpers --- + + def test_public_repos_text_note_returns_note_with_minutes_and_storage(self): + from github_usage.email_report_text import _public_repos_text_note + + note = _public_repos_text_note({"public_minutes": 500.0, "public_storage_avg_mb": 12.5}) + self.assertIn("500.0 min", note) + self.assertIn("12.5 MB avg storage", note) + + def test_public_repos_text_note_omits_storage_line_when_zero(self): + from github_usage.email_report_text import _public_repos_text_note + + note = _public_repos_text_note({"public_minutes": 100.0, "public_storage_avg_mb": 0.0}) + self.assertIn("100.0 min", note) + self.assertNotIn("MB", note) + + def test_public_repos_text_note_returns_empty_when_both_zero(self): + from github_usage.email_report_text import _public_repos_text_note + + self.assertEqual( + "", _public_repos_text_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}) + ) + + def test_public_repos_html_note_returns_html_with_data(self): + from github_usage.email_report_html import _public_repos_html_note + + note = _public_repos_html_note({"public_minutes": 200.0, "public_storage_avg_mb": 5.0}) + self.assertIn("200.0 min", note) + self.assertIn("visibility-tag", note) + self.assertIn("5.0 MB avg storage", note) + + def test_public_repos_html_note_returns_empty_when_both_zero(self): + from github_usage.email_report_html import _public_repos_html_note + + self.assertEqual( + "", _public_repos_html_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}) + ) + + def test_format_forecast_section_shows_private_scope_when_split_available(self): + from datetime import date + + from github_usage.email_report_text import _format_forecast_section + + data = { + "actions": { + "minutes": 1500.0, + "minutes_limit": 2000, + "storage_avg_mb": 200.0, + "storage_limit_mb": 500, + "private_minutes": 1200.0, + "public_minutes": 300.0, + "public_storage_avg_mb": 50.0, + }, + "copilot": None, + } + lines = _format_forecast_section( + data, include_forecast=True, reference_date=date(2026, 8, 5) + ) + joined = "\n".join(lines) + self.assertIn("private repos", joined) + self.assertIn("Public repos (free)", joined) + self.assertIn("300.0 min", joined) + + def test_format_html_forecast_section_shows_private_scope_when_split_available(self): + from datetime import date + + from github_usage.email_report_html import _format_html_forecast_section + + data = { + "actions": { + "minutes": 1500.0, + "minutes_limit": 2000, + "storage_avg_mb": 200.0, + "storage_limit_mb": 500, + "private_minutes": 1200.0, + "public_minutes": 300.0, + "public_storage_avg_mb": 50.0, + }, + "copilot": None, + } + parts = _format_html_forecast_section( + data, include_forecast=True, reference_date=date(2026, 8, 5) + ) + html_body = "\n".join(parts) + self.assertIn("private repos", html_body) + self.assertIn("visibility-tag", html_body) + self.assertIn("300.0 min", html_body) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_setup_wizard_visibility.py b/tests/test_setup_wizard_visibility.py index 1e56596..035d843 100644 --- a/tests/test_setup_wizard_visibility.py +++ b/tests/test_setup_wizard_visibility.py @@ -80,6 +80,18 @@ def test_review_summary_includes_email_format(self): def test_wizard_data_default_email_format_is_text(self): self.assertEqual(WizardData().email_format, "text") + def test_load_initial_data_normalizes_uppercase_email_format(self): + write_config(self.paths.config_file, {"email_report": {"email_format": "HTML"}}) + with mock.patch("github_usage.gui.wizard.setup_wizard_flow.read_secrets", return_value={}): + data = load_initial_data(self.paths) + self.assertEqual(data.email_format, "html") + + def test_load_initial_data_rejects_invalid_email_format(self): + write_config(self.paths.config_file, {"email_report": {"email_format": "plaintext"}}) + with mock.patch("github_usage.gui.wizard.setup_wizard_flow.read_secrets", return_value={}): + data = load_initial_data(self.paths) + self.assertEqual(data.email_format, "text") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_setup_workflow.py b/tests/test_setup_workflow.py index d4cfd9f..08e3f8a 100644 --- a/tests/test_setup_workflow.py +++ b/tests/test_setup_workflow.py @@ -159,6 +159,14 @@ def test_custom_cron_appears_in_rendered_output(self): rendered = render_workflow(self._config(cron="0 8 * * 5"), self.root) self.assertIn("cron: '0 8 * * 5'", rendered) + def test_email_format_html_appears_in_profile_args(self): + rendered = render_workflow({"email_report": {"email_format": "html"}}, self.root) + self.assertIn("--email-format html", rendered) + + def test_email_format_text_appears_in_profile_args(self): + rendered = render_workflow({"email_report": {"email_format": "text"}}, self.root) + self.assertIn("--email-format text", rendered) + def test_rendered_output_has_expected_yaml_structure(self): rendered = render_workflow(self._config(), self.root) self.assertIn("name:", rendered) From 021cc13e28d3ba8da802d09475ff348978d43d4a Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:10:04 -0400 Subject: [PATCH 6/8] Fix Sonar S3415: swap assertEqual argument order in empty-note tests actual value must precede expected value ("") per S3415. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_email_report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_email_report.py b/tests/test_email_report.py index bb73dcf..cfebd81 100644 --- a/tests/test_email_report.py +++ b/tests/test_email_report.py @@ -892,7 +892,7 @@ def test_public_repos_text_note_returns_empty_when_both_zero(self): from github_usage.email_report_text import _public_repos_text_note self.assertEqual( - "", _public_repos_text_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}) + _public_repos_text_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}), "" ) def test_public_repos_html_note_returns_html_with_data(self): @@ -907,7 +907,7 @@ def test_public_repos_html_note_returns_empty_when_both_zero(self): from github_usage.email_report_html import _public_repos_html_note self.assertEqual( - "", _public_repos_html_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}) + _public_repos_html_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}), "" ) def test_format_forecast_section_shows_private_scope_when_split_available(self): From 3e7f68ec347c6e9fa29299bc0fbe5054f83f8eed Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:44:38 -0400 Subject: [PATCH 7/8] Apply CodeRabbit review suggestions: narrow ModuleNotFoundError guard, extract visibility tests gui/wizard/__init__.py: re-raises ModuleNotFoundError when exc.name is not None and doesn't start with "textual", so only the optional Textual dependency is silently suppressed; broken internal imports surface immediately. tests/test_email_report_visibility.py: new focused module holding the 9 public/private split tests (actions split, footnote helpers, forecast scope) extracted from test_email_report.py, bringing that file back under 835 lines. Co-Authored-By: Claude Sonnet 4.6 --- src/github_usage/gui/wizard/__init__.py | 5 +- tests/test_email_report.py | 130 ---------------------- tests/test_email_report_visibility.py | 137 ++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 132 deletions(-) create mode 100644 tests/test_email_report_visibility.py diff --git a/src/github_usage/gui/wizard/__init__.py b/src/github_usage/gui/wizard/__init__.py index 8fe7553..3fa0f8a 100644 --- a/src/github_usage/gui/wizard/__init__.py +++ b/src/github_usage/gui/wizard/__init__.py @@ -4,5 +4,6 @@ from .setup_wizard_screen import SetupWizardScreen __all__ = ["SetupWizardScreen"] -except ModuleNotFoundError: - pass +except ModuleNotFoundError as exc: + if exc.name is None or not exc.name.startswith("textual"): + raise diff --git a/tests/test_email_report.py b/tests/test_email_report.py index cfebd81..ffda6d7 100644 --- a/tests/test_email_report.py +++ b/tests/test_email_report.py @@ -830,136 +830,6 @@ def test_html_table_helpers_live_in_dedicated_module(self): self.assertTrue(callable(tables.html_grouped_table)) self.assertTrue(callable(tables.html_storage_row)) - # --- MED-1: actions section uses "public_minutes" as the split signal --- - - def test_format_actions_section_shows_split_when_public_minutes_present(self): - from github_usage.email_report_text import _format_actions_section - - data = { - "actions": { - "minutes": 1500.0, - "minutes_limit": 2000, - "minutes_percent": 75.0, - "storage_avg_mb": 200.0, - "storage_limit_mb": 500, - "storage_percent": 40.0, - "private_minutes": 1200.0, - "public_minutes": 300.0, - }, - "monthly_costs": {"actions": {"net": 0.0}}, - } - lines = _format_actions_section(data) - joined = "\n".join(lines) - self.assertIn("private: 1,200.0 min quota-counted", joined) - self.assertIn("public: 300.0 min free", joined) - - def test_format_actions_section_no_split_without_public_minutes(self): - from github_usage.email_report_text import _format_actions_section - - data = { - "actions": { - "minutes": 500.0, - "minutes_limit": 2000, - "minutes_percent": 25.0, - "storage_avg_mb": 100.0, - "storage_limit_mb": 500, - "storage_percent": 20.0, - }, - "monthly_costs": {"actions": {"net": 0.0}}, - } - lines = _format_actions_section(data) - joined = "\n".join(lines) - self.assertNotIn("private:", joined) - self.assertNotIn("public:", joined) - - # --- LOW-1: new public-repos footnote helpers --- - - def test_public_repos_text_note_returns_note_with_minutes_and_storage(self): - from github_usage.email_report_text import _public_repos_text_note - - note = _public_repos_text_note({"public_minutes": 500.0, "public_storage_avg_mb": 12.5}) - self.assertIn("500.0 min", note) - self.assertIn("12.5 MB avg storage", note) - - def test_public_repos_text_note_omits_storage_line_when_zero(self): - from github_usage.email_report_text import _public_repos_text_note - - note = _public_repos_text_note({"public_minutes": 100.0, "public_storage_avg_mb": 0.0}) - self.assertIn("100.0 min", note) - self.assertNotIn("MB", note) - - def test_public_repos_text_note_returns_empty_when_both_zero(self): - from github_usage.email_report_text import _public_repos_text_note - - self.assertEqual( - _public_repos_text_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}), "" - ) - - def test_public_repos_html_note_returns_html_with_data(self): - from github_usage.email_report_html import _public_repos_html_note - - note = _public_repos_html_note({"public_minutes": 200.0, "public_storage_avg_mb": 5.0}) - self.assertIn("200.0 min", note) - self.assertIn("visibility-tag", note) - self.assertIn("5.0 MB avg storage", note) - - def test_public_repos_html_note_returns_empty_when_both_zero(self): - from github_usage.email_report_html import _public_repos_html_note - - self.assertEqual( - _public_repos_html_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}), "" - ) - - def test_format_forecast_section_shows_private_scope_when_split_available(self): - from datetime import date - - from github_usage.email_report_text import _format_forecast_section - - data = { - "actions": { - "minutes": 1500.0, - "minutes_limit": 2000, - "storage_avg_mb": 200.0, - "storage_limit_mb": 500, - "private_minutes": 1200.0, - "public_minutes": 300.0, - "public_storage_avg_mb": 50.0, - }, - "copilot": None, - } - lines = _format_forecast_section( - data, include_forecast=True, reference_date=date(2026, 8, 5) - ) - joined = "\n".join(lines) - self.assertIn("private repos", joined) - self.assertIn("Public repos (free)", joined) - self.assertIn("300.0 min", joined) - - def test_format_html_forecast_section_shows_private_scope_when_split_available(self): - from datetime import date - - from github_usage.email_report_html import _format_html_forecast_section - - data = { - "actions": { - "minutes": 1500.0, - "minutes_limit": 2000, - "storage_avg_mb": 200.0, - "storage_limit_mb": 500, - "private_minutes": 1200.0, - "public_minutes": 300.0, - "public_storage_avg_mb": 50.0, - }, - "copilot": None, - } - parts = _format_html_forecast_section( - data, include_forecast=True, reference_date=date(2026, 8, 5) - ) - html_body = "\n".join(parts) - self.assertIn("private repos", html_body) - self.assertIn("visibility-tag", html_body) - self.assertIn("300.0 min", html_body) - if __name__ == "__main__": unittest.main() diff --git a/tests/test_email_report_visibility.py b/tests/test_email_report_visibility.py new file mode 100644 index 0000000..b136e29 --- /dev/null +++ b/tests/test_email_report_visibility.py @@ -0,0 +1,137 @@ +"""Tests for public/private visibility split rendering in email report formatters.""" + +from __future__ import annotations + +import unittest + + +class EmailReportVisibilityTests(unittest.TestCase): + def test_format_actions_section_shows_split_when_public_minutes_present(self): + from github_usage.email_report_text import _format_actions_section + + data = { + "actions": { + "minutes": 1500.0, + "minutes_limit": 2000, + "minutes_percent": 75.0, + "storage_avg_mb": 200.0, + "storage_limit_mb": 500, + "storage_percent": 40.0, + "private_minutes": 1200.0, + "public_minutes": 300.0, + }, + "monthly_costs": {"actions": {"net": 0.0}}, + } + lines = _format_actions_section(data) + joined = "\n".join(lines) + self.assertIn("private: 1,200.0 min quota-counted", joined) + self.assertIn("public: 300.0 min free", joined) + + def test_format_actions_section_no_split_without_public_minutes(self): + from github_usage.email_report_text import _format_actions_section + + data = { + "actions": { + "minutes": 500.0, + "minutes_limit": 2000, + "minutes_percent": 25.0, + "storage_avg_mb": 100.0, + "storage_limit_mb": 500, + "storage_percent": 20.0, + }, + "monthly_costs": {"actions": {"net": 0.0}}, + } + lines = _format_actions_section(data) + joined = "\n".join(lines) + self.assertNotIn("private:", joined) + self.assertNotIn("public:", joined) + + def test_public_repos_text_note_returns_note_with_minutes_and_storage(self): + from github_usage.email_report_text import _public_repos_text_note + + note = _public_repos_text_note({"public_minutes": 500.0, "public_storage_avg_mb": 12.5}) + self.assertIn("500.0 min", note) + self.assertIn("12.5 MB avg storage", note) + + def test_public_repos_text_note_omits_storage_line_when_zero(self): + from github_usage.email_report_text import _public_repos_text_note + + note = _public_repos_text_note({"public_minutes": 100.0, "public_storage_avg_mb": 0.0}) + self.assertIn("100.0 min", note) + self.assertNotIn("MB", note) + + def test_public_repos_text_note_returns_empty_when_both_zero(self): + from github_usage.email_report_text import _public_repos_text_note + + self.assertEqual( + _public_repos_text_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}), "" + ) + + def test_public_repos_html_note_returns_html_with_data(self): + from github_usage.email_report_html import _public_repos_html_note + + note = _public_repos_html_note({"public_minutes": 200.0, "public_storage_avg_mb": 5.0}) + self.assertIn("200.0 min", note) + self.assertIn("visibility-tag", note) + self.assertIn("5.0 MB avg storage", note) + + def test_public_repos_html_note_returns_empty_when_both_zero(self): + from github_usage.email_report_html import _public_repos_html_note + + self.assertEqual( + _public_repos_html_note({"public_minutes": 0.0, "public_storage_avg_mb": 0.0}), "" + ) + + def test_format_forecast_section_shows_private_scope_when_split_available(self): + from datetime import date + + from github_usage.email_report_text import _format_forecast_section + + data = { + "actions": { + "minutes": 1500.0, + "minutes_limit": 2000, + "storage_avg_mb": 200.0, + "storage_limit_mb": 500, + "private_minutes": 1200.0, + "public_minutes": 300.0, + "public_storage_avg_mb": 50.0, + }, + "copilot": None, + } + lines = _format_forecast_section( + data, include_forecast=True, reference_date=date(2026, 8, 5) + ) + joined = "\n".join(lines) + self.assertIn("private repos", joined) + self.assertIn("Public repos (free)", joined) + self.assertIn("300.0 min", joined) + + def test_format_html_forecast_section_shows_private_scope_when_split_available(self): + from datetime import date + + from github_usage.email_report_html import _format_html_forecast_section + + data = { + "actions": { + "minutes": 1500.0, + "minutes_limit": 2000, + "storage_avg_mb": 200.0, + "storage_limit_mb": 500, + "private_minutes": 1200.0, + "public_minutes": 300.0, + "public_storage_avg_mb": 50.0, + }, + "copilot": None, + } + parts = _format_html_forecast_section( + data, include_forecast=True, reference_date=date(2026, 8, 5) + ) + html_body = "\n".join(parts) + self.assertIn("private repos", html_body) + self.assertIn("visibility-tag", html_body) + self.assertIn("300.0 min", html_body) + + +if __name__ == "__main__": + unittest.main() From 3a9667b28ab244469689a7f535797da69ba1ba41 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:38:23 -0400 Subject: [PATCH 8/8] Address PR #12 review findings: __all__, html.escape, parens, CHANGELOG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gui/wizard/__init__.py: assign __all__ = [] in the except branch so star-imports and introspection tools don't raise AttributeError when Textual is not installed (HIGH #1) - email_report_html.py: remove two no-op html.escape() calls — one on the already-safe storage_part format string, one on _run_out() which only ever returns "day {int}" or "--" (MEDIUM #3/#4) - email_report_html.py: remove redundant outer parentheses on the day-of-month paragraph in the parts list (LOW #5) - test_email_report_visibility.py: add missing edge-case test for pub_min > 0 with pub_mb == 0 in HTML formatter (LOW #6) - CHANGELOG.md: document HTML workflow default change with migration guidance for users who re-run setup (HIGH #2) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + src/github_usage/email_report_html.py | 9 +++------ src/github_usage/gui/wizard/__init__.py | 1 + tests/test_email_report_visibility.py | 7 +++++++ 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38327d1..9347302 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This project follows the structure from Keep a Changelog and intends to use Sema ### Changed +- **GitHub Actions workflow now defaults to `--email-format html`:** The committed `email-report.yml` and its template were updated from `--email-format text` to `--email-format html`. Existing users who re-run setup (option 5 / wizard) will have their workflow re-rendered with this default. To keep plain-text output, set `email_format = "text"` in `.github-usage/config.toml` before re-running setup, or select **Text** in the wizard. - **Terminology:** the interactive CLI/TUI usage report is now called the **local full report** in user-facing copy (README, `start.sh`, CLI help, TUI). Internal `legacy_*` module names are unchanged for now (tracked in `TO_DO.md`). - **GitHub Actions `setup-python` v7:** Bump `actions/setup-python` from v6 to v7 in CI, security, email-report, and the email-report template (folds in Dependabot #7; no workflow input changes — this repo does not use the removed `pip-install` input). - **CodeRabbit auto-review disabled** via `.coderabbit.yaml` (`reviews.auto_review.enabled: false`). Request a review manually with `@coderabbitai review` on a PR. diff --git a/src/github_usage/email_report_html.py b/src/github_usage/email_report_html.py index 5a5e16d..b274f60 100644 --- a/src/github_usage/email_report_html.py +++ b/src/github_usage/email_report_html.py @@ -310,10 +310,7 @@ def _public_repos_html_note(forecast: dict) -> str: if pub_min <= 0 and pub_mb <= 0: return "" storage_part = f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "" - return ( - f'

+ Public repos (free): ' - f"{pub_min:,.1f} min{html.escape(storage_part)}

" - ) + return f'

+ Public repos (free): {pub_min:,.1f} min{storage_part}

' def _format_html_forecast_section( @@ -353,7 +350,7 @@ def _run_out(value: int | None) -> str: parts = [ f"

Monthly Forecast{scope_note}

", - (f"

Day {forecast['day_of_month']} of {forecast['days_in_month']}

"), + f"

Day {forecast['day_of_month']} of {forecast['days_in_month']}

", "", "", ] @@ -364,7 +361,7 @@ def _run_out(value: int | None) -> str: f"" f"" f"" - f"" + f"" "" ) parts.append("
MetricCurrentProjectedLimitRun-out
{metric['current']:,.1f}{metric['projected']:,.1f}{_limit(metric['limit'])}{html.escape(_run_out(metric['run_out_day']))}{_run_out(metric['run_out_day'])}
") diff --git a/src/github_usage/gui/wizard/__init__.py b/src/github_usage/gui/wizard/__init__.py index 3fa0f8a..51836ae 100644 --- a/src/github_usage/gui/wizard/__init__.py +++ b/src/github_usage/gui/wizard/__init__.py @@ -7,3 +7,4 @@ except ModuleNotFoundError as exc: if exc.name is None or not exc.name.startswith("textual"): raise + __all__ = [] diff --git a/tests/test_email_report_visibility.py b/tests/test_email_report_visibility.py index b136e29..0ef2953 100644 --- a/tests/test_email_report_visibility.py +++ b/tests/test_email_report_visibility.py @@ -75,6 +75,13 @@ def test_public_repos_html_note_returns_html_with_data(self): self.assertIn("visibility-tag", note) self.assertIn("5.0 MB avg storage", note) + def test_public_repos_html_note_omits_storage_when_minutes_positive_mb_zero(self): + from github_usage.email_report_html import _public_repos_html_note + + note = _public_repos_html_note({"public_minutes": 300.0, "public_storage_avg_mb": 0.0}) + self.assertIn("300.0 min", note) + self.assertNotIn("MB", note) + def test_public_repos_html_note_returns_empty_when_both_zero(self): from github_usage.email_report_html import _public_repos_html_note