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,