diff --git a/CHANGELOG.md b/CHANGELOG.md index 370e9dd..be19ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ This project follows the structure from Keep a Changelog and intends to use Sema ### Changed +- **Refactored reporting functions** (export CSV, rate limits, OS breakdown, report data fetching, base-cost and summary rendering) into smaller helpers to cut cognitive complexity and clear SonarCloud S3776 high-severity issues (batch 1/4). - **Refactored repeated string literals** (GUI selectors, HTML fragments, the `reports.` TOML prefix, the Git LFS label, and the workflows pathspec) into module-level constants to clear SonarCloud high-severity S1192/S7688 issues. - **GitHub Actions workflow now defaults to `--email-format html`:** The committed `email-report.yml` and its template were updated from `--email-format text` to `--email-format html`. Existing users who re-run setup (option 5 / wizard) will have their workflow re-rendered with this default. To keep plain-text output, set `email_format = "text"` in `.github-usage/config.toml` before re-running setup, or select **Text** in the wizard. - **Terminology:** the interactive CLI/TUI usage report is now called the **local full report** in user-facing copy (README, `start.sh`, CLI help, TUI). Internal `legacy_*` module names are unchanged for now (tracked in `TO_DO.md`). diff --git a/src/github_usage/export_csv.py b/src/github_usage/export_csv.py index ea5acf5..d0aedb7 100644 --- a/src/github_usage/export_csv.py +++ b/src/github_usage/export_csv.py @@ -70,10 +70,28 @@ def _write_sections( if include_forecast: _write_forecast_section(writer, data, premium_requests_limit=premium_requests_limit) + _write_warnings_section(writer, data) + _write_actions_section(writer, data) + _write_storage_summary_section(writer, data) + _write_copilot_section(writer, data) + _write_git_lfs_section(writer, data) + _write_monthly_costs_section(writer, data) + _write_repo_consumers_sections(writer, data) + _write_artifact_storage_section(writer, data) + _write_storage_analysis_section(writer, data) + _write_release_assets_section(writer, data) + _write_key_insights_section(writer, data) + _write_unavailable_data_section(writer, data) + _write_sources_section(writer, data) + + +def _write_warnings_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Warnings") for warning in data.get("warnings") or []: writer.writerow([warning]) + +def _write_actions_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Actions Usage") actions = _coerce_section(data.get("actions"), {}) for key, value in actions.items(): @@ -97,12 +115,16 @@ def _write_sections( for row in vis_rows: writer.writerow(row) + +def _write_storage_summary_section(writer, data: dict) -> None: # type: ignore[type-arg] storage_summary = _coerce_section(data.get("storage_summary"), {}) if storage_summary: _write_section_header(writer, "Storage Summary") for key, value in storage_summary.items(): writer.writerow([key, value]) + +def _write_copilot_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Copilot Usage") copilot = _coerce_section(data.get("copilot"), {}) for key, value in copilot.items(): @@ -111,11 +133,15 @@ def _write_sections( else: writer.writerow([key, value]) + +def _write_git_lfs_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Git LFS") git_lfs = _coerce_section(data.get("git_lfs"), {}) for key, value in git_lfs.items(): writer.writerow([key, value]) + +def _write_monthly_costs_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Monthly Costs") costs = _coerce_section(data.get("monthly_costs"), {}) for category, amounts in costs.items(): @@ -126,31 +152,32 @@ def _write_sections( else: writer.writerow([category, amounts]) - _write_section_header(writer, "Top Repos by Minutes") + +def _write_consumer_row(writer, entry: dict) -> None: # type: ignore[type-arg] + """Write a single repository consumer row (name, visibility, minutes, cost, storage) to the CSV.""" + writer.writerow( + [ + entry.get("repo", ""), + repo_visibility(entry), + entry.get("minutes", ""), + entry.get("gross", ""), + entry.get("storage_avg_mb", ""), + ] + ) + + +def _write_repo_consumers_sections(writer, data: dict) -> None: # type: ignore[type-arg] consumers = _coerce_section(data.get("repo_consumers"), {}) + _write_section_header(writer, "Top Repos by Minutes") for entry in consumers.get("by_minutes") or []: - writer.writerow( - [ - entry.get("repo", ""), - repo_visibility(entry), - entry.get("minutes", ""), - entry.get("gross", ""), - entry.get("storage_avg_mb", ""), - ] - ) + _write_consumer_row(writer, entry) _write_section_header(writer, "Top Repos by Cost") for entry in consumers.get("by_cost") or []: - writer.writerow( - [ - entry.get("repo", ""), - repo_visibility(entry), - entry.get("minutes", ""), - entry.get("gross", ""), - entry.get("storage_avg_mb", ""), - ] - ) + _write_consumer_row(writer, entry) + +def _write_artifact_storage_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Artifact Storage") artifacts = _coerce_section(data.get("artifact_storage"), {}) for entry in artifacts.get("top_repos") or []: @@ -158,12 +185,16 @@ def _write_sections( [entry.get("repo", ""), repo_visibility(entry), entry.get("artifact_bytes", "")] ) + +def _write_storage_analysis_section(writer, data: dict) -> None: # type: ignore[type-arg] analysis_rows = storage_analysis_export_rows(data.get("storage_analysis")) if analysis_rows: _write_section_header(writer, "Storage Analysis") for row in analysis_rows: writer.writerow(row) + +def _write_release_assets_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Release Assets") releases = _coerce_section(data.get("release_assets"), {}) for entry in releases.get("top_repos") or []: @@ -175,14 +206,20 @@ def _write_sections( ] ) + +def _write_key_insights_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Key Insights") for insight in data.get("insights") or []: writer.writerow([insight]) + +def _write_unavailable_data_section(writer, data: dict) -> None: # type: ignore[type-arg] _write_section_header(writer, "Unavailable Data") for error_key, error_msg in (data.get("errors") or {}).items(): writer.writerow([error_key, error_msg]) + +def _write_sources_section(writer, data: dict) -> None: # type: ignore[type-arg] sources = data.get("sources") source_rows = sources_rows(sources if isinstance(sources, dict) else None) if source_rows: diff --git a/src/github_usage/export_visibility.py b/src/github_usage/export_visibility.py index b7169b9..0b9efd0 100644 --- a/src/github_usage/export_visibility.py +++ b/src/github_usage/export_visibility.py @@ -140,7 +140,7 @@ def storage_analysis_export_rows(storage_analysis: dict | None) -> list[list]: def per_visibility_sku_rows(actions: dict | None) -> list[list]: - """SKU × visibility table from ``actions['skus']`` when present.""" + """SKU x visibility table from ``actions['skus']`` when present.""" if not actions: return [] skus = actions.get("skus") or {} diff --git a/src/github_usage/report_account.py b/src/github_usage/report_account.py index d07dacb..29066da 100644 --- a/src/github_usage/report_account.py +++ b/src/github_usage/report_account.py @@ -13,14 +13,8 @@ def fetch_rate_limits(api) -> dict: return data if isinstance(data, dict) else {} -def render_rate_limits(data: dict) -> None: - """Print rate limits from a pre-fetched ``/rate_limit`` response.""" - print_sep("API Rate Limit") - resources = data.get("resources", {}) - if not isinstance(resources, dict): - resources = {} - - # Standard limits +def _print_standard_rate_limits(resources: dict) -> None: + """Print the core / GraphQL / search / code-scanning rate-limit rows.""" print() for name, key in [ ("Core API", "core"), @@ -37,7 +31,6 @@ def render_rate_limits(data: dict) -> None: lim = r.get("limit") if lim is None: lim = "?" - used = r.get("used", 0) reset_ts = r.get("reset", 0) reset_str = "" if reset_ts: @@ -46,7 +39,9 @@ def render_rate_limits(data: dict) -> None: ) print(f" {name:<25} {rem:>6} / {lim:<6} remaining{reset_str}") - # Premium / high-tier + +def _print_premium_rate_limits(resources: dict) -> None: + """Print the high-tier (limit > 5000) rate-limit rows.""" print() print(" Premium API tiers:") for name, res in resources.items(): @@ -64,6 +59,16 @@ def render_rate_limits(data: dict) -> None: print() +def render_rate_limits(data: dict) -> None: + """Print rate limits from a pre-fetched ``/rate_limit`` response.""" + print_sep("API Rate Limit") + resources = data.get("resources", {}) + if not isinstance(resources, dict): + resources = {} + _print_standard_rate_limits(resources) + _print_premium_rate_limits(resources) + + def fetch_account_info(api) -> dict: """Return account metadata from ``GET /user``.""" user = api.request("GET", "/user") diff --git a/src/github_usage/report_actions.py b/src/github_usage/report_actions.py index e04bf98..8e5e6c7 100644 --- a/src/github_usage/report_actions.py +++ b/src/github_usage/report_actions.py @@ -90,6 +90,26 @@ def show_actions_top_consumers(repo_data, visibility_by_repo=None): print() +def _print_repo_os_breakdown(owner, name, os_millis, total_os): + """Print one repo's per-OS minutes and accumulate its millis into ``total_os``.""" + print(f" {owner}/{name}:") + for os_name in ["UBUNTU", "WINDOWS", "MACOS"]: + mins = os_millis[os_name] / 60000 + total_os[os_name] += os_millis[os_name] + if mins > 0: + print(f" {os_name:<10} {mins:>8.1f} min") + print() + + +def _print_os_totals(total_os): + """Print the aggregated per-OS minute totals across all repos.""" + print(" TOTAL:") + for os_name in ["UBUNTU", "WINDOWS", "MACOS"]: + mins = total_os[os_name] / 60000 + if mins > 0: + print(f" {os_name:<10} {mins:>8.1f} min") + + def show_actions_os_breakdown(api, repos): """Show Ubuntu/Windows/macOS breakdown for top repos.""" print_sep("Actions Compute by OS (from workflow runs)") @@ -104,19 +124,9 @@ def show_actions_os_breakdown(api, repos): minutes, os_millis, _ = get_actions_from_runs(api, owner, name) if minutes > 0: found = True - print(f" {owner}/{name}:") - for os_name in ["UBUNTU", "WINDOWS", "MACOS"]: - mins = os_millis[os_name] / 60000 - total_os[os_name] += os_millis[os_name] - if mins > 0: - print(f" {os_name:<10} {mins:>8.1f} min") - print() + _print_repo_os_breakdown(owner, name, os_millis, total_os) if found: - print(" TOTAL:") - for os_name in ["UBUNTU", "WINDOWS", "MACOS"]: - mins = total_os[os_name] / 60000 - if mins > 0: - print(f" {os_name:<10} {mins:>8.1f} min") + _print_os_totals(total_os) else: print(" No detailed OS breakdown available from workflow runs API.") print(" (Use the Actions Summary above for total minutes by OS type)") diff --git a/src/github_usage/report_data.py b/src/github_usage/report_data.py index e8eed38..2535cd0 100644 --- a/src/github_usage/report_data.py +++ b/src/github_usage/report_data.py @@ -237,6 +237,14 @@ def _rate_limit(api: GitHubAPIClient) -> tuple[int | None, int | None]: return core.get("limit"), core.get("remaining") +def _try_section(report: dict, errors: dict, key: str, getter) -> None: + """Store ``getter()`` under ``report[key]``, or record its RuntimeError in ``errors``.""" + try: + report[key] = getter() + except RuntimeError as exc: + errors[key] = str(exc) + + def _fetch_sections( api: GitHubAPIClient, username: str, @@ -260,10 +268,7 @@ def _fetch_sections( ("git_lfs", include_lfs, lambda: get_gitlfs_usage(api, username)), ]: if enabled: - try: - report[key] = getter() - except RuntimeError as exc: - errors[key] = str(exc) + _try_section(report, errors, key, getter) try: report["monthly_costs"] = get_monthly_costs(api, username) @@ -277,28 +282,36 @@ def _fetch_sections( } if include_consumers: - try: - report["repo_consumers"] = get_repo_consumers(api, repos, max_repos=max_repos) - except RuntimeError as exc: - errors["repo_consumers"] = str(exc) + _try_section( + report, + errors, + "repo_consumers", + lambda: get_repo_consumers(api, repos, max_repos=max_repos), + ) if include_artifact_storage: - try: - report["artifact_storage"] = get_artifact_storage_details(api, repos, max_repos) - except RuntimeError as exc: - errors["artifact_storage"] = str(exc) + _try_section( + report, + errors, + "artifact_storage", + lambda: get_artifact_storage_details(api, repos, max_repos), + ) if include_release_assets: - try: - report["release_assets"] = get_release_asset_details(api, repos, max_repos) - except RuntimeError as exc: - errors["release_assets"] = str(exc) + _try_section( + report, + errors, + "release_assets", + lambda: get_release_asset_details(api, repos, max_repos), + ) if include_consumers and report.get("repo_consumers"): - try: - report["workflow_breakdown"] = workflow_breakdown_for_top_private( + _try_section( + report, + errors, + "workflow_breakdown", + lambda: workflow_breakdown_for_top_private( api, report["repo_consumers"], runs_cache=runs_cache - ) - except RuntimeError as exc: - errors["workflow_breakdown"] = str(exc) + ), + ) def build_report_data( diff --git a/src/github_usage/report_products.py b/src/github_usage/report_products.py index 58a6715..e78bbdb 100644 --- a/src/github_usage/report_products.py +++ b/src/github_usage/report_products.py @@ -216,87 +216,99 @@ def show_monthly_costs(repo_data, username, api): print() -def show_base_costs(api, username, actions_sku, copilot_summary, lfs_summary): - """Show per-unit base costs for all products.""" - print_section("Base Costs (Per-Unit Pricing)") - - # Actions base costs +def _print_actions_compute_costs(actions_sku) -> None: + """Per-unit Actions compute (minutes) pricing rows.""" print("\n Actions Compute:") - actions_minutes_found = False + found = False for sku, item in (actions_sku or {}).items(): - if sku.startswith("_"): + if str(sku).startswith("_"): continue - unit = item.get("unitType", "") - price = item.get("pricePerUnit", 0) - qty = item.get("grossQuantity", 0) - net = item.get("netAmount", 0) - if unit == "minutes": - print(f" {sku:<40} {fmt_price(price)}/min × {qty:.1f} min = {fmt_price(net)}") - actions_minutes_found = True - if not actions_minutes_found: + if item.get("unitType", "") == "minutes": + price = item.get("pricePerUnit", 0) + qty = item.get("grossQuantity", 0) + net = item.get("netAmount", 0) + print(f" {sku:<40} {fmt_price(price)}/min x {qty:.1f} min = {fmt_price(net)}") + found = True + if not found: print(" No compute minutes billed.") print(" Standard tier: ~$0.008/min (Linux), ~$0.016/min (Windows), ~$0.016/min (macOS)") print(" Free tier: 2,000 min/month for personal repos") print() + +def _print_actions_storage_costs(actions_sku) -> None: + """Per-unit Actions storage (GB-hours) pricing rows.""" print(" Actions Storage:") - actions_storage_found = False + found = False for sku, item in (actions_sku or {}).items(): - if sku.startswith("_"): + if str(sku).startswith("_"): continue - unit = item.get("unitType", "") - price = item.get("pricePerUnit", 0) - qty = item.get("grossQuantity", 0) - net = item.get("netAmount", 0) - if unit == "gigabyte-hours": + if item.get("unitType", "") == "gigabyte-hours": + price = item.get("pricePerUnit", 0) + qty = item.get("grossQuantity", 0) + net = item.get("netAmount", 0) avg_mb = gb_hours_to_avg_mb(qty) print( - f" {sku:<40} {fmt_price(price)}/GB-hr × {qty:.2f} GB-hrs ({avg_mb:.0f} MB avg) = {fmt_price(net)}" + f" {sku:<40} {fmt_price(price)}/GB-hr x {qty:.2f} GB-hrs " + f"({avg_mb:.0f} MB avg) = {fmt_price(net)}" ) - actions_storage_found = True - if not actions_storage_found: + found = True + if not found: print(" No storage billed.") print(" Standard: ~$0.01/GB-month") print(" Free tier: 500 MB for personal repos") print() + +def _print_copilot_base_costs(items) -> None: + """Per-unit Copilot premium-request pricing rows from a billing items map.""" print(" Copilot Premium Requests:") - copilot_found = False - if copilot_summary and copilot_summary["items"]: + found = False + if items: all_prices = set() - for sku, item in copilot_summary["items"].items(): + for sku, item in items.items(): price = item.get("pricePerUnit", 0) qty = item.get("grossQuantity", 0) + net = item.get("netAmount", 0) if price > 0: - print( - f" {sku:<40} {fmt_price(price)}/req × {qty:.0f} reqs = {fmt_price(item.get('netAmount', 0))}" - ) - copilot_found = True + print(f" {sku:<40} {fmt_price(price)}/req x {qty:.0f} reqs = {fmt_price(net)}") + found = True all_prices.add(price) if all_prices: print(f" Base rate: {max(all_prices):.4f}/req (highest observed)") - if not copilot_found: + if not found: print(" No premium requests billed.") print(" Copilot Pro: ~$0.04-0.08/request for premium features") print() + +def _print_lfs_base_costs(items) -> None: + """Per-unit Git LFS storage pricing rows from a billing items map.""" print(" Git LFS:") - lfs_found = False - if lfs_summary and lfs_summary["items"]: - for sku, item in lfs_summary["items"].items(): + found = False + if items: + for sku, item in items.items(): price = item.get("pricePerUnit", 0) qty = item.get("grossQuantity", 0) + net = item.get("netAmount", 0) if price > 0: - print( - f" {sku:<40} {fmt_price(price)}/GB × {qty:.2f} GB = {fmt_price(item.get('netAmount', 0))}" - ) - lfs_found = True - if not lfs_found: + print(f" {sku:<40} {fmt_price(price)}/GB x {qty:.2f} GB = {fmt_price(net)}") + found = True + if not found: print(" No LFS storage billed.") print(" Standard: ~$1/GB-month after 1 GB free") print() +def show_base_costs(api, username, actions_sku, copilot_summary, lfs_summary): + """Show per-unit base costs for all products.""" + print_section("Base Costs (Per-Unit Pricing)") + _print_actions_compute_costs(actions_sku) + _print_actions_storage_costs(actions_sku) + _print_copilot_base_costs(copilot_summary.get("items") if copilot_summary else None) + _print_lfs_base_costs(lfs_summary.get("items") if lfs_summary else None) + + def fetch_billing_history(api, username: str) -> list: """Return raw billing history items (no printing).""" full = get_full_billing(api, username) @@ -491,77 +503,7 @@ def render_base_costs( """Print per-unit base costs from pre-fetched billing summaries.""" actions_sku = (actions or {}).get("sku_breakdown") or {} print_section("Base Costs (Per-Unit Pricing)") - print("\n Actions Compute:") - actions_minutes_found = False - for sku, item in actions_sku.items(): - if str(sku).startswith("_"): - continue - unit = item.get("unitType", "") - price = item.get("pricePerUnit", 0) - qty = item.get("grossQuantity", 0) - net = item.get("netAmount", 0) - if unit == "minutes": - print(f" {sku:<40} {fmt_price(price)}/min × {qty:.1f} min = {fmt_price(net)}") - actions_minutes_found = True - if not actions_minutes_found: - print(" No compute minutes billed.") - print(" Standard tier: ~$0.008/min (Linux), ~$0.016/min (Windows), ~$0.016/min (macOS)") - print(" Free tier: 2,000 min/month for personal repos") - print() - print(" Actions Storage:") - actions_storage_found = False - for sku, item in actions_sku.items(): - if str(sku).startswith("_"): - continue - unit = item.get("unitType", "") - price = item.get("pricePerUnit", 0) - qty = item.get("grossQuantity", 0) - net = item.get("netAmount", 0) - if unit == "gigabyte-hours": - avg_mb = gb_hours_to_avg_mb(qty) - print( - f" {sku:<40} {fmt_price(price)}/GB-hr × {qty:.2f} GB-hrs " - f"({avg_mb:.0f} MB avg) = {fmt_price(net)}" - ) - actions_storage_found = True - if not actions_storage_found: - print(" No storage billed.") - print(" Standard: ~$0.01/GB-month") - print(" Free tier: 500 MB for personal repos") - print() - print(" Copilot Premium Requests:") - copilot_found = False - if copilot_billing and copilot_billing.get("items"): - all_prices = set() - for sku, item in copilot_billing["items"].items(): - price = item.get("pricePerUnit", 0) - qty = item.get("grossQuantity", 0) - if price > 0: - print( - f" {sku:<40} {fmt_price(price)}/req × {qty:.0f} reqs " - f"= {fmt_price(item.get('netAmount', 0))}" - ) - copilot_found = True - all_prices.add(price) - if all_prices: - print(f" Base rate: {max(all_prices):.4f}/req (highest observed)") - if not copilot_found: - print(" No premium requests billed.") - print(" Copilot Pro: ~$0.04-0.08/request for premium features") - print() - print(" Git LFS:") - lfs_found = False - if lfs_billing and lfs_billing.get("items"): - for sku, item in lfs_billing["items"].items(): - price = item.get("pricePerUnit", 0) - qty = item.get("grossQuantity", 0) - if price > 0: - print( - f" {sku:<40} {fmt_price(price)}/GB × {qty:.2f} GB " - f"= {fmt_price(item.get('netAmount', 0))}" - ) - lfs_found = True - if not lfs_found: - print(" No LFS storage billed.") - print(" Standard: ~$1/GB-month after 1 GB free") - print() + _print_actions_compute_costs(actions_sku) + _print_actions_storage_costs(actions_sku) + _print_copilot_base_costs(copilot_billing.get("items") if copilot_billing else None) + _print_lfs_base_costs(lfs_billing.get("items") if lfs_billing else None) diff --git a/src/github_usage/report_summary.py b/src/github_usage/report_summary.py index 0e9fef9..5d0434b 100644 --- a/src/github_usage/report_summary.py +++ b/src/github_usage/report_summary.py @@ -164,6 +164,7 @@ def render_final_summary_from_data(data: dict) -> None: def _print_cost_overview(total_gross, total_discount, total_net): + """Section 1: gross/discount/net cost summary line.""" print("\n 1. COST OVERVIEW") print(f" {'─' * 55}") print(f" Total Gross: {fmt_price(total_gross or 0):>12}") @@ -177,22 +178,8 @@ 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, - visibility_by_repo=None, - *, - repo_consumers=None, - private_minutes=None, -): - print(" 2. BIGGEST CONSUMERS BY CATEGORY") - print(f" {'─' * 55}") - - # Actions — top repos by minutes - sorted_repos = sorted(repo_data, key=lambda x: x[1], reverse=True) if repo_data else [] +def _print_actions_minutes_top(sorted_repos, user_minutes, visibility_by_repo): + """Actions minutes for the top 5 repos (section of BIGGEST CONSUMERS).""" 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 @@ -202,8 +189,9 @@ def _print_top_consumers( print(" No Actions usage found.") print() - # Actions — top repos by cost - sorted_by_cost = sorted(repo_data, key=lambda x: x[4], reverse=True) if repo_data else [] + +def _print_actions_cost_top(sorted_by_cost, actions_gross, visibility_by_repo): + """Actions cost for the top 5 repos (section of BIGGEST CONSUMERS).""" 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 @@ -211,43 +199,50 @@ def _print_top_consumers( print(f" {label:<45} {fmt_price(gross):>10} ({pct:5.1f}%)") print() - if repo_consumers: - by_minutes = repo_consumers.get("by_minutes") or [] - by_minutes_private = repo_consumers.get("by_minutes_private") or [] - if by_minutes_private and not private_list_is_redundant( - by_minutes[:5], by_minutes_private[:5] - ): - print(" Private Actions Minutes (top 5 repos):") - for row in by_minutes_private[:5]: - mins = row["minutes"] - pct = ( - mins / private_minutes * 100.0 - if private_minutes and private_minutes > 0 - else 0.0 - ) - label = repo_label(row["repo"], visibility_by_repo) - print(f" {label:<45} {mins:>8.1f} min ({pct:5.1f}% of private minutes)") - print() - by_storage = repo_consumers.get("by_storage") or [] - if by_storage: - print(" Actions Storage (top 5 repos, billed):") - for row in by_storage[:5]: - label = repo_label(row["repo"], visibility_by_repo) - print(f" {label:<45} {row['storage_avg_mb']:>8.1f} MB") - print() +def _print_repo_consumer_breakdowns(repo_consumers, private_minutes, visibility_by_repo): + """Private-minutes and (private) storage breakdowns from repo_consumers.""" + by_minutes = repo_consumers.get("by_minutes") or [] + by_minutes_private = repo_consumers.get("by_minutes_private") or [] + if by_minutes_private and not private_list_is_redundant(by_minutes[:5], by_minutes_private[:5]): + print(" Private Actions Minutes (top 5 repos):") + for row in by_minutes_private[:5]: + mins = row["minutes"] + pct = mins / private_minutes * 100.0 if private_minutes and private_minutes > 0 else 0.0 + label = repo_label(row["repo"], visibility_by_repo) + print(f" {label:<45} {mins:>8.1f} min ({pct:5.1f}% of private minutes)") + print() - by_storage_private = repo_consumers.get("by_storage_private") or [] - if by_storage_private and not private_list_is_redundant( - by_storage[:5], by_storage_private[:5] - ): - print(" Private Actions Storage (top 5 repos, billed):") - for row in by_storage_private[:5]: - label = repo_label(row["repo"], visibility_by_repo) - print(f" {label:<45} {row['storage_avg_mb']:>8.1f} MB") - print() + by_storage = repo_consumers.get("by_storage") or [] + by_storage_private = repo_consumers.get("by_storage_private") or [] + _print_repo_storage_breakdowns(by_storage, by_storage_private, visibility_by_repo) + + +def _print_repo_storage_breakdowns(by_storage, by_storage_private, visibility_by_repo): + """(Private) storage breakdowns from repo_consumers.""" + if by_storage: + print(" Actions Storage (top 5 repos, billed):") + for row in by_storage[:5]: + label = repo_label(row["repo"], visibility_by_repo) + print(f" {label:<45} {row['storage_avg_mb']:>8.1f} MB") + print() + + if not by_storage_private: + return - # Copilot — by model + top_storage = by_storage[:5] if by_storage else [] + top_private = by_storage_private[:5] + + if not private_list_is_redundant(top_storage, top_private): + print(" Private Actions Storage (top 5 repos, billed):") + for row in top_private: + label = repo_label(row["repo"], visibility_by_repo) + print(f" {label:<45} {row['storage_avg_mb']:>8.1f} MB") + print() + + +def _print_copilot_by_model(premium_by_model): + """Copilot premium requests grouped by model (section of BIGGEST CONSUMERS).""" print(" Copilot Premium Requests (by model):") if premium_by_model: for model, data in sorted( @@ -266,7 +261,9 @@ def _print_top_consumers( print(" No model-level data available.") print() - # Git LFS + +def _print_lfs_storage(lfs_summary): + """Git LFS storage rows (section of BIGGEST CONSUMERS).""" if lfs_summary and lfs_summary.get("items"): print(" Git LFS Storage:") for sku, item in lfs_summary["items"].items(): @@ -282,7 +279,36 @@ def _print_top_consumers( print() +def _print_top_consumers( + user_minutes, + actions_gross, + repo_data, + premium_by_model, + lfs_summary, + visibility_by_repo=None, + *, + repo_consumers=None, + private_minutes=None, +): + """Section 2: biggest consumers by category (minutes, storage, Copilot).""" + print(" 2. BIGGEST CONSUMERS BY CATEGORY") + print(f" {'─' * 55}") + + sorted_repos = sorted(repo_data, key=lambda x: x[1], reverse=True) if repo_data else [] + _print_actions_minutes_top(sorted_repos, user_minutes, visibility_by_repo) + + sorted_by_cost = sorted(repo_data, key=lambda x: x[4], reverse=True) if repo_data else [] + _print_actions_cost_top(sorted_by_cost, actions_gross, visibility_by_repo) + + if repo_consumers: + _print_repo_consumer_breakdowns(repo_consumers, private_minutes, visibility_by_repo) + + _print_copilot_by_model(premium_by_model) + _print_lfs_storage(lfs_summary) + + def _print_storage_breakdown(storage_analysis): + """Section 3: storage usage broken down by repository.""" print(" 3. STORAGE BREAKDOWN BY REPOSITORY") print(f" {'─' * 55}") diff --git a/tests/test_report_products.py b/tests/test_report_products.py index bd47f28..63cfd15 100644 --- a/tests/test_report_products.py +++ b/tests/test_report_products.py @@ -33,3 +33,68 @@ def test_show_copilot_summary_prints_totals(self): output = stdout.getvalue() self.assertIn("gross: $10.0000", output) self.assertIn("net: $8.0000", output) + + def test_show_base_costs_handles_missing_items_key(self): + from github_usage.report_products import show_base_costs + + api = mock.Mock() + stdout = StringIO() + with redirect_stdout(stdout): + # Pass dicts without "items" key to ensure it doesn't raise KeyError + show_base_costs(api, "octocat", {}, {}, {}) + + output = stdout.getvalue() + self.assertIn("Base Costs", output) + + def test_render_base_costs_with_items(self): + from github_usage.report_products import render_base_costs + + actions = { + "sku_breakdown": { + "Linux": { + "unitType": "minutes", + "pricePerUnit": 0.008, + "grossQuantity": 1000, + "netAmount": 8.0, + } + } + } + copilot_billing = { + "items": { + "copilot": { + "pricePerUnit": 0.04, + "grossQuantity": 100, + "netAmount": 4.0, + } + } + } + lfs_billing = { + "items": { + "git_lfs": { + "pricePerUnit": 1.0, + "grossQuantity": 2.5, + "netAmount": 2.5, + } + } + } + + stdout = StringIO() + with redirect_stdout(stdout): + render_base_costs(actions, copilot_billing, lfs_billing) + + output = stdout.getvalue() + self.assertIn("Base Costs", output) + self.assertIn("Copilot Premium Requests", output) + self.assertIn("Git LFS", output) + self.assertIn("Linux", output) + + def test_render_base_costs_handles_missing_items_key(self): + from github_usage.report_products import render_base_costs + + stdout = StringIO() + with redirect_stdout(stdout): + # Pass billing dicts without "items" key to ensure no KeyError. + render_base_costs({}, {}, {}) + + output = stdout.getvalue() + self.assertIn("Base Costs", output)