From 23e7e3ea0b1e22938a5715156a1ac69bb95445a8 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:05:01 -0400 Subject: [PATCH 1/5] Add plan: fix email forecast visibility split --- ...-10-fix-email-forecast-visibility-split.md | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-fix-email-forecast-visibility-split.md diff --git a/docs/superpowers/plans/2026-08-10-fix-email-forecast-visibility-split.md b/docs/superpowers/plans/2026-08-10-fix-email-forecast-visibility-split.md new file mode 100644 index 0000000..fabee4d --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-fix-email-forecast-visibility-split.md @@ -0,0 +1,85 @@ +# Fix: Email report forecast should use private-only quota inputs + +> **Status:** IN PROGRESS + +## Problem + +The email report forecast projects total Actions minutes (private + public) against the 2,000-minute free-tier limit. Only private repos consume quota — public repos are free. The legacy terminal report handles this correctly; the email report does not. + +The consumers section already shows a private/public breakdown (computed independently from per-repo data in `get_repo_consumers()`), but the **account-level** visibility split is missing from `report["actions"]`. This affects: + +| Section | What it reads | Impact | +|---|---|---| +| Forecast scope note | `"public_minutes" in forecast` | No "(private repos — quota-counted)" label | +| Key Insight 1 | `actions["private_minutes_percent"]` | Insight silently dropped when >=100% | +| Warning threshold | Prefers `private_minutes_percent`, falls back to `minutes_percent` | Evaluates against total (private+public) | +| Text actions sub-line | `"public_minutes" in actions` | Private/public breakdown not shown | + +## Root Cause + +`report_data.build_report_data()` (the email path) fetches account-level billing via `get_actions_usage()` which returns combined private+public minutes, then never calls `attach_actions_visibility_split()` to reconcile the per-repo split into account-level private/public aggregates. + +The legacy path (`legacy_report_data.build_legacy_report_data()`) correctly calls `attach_actions_visibility_split()` at line 326, which adds `private_minutes`, `public_minutes`, `private_minutes_percent`, etc. to `report["actions"]`. + +## Files Involved + +- `src/github_usage/report_data.py` — `build_report_data()` (line 319) — **missing the split call** +- `src/github_usage/legacy_report_data.py` — `build_legacy_report_data()` (line 326) — **has the call (reference)** +- `src/github_usage/usage_split.py` — `attach_actions_visibility_split()` (line 282) — requires `repo_actions` list +- `src/github_usage/report_forecast_data.py` — `_private_quota_inputs()` (line 17) — fallback logic when split is absent +- `src/github_usage/report_data.py` — `get_key_insights()` (line 203) — insight 1 depends on `private_minutes_percent` +- `src/github_usage/report_data.py` — `get_warning_state()` (line 155) — threshold prefers `private_minutes_percent` +- `src/github_usage/email_report_html.py` — forecast rendering (line 345) — checks `"public_minutes" in forecast` +- `src/github_usage/email_report_text.py` — actions section (line 47) — checks `"public_minutes" in actions` +- `src/github_usage/report_optional.py` — `get_repo_consumers()` (line 25) — already fetches per-repo data; needs to expose raw rows +- `src/github_usage/report_actions.py` — `fetch_repo_actions_table()` (line 172) — returns `(rows, errors)` tuple, used by legacy path + +## Recommendation + +Add a call to `attach_actions_visibility_split()` in `build_report_data()` after `_fetch_sections()` returns and before `get_key_insights()` / `get_warning_state()`. The split needs per-repo Actions billing data (`repo_actions`) as its second argument. + +### Approach: hybrid — reuse consumers rows when available, fetch separately otherwise + +Two code paths feed the split, depending on whether consumers are enabled: + +1. **`include_consumers=True`** — `get_repo_consumers()` already calls `get_actions_per_repo()` for every repo (`report_optional.py:35`) and builds rows with `repo`, `minutes`, `storage_avg_mb`, and `visibility` — the same shape `attach_actions_visibility_split()` needs. Extend `get_repo_consumers()` to return its raw rows under a `_raw_rows` key (or a dedicated public key), then pass them directly to `attach_actions_visibility_split()`. This avoids duplicating every per-repo API call. + +2. **`include_consumers=False`** — `get_repo_consumers()` is not called, so no rows are available. Call `fetch_repo_actions_table(api, repos)` from `report_actions.py:172`, which returns `(rows, errors)` — the same function the legacy path uses. + +### Prerequisite: ensure `repos` is populated when `include_actions=True` + +Currently `build_report_data()` only fetches repos when `needs_repos = include_consumers or include_artifact_storage or include_release_assets` (line 338). When `include_actions=True` alone, `repos` is `[]` and the split has nothing to iterate. Fix: add `include_actions` to `needs_repos` so repos are always available when the split is needed. (The legacy path always fetches repos, so this is consistent.) + +### Recommended call-site ordering + +`build_report_data()` computes `report["insights"]` and `report["warnings"]` immediately after `_fetch_sections()` returns (lines 395-396), and both `get_key_insights()` (reads `private_minutes_percent`) and `get_warning_state()` (prefers `private_minutes_percent`, falls back to `minutes_percent`) consume the split keys. The `attach_actions_visibility_split()` call must therefore be inserted **after** `actions` is populated by `_fetch_sections()` and **before** the insights/warnings lines — i.e., between line 393 and line 395 in `report_data.py`. Placing it inside `_fetch_sections()` is acceptable too, but it must run before those two consumers read `report["actions"]`. Failing to honor this ordering silently leaves the bug in place even after "adding the call." + +### Scope note: `include_actions=False` + +`attach_actions_visibility_split()` already no-ops when `report["actions"]` is `None` (usage_split.py:296-297), so the call is safe to add unconditionally. However, the per-repo Actions fetch (path 2 above) should be skipped when `include_actions=False`, since the split has nothing to attach to. + +### API cost note + +When `include_consumers=True`, approach 1 reuses the consumers' existing per-repo calls — zero extra API cost. When `include_consumers=False`, approach 2 adds one API call per repo (same as the legacy path). For users with `max_repos=100`, this is up to 100 additional calls. This is acceptable and consistent with the legacy path's behavior. + +## Tasks + +- [ ] Expand `needs_repos` in `build_report_data()` to include `include_actions` so repos are always fetched when the split is needed +- [ ] Extend `get_repo_consumers()` in `report_optional.py` to return raw per-repo rows (e.g. `_raw_rows` key) for reuse by the split +- [ ] Add `attach_actions_visibility_split()` call to `build_report_data()` in `report_data.py`, using consumers raw rows when available, falling back to `fetch_repo_actions_table()` when not +- [ ] Verify `get_key_insights()` and `get_warning_state()` need no changes (they already handle the fallback correctly — the fix just ensures the split keys are present) +- [ ] Add test: email report path with `include_consumers=True` reuses consumer rows for the split (no duplicate API calls) +- [ ] Add test: email report path with `include_actions=True` and `include_consumers=False` uses `fetch_repo_actions_table()` for the split +- [ ] Add test: `include_actions=False` skips the per-repo fetch entirely +- [ ] Run `scripts/check` and `scripts/smoke` +- [ ] Update CHANGELOG.md + +## Verification + +1. Generate an email report and confirm the forecast section shows "(private repos — quota-counted)" label +2. Confirm Key Insight 1 appears when private minutes >= 100% +3. Confirm the text actions section shows the private/public sub-line +4. Confirm the warning threshold evaluates against private-only minutes +5. Confirm the consumers section still works correctly (it should be unaffected) +6. Confirm the email path with `include_actions=True` and `include_consumers=False` shows the split (exercises the `fetch_repo_actions_table()` fallback path) +7. Confirm no duplicate per-repo API calls when `include_consumers=True` (the consumers' existing calls are reused for the split) From 9ea3ba50a4e22d471788ac1d2aaa584250693393 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:24:20 -0400 Subject: [PATCH 2/5] Add TO_DO item: raise coverage to 80% --- TO_DO.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/TO_DO.md b/TO_DO.md index bd4e946..36d6817 100644 --- a/TO_DO.md +++ b/TO_DO.md @@ -29,6 +29,8 @@ - [ ] Refactor `src/github_usage/setup_config.py` (507 lines, over the 500-line limit per `scripts/check-sizes`). Extract a focused submodule — e.g. profile schema/loading (`load_report_profiles`, `find_profile`, `ensure_profiles`, `_default_profile`) and/or the TOML writer helpers (`_emit_*_block`, `write_config`) — to bring the file back under the threshold. Also watch `setup_wizard.py` (461 lines) and the `_manage_profiles()`/`_run_email_report()` functions, which are approaching their limits. - [ ] Rename internal `legacy_*` modules/symbols to “local full report” naming (`legacy_report_data` → e.g. `local_report_data`, cache `kind="legacy"`, CLI/TUI internals, tests). User-facing copy already says “local full report”; this is the code rename. Keep a thin `legacy` compatibility shim if external imports still need it. +- [ ] Add tests to raise overall src coverage from the current ~76% to 80%. Update `scripts/coverage-check` default from `COVERAGE_TOTAL_MIN=75` to `80` once achieved. + ## Configuration & Setup - [ ] Write Windows-compatible PowerShell versions of all scripts (setup, check, smoke, docs-check, etc.). From d4bf6026e51da9768e374183da07bdedcaefbafc Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:42:31 -0400 Subject: [PATCH 3/5] Fix email report forecast visibility split --- CHANGELOG.md | 1 + ...-10-fix-email-forecast-visibility-split.md | 22 +- src/github_usage/report_data.py | 21 +- src/github_usage/report_optional.py | 3 + tests/test_report_data.py | 411 ++++++++++++++++++ 5 files changed, 446 insertions(+), 12 deletions(-) rename docs/superpowers/plans/{ => archived}/2026-08-10-fix-email-forecast-visibility-split.md (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 87302b3..9449fdf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ This project follows the structure from Keep a Changelog and intends to use Sema ### Fixed +- **Email report forecast visibility split** ([plan](docs/superpowers/plans/archived/2026-08-10-fix-email-forecast-visibility-split.md)): `build_report_data()` now calls `attach_actions_visibility_split()` so the email report's Actions forecast, key insights, warning threshold, and text/HTML actions sub-line all use private-only minutes against the free-tier limit. When `include_consumers=True`, the consumers' existing per-repo rows are reused (zero extra API calls); when `include_consumers=False`, `fetch_repo_actions_table()` provides the per-repo data. `needs_repos` now includes `include_actions` so repos are always available for the split. - **SonarCloud reliability (S6466):** `get_key_insights` in `report_data.py` adds an explicit `None`-guard (`or []`) on `by_minutes` before indexing, satisfying SonarCloud's S6466 check; new test covers `by_minutes: None`. - **CodeRabbit follow-ups (PR #10):** Soft-fail `workflow_breakdown` fetch on `RuntimeError` (email + legacy paths) so partial reports still render; skip redundant private concentration recommendations when private top-2 matches overall top-2; share `repo_label` / `WORKFLOW_MINUTES_REQUEST_HEADROOM` / public `parse_iso_datetime`; narrow workflow-name-map soft-fail to `RuntimeError`; normalize non-UTC ISO offsets to UTC in `parse_iso_datetime` so expiry/retention day math stays calendar-stable. - **SonarCloud quality gate (PR #10):** Safer list indexing for private consumer findings and workflow breakdown (`S6466`); reduced cognitive complexity in `_format_consumers_section` and `_repo_rows`; deduplicated HTML `` literals; consolidated repeated consumer test fixtures into `tests/_consumer_fixtures.py`. diff --git a/docs/superpowers/plans/2026-08-10-fix-email-forecast-visibility-split.md b/docs/superpowers/plans/archived/2026-08-10-fix-email-forecast-visibility-split.md similarity index 91% rename from docs/superpowers/plans/2026-08-10-fix-email-forecast-visibility-split.md rename to docs/superpowers/plans/archived/2026-08-10-fix-email-forecast-visibility-split.md index fabee4d..8ad3423 100644 --- a/docs/superpowers/plans/2026-08-10-fix-email-forecast-visibility-split.md +++ b/docs/superpowers/plans/archived/2026-08-10-fix-email-forecast-visibility-split.md @@ -1,6 +1,6 @@ # Fix: Email report forecast should use private-only quota inputs -> **Status:** IN PROGRESS +> **Status:** COMPLETE ## Problem @@ -64,15 +64,17 @@ When `include_consumers=True`, approach 1 reuses the consumers' existing per-rep ## Tasks -- [ ] Expand `needs_repos` in `build_report_data()` to include `include_actions` so repos are always fetched when the split is needed -- [ ] Extend `get_repo_consumers()` in `report_optional.py` to return raw per-repo rows (e.g. `_raw_rows` key) for reuse by the split -- [ ] Add `attach_actions_visibility_split()` call to `build_report_data()` in `report_data.py`, using consumers raw rows when available, falling back to `fetch_repo_actions_table()` when not -- [ ] Verify `get_key_insights()` and `get_warning_state()` need no changes (they already handle the fallback correctly — the fix just ensures the split keys are present) -- [ ] Add test: email report path with `include_consumers=True` reuses consumer rows for the split (no duplicate API calls) -- [ ] Add test: email report path with `include_actions=True` and `include_consumers=False` uses `fetch_repo_actions_table()` for the split -- [ ] Add test: `include_actions=False` skips the per-repo fetch entirely -- [ ] Run `scripts/check` and `scripts/smoke` -- [ ] Update CHANGELOG.md +- [x] Expand `needs_repos` in `build_report_data()` to include `include_actions` so repos are always fetched when the split is needed +- [x] Extend `get_repo_consumers()` in `report_optional.py` to return raw per-repo rows (e.g. `_raw_rows` key) for reuse by the split +- [x] Add `attach_actions_visibility_split()` call to `build_report_data()` in `report_data.py`, using consumers raw rows when available, falling back to `fetch_repo_actions_table()` when not +- [x] Verify `get_key_insights()` and `get_warning_state()` need no changes (they already handle the fallback correctly — the fix just ensures the split keys are present) +- [x] Add test: email report path with `include_consumers=True` reuses consumer rows for the split (no duplicate API calls) +- [x] Add test: email report path with `include_actions=True` and `include_consumers=False` uses `fetch_repo_actions_table()` for the split +- [x] Add test: `include_actions=False` skips the per-repo fetch entirely +- [x] Run `scripts/check` and `scripts/smoke` +- [x] Update CHANGELOG.md + +**Done:** 2026-08-10 — All tasks implemented and verified. `scripts/check` and `scripts/smoke` pass. ## Verification diff --git a/src/github_usage/report_data.py b/src/github_usage/report_data.py index 527c76a..f5fc25a 100644 --- a/src/github_usage/report_data.py +++ b/src/github_usage/report_data.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime from typing import Protocol +from .report_actions import fetch_repo_actions_table from .report_helpers import fmt_price, gb_hours_to_avg_mb, sanitize_item_amounts from .report_optional import ( estimate_api_request_count, @@ -13,7 +14,7 @@ get_repo_consumers, ) from .report_workflow_minutes import workflow_breakdown_for_top_private -from .usage_split import REPORT_SOURCES +from .usage_split import REPORT_SOURCES, attach_actions_visibility_split from .visibility import filter_repos_by_visibility, repo_visibility, visibility_label @@ -335,7 +336,9 @@ def build_report_data( errors = {} repos: list = [] truncated = False - needs_repos = include_consumers or include_artifact_storage or include_release_assets + needs_repos = ( + include_actions or 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( @@ -392,6 +395,20 @@ def build_report_data( runs_cache=runs_cache, ) + if include_actions and report.get("actions") is not None: + consumers_rows = None + if include_consumers and report.get("repo_consumers"): + consumers_rows = report["repo_consumers"].get("_raw_rows") + if consumers_rows is not None: + attach_actions_visibility_split( + report, consumers_rows, only_public=only_public, only_private=only_private + ) + else: + rows, _errors = fetch_repo_actions_table(api, repos) + attach_actions_visibility_split( + report, rows, only_public=only_public, only_private=only_private + ) + report["insights"] = get_key_insights(report) report["warnings"] = get_warning_state(report, warn_over) report["sources"] = dict(REPORT_SOURCES) diff --git a/src/github_usage/report_optional.py b/src/github_usage/report_optional.py index 59cbfe2..9bb35ff 100644 --- a/src/github_usage/report_optional.py +++ b/src/github_usage/report_optional.py @@ -53,6 +53,9 @@ def get_repo_consumers(api, repos: list[dict], limit: int = 5, max_repos: int = **rankings, "errors": errors, "by_visibility": split_rows_by_visibility(rows, storage_key="storage_avg_mb", sku_key=None), + # Used by report_data.build_report_data() to pass per-repo rows to + # attach_actions_visibility_split() without duplicate API calls. + "_raw_rows": rows, } diff --git a/tests/test_report_data.py b/tests/test_report_data.py index f37a100..a01aa48 100644 --- a/tests/test_report_data.py +++ b/tests/test_report_data.py @@ -1,4 +1,5 @@ import unittest +from unittest import mock from tests._fakes import FakeAPI @@ -300,3 +301,413 @@ def test_rate_limit_handles_non_dict_response(self): ) self.assertEqual(_rate_limit(api), (None, None)) + + def test_consumers_enabled_reuses_rows_for_visibility_split(self): + from github_usage.report_data import build_report_data + + repos = [ + { + "full_name": "octocat/private-repo", + "owner": {"login": "octocat"}, + "name": "private-repo", + "visibility": "private", + }, + { + "full_name": "octocat/public-repo", + "owner": {"login": "octocat"}, + "name": "public-repo", + "visibility": "public", + }, + ] + api = FakeAPI( + request_responses={ + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Actions"),), + ): { + "usageItems": [ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 1500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + }, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/premium_request/usage", + (("product", "copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "git_lfs"),), + ): {"usageItems": []}, + ("GET", "/rate_limit", ()): { + "resources": {"core": {"limit": 5000, "remaining": 4900}} + }, + }, + pages_responses={"/user/repos": repos}, + ) + with ( + mock.patch( + "github_usage.report_optional.get_actions_per_repo", + side_effect=[ + (1000.0, 0.0, {"sku": {"grossAmount": 0.0}}), + (500.0, 0.0, {"sku": {"grossAmount": 0.0}}), + ], + ), + mock.patch( + "github_usage.report_data.fetch_repo_actions_table", + ) as mock_fetch, + ): + report = build_report_data( + api, + "octocat", + include_actions=True, + include_copilot=False, + include_lfs=False, + include_consumers=True, + include_artifact_storage=False, + include_release_assets=False, + max_repos=100, + warn_over=None, + ) + actions = report["actions"] + self.assertEqual(actions["private_minutes"], 1000.0) + self.assertEqual(actions["public_minutes"], 500.0) + mock_fetch.assert_not_called() + + def test_actions_only_uses_fetch_repo_actions_table(self): + from github_usage.report_data import build_report_data + + repos = [ + { + "full_name": "octocat/private-repo", + "owner": {"login": "octocat"}, + "name": "private-repo", + "visibility": "private", + }, + { + "full_name": "octocat/public-repo", + "owner": {"login": "octocat"}, + "name": "public-repo", + "visibility": "public", + }, + ] + api = FakeAPI( + request_responses={ + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Actions"),), + ): { + "usageItems": [ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 1500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + }, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/premium_request/usage", + (("product", "copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "git_lfs"),), + ): {"usageItems": []}, + ("GET", "/rate_limit", ()): { + "resources": {"core": {"limit": 5000, "remaining": 4900}} + }, + }, + pages_responses={"/user/repos": repos}, + ) + fake_rows = [ + { + "repo": "octocat/private-repo", + "minutes": 1000.0, + "storage_gb_hours": 0.0, + "avg_mb": 0.0, + "gross": 0.0, + "sku": {}, + "visibility": "private", + }, + { + "repo": "octocat/public-repo", + "minutes": 500.0, + "storage_gb_hours": 0.0, + "avg_mb": 0.0, + "gross": 0.0, + "sku": {}, + "visibility": "public", + }, + ] + with mock.patch( + "github_usage.report_data.fetch_repo_actions_table", + return_value=(fake_rows, {}), + ) as mock_fetch: + report = build_report_data( + api, + "octocat", + include_actions=True, + include_copilot=False, + include_lfs=False, + include_consumers=False, + include_artifact_storage=False, + include_release_assets=False, + max_repos=100, + warn_over=None, + ) + actions = report["actions"] + self.assertEqual(actions["private_minutes"], 1000.0) + self.assertEqual(actions["public_minutes"], 500.0) + mock_fetch.assert_called_once_with(api, repos) + + def test_actions_disabled_skips_per_repo_fetch(self): + from github_usage.report_data import build_report_data + + api = FakeAPI( + request_responses={ + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/premium_request/usage", + (("product", "copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "git_lfs"),), + ): {"usageItems": []}, + ("GET", "/rate_limit", ()): { + "resources": {"core": {"limit": 5000, "remaining": 4900}} + }, + }, + ) + with mock.patch( + "github_usage.report_data.fetch_repo_actions_table", + ) as mock_fetch: + report = build_report_data( + api, + "octocat", + include_actions=False, + include_copilot=False, + include_lfs=False, + include_consumers=False, + include_artifact_storage=False, + include_release_assets=False, + max_repos=100, + warn_over=None, + ) + self.assertIsNone(report["actions"]) + mock_fetch.assert_not_called() + + def test_consumers_error_falls_back_to_fetch_repo_actions_table(self): + from github_usage.report_data import build_report_data + + repos = [ + { + "full_name": "octocat/private-repo", + "owner": {"login": "octocat"}, + "name": "private-repo", + "visibility": "private", + }, + { + "full_name": "octocat/public-repo", + "owner": {"login": "octocat"}, + "name": "public-repo", + "visibility": "public", + }, + ] + api = FakeAPI( + request_responses={ + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Actions"),), + ): { + "usageItems": [ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 1500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + }, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/premium_request/usage", + (("product", "copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "git_lfs"),), + ): {"usageItems": []}, + ("GET", "/rate_limit", ()): { + "resources": {"core": {"limit": 5000, "remaining": 4900}} + }, + }, + pages_responses={"/user/repos": repos}, + ) + fake_rows = [ + { + "repo": "octocat/private-repo", + "minutes": 1000.0, + "storage_gb_hours": 0.0, + "avg_mb": 0.0, + "gross": 0.0, + "sku": {}, + "visibility": "private", + }, + { + "repo": "octocat/public-repo", + "minutes": 500.0, + "storage_gb_hours": 0.0, + "avg_mb": 0.0, + "gross": 0.0, + "sku": {}, + "visibility": "public", + }, + ] + with ( + mock.patch( + "github_usage.report_data.get_repo_consumers", + side_effect=RuntimeError("consumers API failure"), + ), + mock.patch( + "github_usage.report_data.fetch_repo_actions_table", + return_value=(fake_rows, {}), + ) as mock_fetch, + ): + report = build_report_data( + api, + "octocat", + include_actions=True, + include_copilot=False, + include_lfs=False, + include_consumers=True, + include_artifact_storage=False, + include_release_assets=False, + max_repos=100, + warn_over=None, + ) + actions = report["actions"] + self.assertEqual(actions["private_minutes"], 1000.0) + self.assertEqual(actions["public_minutes"], 500.0) + mock_fetch.assert_called_once_with(api, repos) + + def test_only_public_passes_through_to_visibility_split(self): + from github_usage.report_data import build_report_data + + repos = [ + { + "full_name": "octocat/public-repo", + "owner": {"login": "octocat"}, + "name": "public-repo", + "visibility": "public", + }, + ] + api = FakeAPI( + request_responses={ + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Actions"),), + ): { + "usageItems": [ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + }, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "Copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/premium_request/usage", + (("product", "copilot"),), + ): {"usageItems": []}, + ( + "GET", + "/users/octocat/settings/billing/usage/summary", + (("product", "git_lfs"),), + ): {"usageItems": []}, + ("GET", "/rate_limit", ()): { + "resources": {"core": {"limit": 5000, "remaining": 4900}} + }, + }, + pages_responses={"/user/repos": repos}, + ) + fake_rows = [ + { + "repo": "octocat/public-repo", + "minutes": 500.0, + "storage_gb_hours": 0.0, + "avg_mb": 0.0, + "gross": 0.0, + "sku": {}, + "visibility": "public", + }, + ] + with mock.patch( + "github_usage.report_data.fetch_repo_actions_table", + return_value=(fake_rows, {}), + ): + report = build_report_data( + api, + "octocat", + include_actions=True, + include_copilot=False, + include_lfs=False, + include_consumers=False, + include_artifact_storage=False, + include_release_assets=False, + max_repos=100, + warn_over=None, + only_public=True, + ) + self.assertTrue(report["actions"]["filtered"]) + self.assertEqual(report["actions"]["public_minutes"], 500.0) + self.assertEqual(report["actions"]["private_minutes"], 0.0) From cf35ce7461bd16467c8e5f71fb21b9e2e1408d5e Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:55:42 -0400 Subject: [PATCH 4/5] Add credential security note and refactor test fixtures --- AGENTS.md | 1 + tests/test_report_data.py | 491 ++++++++++++-------------------------- 2 files changed, 157 insertions(+), 335 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 16579ae..c3e55f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,7 @@ - Use `./start.sh` as the primary entry point for setup, one-off reports, and email-report configuration. - The project uses `pyproject.toml` for all dependency declarations. Do not create a `requirements.txt` unless a specific tool requires it. - Do not print, commit, or store real GitHub tokens, raw private API responses, or generated billing reports. +- **NEVER read or cat** `.env.email-report` or `.sonar_cloud_token` — these files contain sensitive credentials. - Tests should use fake tokens, mocks, and fixtures rather than live GitHub API calls. - Optional live checks must be gated behind an explicit environment variable. diff --git a/tests/test_report_data.py b/tests/test_report_data.py index a01aa48..6159387 100644 --- a/tests/test_report_data.py +++ b/tests/test_report_data.py @@ -3,6 +3,102 @@ from tests._fakes import FakeAPI +_PRIVATE_REPO = { + "full_name": "octocat/private-repo", + "owner": {"login": "octocat"}, + "name": "private-repo", + "visibility": "private", +} + +_PUBLIC_REPO = { + "full_name": "octocat/public-repo", + "owner": {"login": "octocat"}, + "name": "public-repo", + "visibility": "public", +} + +_TWO_REPOS = (_PRIVATE_REPO, _PUBLIC_REPO) + +_PRIVATE_ROW = { + "repo": "octocat/private-repo", + "minutes": 1000.0, + "storage_gb_hours": 0.0, + "avg_mb": 0.0, + "gross": 0.0, + "sku": {}, + "visibility": "private", +} + +_PUBLIC_ROW = { + "repo": "octocat/public-repo", + "minutes": 500.0, + "storage_gb_hours": 0.0, + "avg_mb": 0.0, + "gross": 0.0, + "sku": {}, + "visibility": "public", +} + +_DEFAULT_FAKE_ROWS = (_PRIVATE_ROW, _PUBLIC_ROW) + +_DEFAULT_KWARGS = { + "include_actions": True, + "include_copilot": False, + "include_lfs": False, + "include_consumers": False, + "include_artifact_storage": False, + "include_release_assets": False, + "max_repos": 100, + "warn_over": None, +} + + +def _make_billing_api( + actions_items=None, + copilot_items=None, + premium_items=None, + lfs_items=None, + rate_limit=None, + pages=None, + omit=(), +): + """Return a FakeAPI pre-loaded with standard billing endpoints. + + Pass ``omit=("actions",)`` to exclude the Actions endpoint. + """ + responses = {} + default_rate = {"resources": {"core": {"limit": 5000, "remaining": 4900}}} + + if "actions" not in omit: + responses[ + ("GET", "/users/octocat/settings/billing/usage/summary", (("product", "Actions"),)) + ] = {"usageItems": actions_items or []} + + if "copilot" not in omit: + responses[ + ("GET", "/users/octocat/settings/billing/usage/summary", (("product", "Copilot"),)) + ] = {"usageItems": copilot_items or []} + responses[ + ( + "GET", + "/users/octocat/settings/billing/premium_request/usage", + (("product", "copilot"),), + ) + ] = {"usageItems": premium_items or []} + + if "lfs" not in omit: + responses[ + ("GET", "/users/octocat/settings/billing/usage/summary", (("product", "git_lfs"),)) + ] = {"usageItems": lfs_items or []} + + if "rate_limit" not in omit: + responses[("GET", "/rate_limit", ())] = rate_limit or default_rate + + return FakeAPI( + request_responses=responses, + pages_responses=pages or {}, + ) + class ReportDataTests(unittest.TestCase): def test_build_report_data_captures_partial_endpoint_failure(self): @@ -305,58 +401,18 @@ def test_rate_limit_handles_non_dict_response(self): def test_consumers_enabled_reuses_rows_for_visibility_split(self): from github_usage.report_data import build_report_data - repos = [ - { - "full_name": "octocat/private-repo", - "owner": {"login": "octocat"}, - "name": "private-repo", - "visibility": "private", - }, - { - "full_name": "octocat/public-repo", - "owner": {"login": "octocat"}, - "name": "public-repo", - "visibility": "public", - }, - ] - api = FakeAPI( - request_responses={ - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Actions"),), - ): { - "usageItems": [ - { - "sku": "actions_linux", - "unitType": "minutes", - "grossQuantity": 1500.0, - "grossAmount": 0.0, - "discountAmount": 0.0, - "netAmount": 0.0, - } - ], - }, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/premium_request/usage", - (("product", "copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "git_lfs"),), - ): {"usageItems": []}, - ("GET", "/rate_limit", ()): { - "resources": {"core": {"limit": 5000, "remaining": 4900}} - }, - }, - pages_responses={"/user/repos": repos}, + api = _make_billing_api( + actions_items=[ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 1500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + pages={"/user/repos": _TWO_REPOS}, ) with ( mock.patch( @@ -371,16 +427,7 @@ def test_consumers_enabled_reuses_rows_for_visibility_split(self): ) as mock_fetch, ): report = build_report_data( - api, - "octocat", - include_actions=True, - include_copilot=False, - include_lfs=False, - include_consumers=True, - include_artifact_storage=False, - include_release_assets=False, - max_repos=100, - warn_over=None, + api, "octocat", **{**_DEFAULT_KWARGS, "include_consumers": True} ) actions = report["actions"] self.assertEqual(actions["private_minutes"], 1000.0) @@ -390,139 +437,38 @@ def test_consumers_enabled_reuses_rows_for_visibility_split(self): def test_actions_only_uses_fetch_repo_actions_table(self): from github_usage.report_data import build_report_data - repos = [ - { - "full_name": "octocat/private-repo", - "owner": {"login": "octocat"}, - "name": "private-repo", - "visibility": "private", - }, - { - "full_name": "octocat/public-repo", - "owner": {"login": "octocat"}, - "name": "public-repo", - "visibility": "public", - }, - ] - api = FakeAPI( - request_responses={ - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Actions"),), - ): { - "usageItems": [ - { - "sku": "actions_linux", - "unitType": "minutes", - "grossQuantity": 1500.0, - "grossAmount": 0.0, - "discountAmount": 0.0, - "netAmount": 0.0, - } - ], - }, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/premium_request/usage", - (("product", "copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "git_lfs"),), - ): {"usageItems": []}, - ("GET", "/rate_limit", ()): { - "resources": {"core": {"limit": 5000, "remaining": 4900}} - }, - }, - pages_responses={"/user/repos": repos}, + api = _make_billing_api( + actions_items=[ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 1500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + pages={"/user/repos": _TWO_REPOS}, ) - fake_rows = [ - { - "repo": "octocat/private-repo", - "minutes": 1000.0, - "storage_gb_hours": 0.0, - "avg_mb": 0.0, - "gross": 0.0, - "sku": {}, - "visibility": "private", - }, - { - "repo": "octocat/public-repo", - "minutes": 500.0, - "storage_gb_hours": 0.0, - "avg_mb": 0.0, - "gross": 0.0, - "sku": {}, - "visibility": "public", - }, - ] with mock.patch( "github_usage.report_data.fetch_repo_actions_table", - return_value=(fake_rows, {}), + return_value=(list(_DEFAULT_FAKE_ROWS), {}), ) as mock_fetch: - report = build_report_data( - api, - "octocat", - include_actions=True, - include_copilot=False, - include_lfs=False, - include_consumers=False, - include_artifact_storage=False, - include_release_assets=False, - max_repos=100, - warn_over=None, - ) + report = build_report_data(api, "octocat", **_DEFAULT_KWARGS) actions = report["actions"] self.assertEqual(actions["private_minutes"], 1000.0) self.assertEqual(actions["public_minutes"], 500.0) - mock_fetch.assert_called_once_with(api, repos) + mock_fetch.assert_called_once_with(api, _TWO_REPOS) def test_actions_disabled_skips_per_repo_fetch(self): from github_usage.report_data import build_report_data - api = FakeAPI( - request_responses={ - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/premium_request/usage", - (("product", "copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "git_lfs"),), - ): {"usageItems": []}, - ("GET", "/rate_limit", ()): { - "resources": {"core": {"limit": 5000, "remaining": 4900}} - }, - }, - ) + api = _make_billing_api(omit=("actions",)) with mock.patch( "github_usage.report_data.fetch_repo_actions_table", ) as mock_fetch: report = build_report_data( - api, - "octocat", - include_actions=False, - include_copilot=False, - include_lfs=False, - include_consumers=False, - include_artifact_storage=False, - include_release_assets=False, - max_repos=100, - warn_over=None, + api, "octocat", **{**_DEFAULT_KWARGS, "include_actions": False} ) self.assertIsNone(report["actions"]) mock_fetch.assert_not_called() @@ -530,79 +476,19 @@ def test_actions_disabled_skips_per_repo_fetch(self): def test_consumers_error_falls_back_to_fetch_repo_actions_table(self): from github_usage.report_data import build_report_data - repos = [ - { - "full_name": "octocat/private-repo", - "owner": {"login": "octocat"}, - "name": "private-repo", - "visibility": "private", - }, - { - "full_name": "octocat/public-repo", - "owner": {"login": "octocat"}, - "name": "public-repo", - "visibility": "public", - }, - ] - api = FakeAPI( - request_responses={ - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Actions"),), - ): { - "usageItems": [ - { - "sku": "actions_linux", - "unitType": "minutes", - "grossQuantity": 1500.0, - "grossAmount": 0.0, - "discountAmount": 0.0, - "netAmount": 0.0, - } - ], - }, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/premium_request/usage", - (("product", "copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "git_lfs"),), - ): {"usageItems": []}, - ("GET", "/rate_limit", ()): { - "resources": {"core": {"limit": 5000, "remaining": 4900}} - }, - }, - pages_responses={"/user/repos": repos}, + api = _make_billing_api( + actions_items=[ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 1500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + pages={"/user/repos": _TWO_REPOS}, ) - fake_rows = [ - { - "repo": "octocat/private-repo", - "minutes": 1000.0, - "storage_gb_hours": 0.0, - "avg_mb": 0.0, - "gross": 0.0, - "sku": {}, - "visibility": "private", - }, - { - "repo": "octocat/public-repo", - "minutes": 500.0, - "storage_gb_hours": 0.0, - "avg_mb": 0.0, - "gross": 0.0, - "sku": {}, - "visibility": "public", - }, - ] with ( mock.patch( "github_usage.report_data.get_repo_consumers", @@ -610,104 +496,39 @@ def test_consumers_error_falls_back_to_fetch_repo_actions_table(self): ), mock.patch( "github_usage.report_data.fetch_repo_actions_table", - return_value=(fake_rows, {}), + return_value=(list(_DEFAULT_FAKE_ROWS), {}), ) as mock_fetch, ): report = build_report_data( - api, - "octocat", - include_actions=True, - include_copilot=False, - include_lfs=False, - include_consumers=True, - include_artifact_storage=False, - include_release_assets=False, - max_repos=100, - warn_over=None, + api, "octocat", **{**_DEFAULT_KWARGS, "include_consumers": True} ) actions = report["actions"] self.assertEqual(actions["private_minutes"], 1000.0) self.assertEqual(actions["public_minutes"], 500.0) - mock_fetch.assert_called_once_with(api, repos) + mock_fetch.assert_called_once_with(api, _TWO_REPOS) def test_only_public_passes_through_to_visibility_split(self): from github_usage.report_data import build_report_data - repos = [ - { - "full_name": "octocat/public-repo", - "owner": {"login": "octocat"}, - "name": "public-repo", - "visibility": "public", - }, - ] - api = FakeAPI( - request_responses={ - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Actions"),), - ): { - "usageItems": [ - { - "sku": "actions_linux", - "unitType": "minutes", - "grossQuantity": 500.0, - "grossAmount": 0.0, - "discountAmount": 0.0, - "netAmount": 0.0, - } - ], - }, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "Copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/premium_request/usage", - (("product", "copilot"),), - ): {"usageItems": []}, - ( - "GET", - "/users/octocat/settings/billing/usage/summary", - (("product", "git_lfs"),), - ): {"usageItems": []}, - ("GET", "/rate_limit", ()): { - "resources": {"core": {"limit": 5000, "remaining": 4900}} - }, - }, - pages_responses={"/user/repos": repos}, + api = _make_billing_api( + actions_items=[ + { + "sku": "actions_linux", + "unitType": "minutes", + "grossQuantity": 500.0, + "grossAmount": 0.0, + "discountAmount": 0.0, + "netAmount": 0.0, + } + ], + pages={"/user/repos": (_PUBLIC_REPO,)}, ) - fake_rows = [ - { - "repo": "octocat/public-repo", - "minutes": 500.0, - "storage_gb_hours": 0.0, - "avg_mb": 0.0, - "gross": 0.0, - "sku": {}, - "visibility": "public", - }, - ] + public_row = {**_PUBLIC_ROW, "minutes": 500.0} with mock.patch( "github_usage.report_data.fetch_repo_actions_table", - return_value=(fake_rows, {}), + return_value=([public_row], {}), ): - report = build_report_data( - api, - "octocat", - include_actions=True, - include_copilot=False, - include_lfs=False, - include_consumers=False, - include_artifact_storage=False, - include_release_assets=False, - max_repos=100, - warn_over=None, - only_public=True, - ) + report = build_report_data(api, "octocat", **{**_DEFAULT_KWARGS, "only_public": True}) self.assertTrue(report["actions"]["filtered"]) self.assertEqual(report["actions"]["public_minutes"], 500.0) self.assertEqual(report["actions"]["private_minutes"], 0.0) From 1276a2bad2af96a6a8e84310004e29cd5a4b6d49 Mon Sep 17 00:00:00 2001 From: kgrizz-git <216068303+kgrizz-git@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:22:29 -0400 Subject: [PATCH 5/5] Propagate actions fallback fetch errors into report --- src/github_usage/report_data.py | 4 ++- src/github_usage/report_optional.py | 7 ++++- tests/test_report_data.py | 44 +++++++++++++++++++++++++++-- tests/test_workflow_minutes.py | 2 ++ 4 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/github_usage/report_data.py b/src/github_usage/report_data.py index f5fc25a..1dc8447 100644 --- a/src/github_usage/report_data.py +++ b/src/github_usage/report_data.py @@ -347,6 +347,7 @@ def build_report_data( core_limit, core_remaining = _rate_limit(api) api_estimate = estimate_api_request_count( repo_count=len(repos) + (1 if truncated else 0), + include_actions=include_actions, include_consumers=include_consumers, include_artifact_storage=include_artifact_storage, include_release_assets=include_release_assets, @@ -404,7 +405,8 @@ def build_report_data( report, consumers_rows, only_public=only_public, only_private=only_private ) else: - rows, _errors = fetch_repo_actions_table(api, repos) + rows, fetch_errors = fetch_repo_actions_table(api, repos) + errors.update(fetch_errors) attach_actions_visibility_split( report, rows, only_public=only_public, only_private=only_private ) diff --git a/src/github_usage/report_optional.py b/src/github_usage/report_optional.py index 9bb35ff..c692ee3 100644 --- a/src/github_usage/report_optional.py +++ b/src/github_usage/report_optional.py @@ -42,6 +42,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)), + "storage_gb_hours": float(storage_gb_hours), "visibility": repo_visibility(repo), } ) @@ -123,6 +124,7 @@ def get_release_asset_details(api, repos: list[dict], max_repos: int = 100) -> d def estimate_api_request_count( repo_count: int, + include_actions: bool, include_consumers: bool, include_artifact_storage: bool, include_release_assets: bool, @@ -132,7 +134,10 @@ def estimate_api_request_count( ) -> dict: """Estimate the number of additional API requests optional sections will make.""" repos_considered = min(repo_count, max_repos) - per_repo_options = sum([include_consumers, include_artifact_storage, include_release_assets]) + fallback_actions = include_actions and not include_consumers + per_repo_options = sum( + [include_consumers, include_artifact_storage, include_release_assets, fallback_actions] + ) estimated = repos_considered * per_repo_options if include_consumers: estimated += WORKFLOW_MINUTES_REQUEST_HEADROOM diff --git a/tests/test_report_data.py b/tests/test_report_data.py index 6159387..c9c7f3c 100644 --- a/tests/test_report_data.py +++ b/tests/test_report_data.py @@ -155,6 +155,7 @@ def test_estimate_api_request_count_respects_max_repos_and_options(self): estimate = estimate_api_request_count( repo_count=250, + include_actions=False, include_consumers=True, include_artifact_storage=True, include_release_assets=True, @@ -167,6 +168,32 @@ def test_estimate_api_request_count_respects_max_repos_and_options(self): self.assertEqual(estimate["estimated_incremental_requests"], 85) self.assertEqual(estimate["estimated_percent_of_remaining"], 85.0) + def test_estimate_includes_actions_fallback_when_consumers_disabled(self): + from github_usage.report_data import estimate_api_request_count + + estimate = estimate_api_request_count( + repo_count=10, + include_actions=True, + include_consumers=False, + include_artifact_storage=False, + include_release_assets=False, + max_repos=100, + ) + self.assertEqual(estimate["estimated_incremental_requests"], 10) + + def test_estimate_skips_actions_fallback_when_consumers_enabled(self): + from github_usage.report_data import estimate_api_request_count + + estimate = estimate_api_request_count( + repo_count=10, + include_actions=True, + include_consumers=True, + include_artifact_storage=False, + include_release_assets=False, + max_repos=100, + ) + self.assertEqual(estimate["estimated_incremental_requests"], 20) + def test_get_warning_state_handles_missing_monthly_costs(self): from github_usage.report_data import get_warning_state @@ -507,6 +534,18 @@ def test_consumers_error_falls_back_to_fetch_repo_actions_table(self): self.assertEqual(actions["public_minutes"], 500.0) mock_fetch.assert_called_once_with(api, _TWO_REPOS) + def test_fallback_fetch_errors_propagated_into_report(self): + from github_usage.report_data import build_report_data + + api = _make_billing_api(pages={"/user/repos": _TWO_REPOS}) + fallback_errors = {"octocat/private-repo": "billing fetch failed"} + with mock.patch( + "github_usage.report_data.fetch_repo_actions_table", + return_value=([_PUBLIC_ROW], fallback_errors), + ): + report = build_report_data(api, "octocat", **_DEFAULT_KWARGS) + self.assertIn("octocat/private-repo", report["errors"]) + def test_only_public_passes_through_to_visibility_split(self): from github_usage.report_data import build_report_data @@ -521,14 +560,15 @@ def test_only_public_passes_through_to_visibility_split(self): "netAmount": 0.0, } ], - pages={"/user/repos": (_PUBLIC_REPO,)}, + pages={"/user/repos": _TWO_REPOS}, ) public_row = {**_PUBLIC_ROW, "minutes": 500.0} with mock.patch( "github_usage.report_data.fetch_repo_actions_table", return_value=([public_row], {}), - ): + ) as mock_fetch: report = build_report_data(api, "octocat", **{**_DEFAULT_KWARGS, "only_public": True}) + mock_fetch.assert_called_once_with(api, [_PUBLIC_REPO]) self.assertTrue(report["actions"]["filtered"]) self.assertEqual(report["actions"]["public_minutes"], 500.0) self.assertEqual(report["actions"]["private_minutes"], 0.0) diff --git a/tests/test_workflow_minutes.py b/tests/test_workflow_minutes.py index ef5593e..594e477 100644 --- a/tests/test_workflow_minutes.py +++ b/tests/test_workflow_minutes.py @@ -447,6 +447,7 @@ def test_email_estimate_adds_workflow_headroom_when_consumers_enabled(self) -> N with_consumers = estimate_api_request_count( repo_count=5, + include_actions=False, include_consumers=True, include_artifact_storage=False, include_release_assets=False, @@ -454,6 +455,7 @@ def test_email_estimate_adds_workflow_headroom_when_consumers_enabled(self) -> N ) without = estimate_api_request_count( repo_count=5, + include_actions=False, include_consumers=False, include_artifact_storage=False, include_release_assets=False,