little-sister-github 0.1.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.
@@ -0,0 +1,24 @@
1
+ """little-sister check type: ``github``.
2
+
3
+ Importing this package registers the type in little-sister's ``CHECK_TYPES``. A
4
+ deployment therefore needs one line in its ``wsgi.py``, before it imports
5
+ ``little_sister.app``::
6
+
7
+ import little_sister_github # noqa: F401 registers its type
8
+ from little_sister.app import app # noqa: E402 builds the engine
9
+
10
+ ``require_api`` declares the **check API epoch** this package was built for. It
11
+ refuses at startup, naming both epochs, when the library has moved past the
12
+ surface below — the upgrade an install-time floor cannot see, because a floor has
13
+ no ceiling (little-sister ADR-0051). Without it the same mismatch would surface as
14
+ an ``ImportError`` from inside this package, which reads like our bug.
15
+ """
16
+ from little_sister.checks import require_api
17
+
18
+ require_api(1)
19
+
20
+ from little_sister_github import github # noqa: E402 registers the type
21
+
22
+ __version__ = "0.1.0"
23
+
24
+ __all__ = ["github"]
@@ -0,0 +1,1091 @@
1
+ """The ``github`` check type: a team's repositories, one child per aspect.
2
+
3
+ Discovers the repositories of a GitHub organization (optionally narrowed to one
4
+ team) and reports **one child per aspect** — aspect-first. Most aspect children
5
+ are leaves listing the repositories they flag; the two severity-carrying security
6
+ aspects split once more into source-severity bands. Ported from a Ruby
7
+ overview-check dashboard; the endpoint-to-grade mapping and what was deliberately
8
+ changed are in ``docs/migrating-overview-checks.md``.
9
+
10
+ Each leaf's lines are **keyed entries** rather than plain strings (little-sister
11
+ ADR-0036), slugged ``<repo>-<kind>-<number>`` from GitHub's own per-repository
12
+ numbering: an engineer who opens a ticket for one finding pins that line and the
13
+ rest of the aspect keeps reporting. The parts are identifiers the provider minted,
14
+ never the rendered text and never a position (little-sister ADR-0050).
15
+
16
+ Registered in little-sister's ``CHECK_TYPES`` on import — importing
17
+ ``little_sister_github`` is the one line a deployment's ``wsgi.py`` adds, before
18
+ ``little_sister.app``, so the ``github`` type is known when the engine loads the
19
+ check configs.
20
+
21
+ Everything imported from little-sister below is part of its **check-authoring
22
+ surface** (architecture.md §11), which is what the ``require_api(1)`` in this
23
+ package's ``__init__`` pins.
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import re
30
+ import urllib.error
31
+ import urllib.parse
32
+ import urllib.request
33
+ from dataclasses import dataclass
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from little_sister import values
38
+ from little_sister.checks import (
39
+ Check,
40
+ CheckError,
41
+ CheckResult,
42
+ Entry,
43
+ coerce_code,
44
+ config_markdown,
45
+ parse_secret_refs,
46
+ parse_subnodes,
47
+ plain,
48
+ register,
49
+ resolve_text,
50
+ )
51
+ from little_sister.reasons import slug
52
+ from little_sister.status import StatusCode
53
+
54
+ #: This package's own logger. little-sister does not promise its ``logger`` to
55
+ #: check authors and does not need to: the library configures the root handlers,
56
+ #: so an ordinary module logger's records land in the same place, under a name
57
+ #: that says which package emitted them.
58
+ logger = logging.getLogger(__name__)
59
+
60
+ GITHUB_API = "https://api.github.com"
61
+
62
+ # Source-severity bands, worst first. Dependabot uses the first four; code
63
+ # scanning can also return the analysis severities below them. A deployment may
64
+ # grade every band independently; the defaults below are deliberately strict, and
65
+ # the band shape is what makes overriding one of them a one-line change
66
+ # (little-sister ADR-0042).
67
+ SECURITY_SEVERITY_ORDER = (
68
+ "critical", "high", "medium", "low", "error", "warning", "note", "none",
69
+ )
70
+ DEFAULT_ADVISORY_SEVERITY_MAP = {
71
+ "critical": StatusCode.ERROR,
72
+ "high": StatusCode.ERROR,
73
+ "medium": StatusCode.WARN,
74
+ "low": StatusCode.WARN,
75
+ }
76
+ DEFAULT_CODE_SCANNING_SEVERITY_MAP = dict.fromkeys(
77
+ SECURITY_SEVERITY_ORDER, StatusCode.ERROR)
78
+
79
+ def _severity_map(value: object, field: str) -> dict[str, StatusCode]:
80
+ """One configured source-severity → dashboard-code mapping."""
81
+ if value is None:
82
+ return {}
83
+ if not isinstance(value, dict):
84
+ raise CheckError(f"github '{field}.severity_map' must be a mapping")
85
+ return {str(severity).lower(): coerce_code(code)
86
+ for severity, code in value.items()}
87
+
88
+
89
+ def _positive_int(value: object, field: str) -> int:
90
+ """A configuration integer that cannot disable a coverage backstop."""
91
+ if isinstance(value, bool) or not isinstance(value, (int, str)):
92
+ raise CheckError(f"github '{field}' must be an integer of at least 1")
93
+ try:
94
+ parsed = int(value)
95
+ except (TypeError, ValueError) as error:
96
+ raise CheckError(
97
+ f"github '{field}' must be an integer of at least 1") from error
98
+ if parsed < 1:
99
+ raise CheckError(f"github '{field}' must be an integer of at least 1")
100
+ return parsed
101
+
102
+
103
+ #: The sentence every aspect's `about` ends with, referenced as `{pin_note}` so it
104
+ #: is written once (little-sister ADR-0025). GitHub numbers each finding per
105
+ #: repository, so every line here is separately addressable — which is the part an
106
+ #: operator needs to know before they open a ticket for one of twenty.
107
+ PIN_NOTE = ("Each line is one finding and can be put into maintenance on its own — "
108
+ "pin the line you are working on and the rest keeps reporting.")
109
+
110
+
111
+ #: Built-in display text for the aspect leaves this check emits (little-sister
112
+ #: ADR-0025) — **type-inherent**, so it is written once here rather than copied into
113
+ #: every deployment config, which matters as soon as the type runs more than once
114
+ #: (one check per team). `{org}` / `{team}` expand from the check's own config
115
+ #: (`_subnode_tokens`). A check config's `subnodes:` block
116
+ #: replaces any of these, or extends one by writing `{default}` into its own text;
117
+ #: `nodes.yaml` still wins over both, per node path.
118
+ #:
119
+ #: What is written here is what is true of the **type** — what the aspect reads
120
+ #: and what the reader is looking at. What an installation *does about it* — a
121
+ #: remediation deadline, the day of the week dependency bumps are cleared, who to
122
+ #: tell — is a deployment's policy and belongs in its own `subnodes:` block,
123
+ #: appended with `{default}`. A promise this file cannot keep for a stranger has
124
+ #: no business shipping in the package.
125
+ SUBNODES: dict[str, dict[str, str]] = {
126
+ "pull_requests": {
127
+ "title": "Pull requests",
128
+ "about": """\
129
+ Open pull requests on the repositories in scope, one line per pull request, with
130
+ its author. Pull requests whose title starts with one of
131
+ `pull_requests.ignore_title_prefixes` are not listed.
132
+
133
+ {pin_note}
134
+ """,
135
+ },
136
+ "security_advisories": {
137
+ "title": "Dependabot advisories",
138
+ "about": """\
139
+ GitHub Security Advisories (Dependabot) found known vulnerabilities in the
140
+ dependencies of this team's repositories.
141
+ [All open advisories](https://github.com/orgs/{org}/security/alerts/dependabot?q=is:open%20team:{team}).
142
+
143
+ Findings are grouped into severity-band children; which bands are reported and
144
+ how each one is graded is this deployment's call
145
+ (`security_advisories.severities` and `.severity_map`).
146
+
147
+ {pin_note}
148
+ """,
149
+ },
150
+ "code_scanning_alerts": {
151
+ "title": "Code-scanning alerts",
152
+ "about": """\
153
+ GitHub Code Scanning found potential vulnerabilities in this team's code.
154
+ [All open alerts](https://github.com/orgs/{org}/security/alerts/code-scanning?query=is%3Aopen+team%3A{team}+).
155
+
156
+ Findings are grouped into severity-band children, graded by
157
+ `code_scanning_alerts.severity_map`.
158
+
159
+ {pin_note}
160
+ """,
161
+ },
162
+ "secret_scanning_alerts": {
163
+ "title": "Secret-scanning alerts",
164
+ "about": """\
165
+ GitHub Secret Scanning found secrets committed to this team's repositories.
166
+ [All open alerts](https://github.com/orgs/{org}/security/alerts/secret-scanning?query=team%3A{team}+is%3Aopen).
167
+
168
+ **Remove the secret, rotate it, and clean the history — take care of this
169
+ immediately.** Secret scanning also covers
170
+ [non-provider patterns](https://docs.github.com/en/enterprise-cloud@latest/code-security/secret-scanning/introduction/supported-secret-scanning-patterns#non-provider-patterns)
171
+ (formerly the "experimental" alerts).
172
+
173
+ A repository flagged **"secret scanning not enabled"** has the feature turned
174
+ off, so nothing is watching it for leaked secrets — enable it under the
175
+ repo's Settings → Code security and analysis at "Secret Protection".
176
+
177
+ {pin_note}
178
+ """,
179
+ },
180
+ "sbom_check": {
181
+ "title": "SBOM presence",
182
+ "about": """\
183
+ Every repository with code must have a dependency graph (SBOM) so its libraries
184
+ can be checked for known issues. A repository listed here has none. Some repos
185
+ are exempt — see `sbom_check.ignore` in this check's config.
186
+
187
+ {pin_note}
188
+ """,
189
+ },
190
+ "issues": {
191
+ "title": "Open issues",
192
+ "about": """\
193
+ Open issues across the team's repositories, one line per issue. **Pull requests are
194
+ excluded**: GitHub's REST API counts every pull request as an issue and returns both
195
+ from this endpoint, and open PRs are already reported by *Pull requests*.
196
+ Repositories listed under `issues.ignore` are skipped.
197
+
198
+ {pin_note}
199
+ """,
200
+ },
201
+ "actions": {
202
+ "title": "Workflow runs",
203
+ "about": """\
204
+ The last completed GitHub Actions verdict per workflow (default branch unless
205
+ configured otherwise), with a newer in-flight run shown on the same line: a failed
206
+ run → ERROR, a run awaiting approval → WARN. Workflows matching
207
+ `actions.ignore_workflow_name_patterns` are skipped.
208
+
209
+ {pin_note}
210
+ """,
211
+ },
212
+ }
213
+
214
+
215
+ def _is_pull_request(row: object) -> bool:
216
+ """True for a row the *issues* endpoint returned that is really a pull request.
217
+
218
+ GitHub's REST API considers every pull request an issue, so
219
+ ``/repos/{owner}/{repo}/issues`` returns both; only a PR row carries a
220
+ ``pull_request`` object."""
221
+ return isinstance(row, dict) and "pull_request" in row
222
+
223
+
224
+ def _link(text: str, url: str) -> str:
225
+ """A Markdown link, or just the text when there is no URL."""
226
+ return f"[{text}]({url})" if url else text
227
+
228
+
229
+ def _entry_slug(repo: Repo, kind: str, number: int = 0, url: str = "") -> str:
230
+ """The slug for one per-repo finding — ``<repo>-<kind>-<number>``.
231
+
232
+ GitHub numbers pull requests, issues and every alert family **per repository**,
233
+ and the number is minted with the finding and retired with it. That is the
234
+ identity little-sister ADR-0036 asks for, and it stays readable in a URL:
235
+ `platform-a-pr-42`.
236
+
237
+ A payload with no number falls back to the API's own URL, which is unique per
238
+ finding, and finally to ``<repo>-<kind>`` for the aspects that report at most
239
+ one line per repository (a missing SBOM, secret scanning switched off). What it
240
+ never falls back to is the line's position: an entry above it closing would slide
241
+ the pin onto somebody else's finding.
242
+ """
243
+ if number:
244
+ return slug(repo.name, kind, number)
245
+ return slug(repo.name, kind, url) if url else slug(repo.name, kind)
246
+
247
+
248
+ @dataclass(frozen=True)
249
+ class Repo:
250
+ """One repository from discovery — the fields every aspect reads.
251
+
252
+ Built once at the seam (:meth:`from_api`) so the aspects work on typed values
253
+ rather than an ``Any`` bag whose wrong types only surface at runtime
254
+ (little-sister ADR-0026). ``name`` / ``full_name`` are **raw**: Markdown escaping
255
+ is a render-time step (``plain``), and an escaped value can no longer be
256
+ interpolated into a URL.
257
+ """
258
+
259
+ name: str
260
+ full_name: str
261
+ archived: bool = False
262
+ fork: bool = False
263
+ default_branch: str = ""
264
+
265
+ @classmethod
266
+ def from_api(cls, row: object) -> Repo:
267
+ """Read one ``/repos`` row. ``name`` and ``full_name`` are **structural** —
268
+ every aspect addresses the repo by them — so their absence means the payload
269
+ is not what we think and is raised, not defaulted. The rest are display or
270
+ filter fields and default quietly."""
271
+ try:
272
+ return cls(
273
+ name=values.text(row, "name", required=True, where="repository"),
274
+ full_name=values.text(row, "full_name", required=True,
275
+ where="repository"),
276
+ archived=values.flag(row, "archived"),
277
+ fork=values.flag(row, "fork"),
278
+ default_branch=values.text(row, "default_branch"))
279
+ except CheckError as error:
280
+ raise GitHubError(f"unexpected repository payload: {error}") from error
281
+
282
+
283
+ class GitHubError(Exception):
284
+ """A GitHub API request failed (``status`` is the HTTP code, when known)."""
285
+
286
+ def __init__(self, message: str, *, status: int | None = None) -> None:
287
+ super().__init__(message)
288
+ self.status = status
289
+
290
+
291
+ def _next_link(link_header: str) -> str | None:
292
+ """The ``rel="next"`` URL from a GitHub ``Link`` header, if present."""
293
+ for part in link_header.split(","):
294
+ segments = part.split(";")
295
+ if len(segments) < 2:
296
+ continue
297
+ url = segments[0].strip().lstrip("<").rstrip(">")
298
+ if any(seg.strip() == 'rel="next"' for seg in segments[1:]):
299
+ return url
300
+ return None
301
+
302
+
303
+ class GitHubClient:
304
+ """A minimal GitHub REST client over stdlib ``urllib`` with Link pagination."""
305
+
306
+ def __init__(self, token: str, *, api_url: str = GITHUB_API,
307
+ timeout: float = 30.0) -> None:
308
+ self._token = token
309
+ self._api = api_url.rstrip("/")
310
+ self._timeout = timeout
311
+
312
+ def _request(self, path_or_url: str,
313
+ params: dict[str, Any] | None = None) -> tuple[Any, str]:
314
+ url = (path_or_url if path_or_url.startswith("http")
315
+ else f"{self._api}{path_or_url}")
316
+ if params:
317
+ url = f"{url}?{urllib.parse.urlencode(params)}"
318
+ request = urllib.request.Request(url, method="GET")
319
+ request.add_header("Authorization", f"Bearer {self._token}")
320
+ request.add_header("Accept", "application/vnd.github+json")
321
+ request.add_header("X-GitHub-Api-Version", "2022-11-28")
322
+ try:
323
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
324
+ body = response.read().decode("utf-8")
325
+ link = response.headers.get("Link", "")
326
+ except urllib.error.HTTPError as error:
327
+ detail = error.read().decode("utf-8", "replace")[:200]
328
+ raise GitHubError(f"HTTP {error.code} for {url}: {detail}",
329
+ status=error.code) from error
330
+ except Exception as error: # any transport failure
331
+ raise GitHubError(f"request failed for {url}: {error}") from error
332
+ data = json.loads(body) if body else None
333
+ return data, link
334
+
335
+ def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
336
+ data, _ = self._request(path, params)
337
+ return data
338
+
339
+ def get_paginated(self, path: str,
340
+ params: dict[str, Any] | None = None) -> list[Any]:
341
+ merged = dict(params or {})
342
+ merged.setdefault("per_page", 100)
343
+ items: list[Any] = []
344
+ data, link = self._request(path, merged)
345
+ while True:
346
+ if not isinstance(data, list):
347
+ raise GitHubError(f"expected a list from {path}")
348
+ items.extend(data)
349
+ nxt = _next_link(link)
350
+ if not nxt:
351
+ return items
352
+ data, link = self._request(nxt)
353
+
354
+ def rate_limit(self) -> tuple[int, int, int]:
355
+ core = self.get("/rate_limit")["resources"]["core"]
356
+ return int(core["limit"]), int(core["remaining"]), int(core["reset"])
357
+
358
+
359
+ @register("github")
360
+ class GitHubCheck(Check):
361
+ """Discover a team's repositories and report one child per aspect.
362
+
363
+ Aspect-first, like the dashboard this was ported from: the check's node
364
+ (``path``) is a container with one child per aspect. Flat aspects list the
365
+ repositories they flag; severity-carrying aspects contain band leaves.
366
+ Discovery is team-scoped and filtered by ``name_prefix`` /
367
+ ``include_archived`` / ``include_forks``.
368
+ """
369
+
370
+ #: Aspects this check runs. Used for the rate estimate.
371
+ ASPECTS = ("pull_requests", "security_advisories", "code_scanning_alerts",
372
+ "secret_scanning_alerts", "sbom_check", "actions", "issues")
373
+
374
+ def __init__(self, *, org: str, team: str = "", name_prefix: str = "",
375
+ include_archived: bool = False, include_forks: bool = True,
376
+ api_url: str = GITHUB_API, rate_limit_safety_factor: int = 4,
377
+ expect_min_repos: int = 1,
378
+ pr_ignore_prefixes: tuple[str, ...] = (),
379
+ dependabot_severities: tuple[str, ...] = ("critical", "high"),
380
+ advisory_severity_map: dict[str, StatusCode] | None = None,
381
+ code_scanning_severity_map: dict[str, StatusCode] | None = None,
382
+ secret_scanning_require_enabled: bool = True,
383
+ sbom_ignore: tuple[str, ...] = (),
384
+ actions_ignore_patterns: tuple[re.Pattern[str], ...] = (),
385
+ actions_all_branches: bool = False,
386
+ actions_show_healthy: bool = False,
387
+ issues_ignore: tuple[str, ...] = (),
388
+ subnodes: dict[str, dict[str, str]] | None = None,
389
+ token_ref: str, **kwargs: Any) -> None:
390
+ super().__init__(**kwargs)
391
+ # The API token, resolved **once here** from the reference the config
392
+ # names in its `secrets:` block — `env://GITHUB_TOKEN`, or an
393
+ # `aws-sm://…` address (little-sister ADR-0023) — never re-read
394
+ # during a run. An unresolvable reference leaves this empty and records
395
+ # the failure, and the engine pins this check to a visible ERROR without
396
+ # ever calling run(); a malformed one already raised a CheckError.
397
+ self.token = self.resolve_secret(token_ref)
398
+ self.org = org
399
+ self.team = team
400
+ self.name_prefix = name_prefix
401
+ self.include_archived = include_archived
402
+ self.include_forks = include_forks
403
+ self.api_url = api_url
404
+ self.rate_limit_safety_factor = rate_limit_safety_factor
405
+ self.expect_min_repos = _positive_int(expect_min_repos, "expect_min_repos")
406
+ self.pr_ignore_prefixes = pr_ignore_prefixes
407
+ self.dependabot_severities = dependabot_severities
408
+ self.advisory_severity_map = {
409
+ **DEFAULT_ADVISORY_SEVERITY_MAP,
410
+ **(advisory_severity_map or {}),
411
+ }
412
+ self.code_scanning_severity_map = {
413
+ **DEFAULT_CODE_SCANNING_SEVERITY_MAP,
414
+ **(code_scanning_severity_map or {}),
415
+ }
416
+ self.secret_scanning_require_enabled = secret_scanning_require_enabled
417
+ self.sbom_ignore = sbom_ignore
418
+ self.actions_ignore_patterns = actions_ignore_patterns
419
+ self.actions_all_branches = actions_all_branches
420
+ self.actions_show_healthy = actions_show_healthy
421
+ self.issues_ignore = issues_ignore
422
+ # Per-aspect display text (title/about) declared in this check's own config
423
+ # (`subnodes:`), carried onto each aspect child (little-sister
424
+ # ADR-0025). nodes.yaml still overrides per path.
425
+ self.subnodes = subnodes or {}
426
+
427
+ @classmethod
428
+ def _extra_from_config(cls, config: dict[str, Any],
429
+ base_dir: Path) -> dict[str, Any]:
430
+ org = config.get("org")
431
+ if not org:
432
+ raise CheckError("github check requires an 'org'")
433
+ pull_requests = config.get("pull_requests") or {}
434
+ if not isinstance(pull_requests, dict):
435
+ raise CheckError("github 'pull_requests' must be a mapping")
436
+ ignore = pull_requests.get("ignore_title_prefixes") or []
437
+ if not isinstance(ignore, list):
438
+ raise CheckError("pull_requests.ignore_title_prefixes must be a list")
439
+ security = config.get("security_advisories") or {}
440
+ if not isinstance(security, dict):
441
+ raise CheckError("github 'security_advisories' must be a mapping")
442
+ severities = security.get("severities")
443
+ if severities is None:
444
+ severities = ["critical", "high"]
445
+ if not isinstance(severities, list):
446
+ raise CheckError("security_advisories.severities must be a list")
447
+ advisory_severity_map = _severity_map(
448
+ security.get("severity_map"), "security_advisories")
449
+ code_scanning = config.get("code_scanning_alerts") or {}
450
+ if not isinstance(code_scanning, dict):
451
+ raise CheckError("github 'code_scanning_alerts' must be a mapping")
452
+ code_scanning_severity_map = _severity_map(
453
+ code_scanning.get("severity_map"), "code_scanning_alerts")
454
+ secret_scanning = config.get("secret_scanning") or {}
455
+ sbom = config.get("sbom_check") or {}
456
+ sbom_ignore = sbom.get("ignore") or []
457
+ if not isinstance(sbom_ignore, list):
458
+ raise CheckError("sbom_check.ignore must be a list")
459
+ actions = config.get("actions") or {}
460
+ patterns = actions.get("ignore_workflow_name_patterns") or []
461
+ if not isinstance(patterns, list):
462
+ raise CheckError(
463
+ "actions.ignore_workflow_name_patterns must be a list")
464
+ try:
465
+ action_patterns = tuple(
466
+ re.compile(str(p), re.IGNORECASE) for p in patterns)
467
+ except re.error as error:
468
+ raise CheckError(
469
+ f"actions.ignore_workflow_name_patterns: {error}") from error
470
+ issues = config.get("issues") or {}
471
+ issues_ignore = issues.get("ignore") or []
472
+ if not isinstance(issues_ignore, list):
473
+ raise CheckError("issues.ignore must be a list")
474
+ return {
475
+ "org": str(org),
476
+ "team": str(config.get("team", "")),
477
+ "name_prefix": str(config.get("name_prefix", "")),
478
+ "include_archived": bool(config.get("include_archived", False)),
479
+ "include_forks": bool(config.get("include_forks", True)),
480
+ "api_url": str(config.get("api_url", GITHUB_API)),
481
+ "rate_limit_safety_factor": int(
482
+ config.get("rate_limit_safety_factor", 4)),
483
+ "expect_min_repos": _positive_int(
484
+ config.get("expect_min_repos", 1), "expect_min_repos"),
485
+ "pr_ignore_prefixes": tuple(str(p) for p in ignore),
486
+ "dependabot_severities": tuple(str(s).lower() for s in severities),
487
+ "advisory_severity_map": advisory_severity_map,
488
+ "code_scanning_severity_map": code_scanning_severity_map,
489
+ "secret_scanning_require_enabled": bool(
490
+ secret_scanning.get("require_enabled", True)),
491
+ "sbom_ignore": tuple(str(r) for r in sbom_ignore),
492
+ "actions_ignore_patterns": action_patterns,
493
+ "actions_all_branches": bool(actions.get("all_branches", False)),
494
+ "actions_show_healthy": bool(actions.get("show_healthy", False)),
495
+ "issues_ignore": tuple(str(r) for r in issues_ignore),
496
+ "subnodes": parse_subnodes(config),
497
+ # `secrets: {token: …}` — required, so two checks of this type can
498
+ # each carry their own team's credential (little-sister ADR-0023).
499
+ "token_ref": parse_secret_refs(config, "token")["token"],
500
+ }
501
+
502
+ def config_summary(self) -> str:
503
+ scope = f"{self.org}/{self.team}" if self.team else self.org
504
+ return config_markdown({
505
+ "scope": scope,
506
+ "name prefix": self.name_prefix or None,
507
+ "include archived": "yes" if self.include_archived else "no",
508
+ "expected repositories": str(self.expect_min_repos),
509
+ "show healthy Actions": "yes" if self.actions_show_healthy else "no",
510
+ })
511
+
512
+ def _subnode_tokens(self) -> dict[str, str]:
513
+ """Values a `subnodes:` `about` may reference as `{token}` — this check's
514
+ own `org` / `team`, and the shared `{pin_note}` sentence."""
515
+ return {"org": self.org, "team": self.team, "pin_note": PIN_NOTE}
516
+
517
+ def _meta(self, name: str) -> tuple[str, str]:
518
+ """The (title, about) for aspect `name`: this check type's built-in
519
+ `SUBNODES` text, which the config's `subnodes:` block replaces — or extends,
520
+ where it writes `{default}` into its own text (little-sister ADR-0025).
521
+ Tokens expand in either case."""
522
+ configured = self.subnodes.get(name, {})
523
+ default = SUBNODES.get(name, {})
524
+ tokens = self._subnode_tokens()
525
+ return (resolve_text(configured.get("title", ""),
526
+ default.get("title", ""), tokens),
527
+ resolve_text(configured.get("about", ""),
528
+ default.get("about", ""), tokens))
529
+
530
+ # --- helpers -------------------------------------------------------------
531
+
532
+ def _make_client(self, token: str) -> GitHubClient:
533
+ """Build the API client. Overridden in tests to avoid live calls."""
534
+ return GitHubClient(token, api_url=self.api_url,
535
+ timeout=self.timeout_seconds)
536
+
537
+ def _discover(self, client: GitHubClient) -> list[Repo]:
538
+ """The in-scope repositories (team-scoped when ``team`` is set), as typed
539
+ :class:`Repo` values — this is the seam where the API's ``Any`` stops."""
540
+ if self.team:
541
+ teams = client.get_paginated(f"/orgs/{self.org}/teams")
542
+ match = next((t for t in teams
543
+ if self.team in (values.text(t, "slug"),
544
+ values.text(t, "name"))), None)
545
+ if match is None:
546
+ raise GitHubError(
547
+ f"team {self.team!r} not found in org {self.org!r}")
548
+ # NOT `slug`: that name holds the imported slug builder, and rebinding
549
+ # it here would shadow the function for the rest of this scope — a trap
550
+ # for the next aspect that needs it.
551
+ team_slug = values.text(match, "slug", required=True, where="team")
552
+ repos = client.get_paginated(
553
+ f"/orgs/{self.org}/teams/{team_slug}/repos")
554
+ else:
555
+ repos = client.get_paginated(f"/orgs/{self.org}/repos")
556
+ kept: list[Repo] = []
557
+ for row in repos:
558
+ repo = Repo.from_api(row)
559
+ if repo.archived and not self.include_archived:
560
+ continue
561
+ if repo.fork and not self.include_forks:
562
+ continue
563
+ if self.name_prefix and not repo.name.startswith(self.name_prefix):
564
+ continue
565
+ kept.append(repo)
566
+ return kept
567
+
568
+ # --- aspects -------------------------------------------------------------
569
+
570
+ def _pull_requests(self, client: GitHubClient,
571
+ repos: list[Repo]) -> CheckResult:
572
+ """WARN if any repo has an open pull request (excluding ignored titles)."""
573
+ description = "Open pull requests awaiting attention"
574
+ title, about = self._meta("pull_requests")
575
+ try:
576
+ entries: list[tuple[str, str]] = []
577
+ for repo in repos:
578
+ prs = client.get_paginated(
579
+ f"/repos/{repo.full_name}/pulls", {"state": "open"})
580
+ for pull in prs:
581
+ # NOT `title`: that name holds this leaf's display label, and
582
+ # rebinding it here handed the leaf the last PR's subject.
583
+ subject = values.text(pull, "title")
584
+ if any(subject.upper().startswith(prefix.upper())
585
+ for prefix in self.pr_ignore_prefixes):
586
+ continue
587
+ user = values.text(pull, "user", "login", default="?")
588
+ url = values.text(pull, "html_url")
589
+ number = values.number(pull, "number")
590
+ name = plain(repo.name)
591
+ entries.append((
592
+ _entry_slug(repo, "pr", number, url),
593
+ f"{_link(f'{name}: {plain(subject)}', url)} "
594
+ f"[{plain(user)}]"))
595
+ except GitHubError as error:
596
+ # Prose, not members: one condition (the aspect could not run) rather
597
+ # than a list of independently pinnable findings — so it stays a plain
598
+ # string and the node pin remains its unit of suppression
599
+ # (little-sister ADR-0036).
600
+ return CheckResult(
601
+ StatusCode.ERROR,
602
+ [f"failed to check pull requests: {plain(str(error))}"],
603
+ name="pull_requests", description=description,
604
+ title=title, about=about)
605
+ code = StatusCode.WARN if entries else StatusCode.OK
606
+ return CheckResult(code, entries, name="pull_requests",
607
+ description=description, title=title, about=about)
608
+
609
+ def _collect(self, client: GitHubClient, repos: list[Repo],
610
+ suffix: str) -> tuple[
611
+ list[tuple[Repo, list[Any]]],
612
+ list[tuple[str, str]], list[Repo]]:
613
+ """Fetch an open-alerts list per repo. A **404** means the feature is not
614
+ enabled for that repo; those repos are returned separately (as
615
+ ``not_enabled``) so a caller can skip them quietly *or* flag them. Any
616
+ other failure is surfaced as an error note, so a token/scope problem
617
+ never reads as 'no alerts'.
618
+
619
+ The error notes are keyed like the findings: "this repository could not be
620
+ read" is a condition somebody may well be working on (a missing scope, an
621
+ archived repo), and it should be pinnable without silencing the alerts that
622
+ *did* come back."""
623
+ results: list[tuple[Repo, list[Any]]] = []
624
+ errors: list[tuple[str, str]] = []
625
+ not_enabled: list[Repo] = []
626
+ for repo in repos:
627
+ try:
628
+ alerts = client.get_paginated(
629
+ f"/repos/{repo.full_name}{suffix}", {"state": "open"})
630
+ except GitHubError as error:
631
+ if error.status == 404:
632
+ not_enabled.append(repo)
633
+ continue
634
+ errors.append((_entry_slug(repo, "unreadable"),
635
+ f"{plain(repo.name)}: could not read "
636
+ f"({plain(str(error))})"))
637
+ continue
638
+ results.append((repo, alerts))
639
+ return results, errors, not_enabled
640
+
641
+ def _finalize(self, name: str, description: str,
642
+ entries: list[tuple[str, str]], base_code: StatusCode,
643
+ errors: list[tuple[str, str]]) -> CheckResult:
644
+ """One aspect leaf from its findings and its read failures.
645
+
646
+ The reason is handed over as ``(slug, text)`` pairs, which declares the
647
+ lines **members** (little-sister ADR-0036): each is an independent
648
+ finding, so an operator
649
+ who opens a ticket for one can pin that line and leave the rest of the
650
+ aspect reporting."""
651
+ reason = [*entries, *errors]
652
+ code = base_code
653
+ if errors and code is StatusCode.OK:
654
+ code = StatusCode.WARN
655
+ title, about = self._meta(name)
656
+ return CheckResult(code, reason, name=name, description=description,
657
+ title=title, about=about)
658
+
659
+ def _severity_bands(
660
+ self, name: str, description: str,
661
+ groups: dict[str, list[tuple[str, str]]],
662
+ severity_map: dict[str, StatusCode],
663
+ errors: list[tuple[str, str]],
664
+ *, order: tuple[str, ...] = SECURITY_SEVERITY_ORDER,
665
+ ) -> CheckResult:
666
+ """One aspect branch with one uncoded leaf per source-severity band.
667
+
668
+ The grouping carries severity onto the node rather than burying it in the
669
+ reason text. Empty configured bands still render as OK, making it visible that
670
+ they were watched. Read failures stay on the aspect container at WARN:
671
+ they have no honest source severity and must not be smuggled into one.
672
+ """
673
+ seen = set(order)
674
+ band_order = [*order,
675
+ *(severity for severity in severity_map
676
+ if severity not in seen),
677
+ *(severity for severity in groups
678
+ if severity not in seen and severity not in severity_map)]
679
+ children: list[CheckResult] = []
680
+ for severity in band_order:
681
+ if severity not in severity_map and severity not in groups:
682
+ continue
683
+ entries = groups.get(severity, [])
684
+ code = (severity_map.get(severity, StatusCode.WARN) if entries
685
+ else StatusCode.OK)
686
+ children.append(CheckResult(
687
+ code, entries, name=severity,
688
+ description=f"{severity.capitalize()} {description}",
689
+ title=severity.capitalize()))
690
+ title, about = self._meta(name)
691
+ return CheckResult(
692
+ StatusCode.WARN if errors else StatusCode.OK,
693
+ errors,
694
+ name=name,
695
+ description=description,
696
+ children=tuple(children),
697
+ title=title,
698
+ about=about,
699
+ )
700
+
701
+ def _security_advisories(self, client: GitHubClient,
702
+ repos: list[Repo]) -> CheckResult:
703
+ """Open Dependabot alerts, grouped into configured severity bands."""
704
+ results, errors, _ = self._collect(client, repos, "/dependabot/alerts")
705
+ groups: dict[str, list[tuple[str, str]]] = {}
706
+ for repo, alerts in results:
707
+ for alert in alerts:
708
+ severity = values.text(alert, "security_advisory", "severity",
709
+ default="unknown").lower()
710
+ if severity not in self.dependabot_severities:
711
+ continue
712
+ summary = values.text(alert, "security_advisory", "summary")
713
+ url = values.text(alert, "html_url")
714
+ number = values.number(alert, "number")
715
+ name = plain(repo.name)
716
+ groups.setdefault(severity, []).append((
717
+ _entry_slug(repo, "advisory", number, url),
718
+ _link(f"{name}: {plain(summary)}", url)))
719
+ severity_map = {
720
+ severity: self.advisory_severity_map.get(severity, StatusCode.WARN)
721
+ for severity in self.dependabot_severities
722
+ }
723
+ return self._severity_bands(
724
+ "security_advisories", "Dependabot advisories", groups,
725
+ severity_map, errors, order=self.dependabot_severities)
726
+
727
+ def _code_scanning_alerts(self, client: GitHubClient,
728
+ repos: list[Repo]) -> CheckResult:
729
+ """Open code-scanning alerts, grouped into source-severity bands."""
730
+ results, errors, _ = self._collect(client, repos, "/code-scanning/alerts")
731
+ groups: dict[str, list[tuple[str, str]]] = {}
732
+ for repo, alerts in results:
733
+ for alert in alerts:
734
+ severity = values.text(alert, "rule", "security_severity_level",
735
+ default="none").lower()
736
+ detail = (values.text(alert, "rule", "description")
737
+ or values.text(alert, "rule", "id")
738
+ or "alert")
739
+ url = values.text(alert, "html_url")
740
+ number = values.number(alert, "number")
741
+ name = plain(repo.name)
742
+ groups.setdefault(severity, []).append((
743
+ _entry_slug(repo, "codescan", number, url),
744
+ _link(f"{name}: {plain(detail)}", url)))
745
+ return self._severity_bands(
746
+ "code_scanning_alerts", "code-scanning alerts", groups,
747
+ self.code_scanning_severity_map, errors)
748
+
749
+ def _secret_scanning_alerts(self, client: GitHubClient,
750
+ repos: list[Repo]) -> CheckResult:
751
+ """Any open secret-scanning alert → ERROR. Unless
752
+ ``secret_scanning.require_enabled`` is false, a repo with secret scanning
753
+ **not enabled** is flagged too (also → ERROR): the alerts endpoint 404s
754
+ when scanning is disabled for the repo, which would otherwise read as
755
+ 'no alerts'."""
756
+ results, errors, not_enabled = self._collect(
757
+ client, repos, "/secret-scanning/alerts")
758
+ entries: list[tuple[str, str]] = []
759
+ for repo, alerts in results:
760
+ for alert in alerts:
761
+ secret = (values.text(alert, "secret_type_display_name")
762
+ or values.text(alert, "secret_type")
763
+ or "secret")
764
+ created = values.text(alert, "created_at")
765
+ url = values.text(alert, "html_url")
766
+ number = values.number(alert, "number")
767
+ name = plain(repo.name)
768
+ entries.append((
769
+ _entry_slug(repo, "secret", number, url),
770
+ _link(f"{name}: {plain(secret)} detected {plain(created)}",
771
+ url)))
772
+ if self.secret_scanning_require_enabled:
773
+ for repo in not_enabled:
774
+ name = plain(repo.name)
775
+ settings = (f"https://github.com/{repo.full_name}"
776
+ "/settings/security_analysis")
777
+ # A distinct kind, not `secret`: "scanning is off" is a different
778
+ # condition from "an alert fired", and pinning the one must not
779
+ # need the other's number.
780
+ entries.append((
781
+ _entry_slug(repo, "secret-scanning-off"),
782
+ _link(f"{name}: secret scanning not enabled", settings)))
783
+ code = StatusCode.ERROR if entries else StatusCode.OK
784
+ return self._finalize(
785
+ "secret_scanning_alerts",
786
+ "Open secret-scanning alerts (and repos with it disabled)",
787
+ entries, code, errors)
788
+
789
+ def _sbom_check(self, client: GitHubClient,
790
+ repos: list[Repo]) -> CheckResult:
791
+ """A repo with code but no dependency graph (SBOM) → ERROR. Repos in
792
+ ``sbom_ignore`` are skipped; a 404 counts as missing, a permission error
793
+ is surfaced."""
794
+ entries: list[tuple[str, str]] = []
795
+ errors: list[tuple[str, str]] = []
796
+ for repo in repos:
797
+ if repo.name in self.sbom_ignore:
798
+ continue
799
+ name = plain(repo.name)
800
+ network = f"https://github.com/{repo.full_name}/network/dependencies"
801
+ # At most one line per repository, so the repo and the aspect are the
802
+ # whole identity — no number to hang it on and none needed.
803
+ missing = (_entry_slug(repo, "sbom"),
804
+ _link(f"{name}: missing SBOM", network))
805
+ try:
806
+ sbom = client.get(
807
+ f"/repos/{repo.full_name}/dependency-graph/sbom")
808
+ except GitHubError as error:
809
+ if error.status == 404:
810
+ entries.append(missing)
811
+ continue
812
+ errors.append((_entry_slug(repo, "unreadable"),
813
+ f"{name}: could not read ({plain(str(error))})"))
814
+ continue
815
+ if not values.rows(sbom, "sbom", "relationships", where="sbom"):
816
+ entries.append(missing)
817
+ code = StatusCode.ERROR if entries else StatusCode.OK
818
+ return self._finalize("sbom_check",
819
+ "Repositories missing an SBOM (dependency graph)",
820
+ entries, code, errors)
821
+
822
+ #: Workflow-run conclusions that count as a failure.
823
+ _ACTIONS_FAIL = ("failure", "timed_out", "startup_failure")
824
+ #: Empty-conclusion states that positively mean work is in flight. Unknown
825
+ #: states do not default to running (little-sister ADR-0032 rule 7).
826
+ _ACTIONS_RUNNING = ("queued", "in_progress", "pending", "requested")
827
+
828
+ @classmethod
829
+ def _action_verdict(cls, run: object) -> tuple[StatusCode, str] | None:
830
+ """The completed verdict a run contributes, or none when it contributes
831
+ only an in-flight/neutral fact. Cancelled and skipped runs deliberately do
832
+ not erase the last useful verdict beneath them."""
833
+ status = values.text(run, "status").lower()
834
+ conclusion = values.text(run, "conclusion").lower()
835
+ if conclusion in cls._ACTIONS_FAIL:
836
+ return StatusCode.ERROR, "failed"
837
+ if status == "waiting" or conclusion == "action_required":
838
+ return StatusCode.WARN, "waiting"
839
+ if conclusion == "success":
840
+ return StatusCode.OK, "passed"
841
+ return None
842
+
843
+ @staticmethod
844
+ def _action_text(repo: Repo, workflow: str, branch: str,
845
+ completed: object | None, running: object | None,
846
+ verdict: tuple[StatusCode, str] | None) -> str:
847
+ """One workflow line carrying its last verdict and current run together."""
848
+ where = (f"{plain(repo.name)} ({plain(branch)}) / "
849
+ f"{plain(workflow)}")
850
+ completed_url = (values.text(completed, "html_url")
851
+ if completed is not None else "")
852
+ running_url = (values.text(running, "html_url")
853
+ if running is not None else "")
854
+ run_number = (values.number(completed, "run_number")
855
+ if completed is not None else 0)
856
+ if verdict is None:
857
+ text = f"{_link(where, running_url)}: no completed run"
858
+ else:
859
+ _code, word = verdict
860
+ number = f" (#{run_number})" if run_number else ""
861
+ text = f"{_link(where, completed_url)}: {word}{number}"
862
+ if running is not None:
863
+ running_number = values.number(running, "run_number")
864
+ label = f"#{running_number} running" if running_number else "running"
865
+ text += f" · {_link(label, running_url)}"
866
+ return text
867
+
868
+ def _actions(self, client: GitHubClient,
869
+ repos: list[Repo]) -> CheckResult:
870
+ """One coded entry per workflow/branch that has something to say.
871
+
872
+ The entry code is the newest useful completed verdict. A newer in-flight
873
+ run is an additional flag and words on that same stable entry, so a retry
874
+ cannot hide the failure it is trying to fix. Healthy idle workflows are
875
+ optional; a run in flight is always emitted. Only runs of a currently
876
+ existing workflow count.
877
+ """
878
+ problem_entries: list[Entry] = []
879
+ running_entries: list[Entry] = []
880
+ healthy_entries: list[Entry] = []
881
+ for repo in repos:
882
+ name = plain(repo.name)
883
+ full = repo.full_name
884
+ try:
885
+ existing = self._existing_workflow_ids(client, full)
886
+ except GitHubError as error:
887
+ if error.status == 404:
888
+ continue # Actions not enabled
889
+ problem_entries.append(Entry(
890
+ _entry_slug(repo, "workflows-unreadable"),
891
+ f"{name}: could not read workflows ({plain(str(error))})",
892
+ code=StatusCode.WARN))
893
+ continue
894
+ params: dict[str, Any] = {"per_page": 100}
895
+ if not self.actions_all_branches and repo.default_branch:
896
+ params["branch"] = repo.default_branch
897
+ try:
898
+ data = client.get(f"/repos/{full}/actions/runs", params)
899
+ except GitHubError as error:
900
+ if error.status == 404:
901
+ continue # Actions not enabled
902
+ problem_entries.append(Entry(
903
+ _entry_slug(repo, "runs-unreadable"),
904
+ f"{name}: could not read ({plain(str(error))})",
905
+ code=StatusCode.WARN))
906
+ continue
907
+ # GitHub returns newest first. Keep the first in-flight run and the
908
+ # first useful completed verdict independently for each stable
909
+ # (workflow, branch) identity. The old one-set loop let the former
910
+ # claim the key and made the latter disappear.
911
+ states: dict[
912
+ tuple[int, str],
913
+ tuple[str, object | None, object | None,
914
+ tuple[StatusCode, str] | None],
915
+ ] = {}
916
+ for run in values.rows(data, "workflow_runs"):
917
+ workflow_id = values.number(run, "workflow_id")
918
+ if workflow_id not in existing:
919
+ continue # run of a deleted workflow
920
+ workflow = (values.text(run, "name")
921
+ or str(workflow_id) or "workflow")
922
+ branch = values.text(run, "head_branch", default="?")
923
+ key = (workflow_id, branch)
924
+ if any(p.search(workflow) for p in self.actions_ignore_patterns):
925
+ continue
926
+ status = values.text(run, "status").lower()
927
+ current = states.get(key, (workflow, None, None, None))
928
+ current_workflow, completed, running, verdict = current
929
+ if running is None and status in self._ACTIONS_RUNNING:
930
+ running = run
931
+ run_verdict = self._action_verdict(run)
932
+ if completed is None and run_verdict is not None:
933
+ completed, verdict = run, run_verdict
934
+ states[key] = (current_workflow, completed, running, verdict)
935
+
936
+ for (workflow_id, branch), state in states.items():
937
+ workflow, completed, running, verdict = state
938
+ if verdict is None and running is None:
939
+ continue
940
+ entry_code = verdict[0] if verdict else StatusCode.UNDEFINED
941
+ if (entry_code is StatusCode.OK and running is None
942
+ and not self.actions_show_healthy):
943
+ continue
944
+ entry = Entry(
945
+ slug(repo.name, "workflow", workflow_id, branch),
946
+ self._action_text(
947
+ repo, workflow, branch, completed, running, verdict),
948
+ code=entry_code,
949
+ running=running is not None,
950
+ )
951
+ if entry_code in (StatusCode.ERROR, StatusCode.WARN):
952
+ problem_entries.append(entry)
953
+ elif running is not None:
954
+ running_entries.append(entry)
955
+ else:
956
+ healthy_entries.append(entry)
957
+ title, about = self._meta("actions")
958
+ return CheckResult(
959
+ reason=(*problem_entries, *running_entries, *healthy_entries),
960
+ name="actions",
961
+ description="Latest completed and in-flight workflow-run state",
962
+ title=title,
963
+ about=about,
964
+ entries=True,
965
+ )
966
+
967
+ @staticmethod
968
+ def _existing_workflow_ids(client: GitHubClient, full_name: str) -> set[int]:
969
+ """IDs of the repo's workflows that still exist (``state`` != ``deleted``),
970
+ so runs of a deleted workflow can be dropped."""
971
+ data = client.get(f"/repos/{full_name}/actions/workflows",
972
+ {"per_page": 100})
973
+ return {values.number(workflow, "id")
974
+ for workflow in values.rows(data, "workflows")
975
+ if values.text(workflow, "state") != "deleted"}
976
+
977
+ def _issues(self, client: GitHubClient, repos: list[Repo]) -> CheckResult:
978
+ """An open issue → WARN, one line per issue. Repos listed under
979
+ ``issues.ignore`` are skipped.
980
+
981
+ **Pull requests are dropped** (:func:`_is_pull_request`): the issues endpoint
982
+ returns them too, and counting them here would report every open PR twice —
983
+ once here and once under ``pull_requests``. A **404** means issues are turned
984
+ off for that repo, which is worth showing rather than reading as 'none'."""
985
+ entries: list[tuple[str, str]] = []
986
+ errors: list[tuple[str, str]] = []
987
+ for repo in repos:
988
+ if repo.name in self.issues_ignore:
989
+ continue
990
+ name = plain(repo.name)
991
+ issues_url = f"https://github.com/{repo.full_name}/issues"
992
+ try:
993
+ # paginated: the plain endpoint caps at GitHub's default page size,
994
+ # which would silently under-report a busy repository
995
+ rows = client.get_paginated(
996
+ f"/repos/{repo.full_name}/issues", {"state": "open"})
997
+ except GitHubError as error:
998
+ if error.status == 404:
999
+ entries.append((
1000
+ _entry_slug(repo, "issues-off"),
1001
+ _link(f"{name}: issues are disabled", issues_url)))
1002
+ continue
1003
+ errors.append((_entry_slug(repo, "unreadable"),
1004
+ f"{name}: could not read ({plain(str(error))})"))
1005
+ continue
1006
+ for row in rows:
1007
+ if _is_pull_request(row):
1008
+ continue
1009
+ # a real int, so it can be put in the URL — an escaped value could not
1010
+ number = values.number(row, "number")
1011
+ subject = values.text(row, "title", default="unknown")
1012
+ entries.append((
1013
+ _entry_slug(repo, "issue", number),
1014
+ f"{_link(f'{name}: has issue {number}', f'{issues_url}/{number}')}"
1015
+ f": {plain(subject)}"))
1016
+ code = StatusCode.WARN if entries else StatusCode.OK
1017
+ return self._finalize("issues", "Open issues per repository",
1018
+ entries, code, errors)
1019
+
1020
+
1021
+ # --- run -----------------------------------------------------------------
1022
+
1023
+ def _scope_reading(self, repos: list[Repo]) -> tuple[StatusCode, str]:
1024
+ """The coverage backstop on the check's owned container
1025
+ (little-sister ADR-0043)."""
1026
+ count = len(repos)
1027
+ noun = "repository" if count == 1 else "repositories"
1028
+ if count >= self.expect_min_repos:
1029
+ return StatusCode.OK, f"{count} {noun} in scope"
1030
+ if count:
1031
+ return (StatusCode.WARN,
1032
+ f"{count} {noun} in scope, expected at least "
1033
+ f"{self.expect_min_repos}")
1034
+ filters = [f"org {plain(self.org)}"]
1035
+ if self.team:
1036
+ filters.append(f"team {plain(self.team)}")
1037
+ if self.name_prefix:
1038
+ filters.append(f'prefix "{plain(self.name_prefix)}"')
1039
+ return (StatusCode.WARN,
1040
+ f"no repositories in scope ({', '.join(filters)})")
1041
+
1042
+ @staticmethod
1043
+ def _scope_report(repos: list[Repo]) -> str:
1044
+ """The discovered roster: presence without a status claim
1045
+ (little-sister ADR-0044)."""
1046
+ return "\n".join(
1047
+ f"- [{plain(repo.name)}](https://github.com/{repo.full_name})"
1048
+ for repo in repos)
1049
+
1050
+ def run(self) -> CheckResult:
1051
+ try:
1052
+ client = self._make_client(self.token)
1053
+ repos = self._discover(client)
1054
+ except GitHubError as error:
1055
+ return CheckResult(StatusCode.ERROR,
1056
+ [f"discovery failed: {plain(str(error))}"])
1057
+ # names only — the payloads are large and one line per run is enough to see
1058
+ # what the discovery filter actually selected
1059
+ logger.info("%s: %d repositories in scope: %s", self.path, len(repos),
1060
+ ", ".join(repo.name for repo in repos) or "(none)")
1061
+ scope_code, scope_reason = self._scope_reading(repos)
1062
+ scope_report = self._scope_report(repos)
1063
+
1064
+ try:
1065
+ _limit, remaining, _reset = client.rate_limit()
1066
+ needed = max(1, len(repos)) * len(self.ASPECTS)
1067
+ if remaining < self.rate_limit_safety_factor * needed:
1068
+ return CheckResult(
1069
+ StatusCode.WARN,
1070
+ [f"skipped this run: {remaining} API calls left, need > "
1071
+ f"{self.rate_limit_safety_factor}×{needed} "
1072
+ f"for {len(repos)} repo(s)", scope_reason],
1073
+ report=scope_report)
1074
+ except GitHubError:
1075
+ pass # rate-limit endpoint unavailable — proceed rather than block
1076
+
1077
+ children = (
1078
+ self._pull_requests(client, repos),
1079
+ self._security_advisories(client, repos),
1080
+ self._code_scanning_alerts(client, repos),
1081
+ self._secret_scanning_alerts(client, repos),
1082
+ self._sbom_check(client, repos),
1083
+ self._actions(client, repos),
1084
+ self._issues(client, repos),
1085
+ )
1086
+ return CheckResult(
1087
+ scope_code,
1088
+ [scope_reason],
1089
+ children=children,
1090
+ report=scope_report,
1091
+ )
File without changes
@@ -0,0 +1,141 @@
1
+ Metadata-Version: 2.4
2
+ Name: little-sister-github
3
+ Version: 0.1.0
4
+ Summary: GitHub overview check type for little-sister.
5
+ Keywords: monitoring,status,github,little-sister
6
+ Author: Michael Meyling
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Requires-Dist: little-sister>=0.3.11
16
+ Requires-Python: >=3.11
17
+ Project-URL: homepage, https://github.com/m-31/little-sister-github
18
+ Project-URL: repository, https://github.com/m-31/little-sister-github
19
+ Project-URL: issues, https://github.com/m-31/little-sister-github/issues
20
+ Project-URL: changelog, https://github.com/m-31/little-sister-github/blob/main/CHANGELOG.md
21
+ Description-Content-Type: text/markdown
22
+
23
+ # little-sister-github
24
+
25
+ The **`github`** check type for [little-sister](https://github.com/m-31/little-sister):
26
+ one node per configured team, with a child per aspect — open pull requests,
27
+ Dependabot advisories, code-scanning and secret-scanning alerts, SBOM presence,
28
+ workflow runs and open issues.
29
+
30
+ Every finding is an individually addressable line, so an operator who opens a
31
+ ticket for one alert can put **that line** into maintenance and the rest of the
32
+ aspect keeps reporting.
33
+
34
+ ## The contract
35
+
36
+ - **Requires** `little-sister >= 0.3.11` — a floor, never a pin.
37
+ - **Runs on** Python **3.11 or newer** — the library's floor, not a higher
38
+ one of its own.
39
+ - **Registers** one check type: **`github`**.
40
+
41
+ ## Install
42
+
43
+ ```toml
44
+ # your deployment's pyproject.toml
45
+ [project]
46
+ dependencies = ["little-sister", "little-sister-github"]
47
+
48
+ # Only while *this* one comes from git: little-sister resolves from the index.
49
+ # Delete the table once this package is on an index too — nothing else changes.
50
+ [tool.uv.sources]
51
+ little-sister-github = { git = "…/little-sister-github.git", tag = "v0.1.0" }
52
+ ```
53
+
54
+ ```python
55
+ # wsgi.py — registrations first, the app last. The order is load-bearing:
56
+ # importing little_sister.app builds the engine and loads the check configs, so
57
+ # every check type must already be registered. `isort: off` keeps an import
58
+ # sorter from quietly reversing that.
59
+ # isort: off
60
+ import little_sister_github # noqa: F401 registers the `github` type
61
+ from little_sister.app import app
62
+ # isort: on
63
+
64
+ __all__ = ["app"] # without it, lint calls the app import unused
65
+ ```
66
+
67
+ ## Configure
68
+
69
+ Copy [`examples/github.yaml`](https://github.com/m-31/little-sister-github/blob/v0.1.0/examples/github.yaml) into your deployment's
70
+ `config/checks/`, set `org`, `team` and the token reference, and you are done —
71
+ one file per team. The credential is a **reference**, never a value:
72
+
73
+ ```yaml
74
+ type: github
75
+ path: /platform/github
76
+ secrets:
77
+ token: env://PLATFORM_GITHUB_TOKEN
78
+ org: example-org
79
+ team: platform
80
+ ```
81
+
82
+ The token needs `read:org`, `repo`, `security_events` and dependency-graph read
83
+ access. Each team's check carries its own credential, so a second team is a second
84
+ config file rather than a code change.
85
+
86
+ The per-aspect display text ships **with the type** and expands `{org}` / `{team}`
87
+ from the config, so it is not copied per team. Your deployment's own policy — a
88
+ remediation deadline, who to notify — goes in that config's `subnodes:` block,
89
+ appended to the shipped text with `{default}`.
90
+
91
+ ## What it reads
92
+
93
+ | Aspect | Endpoint | Grade |
94
+ |---|---|---|
95
+ | `pull_requests` | `GET /repos/{r}/pulls?state=open` | any open PR (minus `ignore_title_prefixes`) → **WARN** |
96
+ | `security_advisories` | `GET /repos/{r}/dependabot/alerts?state=open` | one leaf per selected severity, graded by `security_advisories.severity_map` |
97
+ | `code_scanning_alerts` | `GET /repos/{r}/code-scanning/alerts?state=open` | one leaf per severity, graded by `code_scanning_alerts.severity_map` |
98
+ | `secret_scanning_alerts` | `GET /repos/{r}/secret-scanning/alerts?state=open` | any open alert → **ERROR**; scanning disabled → **ERROR** (`secret_scanning.require_enabled`) |
99
+ | `sbom_check` | `GET /repos/{r}/dependency-graph/sbom` | no dependency graph → **ERROR** (`sbom_check.ignore`) |
100
+ | `actions` | `GET /repos/{r}/actions/runs` | coded entries: last completed verdict, plus a newer in-flight run |
101
+ | `issues` | `GET /repos/{r}/issues?state=open` | any open issue → **WARN** (`issues.ignore`); issues disabled → **WARN** |
102
+
103
+ The check's own node carries the discovery coverage reading (`expect_min_repos`)
104
+ and the repository roster, and rolls up worst-of its aspects. Only stdlib
105
+ `urllib` is used — the package has no dependency but little-sister itself.
106
+
107
+ ## Develop
108
+
109
+ little-sister is declared as a **floor** — the release that promised the surface
110
+ this package imports — and it resolves **from the index**, like any other
111
+ dependency. There is no `[tool.uv.sources]` table here, and the committed
112
+ `uv.lock` is what a release runs against. To work against a local library
113
+ checkout, add the redirect and **do not commit it**: uv reads the sources table of
114
+ a dependency it resolves from a path or a checkout, so a committed line would
115
+ follow this package into every deployment that installs it.
116
+
117
+ ```toml
118
+ # pyproject.toml — locally, never committed
119
+ [tool.uv.sources]
120
+ little-sister = { path = "../little-sister" }
121
+ ```
122
+
123
+ Restore `uv.lock` with it. The next `uv run` — the pre-commit gate is one — rewrites
124
+ the lock to `source = { directory = … }`, so a redirect kept out of `pyproject.toml`
125
+ can still reach a commit through the lock beside it.
126
+
127
+ ```bash
128
+ uv sync
129
+ uv run ruff check
130
+ uv run mypy
131
+ uv run mypy --python-version 3.11 # against the floor, not the interpreter you have
132
+ uv run pytest -q
133
+ # The same gate runs before every commit once the hook is enabled:
134
+ git config core.hooksPath hooks
135
+ ```
136
+
137
+ The tests are fixture-based; nothing in this repository calls GitHub.
138
+
139
+ ## License
140
+
141
+ MIT — see [LICENSE](https://github.com/m-31/little-sister-github/blob/v0.1.0/LICENSE).
@@ -0,0 +1,7 @@
1
+ little_sister_github/__init__.py,sha256=hygXS8DZ44ofmBZQLm7pUiP0fUyCY3x1E9rddDKN5sk,948
2
+ little_sister_github/github.py,sha256=MVM5XqGssSsJkYeIsYx10_tU3HSluy8NHx_vkwOYhwI,51889
3
+ little_sister_github/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ little_sister_github-0.1.0.dist-info/licenses/LICENSE,sha256=vftmudSYxZDfQHzcDV2fmdE4DJlqqyVjOLaeuE4QP2E,1072
5
+ little_sister_github-0.1.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
6
+ little_sister_github-0.1.0.dist-info/METADATA,sha256=YpNHARJkvvAgxXu-bv3w1mQDz2FUajpz9NhLWgsBVCk,6039
7
+ little_sister_github-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Michael Meyling
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.