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']}
",
"",
"| Metric | Current | Projected | Limit | Run-out |
",
]
@@ -346,10 +361,14 @@ def _run_out(value: int | None) -> str:
f"{metric['current']:,.1f} | "
f"{metric['projected']:,.1f} | "
f"{_limit(metric['limit'])} | "
- f"{html.escape(_run_out(metric['run_out_day']))} | "
+ f"{_run_out(metric['run_out_day'])} | "
""
)
parts.append("
")
+ 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)