-
Notifications
You must be signed in to change notification settings - Fork 0
Fix email report forecast visibility split #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
23e7e3e
9ea3ba5
d4bf602
cf35ce7
1276a2b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| # Fix: Email report forecast should use private-only quota inputs | ||
|
|
||
| > **Status:** COMPLETE | ||
|
|
||
| ## 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 | ||
|
|
||
| - [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 | ||
|
|
||
| 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) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| ) | ||
|
Comment on lines
+339
to
+341
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the estimator definition and all call sites.
ast-grep outline src/github_usage/report_data.py --items all --type function
rg -n -A100 -B5 '^def estimate_api_request_count\(' src/github_usage/report_data.py
rg -n -C5 'estimate_api_request_count\(' src tests
# Inspect Actions-only coverage and quota-related tests.
rg -n -C5 'include_actions|core_remaining|estimated_incremental_requests|quota' tests/test_report_data.pyRepository: kgrizz-git/github-usage Length of output: 1261 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- report_data.py relevant sections ---'
cat -n src/github_usage/report_data.py | sed -n '230,430p'
printf '%s\n' '--- estimator definitions and call sites ---'
rg -n -C8 'estimate_api_request_count|estimated_incremental_requests|core_remaining' . \
-g '*.py' -g '*.toml' -g '*.yaml' -g '*.yml'
printf '%s\n' '--- report-data test files ---'
git ls-files 'tests/*' | sortRepository: kgrizz-git/github-usage Length of output: 30109 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- Actions fallback implementation ---'
rg -n -A100 -B10 'def fetch_repo_actions_table|def get_actions_usage' src/github_usage/report_actions.py src/github_usage/report_data.py
printf '%s\n' '--- Actions request tests and report-data fixtures ---'
rg -n -C12 'fetch_repo_actions_table|include_actions|build_report_data|rate_limit|quota' \
tests/test_report_actions.py tests/test_report_data.py tests/_fakes.py
printf '%s\n' '--- optional estimator implementation and constants ---'
cat -n src/github_usage/report_optional.py | sed -n '1,180p'Repository: kgrizz-git/github-usage Length of output: 45996 Include Actions fallback requests in the quota estimate. When 🤖 Prompt for AI Agents |
||
| if needs_repos: | ||
| repos, truncated = _limited_repos(api, max_repos) | ||
| repos = filter_repos_by_visibility( | ||
|
|
@@ -344,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, | ||
|
|
@@ -392,6 +396,21 @@ 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, 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 | ||
| ) | ||
|
|
||
| report["insights"] = get_key_insights(report) | ||
| report["warnings"] = get_warning_state(report, warn_over) | ||
| report["sources"] = dict(REPORT_SOURCES) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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), | ||
| } | ||
| ) | ||
|
|
@@ -53,6 +54,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, | ||
|
Comment on lines
+57
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the split helper defaults and row-field access.
ast-grep outline src/github_usage/usage_split.py --items all --type function
rg -n -A120 -B5 '^def split_rows_by_visibility\(' src/github_usage/usage_split.py
rg -n -A80 -B5 '^def finalize_actions_split\(' src/github_usage/usage_split.py
# Compare fallback and consumer row construction.
rg -n -A45 -B5 '^def fetch_repo_actions_table\(' src/github_usage/report_actions.py
rg -n -A45 -B5 '^def get_repo_consumers\(' src/github_usage/report_optional.pyRepository: kgrizz-git/github-usage Length of output: 14870 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the accumulator and the complete attachment path.
sed -n '100,205p' src/github_usage/usage_split.py
sed -n '282,345p' src/github_usage/usage_split.py
sed -n '370,425p' src/github_usage/report_data.py
# Find all consumers of the reused rows and all storage_avg_mb/storage_gb_hours
# transformations to determine whether the schema mismatch affects output.
rg -n -A20 -B10 'storage_avg_mb|storage_gb_hours|_raw_rows|attach_actions_visibility_split' src tests 2>/dev/null || trueRepository: kgrizz-git/github-usage Length of output: 50380 🏁 Script executed: #!/bin/bash
set -euo pipefail
sed -n '100,205p' src/github_usage/usage_split.py
sed -n '282,345p' src/github_usage/usage_split.py
sed -n '370,425p' src/github_usage/report_data.py
rg -n -A20 -B10 'storage_avg_mb|storage_gb_hours|_raw_rows|attach_actions_visibility_split' src tests 2>/dev/null || trueRepository: kgrizz-git/github-usage Length of output: 50380 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- usage_split accumulator ---'
sed -n '145,200p' src/github_usage/usage_split.py
printf '%s\n' '--- attach_actions_visibility_split ---'
sed -n '282,345p' src/github_usage/usage_split.py
printf '%s\n' '--- report_data attachment call ---'
sed -n '390,415p' src/github_usage/report_data.pyRepository: kgrizz-git/github-usage Length of output: 4160 Normalize consumer rows before attaching the Actions split.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
|
|
||
|
|
@@ -120,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, | ||
|
|
@@ -129,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 | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Propagate fallback fetch errors before finalizing the visibility split.
fetch_repo_actions_table()returns(rows, errors), butbuild_report_data()currently binds the second value to_errorsand drops it. A failed repository request then produces a partial visibility split without an entry inreport["errors"]. The email can undercount private minutes and the forecast.Merge the fallback errors before calling
attach_actions_visibility_split().Proposed fix
🤖 Prompt for AI Agents