diff --git a/.github-usage/config.example.toml b/.github-usage/config.example.toml
index 397de55..253b69d 100644
--- a/.github-usage/config.example.toml
+++ b/.github-usage/config.example.toml
@@ -27,6 +27,8 @@ warn_over = [
skip_actions = false
skip_copilot = false
skip_lfs = false
+only_public = false
+only_private = false
[schedule]
weekday = 1
diff --git a/.github/workflows/email-report.yml b/.github/workflows/email-report.yml
index 7902299..1451811 100644
--- a/.github/workflows/email-report.yml
+++ b/.github/workflows/email-report.yml
@@ -37,6 +37,22 @@ on:
options:
- 'false'
- 'true'
+ only_public:
+ description: Include only public repos in repo-level sections
+ required: false
+ default: 'false'
+ type: choice
+ options:
+ - 'false'
+ - 'true'
+ only_private:
+ description: Include only private and internal repos in repo-level sections
+ required: false
+ default: 'false'
+ type: choice
+ options:
+ - 'false'
+ - 'true'
report_email:
description: Override the REPORT_EMAIL secret for this manual run
required: false
@@ -78,6 +94,12 @@ jobs:
if [ "${{ inputs.include_forecast || 'true' }}" = "true" ]; then
args+=(--include-forecast)
fi
+ if [ "${{ inputs.only_public }}" = "true" ]; then
+ args+=(--only-public)
+ fi
+ if [ "${{ inputs.only_private }}" = "true" ]; then
+ args+=(--only-private)
+ fi
profile_args=(--max-repos 50 --email-format text --warn-over 25 --warn-over 80%)
if [ ${#profile_args[@]} -gt 0 ]; then
args+=("${profile_args[@]}")
diff --git a/.github/workflows/email-report.yml.template b/.github/workflows/email-report.yml.template
index d482b59..9b38b91 100644
--- a/.github/workflows/email-report.yml.template
+++ b/.github/workflows/email-report.yml.template
@@ -37,6 +37,22 @@ on:
options:
- 'false'
- 'true'
+ only_public:
+ description: Include only public repos in repo-level sections
+ required: false
+ default: 'false'
+ type: choice
+ options:
+ - 'false'
+ - 'true'
+ only_private:
+ description: Include only private and internal repos in repo-level sections
+ required: false
+ default: 'false'
+ type: choice
+ options:
+ - 'false'
+ - 'true'
report_email:
description: Override the REPORT_EMAIL secret for this manual run
required: false
@@ -78,6 +94,12 @@ jobs:
if [ "${{ inputs.include_forecast || '__INCLUDE_FORECAST_DEFAULT__' }}" = "true" ]; then
args+=(--include-forecast)
fi
+ if [ "${{ inputs.only_public }}" = "true" ]; then
+ args+=(--only-public)
+ fi
+ if [ "${{ inputs.only_private }}" = "true" ]; then
+ args+=(--only-private)
+ fi
profile_args=(__PROFILE_ARGS__)
if [ ${#profile_args[@]} -gt 0 ]; then
args+=("${profile_args[@]}")
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f2b2c8b..da83d91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ This project follows the structure from Keep a Changelog and intends to use Sema
### Added
+- **Public/private repo visibility in reports:** Per-repo Actions tables in the legacy Usage Report group rows by visibility (private, internal, public) with subtotals. Repo lists elsewhere annotate non-public repos with `[private]` / `[internal]` tags. CSV/XLSX exports include a `visibility` column; JSON retains the field on repo-level entries. Added `--only-public` and `--only-private` CLI flags (and `only_public` / `only_private` in `[email_report]`) to filter which repositories appear in repo-level sections. Filters apply after the `--max-repos` slice.
- **Usage forecast for legacy and email reports** ([plan](docs/superpowers/plans/archived/2026-07-03-usage-forecast.md)): render-time end-of-month projections for Actions minutes, artifact storage (average MB), and Copilot premium requests, plus run-out day estimates when limits are known. Computed from current usage values at render/export time so cached reports never show stale projections. Configurable via `include_forecast` and `premium_requests_limit` in `[email_report]`, `--include-forecast` / `--no-include-forecast` and `--premium-requests-limit` CLI flags, and the TUI profile editor / setup wizard. Included in plain-text and HTML email bodies, legacy terminal output, TUI detail rows, and all export formats (JSON, CSV, XLSX, PDF, text).
- **basedpyright static type checking** ([plan](docs/superpowers/plans/archived/2026-07-04-basedpyright-type-checking.md)): `basedpyright` runs on `src/` and `tests/` via `scripts/typecheck`, included in `scripts/check`, a pre-push hook, and a `typecheck` CI job. Existing codebase errors resolved or suppressed to establish a clean baseline. Configurable via `[tool.basedpyright]` in `pyproject.toml`.
- **TUI guided setup wizard:** **Start guided setup** on the Setup tab (and an optional first-run prompt) walks through secrets, report options, local and cloud schedules, review, verify, and optional LaunchAgent install for the default profile. CI secrets and dev hooks remain CLI-only (`./start.sh setup`).
diff --git a/README.md b/README.md
index 51cd6db..52d81d6 100644
--- a/README.md
+++ b/README.md
@@ -183,6 +183,7 @@ github-usage email-report \
[--warn-over 25] \
[--warn-over 80%] \
[--skip-actions] [--skip-copilot] [--skip-lfs] \
+ [--only-public | --only-private] \
[--dry-run] \
[--export csv|xlsx|pdf|json|text|none] \
[--output PATH] \
@@ -191,6 +192,8 @@ github-usage email-report \
`--include-consumers`, `--include-artifact-storage`, and `--include-release-assets` add repo-level API calls. They consume GitHub REST API request quota, not Actions minutes, Actions storage, Copilot requests, Git LFS quota, or billable GitHub usage. Use monthly schedules and conservative `--max-repos` values for accounts with many repositories.
+Repo-level sections annotate non-public repositories with `[private]` or `[internal]` tags. The legacy Usage Report per-repo Actions table groups rows by visibility with subtotals. `--only-public` and `--only-private` (mutually exclusive) filter which repositories are included in repo-level sections; filtering applies after the `--max-repos` limit. Set `only_public` / `only_private` in `[email_report]` in `config.toml` for scheduled runs.
+
Release assets are optional inventory, not a billing/quota report. The CLI asks for confirmation in interactive terminals, and CI must pass `--yes-include-release-assets`.
## Viewing Configured Runs
diff --git a/docs/superpowers/plans/archived/2026-07-22-public-private-visibility.md b/docs/superpowers/plans/archived/2026-07-22-public-private-visibility.md
new file mode 100644
index 0000000..d616346
--- /dev/null
+++ b/docs/superpowers/plans/archived/2026-07-22-public-private-visibility.md
@@ -0,0 +1,536 @@
+> **Status:** COMPLETE
+
+> Line numbers are accurate as of 2026-07-22; relocate by anchor (function name + dict key) if they drift.
+
+# Public/Private Repo Visibility Separation
+
+Add visibility awareness (public/private/internal) to all repo-level report sections. Per-repo tables split into grouped sub-tables by visibility, and top-consumer / annotated lists tag each entry with its visibility. Complementary `--only-public` / `--only-private` CLI flags allow filtering repos entirely. Grouping/annotation is on by default; filters are opt-in.
+
+The GitHub API already returns `visibility` (`"public"`, `"private"`, `"internal"`) and `private` (boolean) on every repo object from `GET /user/repos`. No additional API calls are needed for enrichment. Filter flags may optionally narrow the list request via the `visibility` query param (see Phase 6c).
+
+**Scope note:** Email/export paths do **not** have a full per-repo Actions table, so they get **annotations** (and a `visibility` column/key where tabular), not grouped sub-tables. Grouped sub-tables apply to the legacy terminal `render_repo_actions_table()` only.
+
+---
+
+## Phase 1 — Data layer: carry visibility through repo processing
+
+### 1a. `_limited_repos()` (`report_data.py:147`)
+
+No visibility enrichment needed — already returns raw repo dicts which include `visibility` and `private`. Phase 6 may extend this helper with an optional API `visibility` query param for filter efficiency; that is separate from enrichment.
+
+### 1b. Shared resolver (use everywhere below)
+
+Do **not** inline fallbacks independently. Phase 2's `repo_visibility()` is the single source of truth:
+
+```python
+"visibility": repo_visibility(repo),
+```
+
+Until `visibility.py` exists, implement Phase 2 first (or land a tiny stub in the same PR as Phase 1). Every enrichment site must call `repo_visibility()`, not `row.get("visibility", "public")` alone — the bare default loses the `private` boolean fallback.
+
+### 1c. `fetch_repo_actions_table()` (`report_actions.py:147`)
+
+Add `"visibility"` key to each row dict (lines 164–173, inside `rows.append({...})`):
+
+```python
+"visibility": repo_visibility(repo),
+```
+
+### 1d. `fetch_actions_os_breakdown()` (`report_actions.py:177`)
+
+Add `"visibility"` to each `repo_rows` entry (line ~193). Note the name key is `"name"` (not `"repo"`) in this structure.
+
+### 1e. `get_repo_consumers()` (`report_optional.py:21`)
+
+Add `"visibility"` to each row dict (line ~35).
+
+### 1f. `get_artifact_storage_details()` (`report_optional.py:53`)
+
+Add `"visibility"` to each row dict (line ~67).
+
+### 1g. `get_release_asset_details()` (`report_optional.py:78`)
+
+Add `"visibility"` to each row dict (line ~96).
+
+### 1h. `get_storage_analysis()` (`storage.py:6`)
+
+Add `"visibility"` to each repo_storage entry (line ~63). Storage rows use `"name"` for the repo full name.
+
+### 1i. `derive_repo_consumers()` / `derive_artifact_storage()` / `derive_release_assets()` (`legacy_report_data.py`)
+
+These functions build consumer/storage dicts from `repo_actions` and `storage_analysis`. Since visibility is now carried in those upstream dicts, propagate it into the derived rows:
+
+- `derive_repo_consumers()`: include `"visibility": repo_visibility(row)` in each consumer row (lines 48–55).
+- `derive_artifact_storage()`: add `"visibility": repo_visibility(repo)` to row dict (line ~90). Upstream key is `repo["name"]`.
+- `derive_release_assets()`: add `"visibility": repo_visibility(repo)` to row dict (line ~111).
+
+---
+
+## Phase 2 — Visibility helpers
+
+Create `src/github_usage/visibility.py` with a small pure module (target: under ~60 lines):
+
+```python
+VISIBILITY_ORDER = ["private", "internal", "public"]
+
+def repo_visibility(repo: dict, key: str = "visibility") -> str:
+ """Resolve visibility from a GitHub repo or enriched row dict.
+
+ Prefer an explicit ``visibility`` string. If missing, infer from the
+ ``private`` boolean (``True`` → ``"private"``, else ``"public"``).
+ On GHES without ``visibility``, internal repos may appear as private —
+ best-effort (see Resolved decisions).
+ """
+ raw = repo.get(key)
+ if isinstance(raw, str) and raw:
+ return raw
+ return "private" if repo.get("private") else "public"
+
+def group_by_visibility(rows: list[dict], key: str = "visibility") -> dict[str, list[dict]]:
+ """Group rows by visibility: private, internal, public, then any unknown keys."""
+ groups: dict[str, list[dict]] = {}
+ for row in rows:
+ vis = repo_visibility(row, key=key)
+ groups.setdefault(vis, []).append(row)
+ result = {v: groups[v] for v in VISIBILITY_ORDER if v in groups}
+ for vis, items in groups.items():
+ if vis not in result:
+ result[vis] = items
+ return result
+
+def visibility_label(visibility: str) -> str:
+ """Return a display suffix like ``' [private]'``, or ``''`` for public."""
+ if visibility == "public":
+ return ""
+ return f" [{visibility}]"
+
+def filter_repos_by_visibility(
+ repos: list[dict],
+ *,
+ only_public: bool = False,
+ only_private: bool = False,
+) -> list[dict]:
+ """Filter repos by visibility. No-op when neither flag is set.
+
+ ``only_private`` includes both ``private`` and ``internal``.
+ Callers must not set both flags (CLI/GUI enforce mutual exclusion).
+ """
+ if only_public:
+ return [r for r in repos if repo_visibility(r) == "public"]
+ if only_private:
+ return [r for r in repos if repo_visibility(r) in ("private", "internal")]
+ return repos
+```
+
+**Label spacing:** `visibility_label` includes the leading space so callers can write `f"{full}{visibility_label(vis)}"` and get `owner/repo [private]` (not `owner/repo[private]`). Public returns `""` so no trailing space appears.
+
+`filter_repos_by_visibility` lives here from the start (used in Phase 6); no need to split across phases.
+
+---
+
+## Phase 3 — Terminal rendering (group/split)
+
+### 3a. `render_repo_actions_table()` (`report_actions.py:231`)
+
+Replace the single flat table with grouped sub-tables:
+
+```
+ Per-Repository Actions Breakdown
+ ─────────────────────────────────────────────────────────────
+
+ Private Repos:
+ REPO MINUTES GB-HRS AVG MB GROSS
+ ───────────────────────────────────────────── ────────── ────────── ────────── ──────────
+ owner/private-repo 123.4 0.0012 15.2 $0.25
+ ───────────────────────────────────────────── ────────── ────────── ────────── ──────────
+ SUBTOTAL 123.4 0.0012 15.2 $0.25
+
+ Internal Repos:
+ REPO MINUTES GB-HRS AVG MB GROSS
+ ───────────────────────────────────────────── ────────── ────────── ────────── ──────────
+ org/internal-repo 25.0 0.0003 4.0 $0.05
+ ───────────────────────────────────────────── ────────── ────────── ────────── ──────────
+ SUBTOTAL 25.0 0.0003 4.0 $0.05
+
+ Public Repos:
+ REPO MINUTES GB-HRS AVG MB GROSS
+ ───────────────────────────────────────────── ────────── ────────── ────────── ──────────
+ owner/public-repo 50.0 0.0005 8.1 $0.10
+ ───────────────────────────────────────────── ────────── ────────── ────────── ──────────
+ SUBTOTAL 50.0 0.0005 8.1 $0.10
+
+ ───────────────────────────────────────────── ────────── ────────── ────────── ──────────
+ TOTAL 198.4 0.0020 27.3 $0.40
+```
+
+Use `group_by_visibility()` from Phase 2. If only one visibility group exists, skip the group header / SUBTOTAL rows and render the existing flat table (no visual regression for users with only one type). Groups appear in order: private, internal, public.
+
+Extract a small private helper (e.g. `_print_repo_actions_group(rows, *, show_subtotal: bool)`) so the multi-group path does not duplicate the column-format loop.
+
+### 3b. `render_actions_top_consumers()` (`report_actions.py:254`)
+
+Annotate each entry with visibility tag (top-N list — annotate, do not regroup):
+
+```
+ Top 10 Repos by Actions Minutes
+
+ 123.4 min | 15.2 MB | owner/private-repo [private]
+ 50.0 min | 8.1 MB | owner/public-repo
+```
+
+### 3c. `render_actions_os_breakdown()` (`report_actions.py:264`)
+
+Annotate repo names with visibility tags in the per-repo OS breakdown list (`row["name"]` + `visibility_label(repo_visibility(row))`). *(Depends on Phase 1d.)*
+
+### 3d. `render_final_summary_from_data()` (`report_summary.py:61`)
+
+This function builds `repo_data` as a list of **tuples** `(full, minutes, storage_gb_hours, avg_mb, gross, sku)` from `data["repo_actions"]` (lines 71–80). The tuples are then passed by positional index to `_print_top_consumers()`, `_print_impactful_findings()`, and `_print_recommendations()`.
+
+**Approach:** Rather than changing the tuple shape (which would require updating every unpack site), build a `visibility_by_repo: dict[str, str]` lookup from `data["repo_actions"]` at the top of `render_final_summary_from_data()`, and pass it down to helpers that annotate Actions repo names:
+
+```python
+visibility_by_repo = {
+ row["repo"]: repo_visibility(row)
+ for row in (data.get("repo_actions") or [])
+}
+```
+
+Pass `visibility_by_repo` to `_print_top_consumers()`, `_print_impactful_findings()`, and `_print_recommendations()`. Format as `f"{full}{visibility_label(visibility_by_repo.get(full, 'public'))}"`.
+
+**Storage is different:** `_print_storage_breakdown()` should **not** rely on `visibility_by_repo` from Actions. Read `visibility` from each storage repo dict via `repo_visibility(r)` so storage-only repos (no Actions minutes) stay correct. Signature can stay storage-only; no need to thread the Actions lookup into this helper.
+
+Apply the same optional lookup to the standalone `show_final_summary()` function (line 10): add `visibility_by_repo: dict[str, str] | None = None`; when `None`, skip annotation (backward compatible). *Note: `show_final_summary()` has no active call sites in `src/` (only legacy re-exports and unit tests); adding the optional param is defensive — verify with `rg 'show_final_summary\('` before adding. Prefer skipping the param entirely if tests are the only callers and can be updated later.*
+
+Changes by helper function:
+
+- **`_print_top_consumers()`** (line 143): Add `visibility_by_repo` param. In the "Actions Minutes" and "Actions Cost" loops (lines 150, 160), annotate repo names.
+- **`_print_storage_breakdown()`** (line 200): Annotate from storage dicts via `repo_visibility(r)` in the storage table (line 211) and top consumer line (line 216). Do not take `visibility_by_repo`.
+- **`_print_impactful_findings()`** (line 263): Add `visibility_by_repo` param. Annotate `top_repo[0]` (line 287) and `top_cost[0]` (line 294). Storage findings in this function should use storage-row visibility when present.
+- **`_print_recommendations()`** (line 325): Add `visibility_by_repo` param. Annotate repo names in recommendation text.
+
+### 3e. Legacy terminal sections (`legacy_terminal.py`)
+
+No changes needed — it delegates to the renderers above.
+
+### 3f. `legacy_report_summary.py` TUI rows
+
+`_repo_rows()` (line 255) and `legacy_report_consumer_rows()` (line 387) read from `data["repo_consumers"]`, which carries `visibility` after Phase 1i. Annotate repo names in these functions:
+
+- **`_repo_rows()`**: In the "by_minutes" loop (line 264–268) and "by_cost" loop (line 272–276), append `visibility_label(repo_visibility(item))` to the `repo` string. Also annotate `_repo_billed_storage_rows()` (line 83) and artifact storage entries (line 290–293).
+- **`legacy_report_consumer_rows()`**: In the consumer loop (line 391–394), append the visibility label to the `repo` string.
+
+Both functions produce `(label, value)` tuples for TUI tables, so the annotation goes into the label string.
+
+### 3g. Key insights (`get_key_insights` in `report_data.py:197`)
+
+When the insight string names a top consumer repo (line ~207), append `visibility_label(repo_visibility(top))` so email/terminal insight lines stay consistent with annotated lists.
+
+---
+
+## Phase 4 — Email report rendering
+
+Email has no full per-repo Actions table — **annotate only** (no grouped sub-tables).
+
+### 4a. Plain-text email (`email_report_text.py`)
+
+In the section formatters (`_format_consumers_section()` line 82, `_format_artifact_storage_section()` line 104, `_format_release_assets_section()` line 119): annotate each repo entry with `visibility_label(...)` (public → no tag).
+
+### 4b. HTML email (`email_report_html.py`)
+
+Same annotation approach in `_format_html_consumers_section()` (line 98), `_format_html_artifact_storage_section()` (line 133), and `_format_html_release_assets_section()` (line 155). Wrap the tag in a muted span, e.g. `[private]` (HTML can omit the leading space and put spacing in CSS/` ` as needed).
+
+Add CSS to `_HTML_DOCUMENT_HEAD` styles (near `.meta`):
+
+```css
+.visibility-tag { color: #656d76; font-weight: normal; }
+```
+
+---
+
+## Phase 5 — Export formats
+
+### 5a. CSV (`export_csv.py`)
+
+Add a `"visibility"` column to the "Top Repos by Minutes" and "Top Repos by Cost" sections (after `repo`, before `minutes`/`gross`). Also add to "Artifact Storage" and "Release Assets" sections (after `repo`).
+
+### 5b. XLSX (`export_xlsx.py`)
+
+Add `"visibility"` column to the same repo-level sheets/sections as CSV.
+
+### 5c. PDF (`export_pdf.py`)
+
+Add visibility annotation to repo names in `_write_consumers_page`, `_write_artifact_storage_page`, and the release-assets page helper (annotate labels; PDF has no separate visibility column today).
+
+### 5d. JSON (`export_json.py`)
+
+`export_json` largely serializes the report dict as-is. Once Phase 1 puts `"visibility"` on consumer/storage/release rows, JSON picks it up automatically — **verify** rather than adding a parallel transform. If any export-time reshaping drops unknown keys, preserve `visibility`.
+
+### 5e. Text (`export_text.py`)
+
+Delegates to text email formatter — handled by Phase 4a. Note: `export_text.write` calls `email_report.format_report_email`, which re-exports `email_report_text.format_report_email` (`email_report.py:12`). No additional code change is needed; verify the re-export still points at the modified function after Phase 4a.
+
+---
+
+## Phase 6 — Filter flags (`--only-public` / `--only-private`)
+
+### 6a. CLI parsers (`cli_parsers.py`)
+
+Add to `_email_parser()` via `add_mutually_exclusive_group()` (same pattern as `--api`/`--diff` in `_runs_parser()`):
+
+- `--only-public` — include only public repos in repo-level sections
+- `--only-private` — include only private + internal repos in repo-level sections
+
+Add to `_legacy_parser()`: same mutually exclusive pair.
+
+Also extend `_validate_email_flags()` (or equivalent) only if mutual exclusion is not fully handled by argparse (it should be). Document both flags in the module/CLI help text that `scripts/docs-check` / smoke cover.
+
+### 6b. Filtering (already in `visibility.py` from Phase 2)
+
+Use `filter_repos_by_visibility()`. No second copy of the filter logic.
+
+### 6c. `build_report_data()` (`report_data.py:282`)
+
+Accept `only_public: bool = False` and `only_private: bool = False` kwargs.
+
+Apply filtering **after** `_limited_repos()` returns (line ~301):
+
+```python
+repos = filter_repos_by_visibility(
+ repos, only_public=only_public, only_private=only_private
+)
+```
+
+Do **not** thread the flags into `_fetch_sections()` — filtering the `repos` list before the call is sufficient.
+
+**`max_repos` interaction (accepted limitation):** `_limited_repos` truncates first, then the filter runs on that slice. A user with `max_repos=100` and `--only-private` may get fewer than 100 private repos if the first 100 API results were mixed. Document this in README (Phase 9).
+
+**Optional efficiency (same change set if small):** when `only_public` / `only_private` is set, teach `_limited_repos` to pass GitHub's `visibility=public` or `visibility=private` query param **instead of** `type=all` (GitHub rejects combining `type` with `visibility`). Client-side `filter_repos_by_visibility` remains as a safety net (`visibility=private` can still include internal in some enterprise responses). Skip this API tweak if it complicates `_limited_repos` beyond a few lines — client-side filter alone is correct under the documented limit caveat.
+
+### 6d. Cache keys (`report_cache.py`)
+
+Update the param builders (not ad-hoc dicts at call sites):
+
+- `email_cache_params()` — add `only_public: bool = False`, `only_private: bool = False` to the returned params dict.
+- `legacy_cache_params()` — same.
+
+Then thread the flags into every caller of those helpers (`cli_email_report.py`, `legacy_report.py`, `gui_backend.py`, tests). Filtered reports must not collide with unfiltered cache entries.
+
+### 6e. `build_legacy_report_data()` (`legacy_report_data.py:190`)
+
+Accept the filter flags, apply after `_limited_repos()` returns (line ~206).
+
+### 6f. Email CLI path (`cli.py`, `cli_email_report.py`)
+
+Thread `only_public` / `only_private` from CLI args through `email_cache_params(...)` and `build_report_data(...)`.
+
+### 6g. Legacy CLI path (`cli.py`, `legacy_report.py`)
+
+- Add kwargs to `run_legacy_report_session(...)`.
+- Pass them from `_run_legacy_report` via `getattr(args, "only_public", False)` / `only_private`.
+- Include them in `legacy_cache_params(...)` and `build_legacy_report_data(...)`.
+
+### 6h. Profile config (`setup_config.py`)
+
+`setup_config.py` is already over the size budget (see `TO_DO.md`). Keep additions minimal — a few keys/lines only; do not expand into new abstractions unless extracting is already in scope.
+
+1. Add to `DEFAULT_EMAIL_REPORT`:
+ ```python
+ "only_public": False,
+ "only_private": False,
+ ```
+2. Emit in `_emit_email_report_block()` (TOML writer) — required so `write_config` / setup persist the keys:
+ ```toml
+ only_public = false
+ only_private = false
+ ```
+3. Emit CLI flags from `_email_flags_from_dict()`:
+ ```python
+ if email.get("only_public"):
+ args.append("--only-public")
+ if email.get("only_private"):
+ args.append("--only-private")
+ ```
+4. Emit the same from `profile_workflow_extra_args()` (line 389) — this function does **not** call `_email_flags_from_dict()`, so it must be updated separately (mirror `skip_actions` / `skip_copilot` / `skip_lfs` at lines 404–409).
+
+`email_report_args()` automatically picks up (3) via `_email_flags_from_dict()` — no separate change.
+
+If both config keys are somehow `true` (hand-edited TOML), prefer failing at CLI parse time when flags are expanded, or treat as invalid in wizard validation (`validate_options`). Do not silently prefer one.
+
+### 6i. GUI / wizard
+
+Correct targets (previous draft pointed at the wrong view for profile toggles):
+
+- `gui/views/setup_profiles_panel.py` — add mutually exclusive-ish checkboxes (or a single select) for only-public / only-private; wire into `collect_options()` / `load_profile()` alongside existing include_* checkboxes.
+- `gui/wizard/setup_wizard_flow.py` — add fields on `WizardData`; load/save via `save_options_step`; mention in `review_summary`; reject both-true in `validate_options`.
+- `gui/wizard/setup_wizard_screen.py` — add UI controls on the options step; sync like other checkboxes.
+- `gui/views/report_view.py` — **no new toggles** (it only reads profile defaults for forecast). Confirm `DEFAULT_EMAIL_REPORT` merge still works when new keys appear; no UI change required unless report-run options are later expanded.
+- `gui_backend.py` — verify profile → CLI arg expansion via `email_report_args()` surfaces the new flags (lines ~165, 427, 447).
+- `setup_wizard.py` — verify dry-run path still works once flags exist in config (line ~91).
+
+UX: selecting both must be impossible or immediately rejected with a clear message (match CLI mutual exclusion).
+
+### 6j. Workflow template
+
+Update `.github/workflows/email-report.yml.template` with `only_public` / `only_private` `workflow_dispatch` inputs (default `false`) and shell blocks mirroring include_* flags. Mirror into the live `.github/workflows/email-report.yml` **or** regenerate via `setup_workflow.py` from the template — do not leave template and live workflow divergent.
+
+Note: profile `__PROFILE_ARGS__` will also carry the flags once `profile_workflow_extra_args` is updated; workflow inputs are for one-off dispatch overrides. Prefer the same “inputs OR profile args” composition style used for include_* today — avoid emitting duplicate contradictory flags.
+
+---
+
+## Phase 7 — Configuration defaults (summary)
+
+- Grouping/annotation (Phases 3–5) is always on. Single-visibility tables look unchanged (no group headers).
+- `--only-public` / `--only-private` default to `False` (opt-in).
+- Defaults live in `DEFAULT_EMAIL_REPORT` (Phase 6h); do not duplicate that block elsewhere in the plan/implementation notes.
+
+---
+
+## Phase 8 — Tests
+
+Map each change to an existing test module where one already covers that surface. Prefer extending fixtures/assertions over parallel duplicate suites. Do **not** add heavy Textual GUI widget tests; cover wizard/config logic at the pure-function layer instead.
+
+### 8a. Unit tests for `visibility.py` → new `tests/test_visibility.py`
+
+- `test_repo_visibility_prefers_field()`
+- `test_repo_visibility_falls_back_to_private_bool()`
+- `test_group_by_visibility_mixed()` — three groups in order
+- `test_group_by_visibility_all_public()` — single group
+- `test_group_by_visibility_empty()`
+- `test_group_by_visibility_unknown_value()` — unknown keys after standard groups
+- `test_visibility_label_private()` — returns `" [private]"` (leading space)
+- `test_visibility_label_public()` — returns `""`
+- `test_filter_repos_by_visibility_only_public()`
+- `test_filter_repos_by_visibility_only_private_includes_internal()`
+- `test_filter_repos_by_visibility_neither()` — no-op
+- `test_filter_repos_by_visibility_uses_private_fallback()` — repo missing `visibility` but `private=True` kept by only-private
+
+### 8b. Data layer enrichment
+
+| File | What to assert |
+|---|---|
+| `tests/test_report_actions.py` | `fetch_repo_actions_table` / `fetch_actions_os_breakdown` rows include `visibility` from the source repo |
+| `tests/test_report_optional.py` | consumer / artifact / release row dicts include `visibility` |
+| `tests/test_storage.py` | storage analysis repo entries include `visibility` |
+| `tests/test_legacy_report_data.py` | extend `test_derive_repo_consumers_from_repo_actions` and `test_derive_artifact_storage_from_storage_analysis` (and release-assets derive if covered) so derived rows propagate `visibility` |
+
+### 8c. Builder-level filter tests (not only CLI)
+
+Add (or extend) cases that call the builders with mocked repos — do not rely solely on parser tests:
+
+| File | Cases |
+|---|---|
+| `tests/test_report_data.py` | `test_build_report_data_only_public_filters_repos()` / `test_build_report_data_only_private_includes_internal()` — stub `_limited_repos` (or the API page) with mixed visibility; assert only matching repos reach consumers/artifact collectors |
+| `tests/test_legacy_report_data.py` | `test_build_legacy_report_data_only_public_filters_repos()` / `test_build_legacy_report_data_only_private_includes_internal()` — same idea for `repo_actions` / storage inputs |
+
+Also extend `test_get_key_insights_reports_top_repo_share_when_consumers_present` (or add sibling) so the insight string includes the visibility label when the top consumer is non-public.
+
+### 8d. Renderer / terminal / TUI tests
+
+| File | Cases |
+|---|---|
+| `tests/test_report_actions.py` | `test_render_repo_actions_table_groups_by_visibility()` — group headers + SUBTOTAL + TOTAL; `test_render_repo_actions_table_single_visibility()` — flat, no group headers/subtotals; `test_render_actions_top_consumers_annotates_visibility()`; `test_render_actions_os_breakdown_annotates_visibility()` — Phase 3c (`row["name"]` + label) |
+| `tests/test_report_summary.py` | `test_final_summary_annotates_visibility()` — Actions paths via `visibility_by_repo`; storage lines via storage-row visibility (include a storage-only private repo with no Actions row) |
+| `tests/test_legacy_report_summary.py` | `test_repo_rows_annotates_visibility()` / `test_legacy_report_consumer_rows_annotates_visibility()` (and billed-storage / artifact labels if those helpers are already tested) |
+
+### 8e. CLI / parser / config / cache / workflow / wizard
+
+| File | Cases |
+|---|---|
+| `tests/test_cli_parsers.py` | `test_email_only_public_and_only_private_mutually_exclusive()`; `test_legacy_only_public_and_only_private_mutually_exclusive()`; happy-path parse for each flag alone on both parsers |
+| `tests/test_setup_config.py` | `_email_flags_from_dict` / `email_report_args` emit `--only-public` / `--only-private`; `profile_workflow_extra_args` emits them independently; `_emit_email_report_block` / `write_config` persist `only_public` / `only_private`; `DEFAULT_EMAIL_REPORT` defaults both to `False` |
+| `tests/test_report_cache.py` | `email_cache_params` / `legacy_cache_params` include the flags; filtered vs unfiltered params produce **distinct** cache paths |
+| `tests/test_workflow_templates.py` | Extend `test_email_report_workflow_uses_safe_secret_names_and_dispatch_inputs` (or sibling): template contains `only_public:` / `only_private:` inputs and `--only-public` / `--only-private` shell wiring |
+| Wizard flow | Add focused unit tests for `gui/wizard/setup_wizard_flow.py` `validate_options` rejecting both-true, and for load/save of the new `WizardData` fields (new small test module or extend an existing GUI/helpers test — **not** a full Textual screen run). Pure `validate_options` is enough if save/load is covered via config round-trip. |
+
+Skip full Textual checkbox interaction tests for `setup_profiles_panel.py` / `setup_wizard_screen.py` unless the repo already has a cheap pattern for that panel; config + `validate_options` + CLI flag emission cover the risk.
+
+### 8f. Export tests
+
+| File | Cases |
+|---|---|
+| `tests/test_export_csv.py` | `visibility` column present (after `repo`) in Top Repos by Minutes/Cost, Artifact Storage, and Release Assets sections; values match fixture rows |
+| `tests/test_export_xlsx.py` | same columns on the corresponding sheets |
+| `tests/test_export_pdf.py` | repo labels in consumers / artifact / release pages include ` [private]` / ` [internal]` where expected; public repos untagged |
+| `tests/test_export_json.py` | repo-level consumer/storage/release objects retain `"visibility"` from the report dict (no silent key drop) |
+| `tests/test_export_text.py` | only if it asserts body content today — otherwise covered by email formatter tests via the re-export |
+
+### 8g. Email formatter tests → `tests/test_email_report.py`
+
+- Text body: private/internal consumer (and artifact/release) lines include the visibility label; public lines do not
+- HTML body: same annotations; output includes `class="visibility-tag"` (and the CSS rule is present in the document head)
+- Prefer extending existing `test_format_report_email_renders_plain_text_sections` / `test_format_html_report_renders_html_sections` fixtures with mixed-visibility repos rather than only adding isolated micro-tests
+
+### 8h. Out of scope for this plan’s tests
+
+- Optional API `visibility=` query param on `_limited_repos` (nicety) — if implemented, one small unit test on the request params is enough; not required for merge
+- Live GitHub API calls
+- Full GUI screenshot / Textual pilot runs for the new checkboxes
+
+---
+
+
+## Phase 9 — Documentation and changelog
+
+### 9a. README
+
+Document:
+
+- Visibility grouping is automatic for the legacy per-repo Actions table; lists elsewhere annotate `[private]` / `[internal]`
+- `--only-public` and `--only-private` filter flags (mutually exclusive)
+- Config equivalents in `[email_report]`
+- Caveat: filters apply to the post-`max_repos` slice (may return fewer than `max_repos` matches)
+
+### 9b. Example config
+
+Update `.github-usage/config.example.toml` with `only_public` and `only_private`.
+
+### 9c. CHANGELOG.md
+
+Add under `[Unreleased] > Added`:
+
+- "Per-repo Actions tables now group entries by visibility (public/private/internal) with subtotals"
+- "Repo lists annotate non-public visibility; CSV/XLSX/JSON include a visibility field"
+- "Added `--only-public` and `--only-private` flags to filter repos in repo-level report sections"
+
+### 9d. TO_DO.md
+
+No visibility items currently exist in `TO_DO.md` — nothing to remove unless one is added during implementation. Do not invent a completed checkbox.
+
+---
+
+## Resolved decisions
+
+1. **Grouping always on** — No `--group-by-visibility` toggle. When only one visibility type exists, the Actions table matches today's flat layout (no group headers / subtotals).
+2. **Internal repos distinct in rendering** — Own "Internal Repos:" sub-table. **`--only-private` includes `"internal"`** (filter semantics). Matches `VISIBILITY_ORDER`.
+3. **Visibility tag on public repos** — No tag. Only `[private]` and `[internal]` (with leading space in text UIs).
+4. **Mutually exclusive filters** — argparse `add_mutually_exclusive_group`. Config/GUI must not allow both true.
+5. **Visibility field fallback** — Centralized in `repo_visibility()`. Missing `visibility` → infer from `private` bool. GHES without `visibility` may bucket internal under private — acceptable / best-effort.
+6. **Subtotals in grouped tables** — Per-group SUBTOTAL; grand TOTAL at bottom. Omitted when only one group (flat table).
+7. **Summary annotation via lookup** — Actions helpers use `visibility_by_repo`; storage helpers read visibility from storage dicts.
+8. **Email/exports annotate, legacy Actions table groups** — Email has no full per-repo Actions table.
+9. **Filter vs `max_repos`** — Filter after limit. Documented caveat; optional API `visibility=` query is a nicety, not required for correctness.
+10. **Cache keys** — Always via `email_cache_params` / `legacy_cache_params`.
+11. **Label helper owns spacing** — `visibility_label` returns `" [private]"` or `""`.
+
+---
+
+## Implementation order (recommended)
+
+1. Phase 2 (`visibility.py`) + Phase 8a tests
+2. Phase 1 enrichment + 8b
+3. Phase 6 filter wiring in builders (6c/6e minimally) + 8c builder filter tests — can land ahead of full CLI/GUI if kwargs are present
+4. Phase 3 terminal/TUI + 8d renderer tests (+ insights assertion in 8c)
+5. Phase 4 email + Phase 5 exports + 8f/8g
+6. Phase 6 remainder (parsers, config, cache, GUI, workflow) + 8e
+7. Phase 7 confirmation + Phase 9 docs/changelog
+8. `scripts/check`, `scripts/smoke` (after 6a), `scripts/docs-check` (after 9)
+
+---
+
+## Verification
+
+Run after implementation:
+
+- `scripts/check` — lint, type checks, tests, sizes (`setup_config.py` is already over budget; avoid growing it more than the few lines this feature needs)
+- `scripts/smoke` — after CLI entrypoint/parser changes (Phase 6a)
+- `scripts/docs-check` — after README, CLI help text, docs, and workflow template changes (Phase 9)
diff --git a/src/github_usage/cli.py b/src/github_usage/cli.py
index aef67dc..fe7a690 100644
--- a/src/github_usage/cli.py
+++ b/src/github_usage/cli.py
@@ -360,6 +360,8 @@ def _run_legacy_report(argv: Sequence[str]) -> int:
timeout=getattr(args, "timeout", None),
max_retries=getattr(args, "max_retries", None),
refresh=getattr(args, "refresh", False),
+ only_public=getattr(args, "only_public", False),
+ only_private=getattr(args, "only_private", False),
)
if code != 0:
return code
diff --git a/src/github_usage/cli_email_report.py b/src/github_usage/cli_email_report.py
index 4492207..ed8515f 100644
--- a/src/github_usage/cli_email_report.py
+++ b/src/github_usage/cli_email_report.py
@@ -225,6 +225,8 @@ def _load_or_fetch_email_data(
include_release_assets=args.include_release_assets,
max_repos=args.max_repos,
warn_over=args.warn_over,
+ only_public=getattr(args, "only_public", False),
+ only_private=getattr(args, "only_private", False),
)
cached_data, cached_username, cache_hit = load_cached_report(
paths,
@@ -257,6 +259,8 @@ def _load_or_fetch_email_data(
include_release_assets=args.include_release_assets,
max_repos=args.max_repos,
warn_over=args.warn_over,
+ only_public=getattr(args, "only_public", False),
+ only_private=getattr(args, "only_private", False),
)
if max_age > 0:
store_cached_report(
diff --git a/src/github_usage/cli_parsers.py b/src/github_usage/cli_parsers.py
index b0b2bc3..a75fc4d 100644
--- a/src/github_usage/cli_parsers.py
+++ b/src/github_usage/cli_parsers.py
@@ -34,6 +34,17 @@ def _legacy_parser() -> argparse.ArgumentParser:
action="store_true",
help="Bypass the local report cache and fetch fresh billing data",
)
+ visibility = parser.add_mutually_exclusive_group()
+ visibility.add_argument(
+ "--only-public",
+ action="store_true",
+ help="Include only public repos in repo-level sections",
+ )
+ visibility.add_argument(
+ "--only-private",
+ action="store_true",
+ help="Include only private and internal repos in repo-level sections",
+ )
return parser
@@ -152,4 +163,15 @@ def _email_parser() -> argparse.ArgumentParser:
action="store_true",
help="Bypass the local report cache and fetch fresh billing data",
)
+ visibility = parser.add_mutually_exclusive_group()
+ visibility.add_argument(
+ "--only-public",
+ action="store_true",
+ help="Include only public repos in repo-level sections",
+ )
+ visibility.add_argument(
+ "--only-private",
+ action="store_true",
+ help="Include only private and internal repos in repo-level sections",
+ )
return parser
diff --git a/src/github_usage/email_report_html.py b/src/github_usage/email_report_html.py
index 035fa10..06e94f4 100644
--- a/src/github_usage/email_report_html.py
+++ b/src/github_usage/email_report_html.py
@@ -7,6 +7,16 @@
from ._email_report_common import _bytes_to_mb, _generated_line
from .report_forecast_data import build_report_forecast
from .report_helpers import fmt_price
+from .visibility import repo_visibility, visibility_label
+
+
+def _html_repo_cell(row: dict) -> str:
+ repo = html.escape(row["repo"])
+ vis = repo_visibility(row)
+ if vis == "public":
+ return repo
+ tag = html.escape(visibility_label(vis).strip())
+ return f'{repo} [{tag}]'
def _html_cost_row(label: str, cost: dict[str, float]) -> str:
@@ -106,7 +116,7 @@ def _format_html_consumers_section(data: dict) -> list[str]:
]
for row in consumers.get("by_minutes", []):
parts.append(
- f"
| {html.escape(row['repo'])} | "
+ f"
| {_html_repo_cell(row)} | "
f"{row['minutes']:,.1f} min | "
f"{fmt_price(row['gross'])} | "
f"{row['storage_avg_mb']:,.1f} MB avg |
"
@@ -117,7 +127,7 @@ def _format_html_consumers_section(data: dict) -> list[str]:
parts.append("| Repo | Gross | Minutes | Storage |
")
for row in consumers.get("by_cost", []):
parts.append(
- f"| {html.escape(row['repo'])} | "
+ f"
| {_html_repo_cell(row)} | "
f"{fmt_price(row['gross'])} | "
f"{row['minutes']:,.1f} min | "
f"{row['storage_avg_mb']:,.1f} MB avg |
"
@@ -140,7 +150,7 @@ def _format_html_artifact_storage_section(data: dict) -> list[str]:
]
for row in artifact_storage.get("top_repos", []):
parts.append(
- f"{html.escape(row['repo'])}: "
+ f"{_html_repo_cell(row)}: "
f"{_bytes_to_mb(row['artifact_bytes']):,.1f} MB artifacts"
)
parts.append("")
@@ -162,7 +172,7 @@ def _format_html_release_assets_section(data: dict) -> list[str]:
]
for row in release_assets.get("top_repos", []):
parts.append(
- f"{html.escape(row['repo'])}: "
+ f"{_html_repo_cell(row)}: "
f"{_bytes_to_mb(row['release_asset_bytes']):,.1f} MB release assets"
)
parts.append("")
@@ -282,6 +292,7 @@ def _run_out(value: int | None) -> str:
" .warning { background: #fff8c5; border: 1px solid #d4a72c; padding: 8px; border-radius: 4px; }\n"
" .meta, p em { color: #656d76; }\n"
" .meta { font-size: 14px; }\n"
+ " .visibility-tag { color: #656d76; font-weight: normal; }\n"
" \n"
"\n"
"\n"
diff --git a/src/github_usage/email_report_text.py b/src/github_usage/email_report_text.py
index 3634634..a4ef5ef 100644
--- a/src/github_usage/email_report_text.py
+++ b/src/github_usage/email_report_text.py
@@ -5,6 +5,11 @@
from ._email_report_common import _bytes_to_mb, _generated_line
from .report_forecast_data import build_report_forecast
from .report_helpers import fmt_price
+from .visibility import repo_visibility, visibility_label
+
+
+def _annotated_repo_name(row: dict) -> str:
+ return f"{row['repo']}{visibility_label(repo_visibility(row))}"
def _cost_line(label: str, cost: dict[str, float]) -> str:
@@ -86,13 +91,13 @@ def _format_consumers_section(data: dict) -> list[str]:
lines = ["Top Repositories by Actions Minutes"]
for row in consumers.get("by_minutes", []):
lines.append(
- f"- {row['repo']}: {row['minutes']:,.1f} min, "
+ f"- {_annotated_repo_name(row)}: {row['minutes']:,.1f} min, "
f"{fmt_price(row['gross'])}, {row['storage_avg_mb']:,.1f} MB avg storage"
)
lines.extend(["", "Top Repositories by Actions Cost"])
for row in consumers.get("by_cost", []):
lines.append(
- f"- {row['repo']}: {fmt_price(row['gross'])}, "
+ f"- {_annotated_repo_name(row)}: {fmt_price(row['gross'])}, "
f"{row['minutes']:,.1f} min, {row['storage_avg_mb']:,.1f} MB avg storage"
)
if consumers.get("truncated"):
@@ -107,7 +112,9 @@ def _format_artifact_storage_section(data: dict) -> list[str]:
return []
lines = ["Actions Artifact Storage"]
for row in artifact_storage.get("top_repos", []):
- lines.append(f"- {row['repo']}: {_bytes_to_mb(row['artifact_bytes']):,.1f} MB artifacts")
+ lines.append(
+ f"- {_annotated_repo_name(row)}: {_bytes_to_mb(row['artifact_bytes']):,.1f} MB artifacts"
+ )
if artifact_storage.get("truncated"):
lines.append(
f"- Artifact scan truncated at {artifact_storage.get('max_repos')} repositories."
@@ -123,7 +130,8 @@ def _format_release_assets_section(data: dict) -> list[str]:
lines = ["Release Asset Inventory"]
for row in release_assets.get("top_repos", []):
lines.append(
- f"- {row['repo']}: {_bytes_to_mb(row['release_asset_bytes']):,.1f} MB release assets"
+ f"- {_annotated_repo_name(row)}: "
+ f"{_bytes_to_mb(row['release_asset_bytes']):,.1f} MB release assets"
)
if release_assets.get("truncated"):
lines.append(
diff --git a/src/github_usage/export_csv.py b/src/github_usage/export_csv.py
index 979cc8c..23c7176 100644
--- a/src/github_usage/export_csv.py
+++ b/src/github_usage/export_csv.py
@@ -19,6 +19,7 @@
import csv
from .report_forecast_data import build_report_forecast
+from .visibility import repo_visibility
def write(
@@ -103,6 +104,7 @@ def _write_sections(
writer.writerow(
[
entry.get("repo", ""),
+ repo_visibility(entry),
entry.get("minutes", ""),
entry.get("gross", ""),
entry.get("storage_avg_mb", ""),
@@ -114,6 +116,7 @@ def _write_sections(
writer.writerow(
[
entry.get("repo", ""),
+ repo_visibility(entry),
entry.get("minutes", ""),
entry.get("gross", ""),
entry.get("storage_avg_mb", ""),
@@ -123,12 +126,20 @@ def _write_sections(
_write_section_header(writer, "Artifact Storage")
artifacts = _coerce_section(data.get("artifact_storage"), {})
for entry in artifacts.get("top_repos") or []:
- writer.writerow([entry.get("repo", ""), entry.get("artifact_bytes", "")])
+ writer.writerow(
+ [entry.get("repo", ""), repo_visibility(entry), entry.get("artifact_bytes", "")]
+ )
_write_section_header(writer, "Release Assets")
releases = _coerce_section(data.get("release_assets"), {})
for entry in releases.get("top_repos") or []:
- writer.writerow([entry.get("repo", ""), entry.get("release_asset_bytes", "")])
+ writer.writerow(
+ [
+ entry.get("repo", ""),
+ repo_visibility(entry),
+ entry.get("release_asset_bytes", ""),
+ ]
+ )
_write_section_header(writer, "Key Insights")
for insight in data.get("insights") or []:
diff --git a/src/github_usage/export_pdf.py b/src/github_usage/export_pdf.py
index 91acd42..c25bf46 100644
--- a/src/github_usage/export_pdf.py
+++ b/src/github_usage/export_pdf.py
@@ -17,6 +17,7 @@
from collections.abc import Callable
from .report_forecast_data import build_report_forecast
+from .visibility import repo_visibility, visibility_label
_MAX_SECTION_ROWS = 30
@@ -159,19 +160,24 @@ def _write_monthly_costs_page(add_section: AddSectionFn, data: dict) -> None:
add_section("Monthly Costs", rows)
+def _annotated_repo_label(entry: dict) -> str:
+ repo = str(entry.get("repo", "unknown"))
+ return f"{repo}{visibility_label(repo_visibility(entry))}"
+
+
def _write_consumers_page(add_section: AddSectionFn, data: dict) -> None:
consumers = data.get("repo_consumers") or {}
by_minutes = consumers.get("by_minutes") or []
if by_minutes:
rows = [
- (entry.get("repo", "unknown"), f"{_fmt_num(entry.get('minutes'))} minutes")
+ (_annotated_repo_label(entry), f"{_fmt_num(entry.get('minutes'))} minutes")
for entry in by_minutes
]
add_section("Top Repos by Minutes", rows)
by_cost = consumers.get("by_cost") or []
if by_cost:
rows = [
- (entry.get("repo", "unknown"), f"${_fmt_num(entry.get('gross'))}") for entry in by_cost
+ (_annotated_repo_label(entry), f"${_fmt_num(entry.get('gross'))}") for entry in by_cost
]
add_section("Top Repos by Cost", rows)
@@ -181,7 +187,10 @@ def _write_artifact_storage_page(add_section: AddSectionFn, data: dict) -> None:
artifact_repos = artifacts.get("top_repos") or []
if artifact_repos:
rows = [
- (entry.get("repo", "unknown"), f"{_fmt_num(entry.get('artifact_bytes'))} bytes")
+ (
+ _annotated_repo_label(entry),
+ f"{_fmt_num(entry.get('artifact_bytes'))} bytes",
+ )
for entry in artifact_repos
]
add_section("Artifact Storage", rows)
@@ -192,7 +201,10 @@ def _write_release_assets_page(add_section: AddSectionFn, data: dict) -> None:
release_repos = releases.get("top_repos") or []
if release_repos:
rows = [
- (entry.get("repo", "unknown"), f"{_fmt_num(entry.get('release_asset_bytes'))} bytes")
+ (
+ _annotated_repo_label(entry),
+ f"{_fmt_num(entry.get('release_asset_bytes'))} bytes",
+ )
for entry in release_repos
]
add_section("Release Assets", rows)
diff --git a/src/github_usage/export_xlsx.py b/src/github_usage/export_xlsx.py
index 5901f85..9ff7e3d 100644
--- a/src/github_usage/export_xlsx.py
+++ b/src/github_usage/export_xlsx.py
@@ -16,6 +16,7 @@
from collections.abc import Callable
from .report_forecast_data import build_report_forecast
+from .visibility import repo_visibility
_FORMULA_PREFIXES = ("=", "+", "-", "@")
_MAX_SHEET_NAME = 31
@@ -169,11 +170,12 @@ def _write_monthly_costs_sheet(write_sheet: WriteSheetFn, data: dict) -> None:
def _write_consumers_sheet(write_sheet: WriteSheetFn, data: dict) -> None:
consumers = data.get("repo_consumers") or {}
if consumers.get("by_minutes"):
- rows = [["Repo", "Minutes", "Gross", "Storage Avg MB"]]
+ rows = [["Repo", "Visibility", "Minutes", "Gross", "Storage Avg MB"]]
for entry in consumers["by_minutes"]:
rows.append(
[
entry.get("repo"),
+ repo_visibility(entry),
entry.get("minutes"),
entry.get("gross"),
entry.get("storage_avg_mb"),
@@ -181,11 +183,12 @@ def _write_consumers_sheet(write_sheet: WriteSheetFn, data: dict) -> None:
)
write_sheet("Repos Minutes", "Top Repos by Minutes", rows)
if consumers.get("by_cost"):
- rows = [["Repo", "Minutes", "Gross", "Storage Avg MB"]]
+ rows = [["Repo", "Visibility", "Minutes", "Gross", "Storage Avg MB"]]
for entry in consumers["by_cost"]:
rows.append(
[
entry.get("repo"),
+ repo_visibility(entry),
entry.get("minutes"),
entry.get("gross"),
entry.get("storage_avg_mb"),
@@ -197,18 +200,20 @@ def _write_consumers_sheet(write_sheet: WriteSheetFn, data: dict) -> None:
def _write_artifact_storage_sheet(write_sheet: WriteSheetFn, data: dict) -> None:
artifacts = data.get("artifact_storage") or {}
if artifacts.get("top_repos"):
- rows = [["Repo", "Artifact Bytes"]]
+ rows = [["Repo", "Visibility", "Artifact Bytes"]]
for entry in artifacts["top_repos"]:
- rows.append([entry.get("repo"), entry.get("artifact_bytes")])
+ rows.append([entry.get("repo"), repo_visibility(entry), entry.get("artifact_bytes")])
write_sheet("Artifacts", "Artifact Storage", rows)
def _write_release_assets_sheet(write_sheet: WriteSheetFn, data: dict) -> None:
releases = data.get("release_assets") or {}
if releases.get("top_repos"):
- rows = [["Repo", "Release Asset Bytes"]]
+ rows = [["Repo", "Visibility", "Release Asset Bytes"]]
for entry in releases["top_repos"]:
- rows.append([entry.get("repo"), entry.get("release_asset_bytes")])
+ rows.append(
+ [entry.get("repo"), repo_visibility(entry), entry.get("release_asset_bytes")]
+ )
write_sheet("Releases", "Release Assets", rows)
diff --git a/src/github_usage/gui/views/setup_profiles_panel.py b/src/github_usage/gui/views/setup_profiles_panel.py
index c32caf0..4179136 100644
--- a/src/github_usage/gui/views/setup_profiles_panel.py
+++ b/src/github_usage/gui/views/setup_profiles_panel.py
@@ -73,6 +73,8 @@ def compose(self) -> ComposeResult:
yield Checkbox("Include top consumers", id="include-consumers")
yield Checkbox("Include artifact storage", id="include-artifact")
yield Checkbox("Include release assets", id="include-release")
+ yield Checkbox("Only public repos", id="only-public")
+ yield Checkbox("Only private repos (incl. internal)", id="only-private")
with FormGrid():
yield Label("Max repos", classes="field-key")
yield Static(
@@ -133,6 +135,8 @@ def reload_profile_options(self, profile: dict[str, Any]) -> None:
self.query_one("#include-release", Checkbox).value = bool(
email.get("include_release_assets")
)
+ self.query_one("#only-public", Checkbox).value = bool(email.get("only_public"))
+ self.query_one("#only-private", Checkbox).value = bool(email.get("only_private"))
self.query_one("#max-repos", Input).value = str(email.get("max_repos", 100))
self.query_one("#target-email", Input).value = profile.get("target_email", "")
@@ -144,6 +148,8 @@ def read_profile_options(self) -> dict[str, Any]:
"include_consumers": self.query_one("#include-consumers", Checkbox).value,
"include_artifact_storage": self.query_one("#include-artifact", Checkbox).value,
"include_release_assets": self.query_one("#include-release", Checkbox).value,
+ "only_public": self.query_one("#only-public", Checkbox).value,
+ "only_private": self.query_one("#only-private", Checkbox).value,
"max_repos_str": max_repos_str,
"target_email": self.query_one("#target-email", Input).value.strip(),
}
@@ -176,6 +182,16 @@ async def _on_profile_changed(self) -> None:
self.app.app_state.set_current_profile(new_name) # type: ignore[attr-defined]
self._coordinator.reload_form()
+ @on(Checkbox.Changed, "#only-public, #only-private")
+ def _on_visibility_filter_changed(self, event: Checkbox.Changed) -> None:
+ if self._coordinator.is_form_loading():
+ return
+ if event.checkbox.id == "only-public" and event.value:
+ self.query_one("#only-private", Checkbox).value = False
+ elif event.checkbox.id == "only-private" and event.value:
+ self.query_one("#only-public", Checkbox).value = False
+ self._coordinator.mark_dirty()
+
@on(Input.Changed)
@on(Checkbox.Changed)
def _on_field_changed(self) -> None:
diff --git a/src/github_usage/gui/views/setup_view.py b/src/github_usage/gui/views/setup_view.py
index 3093f75..b342a93 100644
--- a/src/github_usage/gui/views/setup_view.py
+++ b/src/github_usage/gui/views/setup_view.py
@@ -251,6 +251,8 @@ def action_save(self) -> None:
"include_artifact_storage"
]
profile["email_report"]["include_release_assets"] = options["include_release_assets"]
+ profile["email_report"]["only_public"] = options["only_public"]
+ profile["email_report"]["only_private"] = options["only_private"]
profile["email_report"]["max_repos"] = max_repos
profile["target_email"] = options["target_email"]
update_profile(config, profile)
diff --git a/src/github_usage/gui/wizard/setup_wizard_flow.py b/src/github_usage/gui/wizard/setup_wizard_flow.py
index bd5ecd0..3f6b1c1 100644
--- a/src/github_usage/gui/wizard/setup_wizard_flow.py
+++ b/src/github_usage/gui/wizard/setup_wizard_flow.py
@@ -45,6 +45,8 @@ class WizardData:
include_consumers: bool = False
include_artifact_storage: bool = False
include_release_assets: bool = False
+ only_public: bool = False
+ only_private: bool = False
max_repos: int = 100
target_email: str = ""
local_weekday: int = 1
@@ -73,6 +75,8 @@ def load_initial_data(paths: SetupPaths) -> WizardData:
data.include_consumers = bool(email.get("include_consumers"))
data.include_artifact_storage = bool(email.get("include_artifact_storage"))
data.include_release_assets = bool(email.get("include_release_assets"))
+ 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.target_email = profile.get("target_email", "")
sched = profile.get("schedule", {})
@@ -100,6 +104,8 @@ def validate_options(data: WizardData) -> str | None:
"""Return an error message when report option fields are invalid."""
if data.max_repos < 1:
return "Max repos must be at least 1"
+ if data.only_public and data.only_private:
+ return "Only one visibility filter can be enabled: public or private"
return None
@@ -121,6 +127,8 @@ def save_options_step(paths: SetupPaths, data: WizardData) -> None:
email["include_consumers"] = data.include_consumers
email["include_artifact_storage"] = data.include_artifact_storage
email["include_release_assets"] = data.include_release_assets
+ email["only_public"] = data.only_public
+ email["only_private"] = data.only_private
email["max_repos"] = data.max_repos
profile["target_email"] = data.target_email
update_profile(config, profile)
@@ -179,7 +187,8 @@ def review_summary(data: WizardData) -> str:
f"[b]Recipient[/b]: {recipient}",
f"[b]Report sections[/b]: forecast={data.include_forecast}, "
f"consumers={data.include_consumers}, artifacts={data.include_artifact_storage}, "
- f"releases={data.include_release_assets}",
+ 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]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 932b1b5..0247ffc 100644
--- a/src/github_usage/gui/wizard/setup_wizard_screen.py
+++ b/src/github_usage/gui/wizard/setup_wizard_screen.py
@@ -103,6 +103,8 @@ def compose(self) -> ComposeResult:
yield Checkbox("Include top consumers", id="wizard-consumers")
yield Checkbox("Include artifact storage", id="wizard-artifact")
yield Checkbox("Include release assets", id="wizard-release")
+ yield Checkbox("Only public repos", id="wizard-only-public")
+ yield Checkbox("Only private repos (incl. internal)", id="wizard-only-private")
with FormGrid():
yield Label("Max repos:")
yield Input(value="100", id="wizard-max-repos")
@@ -179,6 +181,8 @@ def _populate_options(self) -> None:
self.query_one("#wizard-consumers", Checkbox).value = self._data.include_consumers
self.query_one("#wizard-artifact", Checkbox).value = self._data.include_artifact_storage
self.query_one("#wizard-release", Checkbox).value = self._data.include_release_assets
+ 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-target-email", Input).value = self._data.target_email
@@ -198,6 +202,8 @@ def _read_options_from_form(self) -> None:
self._data.include_consumers = self.query_one("#wizard-consumers", Checkbox).value
self._data.include_artifact_storage = self.query_one("#wizard-artifact", Checkbox).value
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
self._data.target_email = self.query_one("#wizard-target-email", Input).value.strip()
def _read_local_from_form(self) -> bool:
@@ -253,6 +259,13 @@ def _toggle_wizard_secrets(self, event: Checkbox.Changed) -> None:
if hidden:
self.query_one(f"#wizard-{field_id}", Input).password = not visible
+ @on(Checkbox.Changed, "#wizard-only-public, #wizard-only-private")
+ def _toggle_wizard_visibility_filters(self, event: Checkbox.Changed) -> None:
+ if event.checkbox.id == "wizard-only-public" and event.value:
+ self.query_one("#wizard-only-private", Checkbox).value = False
+ elif event.checkbox.id == "wizard-only-private" and event.value:
+ self.query_one("#wizard-only-public", Checkbox).value = False
+
@on(Button.Pressed, "#wizard-cancel")
def _cancel_wizard(self) -> None:
self.dismiss(False)
diff --git a/src/github_usage/legacy_report.py b/src/github_usage/legacy_report.py
index b46b1f1..762d1e5 100644
--- a/src/github_usage/legacy_report.py
+++ b/src/github_usage/legacy_report.py
@@ -47,6 +47,8 @@ def run_legacy_report_session(
warn_over: list[str] | str | None = None,
max_repos: int = LEGACY_DEFAULT_MAX_REPOS,
refresh: bool = False,
+ only_public: bool = False,
+ only_private: bool = False,
paths: SetupPaths | None = None,
cache_max_age_seconds: int | None = None,
) -> tuple[int, dict | None, str | None, CacheHit]:
@@ -65,6 +67,8 @@ def run_legacy_report_session(
max_repos=max_repos,
warn_over=warn_over,
include_release_assets=False,
+ only_public=only_public,
+ only_private=only_private,
)
cached_data, cached_username, cache_hit = load_cached_report(
resolved_paths,
@@ -101,6 +105,8 @@ def run_legacy_report_session(
max_repos=max_repos,
warn_over=warn_over,
include_release_assets=False,
+ only_public=only_public,
+ only_private=only_private,
account=account,
rate_limits=rate_limits,
)
diff --git a/src/github_usage/legacy_report_data.py b/src/github_usage/legacy_report_data.py
index 0603b89..6fac1a3 100644
--- a/src/github_usage/legacy_report_data.py
+++ b/src/github_usage/legacy_report_data.py
@@ -29,6 +29,7 @@
)
from .report_products import fetch_billing_history
from .storage import get_storage_analysis
+from .visibility import filter_repos_by_visibility, repo_visibility
LEGACY_DEFAULT_MAX_REPOS = 100
OS_BREAKDOWN_LIMIT = 10
@@ -51,6 +52,7 @@ def derive_repo_consumers(
"minutes": float(row["minutes"]),
"gross": float(row["gross"]),
"storage_avg_mb": float(row["avg_mb"]),
+ "visibility": repo_visibility(row),
}
for row in repo_actions
]
@@ -87,7 +89,13 @@ def derive_artifact_storage(
for repo in storage_analysis.get("repos", []):
artifact_bytes = _bytes_from_storage_items(repo.get("items", []), "Artifact")
if artifact_bytes:
- rows.append({"repo": repo["name"], "artifact_bytes": artifact_bytes})
+ rows.append(
+ {
+ "repo": repo["name"],
+ "artifact_bytes": artifact_bytes,
+ "visibility": repo_visibility(repo),
+ }
+ )
return {
"scanned_repo_count": scanned_repo_count,
"max_repos": max_repos,
@@ -108,7 +116,13 @@ def derive_release_assets(
for repo in storage_analysis.get("repos", []):
release_bytes = _bytes_from_storage_items(repo.get("items", []), "Release Asset")
if release_bytes:
- rows.append({"repo": repo["name"], "release_asset_bytes": release_bytes})
+ rows.append(
+ {
+ "repo": repo["name"],
+ "release_asset_bytes": release_bytes,
+ "visibility": repo_visibility(repo),
+ }
+ )
return {
"scanned_repo_count": scanned_repo_count,
"max_repos": max_repos,
@@ -194,6 +208,8 @@ def build_legacy_report_data(
max_repos: int = LEGACY_DEFAULT_MAX_REPOS,
warn_over: list[str] | str | None = None,
include_release_assets: bool = False,
+ only_public: bool = False,
+ only_private: bool = False,
account: dict | None = None,
rate_limits: dict | None = None,
) -> dict[str, Any]:
@@ -204,6 +220,7 @@ def build_legacy_report_data(
if rate_limits is None:
rate_limits = fetch_rate_limits(api)
repos, truncated = _limited_repos(api, max_repos)
+ repos = filter_repos_by_visibility(repos, only_public=only_public, only_private=only_private)
scanned_repo_count = len(repos)
repo_count = scanned_repo_count + (1 if truncated else 0)
core_limit, core_remaining = _rate_limit(api)
diff --git a/src/github_usage/legacy_report_summary.py b/src/github_usage/legacy_report_summary.py
index a9e6730..0e7a5bf 100644
--- a/src/github_usage/legacy_report_summary.py
+++ b/src/github_usage/legacy_report_summary.py
@@ -6,6 +6,7 @@
from .report_forecast_data import build_report_forecast
from .report_helpers import fmt_price
+from .visibility import repo_visibility, visibility_label
def _section(title: str) -> tuple[str, str]:
@@ -13,6 +14,11 @@ def _section(title: str) -> tuple[str, str]:
return (f"── {title} ──", "")
+def _annotated_repo(item: dict[str, Any], *, key: str = "repo") -> str:
+ repo = str(item.get(key, "?"))
+ return f"{repo}{visibility_label(repo_visibility(item))}"
+
+
def _format_cost_block(monthly: dict[str, Any], product: str) -> str:
block = monthly.get(product) or {}
return fmt_price(float(block.get("net", 0.0)))
@@ -71,7 +77,7 @@ def _artifact_release_storage_rows(
)
return rows
for repo in repos[:limit]:
- name = repo.get("name", "?")
+ name = _annotated_repo(repo, key="name")
gb = float(repo.get("total_storage", 0.0))
value = f"{gb:.2f} GB" if gb > 0 else "0"
rows.append((name, value))
@@ -93,7 +99,7 @@ def _repo_billed_storage_rows(data: dict[str, Any], *, limit: int = 10) -> list[
rows.append(("Note", "No per-repo billed Actions storage this period."))
return rows
for row in with_storage[:limit]:
- repo = row.get("repo", "?")
+ repo = _annotated_repo(row)
avg_mb = float(row.get("avg_mb", 0.0))
gb_hours = float(row.get("storage_gb_hours", 0.0))
rows.append((repo, f"{avg_mb:.1f} MB avg · {gb_hours:.4f} GB-hrs"))
@@ -262,7 +268,7 @@ def _repo_rows(data: dict[str, Any]) -> list[tuple[str, str]]:
if by_minutes:
rows.append(_section("Top repos by Actions minutes"))
for item in by_minutes[:10]:
- repo = item.get("repo", "?")
+ repo = _annotated_repo(item)
minutes = float(item.get("minutes", 0.0))
gross = float(item.get("gross", 0.0))
rows.append((repo, f"{minutes:.1f} min · {fmt_price(gross)}"))
@@ -270,7 +276,7 @@ def _repo_rows(data: dict[str, Any]) -> list[tuple[str, str]]:
if by_cost:
rows.append(_section("Top repos by Actions cost"))
for item in by_cost[:10]:
- repo = item.get("repo", "?")
+ repo = _annotated_repo(item)
gross = float(item.get("gross", 0.0))
minutes = float(item.get("minutes", 0.0))
rows.append((repo, f"{fmt_price(gross)} · {minutes:.1f} min"))
@@ -288,7 +294,7 @@ def _repo_rows(data: dict[str, Any]) -> list[tuple[str, str]]:
if top_artifacts:
rows.append(_section("Largest artifact storage"))
for item in top_artifacts[:5]:
- repo = item.get("repo", "?")
+ repo = _annotated_repo(item)
gb = float(item.get("artifact_bytes", 0)) / (1024**3)
rows.append((repo, f"{gb:.2f} GB"))
@@ -389,7 +395,7 @@ def legacy_report_consumer_rows(data: dict[str, Any], *, limit: int = 10) -> lis
consumers = (data.get("repo_consumers") or {}).get("by_minutes") or []
rows: list[tuple[str, str]] = []
for item in consumers[:limit]:
- repo = item.get("repo", "?")
+ repo = _annotated_repo(item)
minutes = float(item.get("minutes", 0.0))
rows.append((f"Repo: {repo}", f"{minutes:.1f} min"))
return rows
diff --git a/src/github_usage/report_actions.py b/src/github_usage/report_actions.py
index 6a042e4..0f173d9 100644
--- a/src/github_usage/report_actions.py
+++ b/src/github_usage/report_actions.py
@@ -5,6 +5,12 @@
from .billing import BillingFetchError, get_actions_from_runs, get_actions_per_repo
from .report_helpers import fmt_price, gb_hours_to_avg_mb
from .terminal import print_section, print_sep
+from .visibility import (
+ group_by_visibility,
+ repo_visibility,
+ visibility_group_header,
+ visibility_label,
+)
def show_actions_summary(api, username, user_minutes, user_storage_gb_hours, sku_breakdown):
@@ -169,6 +175,7 @@ def fetch_repo_actions_table(api, repos: list[dict]) -> tuple[list[dict], dict[s
"avg_mb": float(avg_mb),
"gross": gross,
"sku": sku,
+ "visibility": repo_visibility(repo),
}
)
return rows, errors
@@ -190,7 +197,13 @@ def fetch_actions_os_breakdown(api, repos: list[dict], *, limit: int = 10) -> di
os_minutes = {
os_name: os_millis[os_name] / 60000 for os_name in ["UBUNTU", "WINDOWS", "MACOS"]
}
- repo_rows.append({"name": f"{owner}/{name}", "os_minutes": os_minutes})
+ repo_rows.append(
+ {
+ "name": f"{owner}/{name}",
+ "os_minutes": os_minutes,
+ "visibility": repo_visibility(repo),
+ }
+ )
for os_name in ["UBUNTU", "WINDOWS", "MACOS"]:
total_os[os_name] += os_millis[os_name]
return {"repos": repo_rows, "totals": total_os, "found": found}
@@ -228,22 +241,50 @@ def render_actions_summary(actions: dict | None) -> None:
print()
-def render_repo_actions_table(repo_actions: list[dict]) -> None:
- """Print the full per-repository Actions table."""
- print_section("Per-Repository Actions Breakdown")
- print(f" {'REPO':<45} {'MINUTES':>10} {'GB-HRS':>10} {'AVG MB':>10} {'GROSS':>10}")
- print(f" {'-' * 45} {'-' * 10} {'-' * 10} {'-' * 10} {'-' * 10}")
- for row in repo_actions:
+def _print_repo_actions_rows(rows: list[dict], *, label: str | None = None) -> None:
+ """Print repo action rows and optional subtotal label."""
+ for row in rows:
print(
f" {row['repo']:<45} {row['minutes']:>10.1f} "
f"{row['storage_gb_hours']:>10.4f} {row['avg_mb']:>10.1f} "
f"{fmt_price(row['gross']):>10}"
)
+ if label:
+ total_mins = sum(r["minutes"] for r in rows)
+ total_gb = sum(r["storage_gb_hours"] for r in rows)
+ total_mb = gb_hours_to_avg_mb(total_gb)
+ total_gross = sum(r["gross"] for r in rows)
+ print(f" {'-' * 45} {'-' * 10} {'-' * 10} {'-' * 10} {'-' * 10}")
+ print(
+ f" {label:<45} {total_mins:>10.1f} {total_gb:>10.4f} "
+ f"{total_mb:>10.1f} {fmt_price(total_gross):>10}"
+ )
+
+
+def render_repo_actions_table(repo_actions: list[dict]) -> None:
+ """Print the full per-repository Actions table."""
+ print_section("Per-Repository Actions Breakdown")
+ header = f" {'REPO':<45} {'MINUTES':>10} {'GB-HRS':>10} {'AVG MB':>10} {'GROSS':>10}"
+ divider = f" {'-' * 45} {'-' * 10} {'-' * 10} {'-' * 10} {'-' * 10}"
+ print(header)
+ print(divider)
+
+ groups = group_by_visibility(repo_actions)
+ if len(groups) <= 1:
+ _print_repo_actions_rows(repo_actions)
+ else:
+ for visibility, rows in groups.items():
+ print(f"\n {visibility_group_header(visibility)}")
+ print(header)
+ print(divider)
+ _print_repo_actions_rows(rows, label="SUBTOTAL")
+ print()
+
total_mins = sum(r["minutes"] for r in repo_actions)
total_gb = sum(r["storage_gb_hours"] for r in repo_actions)
total_mb = gb_hours_to_avg_mb(total_gb)
total_gross = sum(r["gross"] for r in repo_actions)
- print(f" {'-' * 45} {'-' * 10} {'-' * 10} {'-' * 10} {'-' * 10}")
+ print(divider)
print(
f" {'TOTAL':<45} {total_mins:>10.1f} {total_gb:>10.4f} "
f"{total_mb:>10.1f} {fmt_price(total_gross):>10}"
@@ -257,7 +298,8 @@ def render_actions_top_consumers(repo_actions: list[dict]) -> None:
print()
sorted_repos = sorted(repo_actions, key=lambda row: row["minutes"], reverse=True)
for row in sorted_repos[:10]:
- print(f" {row['minutes']:>8.1f} min | {row['avg_mb']:>8.1f} MB | {row['repo']}")
+ label = f"{row['repo']}{visibility_label(repo_visibility(row))}"
+ print(f" {row['minutes']:>8.1f} min | {row['avg_mb']:>8.1f} MB | {label}")
print()
@@ -272,7 +314,8 @@ def render_actions_os_breakdown(breakdown: dict | None) -> None:
return
total_os = breakdown.get("totals") or {}
for row in breakdown.get("repos", []):
- print(f" {row['name']}:")
+ name = f"{row['name']}{visibility_label(repo_visibility(row))}"
+ print(f" {name}:")
for os_name, mins in row.get("os_minutes", {}).items():
if mins > 0:
print(f" {os_name:<10} {mins:>8.1f} min")
diff --git a/src/github_usage/report_cache.py b/src/github_usage/report_cache.py
index bc96ec3..9e68dc4 100644
--- a/src/github_usage/report_cache.py
+++ b/src/github_usage/report_cache.py
@@ -256,6 +256,8 @@ def legacy_cache_params(
max_repos: int,
warn_over: list[str] | str | None = None,
include_release_assets: bool = False,
+ only_public: bool = False,
+ only_private: bool = False,
) -> dict[str, Any]:
"""Build cache-key parameters for the legacy report superset."""
warn_values: list[str] | None
@@ -269,6 +271,8 @@ def legacy_cache_params(
"max_repos": int(max_repos),
"warn_over": warn_values,
"include_release_assets": bool(include_release_assets),
+ "only_public": bool(only_public),
+ "only_private": bool(only_private),
}
@@ -282,6 +286,8 @@ def email_cache_params(
include_release_assets: bool,
max_repos: int,
warn_over: list[str] | str | None,
+ only_public: bool = False,
+ only_private: bool = False,
) -> dict[str, Any]:
"""Build cache-key parameters for :func:`report_data.build_report_data`."""
warn_values: list[str] | None
@@ -300,4 +306,6 @@ def email_cache_params(
"include_release_assets": bool(include_release_assets),
"max_repos": int(max_repos),
"warn_over": warn_values,
+ "only_public": bool(only_public),
+ "only_private": bool(only_private),
}
diff --git a/src/github_usage/report_data.py b/src/github_usage/report_data.py
index 556993d..0c25564 100644
--- a/src/github_usage/report_data.py
+++ b/src/github_usage/report_data.py
@@ -12,6 +12,7 @@
get_release_asset_details,
get_repo_consumers,
)
+from .visibility import filter_repos_by_visibility, repo_visibility, visibility_label
class GitHubAPIClient(Protocol):
@@ -203,8 +204,9 @@ def get_key_insights(report_data: dict) -> list[str]:
top = consumers["by_minutes"][0]
minutes = float(actions.get("minutes", 0.0))
if minutes:
+ vis = visibility_label(repo_visibility(top))
insights.append(
- f"{top['repo']} accounts for {top['minutes'] / minutes * 100:.0f}% of Actions minutes."
+ f"{top['repo']}{vis} accounts for {top['minutes'] / minutes * 100:.0f}% of Actions minutes."
)
if actions and float(actions.get("storage_percent", 0.0)) < 100:
insights.append("Actions storage is below the free-tier limit.")
@@ -291,6 +293,8 @@ def build_report_data(
include_release_assets: bool,
max_repos: int,
warn_over: list[str] | str | None,
+ only_public: bool = False,
+ only_private: bool = False,
) -> dict:
"""Fetch and assemble all enabled billing sections into a single report dict."""
errors = {}
@@ -299,6 +303,9 @@ def build_report_data(
needs_repos = include_consumers or include_artifact_storage or include_release_assets
if needs_repos:
repos, truncated = _limited_repos(api, max_repos)
+ repos = filter_repos_by_visibility(
+ repos, only_public=only_public, only_private=only_private
+ )
core_limit, core_remaining = _rate_limit(api)
api_estimate = estimate_api_request_count(
repo_count=len(repos) + (1 if truncated else 0),
diff --git a/src/github_usage/report_optional.py b/src/github_usage/report_optional.py
index 63cb730..fe2a926 100644
--- a/src/github_usage/report_optional.py
+++ b/src/github_usage/report_optional.py
@@ -4,6 +4,7 @@
from .billing import BillingFetchError, get_actions_per_repo
from .report_helpers import gb_hours_to_avg_mb
+from .visibility import repo_visibility
def _safe_int_size(value) -> int | None:
@@ -38,6 +39,7 @@ def get_repo_consumers(api, repos: list[dict], limit: int = 5, max_repos: int =
"minutes": float(minutes),
"gross": sum(float(item.get("grossAmount", 0.0)) for item in sku.values()),
"storage_avg_mb": gb_hours_to_avg_mb(float(storage_gb_hours)),
+ "visibility": repo_visibility(repo),
}
)
return {
@@ -65,7 +67,11 @@ def get_artifact_storage_details(api, repos: list[dict], max_repos: int = 100) -
)
if size:
rows.append(
- {"repo": repo.get("full_name") or f"{owner}/{name}", "artifact_bytes": size}
+ {
+ "repo": repo.get("full_name") or f"{owner}/{name}",
+ "artifact_bytes": size,
+ "visibility": repo_visibility(repo),
+ }
)
return {
"scanned_repo_count": len(considered),
@@ -94,7 +100,11 @@ def get_release_asset_details(api, repos: list[dict], max_repos: int = 100) -> d
)
if size:
rows.append(
- {"repo": repo.get("full_name") or f"{owner}/{name}", "release_asset_bytes": size}
+ {
+ "repo": repo.get("full_name") or f"{owner}/{name}",
+ "release_asset_bytes": size,
+ "visibility": repo_visibility(repo),
+ }
)
return {
"scanned_repo_count": len(considered),
diff --git a/src/github_usage/report_summary.py b/src/github_usage/report_summary.py
index a1539b1..fbccb0f 100644
--- a/src/github_usage/report_summary.py
+++ b/src/github_usage/report_summary.py
@@ -5,6 +5,7 @@
from .billing import get_premium_request_usage
from .report_helpers import fmt_price, gb_hours_to_avg_mb
from .terminal import print_section
+from .visibility import repo_visibility, visibility_label
def show_final_summary(
@@ -98,6 +99,10 @@ def render_final_summary_from_data(data: dict) -> None:
for model, values in by_model.items()
}
+ visibility_by_repo = {
+ row["repo"]: repo_visibility(row) for row in (data.get("repo_actions") or [])
+ }
+
print_section("FINAL SUMMARY — Key Insights & Biggest Consumers")
copilot_gross = copilot_summary["total_gross"] if copilot_summary else 0
copilot_discount = copilot_summary["total_discount"] if copilot_summary else 0
@@ -110,7 +115,9 @@ def render_final_summary_from_data(data: dict) -> None:
(actions_net or 0) + (copilot_summary["total_net"] if copilot_summary else 0) + lfs_net
)
_print_cost_overview(total_gross, total_discount, total_net)
- _print_top_consumers(user_minutes, actions_gross, repo_data, premium_by_model, lfs_summary)
+ _print_top_consumers(
+ user_minutes, actions_gross, repo_data, premium_by_model, lfs_summary, visibility_by_repo
+ )
_print_storage_breakdown(storage_analysis)
_print_utilization(user_minutes, user_storage_gb_hours)
_print_impactful_findings(
@@ -122,8 +129,16 @@ def render_final_summary_from_data(data: dict) -> None:
repo_data,
premium_by_model,
storage_analysis,
+ visibility_by_repo,
+ )
+ _print_recommendations(
+ user_minutes,
+ repo_data,
+ premium_by_model,
+ lfs_summary,
+ storage_analysis,
+ visibility_by_repo,
)
- _print_recommendations(user_minutes, repo_data, premium_by_model, lfs_summary, storage_analysis)
def _print_cost_overview(total_gross, total_discount, total_net):
@@ -140,7 +155,20 @@ def _print_cost_overview(total_gross, total_discount, total_net):
print()
-def _print_top_consumers(user_minutes, actions_gross, repo_data, premium_by_model, lfs_summary):
+def _repo_label(full: str, visibility_by_repo: dict[str, str] | None) -> str:
+ if not visibility_by_repo:
+ return full
+ return f"{full}{visibility_label(visibility_by_repo.get(full, 'public'))}"
+
+
+def _print_top_consumers(
+ user_minutes,
+ actions_gross,
+ repo_data,
+ premium_by_model,
+ lfs_summary,
+ visibility_by_repo=None,
+):
print(" 2. BIGGEST CONSUMERS BY CATEGORY")
print(f" {'─' * 55}")
@@ -149,7 +177,8 @@ def _print_top_consumers(user_minutes, actions_gross, repo_data, premium_by_mode
print("\n Actions Minutes (top 5 repos):")
for full, mins, _gb, _avg_mb, gross, _ in sorted_repos[:5]:
pct = mins / user_minutes * 100 if user_minutes and user_minutes > 0 else 0
- print(f" {full:<45} {mins:>8.1f} min ({pct:5.1f}%) {fmt_price(gross)}")
+ label = _repo_label(full, visibility_by_repo)
+ print(f" {label:<45} {mins:>8.1f} min ({pct:5.1f}%) {fmt_price(gross)}")
if not sorted_repos:
print(" No Actions usage found.")
print()
@@ -159,7 +188,8 @@ def _print_top_consumers(user_minutes, actions_gross, repo_data, premium_by_mode
print(" Actions Cost (top 5 repos):")
for full, _mins, _gb, _avg_mb, gross, _ in sorted_by_cost[:5]:
pct = gross / actions_gross * 100 if (actions_gross or 0) > 0 else 0
- print(f" {full:<45} {fmt_price(gross):>10} ({pct:5.1f}%)")
+ label = _repo_label(full, visibility_by_repo)
+ print(f" {label:<45} {fmt_price(gross):>10} ({pct:5.1f}%)")
print()
# Copilot — by model
@@ -208,13 +238,13 @@ def _print_storage_breakdown(storage_analysis):
print(f"\n {'REPO':<45} {'TOTAL':>10}")
print(f" {'-' * 45} {'-' * 10}")
for r in sorted_by_storage[:10]:
- print(f" {r['name']:<45} {r['total_storage']:>10.2f} GB")
+ label = f"{r['name']}{visibility_label(repo_visibility(r))}"
+ print(f" {label:<45} {r['total_storage']:>10.2f} GB")
print()
top_storage = sorted_by_storage[0]
- print(
- f" Top storage consumer: {top_storage['name']} ({top_storage['total_storage']:.2f} GB)"
- )
+ top_label = f"{top_storage['name']}{visibility_label(repo_visibility(top_storage))}"
+ print(f" Top storage consumer: {top_label} ({top_storage['total_storage']:.2f} GB)")
print(" Breakdown:")
for item in top_storage.get("items", []):
print(
@@ -269,6 +299,7 @@ def _print_impactful_findings(
repo_data,
premium_by_model,
storage_analysis,
+ visibility_by_repo=None,
):
print(" 5. TOP 3 MOST IMPACTFUL FINDINGS")
print(f" {'─' * 55}")
@@ -284,21 +315,22 @@ def _print_impactful_findings(
top_repo = sorted_repos[0]
pct_of_total = top_repo[1] / user_minutes * 100 if user_minutes else 0
findings.append(
- f"Biggest Actions consumer: {top_repo[0]} at {top_repo[1]:.0f} min ({pct_of_total:.1f}% of total)"
+ f"Biggest Actions consumer: {_repo_label(top_repo[0], visibility_by_repo)} at {top_repo[1]:.0f} min ({pct_of_total:.1f}% of total)"
)
if sorted_by_cost:
top_cost = sorted_by_cost[0]
pct_cost = top_cost[4] / actions_gross * 100 if actions_gross else 0
findings.append(
- f"Highest Actions cost: {top_cost[0]} at {fmt_price(top_cost[4])} ({pct_cost:.1f}% of total)"
+ f"Highest Actions cost: {_repo_label(top_cost[0], visibility_by_repo)} at {fmt_price(top_cost[4])} ({pct_cost:.1f}% of total)"
)
if sorted_by_storage:
top_st = sorted_by_storage[0]
total_gb = top_st["total_storage"]
size_str = f"{total_gb:.2f} GB" if total_gb >= 1 else f"{total_gb * 1024:.0f} MB"
- findings.append(f"Biggest storage consumer: {top_st['name']} ({size_str})")
+ st_label = f"{top_st['name']}{visibility_label(repo_visibility(top_st))}"
+ findings.append(f"Biggest storage consumer: {st_label} ({size_str})")
if premium_by_model:
top_model = max(premium_by_model.items(), key=lambda x: x[1]["total_requests"])
@@ -323,7 +355,12 @@ def _print_impactful_findings(
def _print_recommendations(
- user_minutes, repo_data, premium_by_model, lfs_summary, storage_analysis
+ user_minutes,
+ repo_data,
+ premium_by_model,
+ lfs_summary,
+ storage_analysis,
+ visibility_by_repo=None,
):
print(" 6. QUICK RECOMMENDATIONS")
print(f" {'─' * 55}")
@@ -364,8 +401,9 @@ def _print_recommendations(
if release_assets:
total_release_size = sum(a["storage"] for a in release_assets)
if total_release_size > 0.1: # 100MB in GB
+ st_label = f"{top_st['name']}{visibility_label(repo_visibility(top_st))}"
recs.append(
- f"Release assets in {top_st['name']} use {total_release_size:.2f} GB — consider using GitHub Pages or external storage for large binaries."
+ f"Release assets in {st_label} use {total_release_size:.2f} GB — consider using GitHub Pages or external storage for large binaries."
)
if not recs:
diff --git a/src/github_usage/setup_config.py b/src/github_usage/setup_config.py
index 41eff38..7c85ac2 100644
--- a/src/github_usage/setup_config.py
+++ b/src/github_usage/setup_config.py
@@ -32,6 +32,8 @@
"skip_actions": False,
"skip_copilot": False,
"skip_lfs": False,
+ "only_public": False,
+ "only_private": False,
}
DEFAULT_SCHEDULE = {
@@ -278,6 +280,8 @@ def _emit_email_report_block(email: dict, *, prefix: str = "") -> str:
skip_actions = {_bool(email.get("skip_actions"))}
skip_copilot = {_bool(email.get("skip_copilot"))}
skip_lfs = {_bool(email.get("skip_lfs"))}
+only_public = {_bool(email.get("only_public"))}
+only_private = {_bool(email.get("only_private"))}
"""
@@ -370,6 +374,10 @@ def _email_flags_from_dict(email: dict, *, include_delivery: bool = True) -> lis
args.append("--skip-copilot")
if email.get("skip_lfs"):
args.append("--skip-lfs")
+ if email.get("only_public"):
+ args.append("--only-public")
+ if email.get("only_private"):
+ args.append("--only-private")
return args
@@ -407,6 +415,10 @@ def profile_workflow_extra_args(
args.append("--skip-copilot")
if email.get("skip_lfs"):
args.append("--skip-lfs")
+ if email.get("only_public"):
+ args.append("--only-public")
+ if email.get("only_private"):
+ args.append("--only-private")
target_subject = (profile.get("target_subject") or "").strip()
if target_subject:
args.extend(["--subject", target_subject])
diff --git a/src/github_usage/storage.py b/src/github_usage/storage.py
index dd9b886..b9dd927 100644
--- a/src/github_usage/storage.py
+++ b/src/github_usage/storage.py
@@ -2,6 +2,8 @@
from __future__ import annotations
+from .visibility import repo_visibility
+
def get_storage_analysis(api, repos):
"""Analyze storage per repo: artifacts, releases, LFS."""
@@ -65,6 +67,7 @@ def get_storage_analysis(api, repos):
"name": full,
"total_storage": total_storage,
"items": items,
+ "visibility": repo_visibility(repo),
}
)
except (KeyError, RuntimeError):
diff --git a/src/github_usage/visibility.py b/src/github_usage/visibility.py
new file mode 100644
index 0000000..48fae80
--- /dev/null
+++ b/src/github_usage/visibility.py
@@ -0,0 +1,68 @@
+"""Visibility helpers for public/private/internal repository grouping and filtering."""
+
+from __future__ import annotations
+
+VISIBILITY_ORDER = ["private", "internal", "public"]
+
+_GROUP_HEADERS = {
+ "private": "Private Repos:",
+ "internal": "Internal Repos:",
+ "public": "Public Repos:",
+}
+
+
+def repo_visibility(repo: dict, key: str = "visibility") -> str:
+ """Resolve visibility from a GitHub repo or enriched row dict.
+
+ Prefer an explicit ``visibility`` string. If missing, infer from the
+ ``private`` boolean (``True`` → ``"private"``, else ``"public"``).
+ On GHES without ``visibility``, internal repos may appear as private —
+ best-effort.
+ """
+ raw = repo.get(key)
+ if isinstance(raw, str) and raw:
+ return raw
+ return "private" if repo.get("private") else "public"
+
+
+def group_by_visibility(rows: list[dict], key: str = "visibility") -> dict[str, list[dict]]:
+ """Group rows by visibility: private, internal, public, then any unknown keys."""
+ groups: dict[str, list[dict]] = {}
+ for row in rows:
+ vis = repo_visibility(row, key=key)
+ groups.setdefault(vis, []).append(row)
+ result = {v: groups[v] for v in VISIBILITY_ORDER if v in groups}
+ for vis, items in groups.items():
+ if vis not in result:
+ result[vis] = items
+ return result
+
+
+def visibility_group_header(visibility: str) -> str:
+ """Return a display header for a visibility group table."""
+ return _GROUP_HEADERS.get(visibility, f"{visibility.title()} Repos:")
+
+
+def visibility_label(visibility: str) -> str:
+ """Return a display suffix like ``' [private]'``, or ``''`` for public."""
+ if visibility == "public":
+ return ""
+ return f" [{visibility}]"
+
+
+def filter_repos_by_visibility(
+ repos: list[dict],
+ *,
+ only_public: bool = False,
+ only_private: bool = False,
+) -> list[dict]:
+ """Filter repos by visibility. No-op when neither flag is set.
+
+ ``only_private`` includes both ``private`` and ``internal``.
+ Callers must not set both flags (CLI/GUI enforce mutual exclusion).
+ """
+ if only_public:
+ return [r for r in repos if repo_visibility(r) == "public"]
+ if only_private:
+ return [r for r in repos if repo_visibility(r) in ("private", "internal")]
+ return repos
diff --git a/tests/fixtures/export_report_data.json b/tests/fixtures/export_report_data.json
index cbcc545..01c9a20 100644
--- a/tests/fixtures/export_report_data.json
+++ b/tests/fixtures/export_report_data.json
@@ -70,12 +70,12 @@
"max_repos": 100,
"truncated": false,
"by_minutes": [
- {"repo": "octocat/api", "minutes": 900.0, "gross": 3.4, "storage_avg_mb": 180.0},
- {"repo": "octocat/web", "minutes": 350.0, "gross": 0.81, "storage_avg_mb": 40.4}
+ {"repo": "octocat/api", "minutes": 900.0, "gross": 3.4, "storage_avg_mb": 180.0, "visibility": "private"},
+ {"repo": "octocat/web", "minutes": 350.0, "gross": 0.81, "storage_avg_mb": 40.4, "visibility": "public"}
],
"by_cost": [
- {"repo": "octocat/api", "minutes": 900.0, "gross": 3.4, "storage_avg_mb": 180.0},
- {"repo": "octocat/web", "minutes": 350.0, "gross": 0.81, "storage_avg_mb": 40.4}
+ {"repo": "octocat/api", "minutes": 900.0, "gross": 3.4, "storage_avg_mb": 180.0, "visibility": "private"},
+ {"repo": "octocat/web", "minutes": 350.0, "gross": 0.81, "storage_avg_mb": 40.4, "visibility": "public"}
]
},
"artifact_storage": {
@@ -83,7 +83,7 @@
"max_repos": 100,
"truncated": false,
"top_repos": [
- {"repo": "octocat/api", "artifact_bytes": 943718400}
+ {"repo": "octocat/api", "artifact_bytes": 943718400, "visibility": "private"}
]
},
"release_assets": {
@@ -91,7 +91,7 @@
"max_repos": 100,
"truncated": false,
"top_repos": [
- {"repo": "octocat/api", "release_asset_bytes": 314572800}
+ {"repo": "octocat/api", "release_asset_bytes": 314572800, "visibility": "private"}
]
},
"api_estimate": {
diff --git a/tests/test_cli_parsers.py b/tests/test_cli_parsers.py
index 10e43c9..4ebcfaa 100644
--- a/tests/test_cli_parsers.py
+++ b/tests/test_cli_parsers.py
@@ -4,7 +4,7 @@
import unittest
-from github_usage.cli_parsers import _email_parser
+from github_usage.cli_parsers import _email_parser, _legacy_parser
class EmailParserTests(unittest.TestCase):
@@ -26,6 +26,22 @@ def test_premium_requests_limit_defaults_to_none(self):
args = parser.parse_args([])
self.assertIsNone(args.premium_requests_limit)
+ def test_email_only_public_and_only_private_mutually_exclusive(self):
+ parser = _email_parser()
+ with self.assertRaises(SystemExit):
+ parser.parse_args(["--only-public", "--only-private"])
+
+ def test_legacy_only_public_and_only_private_mutually_exclusive(self):
+ parser = _legacy_parser()
+ with self.assertRaises(SystemExit):
+ parser.parse_args(["--only-public", "--only-private"])
+
+ def test_email_only_public_flag(self):
+ parser = _email_parser()
+ args = parser.parse_args(["--only-public"])
+ self.assertTrue(args.only_public)
+ self.assertFalse(args.only_private)
+
if __name__ == "__main__":
unittest.main()
diff --git a/tests/test_export_xlsx.py b/tests/test_export_xlsx.py
index f2a4624..74c97f5 100644
--- a/tests/test_export_xlsx.py
+++ b/tests/test_export_xlsx.py
@@ -119,21 +119,21 @@ def test_repo_consumers_sheets(self):
self.assertIn(sheet, wb.sheetnames)
ws = wb[sheet]
rows = list(ws.iter_rows(values_only=True))
- self.assertEqual(rows[3], ("Repo", "Minutes", "Gross", "Storage Avg MB"))
+ self.assertEqual(rows[3], ("Repo", "Visibility", "Minutes", "Gross", "Storage Avg MB"))
def test_artifact_storage_sheet(self):
wb = self._open()
ws = wb["Artifacts"]
rows = list(ws.iter_rows(values_only=True))
- self.assertEqual(rows[3], ("Repo", "Artifact Bytes"))
- self.assertEqual(rows[4], ("octocat/api", 943718400))
+ self.assertEqual(rows[3], ("Repo", "Visibility", "Artifact Bytes"))
+ self.assertEqual(rows[4], ("octocat/api", "private", 943718400))
def test_release_assets_sheet(self):
wb = self._open()
ws = wb["Releases"]
rows = list(ws.iter_rows(values_only=True))
- self.assertEqual(rows[3], ("Repo", "Release Asset Bytes"))
- self.assertEqual(rows[4], ("octocat/api", 314572800))
+ self.assertEqual(rows[3], ("Repo", "Visibility", "Release Asset Bytes"))
+ self.assertEqual(rows[4], ("octocat/api", "private", 314572800))
def test_insights_sheet(self):
wb = self._open()
diff --git a/tests/test_legacy_report_data.py b/tests/test_legacy_report_data.py
index 3bac62b..3f45836 100644
--- a/tests/test_legacy_report_data.py
+++ b/tests/test_legacy_report_data.py
@@ -21,8 +21,20 @@ def _repo(name: str) -> dict:
class LegacyReportDataTests(unittest.TestCase):
def test_derive_repo_consumers_from_repo_actions(self) -> None:
rows = [
- {"repo": "octocat/a", "minutes": 10.0, "gross": 1.0, "avg_mb": 1.0},
- {"repo": "octocat/b", "minutes": 50.0, "gross": 2.0, "avg_mb": 2.0},
+ {
+ "repo": "octocat/a",
+ "minutes": 10.0,
+ "gross": 1.0,
+ "avg_mb": 1.0,
+ "visibility": "private",
+ },
+ {
+ "repo": "octocat/b",
+ "minutes": 50.0,
+ "gross": 2.0,
+ "avg_mb": 2.0,
+ "visibility": "public",
+ },
]
consumers = derive_repo_consumers(
rows,
@@ -31,14 +43,15 @@ def test_derive_repo_consumers_from_repo_actions(self) -> None:
truncated=False,
scanned_repo_count=2,
)
- self.assertEqual(consumers["by_minutes"][0]["repo"], "octocat/b")
- self.assertEqual(consumers["by_cost"][0]["repo"], "octocat/b")
+ self.assertEqual(consumers["by_minutes"][0]["visibility"], "public")
+ self.assertEqual(consumers["by_cost"][0]["visibility"], "public")
def test_derive_artifact_storage_from_storage_analysis(self) -> None:
storage = {
"repos": [
{
"name": "octocat/a",
+ "visibility": "internal",
"items": [
{"type": "Artifact", "storage": 1.0},
{"type": "Release Asset", "storage": 0.5},
@@ -51,6 +64,7 @@ def test_derive_artifact_storage_from_storage_analysis(self) -> None:
)
self.assertEqual(len(derived["top_repos"]), 1)
self.assertEqual(derived["top_repos"][0]["repo"], "octocat/a")
+ self.assertEqual(derived["top_repos"][0]["visibility"], "internal")
self.assertGreater(derived["top_repos"][0]["artifact_bytes"], 0)
def test_estimate_legacy_counts_storage_once_per_repo(self) -> None:
diff --git a/tests/test_report_actions_visibility.py b/tests/test_report_actions_visibility.py
new file mode 100644
index 0000000..5e9268f
--- /dev/null
+++ b/tests/test_report_actions_visibility.py
@@ -0,0 +1,105 @@
+"""Renderer tests for visibility-aware Actions output."""
+
+from __future__ import annotations
+
+import contextlib
+import io
+import unittest
+
+from github_usage.report_actions import (
+ render_actions_os_breakdown,
+ render_actions_top_consumers,
+ render_repo_actions_table,
+)
+
+
+class RenderActionsVisibilityTests(unittest.TestCase):
+ def test_render_repo_actions_table_groups_by_visibility(self) -> None:
+ repo_actions = [
+ {
+ "repo": "o/private",
+ "minutes": 10.0,
+ "storage_gb_hours": 0.1,
+ "avg_mb": 1.0,
+ "gross": 1.0,
+ "visibility": "private",
+ },
+ {
+ "repo": "o/public",
+ "minutes": 20.0,
+ "storage_gb_hours": 0.2,
+ "avg_mb": 2.0,
+ "gross": 2.0,
+ "visibility": "public",
+ },
+ ]
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ render_repo_actions_table(repo_actions)
+ out = buf.getvalue()
+ self.assertIn("Private Repos:", out)
+ self.assertIn("Public Repos:", out)
+ self.assertIn("SUBTOTAL", out)
+ self.assertIn("TOTAL", out)
+
+ def test_render_repo_actions_table_single_visibility(self) -> None:
+ repo_actions = [
+ {
+ "repo": "o/public",
+ "minutes": 20.0,
+ "storage_gb_hours": 0.2,
+ "avg_mb": 2.0,
+ "gross": 2.0,
+ "visibility": "public",
+ },
+ ]
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ render_repo_actions_table(repo_actions)
+ out = buf.getvalue()
+ self.assertNotIn("Private Repos:", out)
+ self.assertNotIn("SUBTOTAL", out)
+ self.assertIn("TOTAL", out)
+
+ def test_render_actions_top_consumers_annotates_visibility(self) -> None:
+ repo_actions = [
+ {
+ "repo": "o/private",
+ "minutes": 10.0,
+ "avg_mb": 1.0,
+ "visibility": "private",
+ },
+ {
+ "repo": "o/public",
+ "minutes": 5.0,
+ "avg_mb": 1.0,
+ "visibility": "public",
+ },
+ ]
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ render_actions_top_consumers(repo_actions)
+ out = buf.getvalue()
+ self.assertIn("o/private [private]", out)
+ self.assertNotIn("o/public [", out)
+
+ def test_render_actions_os_breakdown_annotates_visibility(self) -> None:
+ breakdown = {
+ "found": True,
+ "repos": [
+ {
+ "name": "o/private",
+ "visibility": "private",
+ "os_minutes": {"UBUNTU": 1.0, "WINDOWS": 0.0, "MACOS": 0.0},
+ }
+ ],
+ "totals": {"UBUNTU": 60000, "WINDOWS": 0, "MACOS": 0},
+ }
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ render_actions_os_breakdown(breakdown)
+ self.assertIn("o/private [private]", buf.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_report_data.py b/tests/test_report_data.py
index 9d40113..6446051 100644
--- a/tests/test_report_data.py
+++ b/tests/test_report_data.py
@@ -121,6 +121,20 @@ def test_get_key_insights_reports_top_repo_share_when_consumers_present(self):
self.assertIn("octocat/heavy", insights[0])
self.assertIn("60%", insights[0])
+ def test_get_key_insights_annotates_private_visibility(self):
+ from github_usage.report_data import get_key_insights
+
+ report = {
+ "actions": {"minutes": 100.0, "storage_percent": 100.0},
+ "repo_consumers": {
+ "by_minutes": [
+ {"repo": "octocat/heavy", "minutes": 60.0, "visibility": "private"},
+ ]
+ },
+ }
+ insights = get_key_insights(report)
+ self.assertIn("[private]", insights[0])
+
def test_get_key_insights_omits_share_when_actions_is_none(self):
from github_usage.report_data import get_key_insights
diff --git a/tests/test_report_optional.py b/tests/test_report_optional.py
index 9812792..2ec4ad0 100644
--- a/tests/test_report_optional.py
+++ b/tests/test_report_optional.py
@@ -68,7 +68,10 @@ def test_skips_items_with_non_numeric_size(self):
result = get_artifact_storage_details(api, [_repo("octocat/repo")], max_repos=10)
# "1024" → 1024, "abc" → skipped, 1024.7 → 1024 (truncated), None →
# skipped, 256 → 256. Total = 1024 + 1024 + 256 = 2304.
- self.assertEqual(result["top_repos"], [{"repo": "octocat/repo", "artifact_bytes": 2304}])
+ self.assertEqual(
+ result["top_repos"],
+ [{"repo": "octocat/repo", "artifact_bytes": 2304, "visibility": "public"}],
+ )
def test_omits_repos_with_no_valid_sizes(self):
api = FakeAPI(
@@ -109,7 +112,8 @@ def test_skips_assets_with_non_numeric_size(self):
# "1024" → 1024, "bad" → skipped, 2048.5 → 2048 (truncated), None →
# skipped, 512 → 512. Total = 1024 + 2048 + 512 = 3584.
self.assertEqual(
- result["top_repos"], [{"repo": "octocat/repo", "release_asset_bytes": 3584}]
+ result["top_repos"],
+ [{"repo": "octocat/repo", "release_asset_bytes": 3584, "visibility": "public"}],
)
def test_omits_repos_with_no_valid_asset_sizes(self):
diff --git a/tests/test_setup_config.py b/tests/test_setup_config.py
index 348c8af..eb56a3d 100644
--- a/tests/test_setup_config.py
+++ b/tests/test_setup_config.py
@@ -156,6 +156,34 @@ def test_email_flags_omit_forecast_when_disabled(self):
args = _email_flags_from_dict({"include_forecast": False})
self.assertNotIn("--include-forecast", args)
+ def test_email_flags_emit_visibility_filters(self):
+ from github_usage.setup_config import (
+ DEFAULT_EMAIL_REPORT,
+ _email_flags_from_dict,
+ _emit_email_report_block,
+ profile_workflow_extra_args,
+ )
+
+ self.assertFalse(DEFAULT_EMAIL_REPORT["only_public"])
+ self.assertFalse(DEFAULT_EMAIL_REPORT["only_private"])
+ args = _email_flags_from_dict({"only_public": True})
+ self.assertIn("--only-public", args)
+ self.assertNotIn("--only-private", args)
+ block = _emit_email_report_block({"only_public": True, "only_private": False})
+ self.assertIn("only_public = true", block)
+ config = {
+ "profiles": [
+ {
+ "name": "default",
+ "email_report": {"only_private": True},
+ "schedule": {},
+ "github_actions": {},
+ }
+ ]
+ }
+ workflow_args = profile_workflow_extra_args(config, "default")
+ self.assertIn("--only-private", workflow_args)
+
def test_find_profile_raises_for_unknown(self):
config = load_config(Path("/nonexistent"))
with self.assertRaises(KeyError):
diff --git a/tests/test_setup_wizard_visibility.py b/tests/test_setup_wizard_visibility.py
new file mode 100644
index 0000000..9a83eaa
--- /dev/null
+++ b/tests/test_setup_wizard_visibility.py
@@ -0,0 +1,24 @@
+"""Tests for wizard visibility filter validation."""
+
+from __future__ import annotations
+
+import unittest
+
+from github_usage.gui.wizard.setup_wizard_flow import WizardData, validate_options
+
+
+class WizardVisibilityFilterTests(unittest.TestCase):
+ def test_validate_options_rejects_both_visibility_filters(self) -> None:
+ data = WizardData(only_public=True, only_private=True)
+ self.assertEqual(
+ validate_options(data),
+ "Only one visibility filter can be enabled: public or private",
+ )
+
+ def test_validate_options_allows_single_visibility_filter(self) -> None:
+ data = WizardData(only_public=True)
+ self.assertIsNone(validate_options(data))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_visibility.py b/tests/test_visibility.py
new file mode 100644
index 0000000..9d1473b
--- /dev/null
+++ b/tests/test_visibility.py
@@ -0,0 +1,83 @@
+"""Tests for repository visibility helpers."""
+
+import unittest
+
+from github_usage.visibility import (
+ filter_repos_by_visibility,
+ group_by_visibility,
+ repo_visibility,
+ visibility_group_header,
+ visibility_label,
+)
+
+
+class VisibilityHelperTests(unittest.TestCase):
+ def test_repo_visibility_prefers_field(self) -> None:
+ self.assertEqual(repo_visibility({"visibility": "internal", "private": False}), "internal")
+
+ def test_repo_visibility_falls_back_to_private_bool(self) -> None:
+ self.assertEqual(repo_visibility({"private": True}), "private")
+ self.assertEqual(repo_visibility({"private": False}), "public")
+
+ def test_group_by_visibility_mixed(self) -> None:
+ rows = [
+ {"repo": "a", "visibility": "public"},
+ {"repo": "b", "visibility": "private"},
+ {"repo": "c", "visibility": "internal"},
+ ]
+ grouped = group_by_visibility(rows)
+ self.assertEqual(list(grouped.keys()), ["private", "internal", "public"])
+
+ def test_group_by_visibility_all_public(self) -> None:
+ rows = [{"visibility": "public"}, {"visibility": "public"}]
+ self.assertEqual(list(group_by_visibility(rows).keys()), ["public"])
+
+ def test_group_by_visibility_empty(self) -> None:
+ self.assertEqual(group_by_visibility([]), {})
+
+ def test_group_by_visibility_unknown_value(self) -> None:
+ rows = [{"visibility": "public"}, {"visibility": "custom"}]
+ grouped = group_by_visibility(rows)
+ self.assertEqual(list(grouped.keys()), ["public", "custom"])
+
+ def test_visibility_label_private(self) -> None:
+ self.assertEqual(visibility_label("private"), " [private]")
+
+ def test_visibility_label_public(self) -> None:
+ self.assertEqual(visibility_label("public"), "")
+
+ def test_visibility_group_header(self) -> None:
+ self.assertEqual(visibility_group_header("private"), "Private Repos:")
+
+ def test_filter_repos_by_visibility_only_public(self) -> None:
+ repos = [
+ {"visibility": "public"},
+ {"visibility": "private"},
+ {"visibility": "internal"},
+ ]
+ filtered = filter_repos_by_visibility(repos, only_public=True)
+ self.assertEqual(len(filtered), 1)
+ self.assertEqual(repo_visibility(filtered[0]), "public")
+
+ def test_filter_repos_by_visibility_only_private_includes_internal(self) -> None:
+ repos = [
+ {"visibility": "public"},
+ {"visibility": "private"},
+ {"visibility": "internal"},
+ ]
+ filtered = filter_repos_by_visibility(repos, only_private=True)
+ self.assertEqual({repo_visibility(r) for r in filtered}, {"private", "internal"})
+
+ def test_filter_repos_by_visibility_neither(self) -> None:
+ repos = [{"visibility": "public"}, {"visibility": "private"}]
+ self.assertEqual(filter_repos_by_visibility(repos), repos)
+
+ def test_filter_repos_by_visibility_uses_private_fallback(self) -> None:
+ repos = [{"private": True}, {"private": False}]
+ filtered = filter_repos_by_visibility(repos, only_private=True)
+ self.assertEqual(len(filtered), 1)
+ self.assertEqual(repo_visibility(filtered[0]), "private")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_workflow_templates.py b/tests/test_workflow_templates.py
index 4785356..23e832a 100644
--- a/tests/test_workflow_templates.py
+++ b/tests/test_workflow_templates.py
@@ -15,6 +15,9 @@ def test_email_report_workflow_uses_safe_secret_names_and_dispatch_inputs(self):
self.assertIn("include_consumers:", workflow)
self.assertIn("include_artifact_storage:", workflow)
self.assertIn("include_release_assets:", workflow)
+ self.assertIn("only_public:", workflow)
+ self.assertIn("only_private:", workflow)
+ self.assertIn("--only-public", workflow)
def test_launchd_email_report_runs_monday_morning(self):
plist_path = Path("launchd/com.github.github-usage.email-report.plist")