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/.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/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 3efe6e8..b274f60 100644 --- a/src/github_usage/email_report_html.py +++ b/src/github_usage/email_report_html.py @@ -303,6 +303,16 @@ def _format_html_errors_section(data: dict) -> list[str]: return parts +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: + return "" + storage_part = f" · {pub_mb:,.1f} MB avg storage" if pub_mb > 0 else "" + return f'

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

' + + def _format_html_forecast_section( data: dict, *, @@ -327,6 +337,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,8 +349,8 @@ def _run_out(value: int | None) -> str: ] parts = [ - "

Monthly Forecast

", - (f"

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

"), + f"

Monthly Forecast{scope_note}

", + f"

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

", "", "", ] @@ -346,10 +361,14 @@ 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'])}
") + if has_split: + 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 9ac6b78..dea842b 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 = "public_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]: @@ -337,6 +345,16 @@ def _format_errors_section(data: dict) -> list[str]: return lines +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: + 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, *, @@ -355,10 +373,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 +394,13 @@ 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: + note = _public_repos_text_note(forecast) + if note: + lines.append(note) lines.append("") return lines diff --git a/src/github_usage/gui/wizard/__init__.py b/src/github_usage/gui/wizard/__init__.py index 2d74fd3..51836ae 100644 --- a/src/github_usage/gui/wizard/__init__.py +++ b/src/github_usage/gui/wizard/__init__.py @@ -1,5 +1,10 @@ """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 ModuleNotFoundError as exc: + if exc.name is None or not exc.name.startswith("textual"): + raise + __all__ = [] diff --git a/src/github_usage/gui/wizard/setup_wizard_flow.py b/src/github_usage/gui/wizard/setup_wizard_flow.py index 3f6b1c1..54c5721 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,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)) + 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)) @@ -130,6 +133,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 +194,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..2c9e712 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 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_visibility.py b/tests/test_email_report_visibility.py new file mode 100644 index 0000000..0ef2953 --- /dev/null +++ b/tests/test_email_report_visibility.py @@ -0,0 +1,144 @@ +"""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_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 + + 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 9a83eaa..035d843 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,68 @@ 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") + + 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)