Skip to content
Merged
10 changes: 8 additions & 2 deletions .github-usage/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/email-report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 22 additions & 3 deletions src/github_usage/email_report_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'<p class="visibility-tag">+ Public repos (free): {pub_min:,.1f} min{storage_part}</p>'


def _format_html_forecast_section(
data: dict,
*,
Expand All @@ -327,15 +337,20 @@ 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 = (
' <span class="visibility-tag">(private repos — quota-counted)</span>' if has_split else ""
)

rows = [
("Actions Minutes", forecast["minutes"]),
("Storage (avg MB)", forecast["storage_avg_mb"]),
("Premium Requests", forecast["premium_requests"]),
]

parts = [
"<h2>Monthly Forecast</h2>",
(f"<p>Day {forecast['day_of_month']} of {forecast['days_in_month']}</p>"),
f"<h2>Monthly Forecast{scope_note}</h2>",
f"<p>Day {forecast['day_of_month']} of {forecast['days_in_month']}</p>",
"<table>",
"<tr><th>Metric</th><th>Current</th><th>Projected</th><th>Limit</th><th>Run-out</th></tr>",
]
Expand All @@ -346,10 +361,14 @@ def _run_out(value: int | None) -> str:
f"<td>{metric['current']:,.1f}</td>"
f"<td>{metric['projected']:,.1f}</td>"
f"<td>{_limit(metric['limit'])}</td>"
f"<td>{html.escape(_run_out(metric['run_out_day']))}</td>"
f"<td>{_run_out(metric['run_out_day'])}</td>"
"</tr>"
)
parts.append("</table>")
if has_split:
note = _public_repos_html_note(forecast)
if note:
parts.append(note)
return parts


Expand Down
38 changes: 31 additions & 7 deletions src/github_usage/email_report_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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} / "
Expand All @@ -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]:
Expand Down Expand Up @@ -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,
*,
Expand All @@ -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:
Expand All @@ -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

Expand Down
9 changes: 7 additions & 2 deletions src/github_usage/gui/wizard/__init__.py
Original file line number Diff line number Diff line change
@@ -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__ = []
5 changes: 5 additions & 0 deletions src/github_usage/gui/wizard/setup_wizard_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)}",
]
Expand Down
15 changes: 14 additions & 1 deletion src/github_usage/gui/wizard/setup_wizard_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down
144 changes: 144 additions & 0 deletions tests/test_email_report_visibility.py
Original file line number Diff line number Diff line change
@@ -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()
Loading