Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<table>` literals; consolidated repeated consumer test fixtures into `tests/_consumer_fixtures.py`.
Expand Down
2 changes: 2 additions & 0 deletions TO_DO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down
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.

Copy link
Copy Markdown

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), but build_report_data() currently binds the second value to _errors and drops it. A failed repository request then produces a partial visibility split without an entry in report["errors"]. The email can undercount private minutes and the forecast.

Merge the fallback errors before calling attach_actions_visibility_split().

Proposed fix
-            rows, _errors = fetch_repo_actions_table(api, repos)
+            rows, fetch_errors = fetch_repo_actions_table(api, repos)
+            errors.update(fetch_errors)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@docs/superpowers/plans/archived/2026-08-10-fix-email-forecast-visibility-split.md`
at line 47, Update build_report_data() so the errors returned by the fallback
fetch_repo_actions_table() call are merged into report["errors"] before
attach_actions_visibility_split() runs. Replace the discarded _errors binding
with propagation of those errors, while preserving the existing rows and
visibility-split behavior.


### 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)
23 changes: 21 additions & 2 deletions src/github_usage/report_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.py

Repository: 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/*' | sort

Repository: 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 include_actions is enabled without usable repo_consumers rows, build_report_data() calls fetch_repo_actions_table(api, repos) after the quota check. estimate_api_request_count() does not count these per-repository requests. Add the Actions request count to the estimate and add an Actions-only low-quota test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/github_usage/report_data.py` around lines 339 - 341, Update
estimate_api_request_count() to include one fallback Actions request per
repository when include_actions is enabled, matching the
fetch_repo_actions_table(api, repos) path used by build_report_data() when
repo_consumers data is unavailable. Add a low-quota test covering Actions-only
reporting and verify the quota check accounts for these requests.

if needs_repos:
repos, truncated = _limited_repos(api, max_repos)
repos = filter_repos_by_visibility(
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 9 additions & 1 deletion src/github_usage/report_optional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
)
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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.py

Repository: 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 || true

Repository: 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 || true

Repository: 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.py

Repository: kgrizz-git/github-usage

Length of output: 4160


Normalize consumer rows before attaching the Actions split.

attach_actions_visibility_split() reads storage_gb_hours by default. _raw_rows contains only storage_avg_mb, so reused rows contribute zero visibility storage and place the full account storage in unattributed_storage_gb_hours. Retain storage_gb_hours in get_repo_consumers() rows, or normalize the rows before the attachment call.

📍 Affects 2 files
  • src/github_usage/report_optional.py#L56-L58 (this comment)
  • src/github_usage/report_data.py#L399-L405
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/github_usage/report_optional.py` around lines 56 - 58, Normalize the
per-repository rows before attaching the Actions visibility split so storage is
attributed correctly. In src/github_usage/report_optional.py lines 56-58, update
the _raw_rows data passed from get_repo_consumers() to retain or derive
storage_gb_hours from storage_avg_mb. In src/github_usage/report_data.py lines
399-405, ensure the attachment call consumes rows containing storage_gb_hours
rather than storage_avg_mb alone, preserving existing behavior for other row
fields.

}


Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
Loading