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("RepoGrossMinutesStorage") 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")