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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@
# this repo does not have.
# - check-jsonschema for template-bundles.yml and check-renovate: neither file
# exists here.
# - interrogate: rhiza's hook reads [tool.interrogate], and without that table
# interrogate falls back to its own defaults - a standard nobody here chose.
# Add the table first if docstring coverage should be gated.
# - interrogate: rhiza's hook reads [tool.interrogate] from the root; here the
# table lives in collector/pyproject.toml, so the hook is pointed at it and
# at the package, and runs over the whole package rather than the changed
# files - fail-under is a share of everything, not of one commit's files.
# - bandit: rhiza passes --ini .bandit; here it is scoped to the package by path
# instead, since the tests assert freely and that is not a finding.
# - ruff is pinned to the version in collector/uv.lock, which is what CI runs,
Expand Down Expand Up @@ -76,6 +77,14 @@ repos:
- id: validate-pyproject
files: ^collector/pyproject\.toml$

- repo: https://github.com/econchick/interrogate
rev: 1.7.0
hooks:
- id: interrogate
args: [--config, collector/pyproject.toml, collector/jq_collector]
pass_filenames: false
files: ^collector/(jq_collector/.*\.py|pyproject\.toml)$

- repo: https://github.com/PyCQA/bandit
rev: 1.9.4
hooks:
Expand Down
2 changes: 2 additions & 0 deletions collector/jq_collector/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@


def _refresh_local(cfg: Config, store: Store) -> None:
"""Rescan every working copy and publish it, reusing measurements that have not moved."""
snap = store.snapshot()
branches = {name: repo.default_branch for name, repo in snap.remote.items()}
# The previous scan is the measurement cache: a repo that has not moved
Expand Down Expand Up @@ -186,6 +187,7 @@ def _loop(


def main() -> None:
"""Seed both sources, then serve ``/metrics`` while refreshing each on its own interval."""
logging.basicConfig(
level=os.environ.get("JQ_LOG_LEVEL", "INFO").upper(),
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
Expand Down
3 changes: 3 additions & 0 deletions collector/jq_collector/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@


def _int(name: str, default: int) -> int:
"""The integer in environment variable ``name``, or ``default`` when unset or blank."""
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
return int(raw)


def _csv(name: str, default: tuple[str, ...] = ()) -> tuple[str, ...]:
"""Environment variable ``name`` as a tuple of non-empty, stripped comma-separated parts."""
raw = os.environ.get(name)
if raw is None or raw.strip() == "":
return default
Expand Down Expand Up @@ -201,6 +203,7 @@ def __post_init__(self) -> None:
)

def is_ignored(self, owner: str, name: str) -> bool:
"""Whether JQ_IGNORE names this repo, by bare name or as ``owner/name``."""
return name in self.ignore or f"{owner}/{name}" in self.ignore

def forge_for(self, full_name: str) -> str:
Expand Down
6 changes: 4 additions & 2 deletions collector/jq_collector/forge.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,11 @@ def collect(
self,
ref_cache: dict[str, tuple[str, str]],
coverage_cache: dict[str, tuple[int, tuple[float, int] | None]],
) -> tuple[dict[str, RemoteRepo], frozenset[str]]: ...
) -> tuple[dict[str, RemoteRepo], frozenset[str]]:
"""This forge's repos keyed by ``namespace/name``, and the ones it dropped."""

def close(self) -> None: ...
def close(self) -> None:
"""Release the client's connections."""


# -- shared by both collectors -----------------------------------------------
Expand Down
7 changes: 7 additions & 0 deletions collector/jq_collector/github_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@


class GitHub:
"""A GitHub REST client for one refresh, tracking the rate limit off every response."""

def __init__(self, cfg: Config) -> None:
self._cfg = cfg
headers = {"Accept": _ACCEPT, "X-GitHub-Api-Version": "2022-11-28"}
Expand All @@ -58,14 +60,17 @@ def __init__(self, cfg: Config) -> None:
self.rate_reset = 0.0

def close(self) -> None:
"""Release the client's connections."""
self._client.close()

def _get(self, path: str, **params: str | int) -> httpx.Response:
"""GET ``path``, noting the rate-limit headers whatever the status."""
response = self._client.get(path, params=params or None)
self._note_rate_limit(response)
return response

def _note_rate_limit(self, response: httpx.Response) -> None:
"""Copy the ``x-ratelimit-*`` headers onto the client; a malformed one is skipped."""
for header, attr in (
("x-ratelimit-remaining", "rate_remaining"),
("x-ratelimit-limit", "rate_limit"),
Expand Down Expand Up @@ -105,6 +110,7 @@ def _bytes(self, path: str) -> bytes | None:
return response.content

def _paginate(self, path: str, **params: str | int) -> list[dict[str, Any]]:
"""Every item of a list endpoint, 100 a page, stopping at an empty or short page or page 10."""
items: list[dict[str, Any]] = []
page = 1
while True:
Expand Down Expand Up @@ -195,6 +201,7 @@ def release_tags(self, full_name: str) -> list[str]:
# -- per repo --------------------------------------------------------

def branch_sha(self, full_name: str, branch: str) -> str:
"""The commit sha at the tip of ``branch``, or ``""`` if it cannot be read."""
data = self._json(f"/repos/{full_name}/branches/{branch}")
if not isinstance(data, dict):
return ""
Expand Down
6 changes: 6 additions & 0 deletions collector/jq_collector/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ def _pid(full_name: str) -> str:


class GitLab:
"""A GitLab REST client for one refresh, authenticated with GITLAB_TOKEN when set."""

def __init__(self, cfg: Config) -> None:
self._cfg = cfg
headers = {"Accept": "application/json"}
Expand All @@ -95,6 +97,7 @@ def __init__(self, cfg: Config) -> None:
)

def close(self) -> None:
"""Release the client's connections."""
self._client.close()

def _json(self, path: str, **params: str | int) -> object | None:
Expand Down Expand Up @@ -130,6 +133,7 @@ def _text(self, path: str, **params: str | int) -> str | None:
return response.text if response is not None else None

def _paginate(self, path: str, **params: str | int) -> list[dict[str, Any]]:
"""Every item of a list endpoint, stopping at an empty or short page or page 10."""
items: list[dict[str, Any]] = []
page = 1
while True:
Expand Down Expand Up @@ -183,6 +187,7 @@ def protected_branch(self, full_name: str, branch: str) -> dict[str, Any] | None
return raw if isinstance(raw, dict) else None

def branch_sha(self, full_name: str, branch: str) -> str:
"""The commit sha at the tip of ``branch``, or ``""`` if it cannot be read."""
data = self._json(
f"/projects/{_pid(full_name)}/repository/branches/{quote(branch, safe='')}"
)
Expand Down Expand Up @@ -295,6 +300,7 @@ def open_merge_requests(self, full_name: str) -> tuple[int, list[PullRequest]]:
return total, pulls

def recent_merges(self, full_name: str, limit: int) -> list[MergedPull]:
"""The ``limit`` most recently merged merge requests, newest first; any without a merge time are dropped."""
raw = self._json(
f"/projects/{_pid(full_name)}/merge_requests",
state="merged",
Expand Down
1 change: 1 addition & 0 deletions collector/jq_collector/localgit.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ def _commit_counts(path: str, ref: str) -> tuple[int, int | None, str]:


def _ahead_behind(path: str) -> tuple[int | None, int | None]:
"""``(ahead, behind)`` against the branch's upstream, or ``(None, None)`` with no upstream."""
counts = _git(path, "rev-list", "--left-right", "--count", "@{upstream}...HEAD")
if not counts:
return None, None
Expand Down
16 changes: 16 additions & 0 deletions collector/jq_collector/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@


def _gauge(name: str, doc: str, labels: list[str] | None = None) -> GaugeMetricFamily:
"""A gauge family with ``labels`` (none by default)."""
return GaugeMetricFamily(name, doc, labels=labels or [])


def render(snap: Snapshot) -> Iterator[Metric]:
"""The whole exposition for one snapshot: collector health first, then every repo's series."""
yield from _health(snap)

f = _Families()
Expand Down Expand Up @@ -358,6 +360,7 @@ def __init__(self) -> None:
)

def in_exposition_order(self) -> tuple[GaugeMetricFamily, ...]:
"""Every family, in the order the exposition lists them."""
return (
self.repo_info,
self.cloned,
Expand Down Expand Up @@ -409,6 +412,7 @@ def in_exposition_order(self) -> tuple[GaugeMetricFamily, ...]:
def _add_identity(
f: _Families, key: str, remote: RemoteRepo | None, local: LocalRepo | None
) -> None:
"""The repo's info series (owner, branch, visibility, forge, URLs) and whether it is cloned here."""
# rpartition, not partition: a GitLab namespace nests, so the owner is
# everything before the last slash rather than the first segment.
owner = key.rpartition("/")[0]
Expand All @@ -428,10 +432,12 @@ def _add_identity(


def _default_branch(remote: RemoteRepo | None) -> str:
"""The forge's default branch, or ``main`` when the forge has not answered."""
return remote.default_branch if remote else "main"


def _add_remote(f: _Families, ident: list[str], remote: RemoteRepo) -> None:
"""Every series the forge's answer feeds."""
f.pushed.add_metric(ident, remote.pushed_at)
_add_protection(f, ident, remote)
_add_alerts(f, ident, remote)
Expand All @@ -442,13 +448,15 @@ def _add_remote(f: _Families, ident: list[str], remote: RemoteRepo) -> None:


def _add_protection(f: _Families, ident: list[str], remote: RemoteRepo) -> None:
"""Branch-protection series, left out entirely when the forge would not say."""
if remote.protected is not None:
f.protected.add_metric(ident, 1 if remote.protected else 0)
f.required_reviews.add_metric(ident, remote.required_reviews)
f.force_push.add_metric(ident, 1 if remote.allows_force_push else 0)


def _add_alerts(f: _Families, ident: list[str], remote: RemoteRepo) -> None:
"""Whether alerts are on and, if so, the open count per severity."""
f.alerts_enabled.add_metric(ident, 1 if remote.alerts_enabled else 0)
if remote.alerts_enabled:
# Zero-fill the severities GitHub uses, so a repo that has just
Expand All @@ -462,6 +470,7 @@ def _add_alerts(f: _Families, ident: list[str], remote: RemoteRepo) -> None:


def _add_drift(f: _Families, ident: list[str], remote: RemoteRepo) -> None:
"""Template series: managed or not, the pinned ref, and how many releases it is behind."""
f.managed.add_metric(ident, 1 if remote.rhiza_managed else 0)
if remote.rhiza_ref:
f.ref_info.add_metric([*ident, remote.rhiza_ref], 1)
Expand All @@ -470,6 +479,7 @@ def _add_drift(f: _Families, ident: list[str], remote: RemoteRepo) -> None:


def _latest_per_workflow(workflows: tuple[WorkflowRun, ...]) -> dict[str, WorkflowRun]:
"""The newest conclusive run of each workflow name, so no label set appears twice."""
# Collapse workflows sharing a name, newest run winning. github.py
# already guarantees one per name, but this layer owns the exposition
# contract: duplicate label sets are silently dropped by Prometheus
Expand All @@ -485,6 +495,7 @@ def _latest_per_workflow(workflows: tuple[WorkflowRun, ...]) -> dict[str, Workfl


def _add_ci(f: _Families, ident: list[str], remote: RemoteRepo) -> None:
"""Per-workflow verdicts and times, and the repo-level CI verdict."""
bad = 0
for wf in _latest_per_workflow(remote.workflows).values():
good = wf.conclusion in _GOOD_CONCLUSIONS
Expand Down Expand Up @@ -512,6 +523,7 @@ def _add_ci(f: _Families, ident: list[str], remote: RemoteRepo) -> None:


def _add_pulls(f: _Families, ident: list[str], remote: RemoteRepo) -> None:
"""Open pull request and issue counts, and one info series per open pull request."""
f.pr_count.add_metric(ident, remote.open_pulls_total)
f.issue_count.add_metric(ident, remote.open_issues)
# Red means red. A cancelled check is no verdict - the same rule the
Expand All @@ -536,6 +548,7 @@ def _add_pulls(f: _Families, ident: list[str], remote: RemoteRepo) -> None:


def _add_merged(f: _Families, ident: list[str], remote: RemoteRepo) -> None:
"""One series per recently merged pull request, valued at its merge time."""
# One series per recently merged PR. The value is the merge time, so
# the board can take topk() across the fleet rather than needing a
# per-repo view. Deduped on number because a repo occasionally
Expand All @@ -550,6 +563,7 @@ def _add_merged(f: _Families, ident: list[str], remote: RemoteRepo) -> None:


def _add_local(f: _Families, ident: list[str], local: LocalRepo, remote: RemoteRepo | None) -> None:
"""Every series the working copy feeds."""
_add_working_copy(f, ident, local, remote)
_add_upstream(f, ident, local, remote)
_add_size(f, ident, local)
Expand Down Expand Up @@ -584,6 +598,7 @@ def _add_upstream(


def _add_size(f: _Families, ident: list[str], local: LocalRepo) -> None:
"""Line counts, recent commits and the release series for the checkout."""
f.code_lines.add_metric(ident, local.code_lines)
f.test_lines.add_metric(ident, local.test_lines)
f.commits_30d.add_metric(ident, local.commits_30d)
Expand All @@ -602,4 +617,5 @@ def __init__(self, store: Store) -> None:
self._store = store

def collect(self) -> Iterator[Metric]:
"""The current snapshot, rendered. prometheus_client calls this on every scrape."""
yield from render(self._store.snapshot())
2 changes: 2 additions & 0 deletions collector/jq_collector/origin.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ class Origin:

@property
def full_name(self) -> str:
"""``namespace/name``, the repo's key everywhere else in the collector."""
return f"{self.namespace}/{self.name}"

@property
def forge(self) -> str:
"""Which forge the origin's host is."""
return forge_for_host(self.host)


Expand Down
1 change: 1 addition & 0 deletions collector/jq_collector/repos.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def resolve_path(raw: str, host_root: str) -> str:


def _is_checkout(path: str) -> bool:
"""Whether ``path`` is a git checkout, plain or a worktree."""
# `.git` is a directory in a plain checkout and a file in a worktree.
return os.path.exists(os.path.join(path, ".git"))

Expand Down
8 changes: 8 additions & 0 deletions collector/jq_collector/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

@dataclass(frozen=True)
class PullRequest:
"""An open pull (or merge) request, with its checks rolled up to one word."""

number: int
title: str
author: str
Expand Down Expand Up @@ -171,6 +173,8 @@ class LocalRepo:

@dataclass(frozen=True)
class SourceHealth:
"""How one source's refreshes are going: last success, its duration, and the errors since."""

last_success: float = 0.0
last_duration: float = 0.0
errors: int = 0
Expand Down Expand Up @@ -209,18 +213,22 @@ def __init__(self) -> None:
self._snapshot = Snapshot()

def snapshot(self) -> Snapshot:
"""The current snapshot. It is immutable, so a caller may keep it after the store moves on."""
with self._lock:
return self._snapshot

def update(self, **changes: object) -> None:
"""Replace the given snapshot fields at once, so a reader never sees half an update."""
with self._lock:
self._snapshot = replace(self._snapshot, **changes) # type: ignore[arg-type]

def record_health(self, source: str, health: SourceHealth) -> None:
"""Set one source's health, leaving the other sources' alone."""
with self._lock:
health_map = dict(self._snapshot.health)
health_map[source] = health
self._snapshot = replace(self._snapshot, health=health_map)

def health_for(self, source: str) -> SourceHealth:
"""One source's health, or a fresh SourceHealth if it has never reported."""
return self.snapshot().health.get(source, SourceHealth())
12 changes: 12 additions & 0 deletions collector/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ branch = true
# CI's test step fails on a drop without a --cov-fail-under of its own.
fail_under = 100

[tool.interrogate]
# Every module, class and function in the package carries a docstring, and this
# keeps it so - the hook in .pre-commit-config.yaml reads this table, and CI runs
# that hook. Three kinds are exempt because something else already documents
# them: __init__ (the class docstring), dunder methods (the data model), and
# functions nested in another (the enclosing function, which is what a reader
# looks at - these are the per-repo closures handed to a thread pool).
fail-under = 100
ignore-init-method = true
ignore-magic = true
ignore-nested-functions = true

[tool.mypy]
# The package only, checked against the oldest Python it claims to run on, and
# strict: every signature typed, every generic parameterised. JSON payloads are
Expand Down
Loading