Skip to content

feat: add ghafi overview — org Actions minute-quota audit#10

Merged
OriNachum merged 1 commit into
mainfrom
feat/overview-actions-usage
Jun 19, 2026
Merged

feat: add ghafi overview — org Actions minute-quota audit#10
OriNachum merged 1 commit into
mainfrom
feat/overview-actions-usage

Conversation

@OriNachum

Copy link
Copy Markdown
Contributor

Adds a read-only overview verb that answers "why is the org near its included-minutes limit?" — born out of an audit where agentculture sat near its 3000-minute quota.

What it does

ghafi overview <org> joins the enhanced-billing usage report (GET /organizations/{org}/settings/billing/usage) against each repo's private/public flag, keeps only the private repos (public repos get unlimited free minutes and never touch the quota), and weights each by runner-OS multiplier — Linux ×1, Windows ×2, macOS ×10. A small macOS matrix leg can outrank a busy Linux repo; surfacing that is the point.

ghafi overview agentculture
ghafi overview agentculture --month 2026-06 --repo colleague --json
  • --repo NAME drills into one repo: workflow-run counts grouped by trigger event (most recent 100 runs) + the month's total.
  • --month YYYY-MM defaults to the current calendar month; --json emits a structured envelope.

Verified live against agentculture: the org table reproduces a hand-rolled gh/jq audit to the minute (4,659 quota-weighted private minutes for June).

Surface & safety

  • Read-only — correctly absent from MUTATING_VERBS; a test asserts it issues no writes in any mode.
  • Scope: the billing-usage endpoint needs admin:org (read:org returns 403; the legacy /settings/billing/actions endpoint is retired, HTTP 410). The repo + runs reads are covered by repo. Documented in CLAUDE.md, the explain entry, and an enriched 403 remediation hint.
  • Stdlib-only, zero runtime deps — no gh/jq needed.

Changes

File
ghafi/cli/_commands/overview.py the command
ghafi/cli/__init__.py register the verb
ghafi/explain/catalog.py ghafi explain overview + root verb list
tests/test_cli_overview.py 6 tests (table, JSON, macOS-weight, public exclusion, drill-down, bad-month, 403→admin:org)
CLAUDE.md project shape, run example, admin:org billing-scope note
.claude/skills/actions-usage/ gh/jq shell companion for the same audit
pyproject.toml / CHANGELOG.md bump 0.0.2 → 0.1.0

Known limitation

Multiplier is classified by SKU substring (macos→10, windows→2, else 1) — exact for standard runners but does not model larger runners (e.g. macOS 12-core), which bill differently and do not draw from included minutes. None are in use across the org today.

Verification

pytest 55 passed · black/isort/flake8/bandit clean · markdownlint-cli2 0 errors · portability lint clean · doc-test-align no drift.

  • ghafi (Claude)

Read-only verb that answers "why is the org near its included-minutes
limit". Joins the enhanced-billing usage report
(/organizations/{org}/settings/billing/usage) against repo privacy and
weights by runner-OS multiplier (Linux x1, Windows x2, macOS x10) — only
private repos draw down the quota, and a small macOS matrix leg can
outrank a busy Linux repo. `--repo NAME` drills into one repo's
workflow-run counts by trigger event.

- ghafi/cli/_commands/overview.py: the command (read-only, no --apply)
- register in cli/__init__.py; explain entry + root verb list
- tests/test_cli_overview.py: table, JSON, macOS-weighting, public
  exclusion, drill-down, bad-month, 403->admin:org hint (6 tests)
- CLAUDE.md: project shape, run example, admin:org scope note for the
  billing endpoint (read:org returns 403; legacy endpoint is 410)
- .claude/skills/actions-usage/: gh/jq shell companion for the same audit
- bump 0.0.2 -> 0.1.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add ghafi overview for org GitHub Actions minute-quota auditing
✨ Enhancement 🧪 Tests 📝 Documentation ⚙️ Configuration changes 🕐 20-40 Minutes

Grey Divider

Description

• Add read-only ghafi overview  to attribute included-minutes usage by private repo.
• Weight Actions minutes by runner OS multiplier and support repo drill-down run counts.
• Document required admin:org scope, add shell audit companion, and bump version to 0.1.0.
Diagram

graph TD
  U["User"] --> CLI["ghafi overview"] --> D{"--repo?"}
  D -->|"No"| RAPI{{"List org repos"}} --> BAPI{{"Billing usage"}} --> REP["Render report"] --> OUT["Table / JSON"]
  D -->|"Yes"| RUNAPI{{"List workflow runs"}} --> REP --> OUT
  subgraph Legend
    direction LR
    _proc["Process"] ~~~ _dec{"Decision"} ~~~ _ext{{"GitHub API"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Filter quota-impact using billing fields (net/discount) instead of repo-privacy join
  • ➕ Avoids listing all org repos (fewer API calls and no pagination)
  • ➕ Directly reflects billing semantics if the API consistently encodes “free/public” as discounted
  • ➖ Assumes stable billing fields/semantics; less intuitive than “private repos only”
  • ➖ May fail for edge cases where discounting does not map 1:1 to visibility
2. Use `type=private` when listing repos (or GraphQL for visibility)
  • ➕ Reduces data transfer and simplifies privacy filtering logic
  • ➕ GraphQL can retrieve only needed fields in one call
  • ➖ Still requires org-repo listing and pagination for large orgs
  • ➖ GraphQL introduces a second API surface and more complexity than current stdlib REST flow

Recommendation: The PR’s approach (repo-privacy join + SKU substring multipliers) is a pragmatic, stdlib-only implementation that matches how included minutes are actually consumed, and it’s well-covered by tests (public exclusion, macOS weighting, JSON/text modes, and 403 remediation). If API call volume becomes a concern for large orgs, consider adding type=private to the repo listing as a low-risk optimization; otherwise the current design is the best trade-off for correctness and clarity.

Files changed (9) +669 / -4

Enhancement (2) +263 / -0
__init__.pyRegister 'overview' verb in CLI parser +2/-0

Register 'overview' verb in CLI parser

• Adds the deferred import and subcommand registration so 'ghafi overview' is available alongside existing verbs.

ghafi/cli/init.py

overview.pyImplement 'ghafi overview' org quota breakdown + repo drill-down +261/-0

Implement 'ghafi overview' org quota breakdown + repo drill-down

• Adds a read-only command that fetches private repos, reads the enhanced billing usage report, and aggregates Actions minutes into raw vs quota-weighted totals using OS multipliers. Supports '--repo' to group recent workflow runs by workflow name + trigger event, '--month' parsing/validation, JSON envelope output, and enriches 403 auth errors with an 'admin:org' remediation hint.

ghafi/cli/_commands/overview.py

Tests (1) +142 / -0
test_cli_overview.pyAdd coverage for overview table/JSON, weighting, drill-down, and auth hint +142/-0

Add coverage for overview table/JSON, weighting, drill-down, and auth hint

• Introduces tests covering text table output and totals, JSON envelope shape/sorting, macOS weighting behavior, public repo exclusion, repo drill-down grouping/total_count reporting, bad '--month' validation, and 403 error remediation enrichment. Includes an explicit assertion that the command performs no write HTTP methods.

tests/test_cli_overview.py

Documentation (4) +129 / -3
SKILL.mdAdd Actions-minute audit skill documentation +77/-0

Add Actions-minute audit skill documentation

• Introduces a documented “actions-usage” skill describing the quota model (private-only + OS multipliers) and how to run the companion audit script. Includes scope requirements and practical remediation guidance for common 403/410 billing endpoint issues.

.claude/skills/actions-usage/SKILL.md

CHANGELOG.mdDocument new 'overview' command and actions-usage skill in 0.1.0 +7/-0

Document new 'overview' command and actions-usage skill in 0.1.0

• Adds a 0.1.0 release entry describing the new 'ghafi overview' verb, its weighting model, drill-down mode, read-only behavior, and required 'admin:org' scope, plus the new shell skill companion.

CHANGELOG.md

CLAUDE.mdUpdate project map and token-scope notes for 'overview' +4/-3

Update project map and token-scope notes for 'overview'

• Extends the repo layout to include the new 'overview.py' command, adds a run-from-source example, and documents that 'admin:org' is required for the enhanced billing usage endpoint (and that 'read:org' is insufficient).

CLAUDE.md

catalog.pyAdd 'ghafi explain overview' entry and list overview in root help +41/-0

Add 'ghafi explain overview' entry and list overview in root help

• Documents 'overview' behavior, API surfaces used, required scopes, and JSON output shape. Also adds the command to the root verb list shown by 'ghafi explain'.

ghafi/explain/catalog.py

Other (2) +135 / -1
actions-usage.shAdd gh/jq/awk shell script for org Actions usage auditing +134/-0

Add gh/jq/awk shell script for org Actions usage auditing

• Adds a standalone shell audit that fetches enhanced billing usage, joins it with repo privacy, and prints quota-weighted vs raw minutes per private repo. Supports '--month', '--repo' drill-down, and '--json', with explicit 'admin:org' scope guidance.

.claude/skills/actions-usage/scripts/actions-usage.sh

pyproject.tomlBump project version to 0.1.0 +1/-1

Bump project version to 0.1.0

• Updates the package version from 0.0.2 to 0.1.0 to reflect the new feature release.

pyproject.toml

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (1) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 10 rules

Grey Divider


Remediation recommended

1. actions-usage.sh shells out to gh 📘 Rule violation ⌂ Architecture
Description
The new .claude/skills/actions-usage skill depends on the external gh CLI (and documents it as a
requirement), which conflicts with the policy to avoid runtime reliance on gh for GitHub
interactions. If these skills are intended to be runnable as part of ghafi’s shipped tooling, this
introduces a prohibited dependency path.
Code

.claude/skills/actions-usage/scripts/actions-usage.sh[R59-66]

+if ! gh api "/organizations/${org}/settings/billing/usage?year=${year}&month=${mon}" \
+      > "$usage_json" 2>"$workdir/err"; then
+  echo "billing usage request failed:" >&2
+  cat "$workdir/err" >&2
+  echo "" >&2
+  echo "The enhanced billing endpoint needs admin:org. Try:" >&2
+  echo "  gh auth refresh -h github.com -s admin:org" >&2
+  exit 1
Evidence
Rule 423934 disallows relying on the external gh CLI for GitHub interactions. The added skill
script uses gh api to call GitHub endpoints, and the skill documentation explicitly requires gh
for running the audit.

Rule 423934: Disallow runtime dependency on external gh CLI in ghafi
.claude/skills/actions-usage/scripts/actions-usage.sh[59-66]
.claude/skills/actions-usage/SKILL.md[49-59]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new skill script `.claude/skills/actions-usage/scripts/actions-usage.sh` invokes the external `gh` CLI (e.g., `gh api ...`) and `SKILL.md` lists `gh` as a requirement. This can be interpreted as introducing a runtime dependency on `gh`, which the compliance checklist disallows for `ghafi`.

## Issue Context
The PR adds a shell-based companion audit under `.claude/skills/actions-usage/` that uses `gh api` to query GitHub. The compliance rule requires GitHub interactions to be performed via direct HTTP (not shelling out to `gh`) and documentation should not make `gh` a prerequisite for core functionality.

## Fix Focus Areas
- .claude/skills/actions-usage/scripts/actions-usage.sh[59-96]
- .claude/skills/actions-usage/SKILL.md[49-59]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Totals disagree with rows 🐞 Bug ≡ Correctness
Description
_aggregate() rounds each repo’s weighted/raw minutes per-row but rounds summary totals from
the unrounded sums, so the displayed totals can differ from the sum of displayed rows (e.g., two
repos at 0.4 minutes each show 0+0 in rows but total 1). This can confuse audits and contradict the
command’s goal of reproducing minute totals precisely.
Code

ghafi/cli/_commands/overview.py[R114-140]

+def _aggregate(usage_items: list[dict], private: set[str]) -> dict:
+    """Aggregate Actions minutes into per-private-repo weighted/raw totals."""
+    weighted: dict[str, float] = {}
+    raw: dict[str, float] = {}
+    public_raw = 0.0
+    for item in usage_items:
+        if item.get("product") != "actions" or item.get("unitType") != "Minutes":
+            continue
+        repo = str(item.get("repositoryName", ""))
+        qty = float(item.get("quantity", 0) or 0)
+        if repo in private:
+            mult = _sku_multiplier(str(item.get("sku", "")))
+            weighted[repo] = weighted.get(repo, 0.0) + qty * mult
+            raw[repo] = raw.get(repo, 0.0) + qty
+        else:
+            public_raw += qty
+    rows = sorted(
+        ({"repo": r, "weighted": round(weighted[r]), "raw": round(raw[r])} for r in weighted),
+        key=lambda d: d["weighted"],
+        reverse=True,
+    )
+    return {
+        "repos": rows,
+        "private_total_weighted": round(sum(weighted.values())),
+        "private_total_raw": round(sum(raw.values())),
+        "public_total_raw": round(public_raw),
+    }
Evidence
The code explicitly rounds per-repo values when constructing rows, but computes totals by rounding
the sums of the unrounded weighted/raw dictionaries, which can yield different results than
summing the displayed rows.

ghafi/cli/_commands/overview.py[114-140]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ghafi overview` rounds per-repo row values and summary totals at different aggregation levels. This can produce internally inconsistent output where the summary totals don’t match the sum of the displayed rows.

### Issue Context
- Rows are currently built using `round(weighted[r])` / `round(raw[r])`.
- Totals are computed independently as `round(sum(...))`.
- If GitHub ever returns fractional minute quantities (or if weighting produces fractional values), per-row rounding vs total rounding can diverge.

### Fix Focus Areas
- ghafi/cli/_commands/overview.py[114-140]

### Suggested fix approach
- Decide on a single rounding policy and apply it consistently:
 - Option A (preferred for consistency): keep full-precision (or Decimal) internally and **do not round rows**, emitting the exact numeric values (or fixed decimals) in both table/JSON.
 - Option B: if you must output integers, compute `rows` first (rounded ints) and then compute totals as the sum of those displayed row values (plus a separately tracked public bucket), so totals always equal row sums.
- Consider switching `qty` parsing from `float(...)` to `Decimal` to avoid binary-float artifacts if fractional minutes exist.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

3. Usage items lack validation 🐞 Bug ☼ Reliability
Description
_aggregate() assumes every element in usage_items is a dict and calls item.get(...); if the
API returns a malformed list (or unexpected element types), it will raise AttributeError and be
reported as a generic unexpected: failure. This bypasses the normal GhafiError path and reduces
debuggability.
Code

ghafi/cli/_commands/overview.py[R119-123]

+    for item in usage_items:
+        if item.get("product") != "actions" or item.get("unitType") != "Minutes":
+            continue
+        repo = str(item.get("repositoryName", ""))
+        qty = float(item.get("quantity", 0) or 0)
Evidence
_aggregate() uses item.get(...) directly, which will fail for non-dict elements; the CLI wraps
any such exception into a generic unexpected: error instead of a structured GhafiError.

ghafi/cli/_commands/overview.py[114-130]
ghafi/cli/init.py[74-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_aggregate()` calls `.get()` on each element of `usage_items` without verifying it’s a dict. If a non-dict element appears, the command will crash with an `AttributeError` and be wrapped as a generic `unexpected:` CLI error.

### Issue Context
The CLI dispatcher catches arbitrary exceptions and formats them as `unexpected: <Type>: <msg>`, which is less actionable than a structured `GhafiError` with a clear remediation.

### Fix Focus Areas
- ghafi/cli/_commands/overview.py[114-130]
- ghafi/cli/__init__.py[74-89]

### Suggested fix approach
- Add a defensive check in `_aggregate()`:
 - `if not isinstance(item, dict): continue` (lenient)
 - or raise `GhafiError(code=EXIT_API_ERROR, message=..., remediation=...)` (strict)
- If strict, include enough context (e.g., `type(item)` and a short repr) to debug API schema changes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@OriNachum OriNachum merged commit 20ff80d into main Jun 19, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant