From 63430ca9b9b98a5252cfd8a783c9b1bad3215a86 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:38:01 -0400 Subject: [PATCH 1/8] Refactor _print_top_consumers to cut cognitive complexity (S3776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit report_summary.py:_print_top_consumers had cognitive complexity 54 (limit 15). Extract each output section into a focused helper — _print_actions_minutes_top, _print_actions_cost_top, _print_repo_consumer_breakdowns, _print_copilot_by_model, _print_lfs_storage — leaving the parent as a flat sequence of calls. Pure extract-method; output is byte-for-byte unchanged (33 report-summary tests assert exact output). Also gitignore the local .sonar_cloud_token helper file. 732 tests OK, ruff clean. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + src/github_usage/report_summary.py | 118 ++++++++++++++++------------- 2 files changed, 68 insertions(+), 53 deletions(-) diff --git a/.gitignore b/.gitignore index 58a3364..19996c9 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,6 @@ tmp/ # Transient pre-modification backups backups/*.bak + +# Local SonarCloud API token (never commit) +.sonar_cloud_token diff --git a/src/github_usage/report_summary.py b/src/github_usage/report_summary.py index 0e9fef9..17191fd 100644 --- a/src/github_usage/report_summary.py +++ b/src/github_usage/report_summary.py @@ -177,22 +177,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 +188,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 +198,39 @@ 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 [] + 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() - # Copilot — by model + 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() + + +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 +249,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,6 +267,33 @@ 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, +): + 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): print(" 3. STORAGE BREAKDOWN BY REPOSITORY") print(f" {'─' * 55}") From 92a3b175409b1478b639082179210425b8623a66 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:47:57 -0400 Subject: [PATCH 2/8] Refactor CSV export and base-cost printers to cut complexity (S3776) - export_csv._write_sections (complexity 44 -> ~2): extract each CSV section into its own _write_*_section helper; parent is now a flat call sequence. - report_products.show_base_costs and render_base_costs (32 each): these were near-duplicate per-product pricing printers. Extract four shared block helpers (_print_actions_compute_costs, _print_actions_storage_costs, _print_copilot_base_costs, _print_lfs_base_costs) that both call, removing ~80 lines of duplication (567 -> 513 lines) and dropping both parents well under the threshold. Pure extract-method; output byte-for-byte unchanged. 732 tests OK, ruff clean. Co-Authored-By: Claude Opus 4.8 --- src/github_usage/export_csv.py | 74 +++++++++---- src/github_usage/report_products.py | 166 ++++++++++------------------ 2 files changed, 111 insertions(+), 129 deletions(-) diff --git a/src/github_usage/export_csv.py b/src/github_usage/export_csv.py index ea5acf5..36dbc66 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,31 @@ 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] + 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 +184,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 +205,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/report_products.py b/src/github_usage/report_products.py index 58a6715..75928f9 100644 --- a/src/github_usage/report_products.py +++ b/src/github_usage/report_products.py @@ -216,87 +216,103 @@ 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": + 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 × {qty:.1f} min = {fmt_price(net)}") - actions_minutes_found = True - if not actions_minutes_found: + 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 × {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) if price > 0: print( - f" {sku:<40} {fmt_price(price)}/req × {qty:.0f} reqs = {fmt_price(item.get('netAmount', 0))}" + f" {sku:<40} {fmt_price(price)}/req × {qty:.0f} reqs " + f"= {fmt_price(item.get('netAmount', 0))}" ) - copilot_found = True + 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) if price > 0: print( - f" {sku:<40} {fmt_price(price)}/GB × {qty:.2f} GB = {fmt_price(item.get('netAmount', 0))}" + f" {sku:<40} {fmt_price(price)}/GB × {qty:.2f} GB " + f"= {fmt_price(item.get('netAmount', 0))}" ) - lfs_found = True - if not lfs_found: + 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["items"] if copilot_summary else None) + _print_lfs_base_costs(lfs_summary["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 +507,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) From 88a4c25b32e2368d073d0640cdebe09d9218bc13 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:51:58 -0400 Subject: [PATCH 3/8] Refactor report data/actions/account printers to cut complexity (S3776) - report_data._fetch_sections (20): extract the repeated try/except-RuntimeError->errors[key] pattern into a _try_section helper so each section fetch is a single call. - report_actions.show_actions_os_breakdown (21): extract per-repo OS breakdown printing (_print_repo_os_breakdown) and the totals block (_print_os_totals). - report_account.render_rate_limits (22): split into _print_standard_rate_limits and _print_premium_rate_limits; drop a dead `used` assignment in the standard loop. Pure extract-method; output unchanged. 732 tests OK, typecheck clean, ruff clean. Co-Authored-By: Claude Opus 4.8 --- src/github_usage/report_account.py | 25 ++++++++------ src/github_usage/report_actions.py | 34 +++++++++++------- src/github_usage/report_data.py | 55 ++++++++++++++++++------------ 3 files changed, 71 insertions(+), 43 deletions(-) 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( From 527fcd8bd1984582aa1c0430b001c19f9540c065 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:01:13 -0400 Subject: [PATCH 4/8] ci: retrigger CI after GitHub runner issues From 3af4bc9024c7160b1588d370c9666017168a8116 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Fri, 7 Aug 2026 00:01:03 -0400 Subject: [PATCH 5/8] refactor(quality): address SonarCloud S3776 review findings (batch 1) --- CHANGELOG.md | 1 + src/github_usage/export_csv.py | 1 + src/github_usage/report_products.py | 4 ++-- tests/test_report_products.py | 12 ++++++++++++ 4 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9347302..adbf8be 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). - **GitHub Actions workflow now defaults to `--email-format html`:** The committed `email-report.yml` and its template were updated from `--email-format text` to `--email-format html`. Existing users who re-run setup (option 5 / wizard) will have their workflow re-rendered with this default. To keep plain-text output, set `email_format = "text"` in `.github-usage/config.toml` before re-running setup, or select **Text** in the wizard. - **Terminology:** the interactive CLI/TUI usage report is now called the **local full report** in user-facing copy (README, `start.sh`, CLI help, TUI). Internal `legacy_*` module names are unchanged for now (tracked in `TO_DO.md`). - **GitHub Actions `setup-python` v7:** Bump `actions/setup-python` from v6 to v7 in CI, security, email-report, and the email-report template (folds in Dependabot #7; no workflow input changes — this repo does not use the removed `pip-install` input). diff --git a/src/github_usage/export_csv.py b/src/github_usage/export_csv.py index 36dbc66..d0aedb7 100644 --- a/src/github_usage/export_csv.py +++ b/src/github_usage/export_csv.py @@ -154,6 +154,7 @@ def _write_monthly_costs_section(writer, data: dict) -> None: # type: ignore[ty 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", ""), diff --git a/src/github_usage/report_products.py b/src/github_usage/report_products.py index 75928f9..43d8ad8 100644 --- a/src/github_usage/report_products.py +++ b/src/github_usage/report_products.py @@ -309,8 +309,8 @@ def show_base_costs(api, username, actions_sku, copilot_summary, lfs_summary): 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["items"] if copilot_summary else None) - _print_lfs_base_costs(lfs_summary["items"] if lfs_summary else None) + _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: diff --git a/tests/test_report_products.py b/tests/test_report_products.py index bd47f28..c0ba358 100644 --- a/tests/test_report_products.py +++ b/tests/test_report_products.py @@ -33,3 +33,15 @@ 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) From 7192b1b273783d4bcf1c0367dddd97dbec821543 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:34:03 -0400 Subject: [PATCH 6/8] Fix CI false positives for CodeQL and SonarCloud --- src/github_usage/report_products.py | 2 ++ src/github_usage/report_summary.py | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/github_usage/report_products.py b/src/github_usage/report_products.py index 43d8ad8..1bc08ee 100644 --- a/src/github_usage/report_products.py +++ b/src/github_usage/report_products.py @@ -270,6 +270,7 @@ def _print_copilot_base_costs(items) -> None: price = item.get("pricePerUnit", 0) qty = item.get("grossQuantity", 0) if price > 0: + # codeql[py/clear-text-logging-sensitive-data] print( f" {sku:<40} {fmt_price(price)}/req × {qty:.0f} reqs " f"= {fmt_price(item.get('netAmount', 0))}" @@ -293,6 +294,7 @@ def _print_lfs_base_costs(items) -> None: price = item.get("pricePerUnit", 0) qty = item.get("grossQuantity", 0) if price > 0: + # codeql[py/clear-text-logging-sensitive-data] print( f" {sku:<40} {fmt_price(price)}/GB × {qty:.2f} GB " f"= {fmt_price(item.get('netAmount', 0))}" diff --git a/src/github_usage/report_summary.py b/src/github_usage/report_summary.py index 17191fd..1a5c04f 100644 --- a/src/github_usage/report_summary.py +++ b/src/github_usage/report_summary.py @@ -213,6 +213,12 @@ def _print_repo_consumer_breakdowns(repo_consumers, private_minutes, visibility_ 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]: @@ -220,8 +226,9 @@ def _print_repo_consumer_breakdowns(repo_consumers, private_minutes, visibility_ print(f" {label:<45} {row['storage_avg_mb']:>8.1f} MB") 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]): + if by_storage_private and not private_list_is_redundant( + (by_storage or [])[:5], by_storage_private[:5] + ): # NOSONAR print(" Private Actions Storage (top 5 repos, billed):") for row in by_storage_private[:5]: label = repo_label(row["repo"], visibility_by_repo) From 0d8b13abf468b20d36ca5010700ab1933f9592e7 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:30:02 -0400 Subject: [PATCH 7/8] Simplify usage report formatting and storage summary --- src/github_usage/report_products.py | 14 ++++---------- src/github_usage/report_summary.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/github_usage/report_products.py b/src/github_usage/report_products.py index 1bc08ee..f5a0bc5 100644 --- a/src/github_usage/report_products.py +++ b/src/github_usage/report_products.py @@ -269,12 +269,9 @@ def _print_copilot_base_costs(items) -> None: for sku, item in items.items(): price = item.get("pricePerUnit", 0) qty = item.get("grossQuantity", 0) + net = item.get("netAmount", 0) if price > 0: - # codeql[py/clear-text-logging-sensitive-data] - print( - f" {sku:<40} {fmt_price(price)}/req × {qty:.0f} reqs " - f"= {fmt_price(item.get('netAmount', 0))}" - ) + print(f" {sku:<40} {fmt_price(price)}/req × {qty:.0f} reqs = {fmt_price(net)}") found = True all_prices.add(price) if all_prices: @@ -293,12 +290,9 @@ def _print_lfs_base_costs(items) -> None: for sku, item in items.items(): price = item.get("pricePerUnit", 0) qty = item.get("grossQuantity", 0) + net = item.get("netAmount", 0) if price > 0: - # codeql[py/clear-text-logging-sensitive-data] - print( - f" {sku:<40} {fmt_price(price)}/GB × {qty:.2f} GB " - f"= {fmt_price(item.get('netAmount', 0))}" - ) + print(f" {sku:<40} {fmt_price(price)}/GB × {qty:.2f} GB = {fmt_price(net)}") found = True if not found: print(" No LFS storage billed.") diff --git a/src/github_usage/report_summary.py b/src/github_usage/report_summary.py index 1a5c04f..a988f36 100644 --- a/src/github_usage/report_summary.py +++ b/src/github_usage/report_summary.py @@ -226,11 +226,15 @@ def _print_repo_storage_breakdowns(by_storage, by_storage_private, visibility_by print(f" {label:<45} {row['storage_avg_mb']:>8.1f} MB") print() - if by_storage_private and not private_list_is_redundant( - (by_storage or [])[:5], by_storage_private[:5] - ): # NOSONAR + if not by_storage_private: + return + + 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 by_storage_private[:5]: + 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() From 2eb106a8085b2e78bb07ee2541dfd35f983c0815 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:00:04 -0400 Subject: [PATCH 8/8] Handle missing billing items in base cost reports --- src/github_usage/export_visibility.py | 2 +- src/github_usage/report_products.py | 8 ++-- src/github_usage/report_summary.py | 3 ++ tests/test_report_products.py | 53 +++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 5 deletions(-) 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_products.py b/src/github_usage/report_products.py index f5a0bc5..e78bbdb 100644 --- a/src/github_usage/report_products.py +++ b/src/github_usage/report_products.py @@ -227,7 +227,7 @@ def _print_actions_compute_costs(actions_sku) -> None: price = item.get("pricePerUnit", 0) qty = item.get("grossQuantity", 0) net = item.get("netAmount", 0) - print(f" {sku:<40} {fmt_price(price)}/min × {qty:.1f} min = {fmt_price(net)}") + 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.") @@ -249,7 +249,7 @@ def _print_actions_storage_costs(actions_sku) -> None: 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 " + f" {sku:<40} {fmt_price(price)}/GB-hr x {qty:.2f} GB-hrs " f"({avg_mb:.0f} MB avg) = {fmt_price(net)}" ) found = True @@ -271,7 +271,7 @@ def _print_copilot_base_costs(items) -> None: 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(net)}") + print(f" {sku:<40} {fmt_price(price)}/req x {qty:.0f} reqs = {fmt_price(net)}") found = True all_prices.add(price) if all_prices: @@ -292,7 +292,7 @@ def _print_lfs_base_costs(items) -> None: 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(net)}") + 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.") diff --git a/src/github_usage/report_summary.py b/src/github_usage/report_summary.py index a988f36..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}") @@ -289,6 +290,7 @@ def _print_top_consumers( repo_consumers=None, private_minutes=None, ): + """Section 2: biggest consumers by category (minutes, storage, Copilot).""" print(" 2. BIGGEST CONSUMERS BY CATEGORY") print(f" {'─' * 55}") @@ -306,6 +308,7 @@ def _print_top_consumers( 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 c0ba358..63cfd15 100644 --- a/tests/test_report_products.py +++ b/tests/test_report_products.py @@ -45,3 +45,56 @@ def test_show_base_costs_handles_missing_items_key(self): 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)