ghinsight 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ghinsight/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """ghinsight (pronounced "gin-sight"): reusable tools for GitHub analysis.
2
+
3
+ The core needs only httpx2 and typer:
4
+
5
+ - :mod:`ghinsight.client`: GitHub REST/GraphQL client and token resolution;
6
+ - :mod:`ghinsight.repos`: repositories of an organisation or user;
7
+ - :mod:`ghinsight.activity`: a user's issue and pull request activity.
8
+
9
+ :mod:`ghinsight.actions` (GitHub Actions usage and performance, with a
10
+ dashboard) needs the ``actions`` extra: ``pip install 'ghinsight[actions]'``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from importlib.metadata import PackageNotFoundError, version
16
+
17
+ from .client import GitHubClient, GitHubError, resolve_token
18
+ from .repos import RepoInfo, list_repos
19
+
20
+ try:
21
+ __version__ = version("ghinsight")
22
+ except PackageNotFoundError: # pragma: no cover - running from a bare checkout
23
+ __version__ = "0+unknown"
24
+
25
+ __all__ = [
26
+ "GitHubClient",
27
+ "GitHubError",
28
+ "RepoInfo",
29
+ "__version__",
30
+ "list_repos",
31
+ "resolve_token",
32
+ ]
@@ -0,0 +1,41 @@
1
+ """GitHub Actions usage and performance analysis (the ``actions`` extra).
2
+
3
+ A three-stage, functional pipeline:
4
+
5
+ 1. **fetch** (:mod:`ghinsight.actions.fetch`) — cache raw runs/jobs from GitHub.
6
+ 2. **dataset** (:mod:`ghinsight.actions.dataset`) — parse the cache into the
7
+ canonical tidy intermediate representation, :class:`ActionsDataset`.
8
+ 3. **analysis** — derive metric tables (:mod:`ghinsight.actions.metrics`),
9
+ insights (:mod:`ghinsight.actions.analysis`), and a report
10
+ (:mod:`ghinsight.actions.report`) — all pure projections of the dataset.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .analysis import UsageSummary, summarize_usage
16
+ from .config import AnalysisConfig, DateRange, load_config, parse_period
17
+ from .csv_export import write_reports
18
+ from .dataset import ActionsDataset
19
+ from .domain import Job, Run, RunnerType, RuntimeOS, Visibility
20
+ from .metrics import performance_table, usage_table
21
+ from .report import build_dashboard, render_html
22
+
23
+ __all__ = [
24
+ "ActionsDataset",
25
+ "AnalysisConfig",
26
+ "DateRange",
27
+ "Job",
28
+ "Run",
29
+ "RunnerType",
30
+ "RuntimeOS",
31
+ "UsageSummary",
32
+ "Visibility",
33
+ "build_dashboard",
34
+ "load_config",
35
+ "parse_period",
36
+ "performance_table",
37
+ "render_html",
38
+ "summarize_usage",
39
+ "usage_table",
40
+ "write_reports",
41
+ ]
@@ -0,0 +1,201 @@
1
+ """Stage 3b — derived insights, as pure functions over the dataset.
2
+
3
+ This module contains the *interpretation* layer: billed-equivalent minutes,
4
+ plan-cap projections and monthly time-series. It produces plain DataFrames and
5
+ an immutable :class:`UsageSummary`, with no plotting or HTML concerns, so it is
6
+ the natural entry point for ad-hoc analysis (see the Python tutorial) as well as
7
+ the data source for the dashboard.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass
14
+
15
+ import pandas as pd
16
+
17
+ from .config import AnalysisConfig
18
+ from .dataset import ActionsDataset
19
+ from .metrics import usage_table
20
+
21
+ __all__ = [
22
+ "UsageSummary",
23
+ "billed_equivalent_by_os",
24
+ "billing_monthly_usage",
25
+ "monthly_comparison",
26
+ "monthly_usage",
27
+ "summarize_usage",
28
+ ]
29
+
30
+ MONTHS_PER_YEAR = 12
31
+
32
+
33
+ def _multiplier_series(os_column: pd.Series, multipliers: Mapping[str, float]) -> pd.Series:
34
+ return os_column.astype(str).str.lower().map(multipliers).fillna(1.0)
35
+
36
+
37
+ def billed_equivalent_by_os(
38
+ dataset: ActionsDataset, multipliers: Mapping[str, float]
39
+ ) -> pd.DataFrame:
40
+ """Per-OS usage with the configured billing multiplier applied.
41
+
42
+ Columns: ``Runtime OS``, ``Total minutes``, ``Multiplier``,
43
+ ``Billed equivalent minutes``.
44
+ """
45
+ table = usage_table(dataset, "runtime-os").copy()
46
+ if table.empty:
47
+ return pd.DataFrame(
48
+ columns=["Runtime OS", "Total minutes", "Multiplier", "Billed equivalent minutes"]
49
+ )
50
+ table["Multiplier"] = _multiplier_series(table["Runtime OS"], multipliers)
51
+ table["Billed equivalent minutes"] = table["Total minutes"] * table["Multiplier"]
52
+ return table
53
+
54
+
55
+ def monthly_usage(
56
+ dataset: ActionsDataset, multipliers: Mapping[str, float]
57
+ ) -> pd.DataFrame:
58
+ """Hosted jobs augmented with ``adj_billed`` (billed-equivalent minutes).
59
+
60
+ This is the long-form basis for every monthly chart: one row per hosted job
61
+ with its month, OS, repo, conclusion and multiplier-adjusted minutes.
62
+ """
63
+ # Even an empty frame gets ``adj_billed``, so charts over an empty scope
64
+ # (e.g. private-only when every repository is public) still work.
65
+ frame = dataset.hosted_jobs_frame.copy()
66
+ frame["adj_billed"] = frame["billed_minutes"] * _multiplier_series(
67
+ frame["runtime_os"], multipliers
68
+ )
69
+ return frame
70
+
71
+
72
+ def billing_monthly_usage(
73
+ billing_frame: pd.DataFrame, multipliers: Mapping[str, float]
74
+ ) -> pd.DataFrame:
75
+ """Billing-report Actions minutes augmented with ``adj_billed``.
76
+
77
+ ``billing_frame`` is :meth:`BillingUsage.actions_minutes_frame
78
+ <ghinsight.actions.billing.BillingUsage.actions_minutes_frame>`. The result
79
+ has the same ``month``/``runtime_os``/``adj_billed`` columns as
80
+ :func:`monthly_usage`, so the same monthly charts apply to it.
81
+ """
82
+ frame = billing_frame.copy()
83
+ frame["adj_billed"] = frame["minutes"].astype(float) * _multiplier_series(
84
+ frame["runtime_os"], multipliers
85
+ )
86
+ return frame
87
+
88
+
89
+ def _monthly_all_and_private(
90
+ frame: pd.DataFrame, column: str
91
+ ) -> tuple[pd.Series, pd.Series]:
92
+ """Monthly sums of ``column`` over all repos and over non-public repos."""
93
+ if frame.empty:
94
+ empty = pd.Series(dtype=float)
95
+ return empty, empty
96
+ all_repos = frame.groupby("month")[column].sum()
97
+ private = frame[frame["visibility"] != "public"].groupby("month")[column].sum()
98
+ return all_repos, private.reindex(all_repos.index, fill_value=0.0)
99
+
100
+
101
+ def monthly_comparison(
102
+ estimated: pd.DataFrame, billed: pd.DataFrame | None = None
103
+ ) -> pd.DataFrame:
104
+ """Monthly billed-equivalent minutes: estimates by scope, and as billed.
105
+
106
+ ``estimated`` is :func:`monthly_usage`; its private-only column drops public
107
+ repos and keeps private, internal and unknown ones. ``billed`` (optional)
108
+ is :func:`billing_monthly_usage`, taken as is: the billing report already
109
+ omits free public-repository usage. Billing columns are present only when
110
+ ``billed`` is given; months missing from one source are ``NaN``.
111
+ """
112
+ est_all, est_private = _monthly_all_and_private(estimated, "adj_billed")
113
+ columns = {"Estimated, all repos": est_all, "Estimated, private only": est_private}
114
+ if billed is not None:
115
+ columns |= {
116
+ "Billed (GitHub report)": billed.groupby("month")["adj_billed"].sum(),
117
+ "Actions net charge (USD)": billed.groupby("month")["net_amount"].sum(),
118
+ }
119
+ table = pd.DataFrame(columns).sort_index()
120
+ table.index.name = "Month"
121
+ return table.reset_index()
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class UsageSummary:
126
+ """Headline usage figures for a period. All minutes are billed-equivalent
127
+ unless named ``raw``."""
128
+
129
+ org: str
130
+ period: str
131
+ period_label: str
132
+ raw_total_minutes: float
133
+ billed_equivalent_minutes: float
134
+ estimated_monthly_billed: float
135
+ estimated_daily_billed: float
136
+ plan_minutes: float | None
137
+ plan_pct: float | None
138
+ cap_reached: bool | None
139
+ estimated_unserved_billed: float
140
+
141
+ @property
142
+ def cap_status_text(self) -> str:
143
+ """Human-readable description of the plan-cap situation (HTML-safe)."""
144
+ if self.plan_minutes is None:
145
+ return ""
146
+ if self.cap_reached:
147
+ day_cap_hit = (
148
+ self.plan_minutes / self.estimated_daily_billed
149
+ if self.estimated_daily_billed
150
+ else 0.0
151
+ )
152
+ return (
153
+ f"cap hit around day <b>{day_cap_hit:.1f}</b> of an average month, "
154
+ f"leaving <b>{self.estimated_unserved_billed:,.0f}</b> "
155
+ "billed-equivalent minutes unmet"
156
+ )
157
+ return (
158
+ f"cap <b>not reached</b> in an average month "
159
+ f"(average usage is {self.plan_pct:.0f}% of the "
160
+ f"{self.plan_minutes:,.0f}-minute cap)"
161
+ )
162
+
163
+
164
+ def summarize_usage(dataset: ActionsDataset, config: AnalysisConfig) -> UsageSummary:
165
+ """Compute headline KPIs from the dataset and config.
166
+
167
+ The selected period is treated as an annual window, so monthly demand is the
168
+ billed-equivalent total divided by twelve (this matches the original report).
169
+ """
170
+ by_os = billed_equivalent_by_os(dataset, config.os_multipliers)
171
+ raw_total = float(by_os["Total minutes"].sum()) if not by_os.empty else 0.0
172
+ billed_total = (
173
+ float(by_os["Billed equivalent minutes"].sum()) if not by_os.empty else 0.0
174
+ )
175
+
176
+ est_monthly = billed_total / MONTHS_PER_YEAR
177
+ est_daily = est_monthly / config.days_per_month if config.days_per_month else 0.0
178
+
179
+ plan = config.plan_minutes
180
+ plan_pct: float | None = None
181
+ cap_reached: bool | None = None
182
+ unserved = 0.0
183
+ if plan:
184
+ plan_pct = est_monthly / plan * 100
185
+ cap_reached = est_monthly > plan
186
+ if cap_reached:
187
+ unserved = est_monthly - plan
188
+
189
+ return UsageSummary(
190
+ org=config.org,
191
+ period=config.period,
192
+ period_label=config.period_label,
193
+ raw_total_minutes=raw_total,
194
+ billed_equivalent_minutes=billed_total,
195
+ estimated_monthly_billed=est_monthly,
196
+ estimated_daily_billed=est_daily,
197
+ plan_minutes=plan,
198
+ plan_pct=plan_pct,
199
+ cap_reached=cap_reached,
200
+ estimated_unserved_billed=unserved,
201
+ )
@@ -0,0 +1,195 @@
1
+ """GitHub's billing usage report, as an optional companion to the dataset.
2
+
3
+ The :class:`~ghinsight.actions.dataset.ActionsDataset` *estimates* billed minutes
4
+ from job timestamps. When the account's billing usage report has been cached
5
+ (see :func:`ghinsight.actions.fetch.fetch_billing_usage`), :class:`BillingUsage`
6
+ provides the minutes GitHub actually billed, per day, repository and SKU.
7
+
8
+ The report is optional. :meth:`BillingUsage.from_cache` returns ``None`` when
9
+ nothing was cached, and every consumer must treat that as "use the estimates
10
+ only". There are two ways to cache it:
11
+
12
+ * :func:`ghinsight.actions.fetch.fetch_billing_usage`, via the REST API, which
13
+ appears to admit organisation owners only (billing managers get a 404); or
14
+ * :func:`import_usage_csv`, from the usage report CSV that billing managers can
15
+ download from the organisation's billing pages (the *summarized* report
16
+ covers up to a year and has the per-repository breakdown needed here).
17
+
18
+ The report appears to list only usage that counted towards the plan quota:
19
+ public-repository usage on standard runners, which is free, is omitted. So the
20
+ billed minutes are used as they are, with no visibility filter, and they
21
+ reflect each repository's visibility at the time of use.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import csv
27
+ import json
28
+ from collections.abc import Iterable, Mapping
29
+ from dataclasses import dataclass
30
+ from datetime import timezone
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import pandas as pd
35
+
36
+ from .config import DateRange
37
+ from .domain import BillingUsageItem, Visibility
38
+ from .fetch import CachePaths, billing_months
39
+
40
+ __all__ = ["BillingUsage", "BILLING_COLUMNS", "CSV_TO_API_FIELDS", "import_usage_csv"]
41
+
42
+ BILLING_COLUMNS: tuple[str, ...] = (
43
+ "date",
44
+ "month",
45
+ "repo",
46
+ "visibility",
47
+ "sku",
48
+ "runtime_os",
49
+ "minutes",
50
+ "gross_amount",
51
+ "discount_amount",
52
+ "net_amount",
53
+ )
54
+
55
+
56
+ def _item_to_row(item: BillingUsageItem, visibility: Visibility) -> dict[str, Any]:
57
+ return {
58
+ "date": item.date,
59
+ "month": item.month,
60
+ "repo": item.repo,
61
+ "visibility": visibility.value,
62
+ "sku": item.sku,
63
+ "runtime_os": item.runtime_os.value,
64
+ "minutes": item.quantity,
65
+ "gross_amount": item.gross_amount,
66
+ "discount_amount": item.discount_amount,
67
+ "net_amount": item.net_amount,
68
+ }
69
+
70
+
71
+ def _in_range(item: BillingUsageItem, date_range: DateRange) -> bool:
72
+ if item.date is None:
73
+ return False
74
+ date = item.date if item.date.tzinfo else item.date.replace(tzinfo=timezone.utc)
75
+ since, until = (
76
+ d if d.tzinfo else d.replace(tzinfo=timezone.utc)
77
+ for d in (date_range.since, date_range.until)
78
+ )
79
+ return since <= date < until
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class BillingUsage:
84
+ """Cached billing usage items for one account, plus the months they cover."""
85
+
86
+ org: str
87
+ items: tuple[BillingUsageItem, ...]
88
+ months: tuple[str, ...]
89
+ expected_months: tuple[str, ...] = ()
90
+
91
+ @classmethod
92
+ def from_cache(
93
+ cls, org: str, cache_dir: Path, date_range: DateRange | None = None
94
+ ) -> "BillingUsage | None":
95
+ """Load the cached report, or ``None`` if no month was cached.
96
+
97
+ With ``date_range``, only the months and items inside it are kept and
98
+ :attr:`expected_months` lists every month the range covers.
99
+ """
100
+ paths = CachePaths(cache_dir, org)
101
+ expected = tuple(billing_months(date_range)) if date_range else ()
102
+ files = sorted(paths.billing_dir.glob("*.json")) if paths.billing_dir.exists() else []
103
+ if expected:
104
+ files = [f for f in files if f.stem in expected]
105
+ loaded = dict(_load_months(files))
106
+ if not loaded:
107
+ return None
108
+ items = tuple(item for month_items in loaded.values() for item in month_items)
109
+ if date_range is not None:
110
+ items = tuple(item for item in items if _in_range(item, date_range))
111
+ return cls(org=org, items=items, months=tuple(sorted(loaded)), expected_months=expected)
112
+
113
+ @property
114
+ def missing_months(self) -> tuple[str, ...]:
115
+ """Months in the requested range with no cached report."""
116
+ return tuple(m for m in self.expected_months if m not in self.months)
117
+
118
+ def actions_minutes_frame(self, visibility: Mapping[str, Visibility]) -> pd.DataFrame:
119
+ """One row per Actions-minutes usage item, tagged with repo visibility."""
120
+ rows = [
121
+ _item_to_row(item, visibility.get(item.repo, Visibility.UNKNOWN))
122
+ for item in self.items
123
+ if item.is_actions_minutes
124
+ ]
125
+ if not rows:
126
+ return pd.DataFrame(columns=BILLING_COLUMNS)
127
+ return pd.DataFrame.from_records(rows, columns=BILLING_COLUMNS)
128
+
129
+
130
+ def _load_months(files: Iterable[Path]) -> Iterable[tuple[str, list[BillingUsageItem]]]:
131
+ for path in files:
132
+ try:
133
+ payloads = json.loads(path.read_text(encoding="utf-8"))
134
+ except (OSError, json.JSONDecodeError):
135
+ continue
136
+ if not isinstance(payloads, list):
137
+ continue
138
+ items = [BillingUsageItem.from_payload(p) for p in payloads if isinstance(p, dict)]
139
+ yield path.stem, [item for item in items if item is not None]
140
+
141
+
142
+ # Usage-report CSV columns and the REST API ``usageItems`` fields they map to.
143
+ CSV_TO_API_FIELDS: Mapping[str, str] = {
144
+ "date": "date",
145
+ "product": "product",
146
+ "sku": "sku",
147
+ "quantity": "quantity",
148
+ "unit_type": "unitType",
149
+ "applied_cost_per_quantity": "pricePerUnit",
150
+ "gross_amount": "grossAmount",
151
+ "discount_amount": "discountAmount",
152
+ "net_amount": "netAmount",
153
+ "organization": "organizationName",
154
+ "repository": "repositoryName",
155
+ }
156
+
157
+
158
+ def _csv_row_to_item(row: Mapping[str, str]) -> dict[str, Any]:
159
+ item: dict[str, Any] = {
160
+ api: row[column] for column, api in CSV_TO_API_FIELDS.items() if column in row
161
+ }
162
+ for key in ("quantity", "pricePerUnit", "grossAmount", "discountAmount", "netAmount"):
163
+ if key in item:
164
+ try:
165
+ item[key] = float(item[key])
166
+ except ValueError:
167
+ item[key] = 0.0
168
+ return item
169
+
170
+
171
+ def import_usage_csv(csv_path: Path, org: str, cache_dir: Path) -> list[str]:
172
+ """Cache a billing usage report CSV as if it had been fetched from the API.
173
+
174
+ Rows are converted to the API's ``usageItems`` shape and written one file
175
+ per month under ``{cache_dir}/_billing/{org}/``, replacing any cached
176
+ month the CSV covers. Rows for other organisations are skipped. A month
177
+ the CSV only partly covers is cached as partial, so export whole months.
178
+
179
+ Returns the months written.
180
+ """
181
+ by_month: dict[str, list[dict[str, Any]]] = {}
182
+ # ``utf-8-sig`` drops the byte-order mark GitHub puts before the header.
183
+ with csv_path.open(newline="", encoding="utf-8-sig") as handle:
184
+ for row in csv.DictReader(handle):
185
+ if row.get("organization", org).lower() != org.lower():
186
+ continue
187
+ month = (row.get("date") or "")[:7]
188
+ if len(month) == 7:
189
+ by_month.setdefault(month, []).append(_csv_row_to_item(row))
190
+
191
+ paths = CachePaths(cache_dir, org)
192
+ paths.billing_dir.mkdir(parents=True, exist_ok=True)
193
+ for month, items in by_month.items():
194
+ paths.billing_file(month).write_text(json.dumps(items), encoding="utf-8")
195
+ return sorted(by_month)