diff --git a/collector/jq_collector/github.py b/collector/jq_collector/github.py index ee8c7b8..f01f687 100644 --- a/collector/jq_collector/github.py +++ b/collector/jq_collector/github.py @@ -310,36 +310,14 @@ def latest_runs(self, full_name: str, branch: str) -> list[dict]: exclude_pull_requests="true", per_page=100, ) - newest: dict[Any, dict] = {} feed = data.get("workflow_runs") if isinstance(data, dict) else None - for run in feed or []: - wid = run.get("workflow_id") - if active is not None and wid not in active: - continue # workflow deleted or disabled since this run - if _inconclusive(run): - continue # cancelled or stale: no verdict to report - key = wid if wid is not None else run.get("name") - current = newest.get(key) - if current is None or _ts(run.get("updated_at")) > _ts(current.get("updated_at")): - newest[key] = run + newest = _newest_per_workflow(feed or [], active) # One targeted call per workflow the feed missed. Quiet repos pay # nothing; only the busy ones do, and only for what was actually hidden. - if active is not None: - for wid in active: - if wid in newest: - continue - extra = self._json( - f"/repos/{full_name}/actions/workflows/{wid}/runs", - branch=branch, - status="completed", - exclude_pull_requests="true", - # More than one, because the newest run may be a cancelled - # one; the API has no "conclusive only" filter. - per_page=5, - ) - runs = (extra.get("workflow_runs") if isinstance(extra, dict) else None) or [] - conclusive = next((r for r in runs if not _inconclusive(r)), None) + for wid in active or (): + if wid not in newest: + conclusive = self._newest_conclusive(full_name, branch, wid) if conclusive is not None: newest[wid] = conclusive @@ -348,6 +326,20 @@ def latest_runs(self, full_name: str, branch: str) -> list[dict]: for wid, run in newest.items() ] + def _newest_conclusive(self, full_name: str, branch: str, wid: int) -> dict | None: + """The newest run of one workflow that reached a verdict, if any.""" + extra = self._json( + f"/repos/{full_name}/actions/workflows/{wid}/runs", + branch=branch, + status="completed", + exclude_pull_requests="true", + # More than one, because the newest run may be a cancelled + # one; the API has no "conclusive only" filter. + per_page=5, + ) + runs = (extra.get("workflow_runs") if isinstance(extra, dict) else None) or [] + return next((r for r in runs if not _inconclusive(r)), None) + def coverage_artifact(self, full_name: str, branch: str) -> int: """Id of the newest ``coverage-report`` artifact built on ``branch``. @@ -499,6 +491,25 @@ def _coverage(blob: bytes) -> tuple[float, int] | None: return round(float(rate) * 100, 1), int(root.get("lines-valid") or 0) +def _newest_per_workflow(feed: list[dict], active: dict[int, str] | None) -> dict[Any, dict]: + """The runs feed reduced to the newest conclusive run per active workflow. + + Keyed on workflow id, or on the run's name for a run that carries none. + """ + newest: dict[Any, dict] = {} + for run in feed: + wid = run.get("workflow_id") + if active is not None and wid not in active: + continue # workflow deleted or disabled since this run + if _inconclusive(run): + continue # cancelled or stale: no verdict to report + key = wid if wid is not None else run.get("name") + current = newest.get(key) + if current is None or _ts(run.get("updated_at")) > _ts(current.get("updated_at")): + newest[key] = run + return newest + + def _inconclusive(run: dict) -> bool: return (run.get("conclusion") or "") in INCONCLUSIVE_CONCLUSIONS diff --git a/collector/jq_collector/metrics.py b/collector/jq_collector/metrics.py index e48fd79..c28b1ad 100644 --- a/collector/jq_collector/metrics.py +++ b/collector/jq_collector/metrics.py @@ -34,7 +34,7 @@ from .forge import GOOD_CONCLUSIONS as _GOOD_CONCLUSIONS from .forge import INCONCLUSIVE_CONCLUSIONS as _INCONCLUSIVE_CONCLUSIONS -from .state import Snapshot, WorkflowRun +from .state import LocalRepo, RemoteRepo, Snapshot, WorkflowRun def _gauge(name: str, doc: str, labels: list[str] | None = None) -> GaugeMetricFamily: @@ -42,8 +42,24 @@ def _gauge(name: str, doc: str, labels: list[str] | None = None) -> GaugeMetricF def render(snap: Snapshot): - keys = sorted((set(snap.remote) | set(snap.local)) - snap.excluded) + yield from _health(snap) + f = _Families() + for key in sorted((set(snap.remote) | set(snap.local)) - snap.excluded): + remote = snap.remote.get(key) + local = snap.local.get(key) + ident = [key] + _add_identity(f, key, remote, local) + if remote is not None: + _add_remote(f, ident, remote) + if local is not None: + _add_local(f, ident, local, remote) + + yield from f.in_exposition_order() + + +def _health(snap: Snapshot): + """The fleet-wide families: collector health, the rate limit, the newest template.""" # -- collector health ------------------------------------------------ last_success = _gauge( "jq_collector_last_success_timestamp_seconds", @@ -96,438 +112,471 @@ def render(snap: Snapshot): latest.add_metric([snap.latest_template_ref], 1) yield latest - # -- identity -------------------------------------------------------- - repo_info = _gauge( - "jq_repo_info", - "Always 1; labels carry the repo's identity for joining.", - ["repo", "owner", "default_branch", "visibility", "forge", "repo_url", "pulls_url"], - ) - cloned = _gauge( - "jq_repo_cloned", - "1 if the repo has a working copy on this machine.", - ["repo"], - ) - pushed = _gauge( - "jq_repo_last_push_timestamp_seconds", - "Unix time of the last push to GitHub.", - ["repo"], - ) - # -- template drift -------------------------------------------------- - managed = _gauge( - "jq_rhiza_managed", - "1 if the repo carries a template pointer.", - ["repo"], - ) - ref_info = _gauge( - "jq_rhiza_template_ref_info", - "Always 1; the label carries the pinned template ref.", - ["repo", "ref"], - ) - behind = _gauge( - "jq_rhiza_releases_behind", - "Template releases published after the pinned ref. Absent when the ref is not a release.", - ["repo"], - ) +class _Families: + """Every per-repo family, created empty and filled one repo at a time.""" - # -- default-branch protection --------------------------------------- - # Absent, not zero, when GitHub would not say. `absent` and `0` are - # distinguishable in PromQL; a zero here would be read as a finding. - protected = _gauge( - "jq_branch_protected", - "1 if the default branch is protected. Absent when the token cannot see protection.", - ["repo"], - ) - required_reviews = _gauge( - "jq_branch_required_reviews", - "Approving reviews required to merge into the default branch.", - ["repo"], - ) - force_push = _gauge( - "jq_branch_allows_force_push", - "1 if the protected default branch still allows force pushes.", - ["repo"], - ) + def __init__(self) -> None: + # -- identity -------------------------------------------------------- + self.repo_info = _gauge( + "jq_repo_info", + "Always 1; labels carry the repo's identity for joining.", + ["repo", "owner", "default_branch", "visibility", "forge", "repo_url", "pulls_url"], + ) + self.cloned = _gauge( + "jq_repo_cloned", + "1 if the repo has a working copy on this machine.", + ["repo"], + ) + self.pushed = _gauge( + "jq_repo_last_push_timestamp_seconds", + "Unix time of the last push to GitHub.", + ["repo"], + ) - # -- Dependabot ------------------------------------------------------- - alerts_enabled = _gauge( - "jq_dependabot_alerts_enabled", - "1 if Dependabot alerts are on for the repo.", - ["repo"], - ) - alerts = _gauge( - "jq_dependabot_open_alerts", - "Open Dependabot alerts by severity. Absent when alerts are disabled.", - ["repo", "severity"], - ) + # -- template drift -------------------------------------------------- + self.managed = _gauge( + "jq_rhiza_managed", + "1 if the repo carries a template pointer.", + ["repo"], + ) + self.ref_info = _gauge( + "jq_rhiza_template_ref_info", + "Always 1; the label carries the pinned template ref.", + ["repo", "ref"], + ) + self.behind = _gauge( + "jq_rhiza_releases_behind", + "Template releases published after the pinned ref. Absent when the ref is not a release.", + ["repo"], + ) - # -- CI on the default branch ---------------------------------------- - ci_info = _gauge( - "jq_ci_last_run_info", - "Always 1; labels carry the last completed default-branch run.", - ["repo", "conclusion", "workflow", "url"], - ) - ci_ok = _gauge( - "jq_ci_last_run_success", - "1 if the last completed default-branch run passed.", - ["repo"], - ) - ci_at = _gauge( - "jq_ci_last_run_timestamp_seconds", - "Unix time that run finished.", - ["repo"], - ) - ci_dur = _gauge("jq_ci_last_run_duration_seconds", "How long that run took.", ["repo"]) - wf_ok = _gauge( - "jq_ci_workflow_success", - "Per workflow: 1 if its latest completed default-branch run passed.", - ["repo", "workflow"], - ) - wf_at = _gauge( - "jq_ci_workflow_timestamp_seconds", - "Per workflow: when that run finished.", - ["repo", "workflow"], - ) - # Its own family rather than a label on jq_ci_workflow_success, which is a - # real gauge with 180 days behind it - adding a label there would start a - # new series and orphan all of it. - wf_info = _gauge( - "jq_ci_workflow_info", - "Always 1; the label carries the URL of that workflow's latest run.", - ["repo", "workflow", "url"], - ) - coverage = _gauge( - "jq_ci_coverage_percent", - "Line coverage from the newest default-branch coverage-report artifact. " - "Absent when the repo publishes none.", - ["repo"], - ) - coverage_lines = _gauge( - "jq_ci_coverage_lines", - "Lines CI measured for that coverage figure. The percentage is not " - "interpretable without it, and its denominator is not jq_local_code_lines.", - ["repo"], - ) - wf_failing = _gauge( - "jq_ci_workflows_failing", - "How many of the repo's workflows are red on the default branch.", - ["repo"], - ) + # -- default-branch protection --------------------------------------- + # Absent, not zero, when GitHub would not say. `absent` and `0` are + # distinguishable in PromQL; a zero here would be read as a finding. + self.protected = _gauge( + "jq_branch_protected", + "1 if the default branch is protected. Absent when the token cannot see protection.", + ["repo"], + ) + self.required_reviews = _gauge( + "jq_branch_required_reviews", + "Approving reviews required to merge into the default branch.", + ["repo"], + ) + self.force_push = _gauge( + "jq_branch_allows_force_push", + "1 if the protected default branch still allows force pushes.", + ["repo"], + ) - # -- pull requests --------------------------------------------------- - pr_count = _gauge("jq_open_pull_requests", "Open pull requests.", ["repo"]) - issue_count = _gauge( - "jq_open_issues", - "Open issues, excluding pull requests.", - ["repo"], - ) - pr_failing = _gauge( - "jq_open_pull_requests_failing", - "Open pull requests whose checks are red.", - ["repo"], - ) - pr_info = _gauge( - "jq_pull_request_info", - "Always 1; one series per open pull request.", - ["repo", "number", "title", "author", "checks", "draft", "url"], - ) - merged_at = _gauge( - "jq_merged_pull_request_timestamp_seconds", - "Unix time a pull request was merged. topk() over this gives the newest.", - ["repo", "number", "title", "author"], - ) - # The URL rides on its own family rather than on the timestamp above, - # because that timestamp *is* the merged-PR timeline - the history the - # "Recently merged" panel reads. A label there would have orphaned every - # existing series and, until they went stale, shown each merged PR twice. - merged_info = _gauge( - "jq_merged_pull_request_info", - "Always 1; the label carries the merged pull request's URL.", - ["repo", "number", "url"], - ) - pr_created = _gauge( - "jq_pull_request_created_timestamp_seconds", - "Unix time the pull request was opened.", - ["repo", "number"], - ) + # -- Dependabot ------------------------------------------------------- + self.alerts_enabled = _gauge( + "jq_dependabot_alerts_enabled", + "1 if Dependabot alerts are on for the repo.", + ["repo"], + ) + self.alerts = _gauge( + "jq_dependabot_open_alerts", + "Open Dependabot alerts by severity. Absent when alerts are disabled.", + ["repo", "severity"], + ) - # -- local working copies -------------------------------------------- - local_branch = _gauge( - "jq_local_branch_info", - "Always 1; the label carries the checked-out branch.", - ["repo", "branch"], - ) - on_default = _gauge( - "jq_local_on_default_branch", - "1 if the clone sits on its default branch.", - ["repo"], - ) - dirty = _gauge( - "jq_local_dirty_files", - "Tracked files with uncommitted changes.", - ["repo"], - ) - untracked = _gauge( - "jq_local_untracked_files", - "Untracked files in the working copy.", - ["repo"], - ) - ahead = _gauge( - "jq_local_ahead_commits", - "Commits ahead of upstream, as of the last fetch.", - ["repo"], - ) - behind_local = _gauge( - "jq_local_behind_commits", - "Commits behind upstream, as of the last fetch.", - ["repo"], - ) - stashes = _gauge("jq_local_stash_entries", "Stash entries.", ["repo"]) - last_commit = _gauge( - "jq_local_last_commit_timestamp_seconds", - "Unix time of HEAD's commit.", - ["repo"], - ) - fetch_age = _gauge( - "jq_local_fetch_age_seconds", - "Seconds since this clone last fetched. Read the ahead/behind counts against this.", - ["repo"], - ) - local_ref = _gauge( - "jq_local_template_ref_info", - "Always 1; the ref pinned in the *clone's* pointer. May lag the repo's.", - ["repo", "ref"], - ) - synced = _gauge( - "jq_local_default_branch_synced", - "1 if the local default branch is the same commit GitHub reports. Fetch-independent.", - ["repo"], - ) + # -- CI on the default branch ---------------------------------------- + self.ci_info = _gauge( + "jq_ci_last_run_info", + "Always 1; labels carry the last completed default-branch run.", + ["repo", "conclusion", "workflow", "url"], + ) + self.ci_ok = _gauge( + "jq_ci_last_run_success", + "1 if the last completed default-branch run passed.", + ["repo"], + ) + self.ci_at = _gauge( + "jq_ci_last_run_timestamp_seconds", + "Unix time that run finished.", + ["repo"], + ) + self.ci_dur = _gauge("jq_ci_last_run_duration_seconds", "How long that run took.", ["repo"]) + self.wf_ok = _gauge( + "jq_ci_workflow_success", + "Per workflow: 1 if its latest completed default-branch run passed.", + ["repo", "workflow"], + ) + self.wf_at = _gauge( + "jq_ci_workflow_timestamp_seconds", + "Per workflow: when that run finished.", + ["repo", "workflow"], + ) + # Its own family rather than a label on jq_ci_workflow_success, which is a + # real gauge with 180 days behind it - adding a label there would start a + # new series and orphan all of it. + self.wf_info = _gauge( + "jq_ci_workflow_info", + "Always 1; the label carries the URL of that workflow's latest run.", + ["repo", "workflow", "url"], + ) + self.coverage = _gauge( + "jq_ci_coverage_percent", + "Line coverage from the newest default-branch coverage-report artifact. " + "Absent when the repo publishes none.", + ["repo"], + ) + self.coverage_lines = _gauge( + "jq_ci_coverage_lines", + "Lines CI measured for that coverage figure. The percentage is not " + "interpretable without it, and its denominator is not jq_local_code_lines.", + ["repo"], + ) + self.wf_failing = _gauge( + "jq_ci_workflows_failing", + "How many of the repo's workflows are red on the default branch.", + ["repo"], + ) - # -- size and cadence ------------------------------------------------- - # Lines are counted in the working copy, commits on the default branch. - # Re-measured only when the clone moves, so these are flat between commits - # by design rather than by a stuck collector. - code_lines = _gauge( - "jq_local_code_lines", - "Lines of tracked source outside the test tree, in the working copy.", - ["repo"], - ) - test_lines = _gauge( - "jq_local_test_lines", - "Lines of tracked source under the test tree, in the working copy.", - ["repo"], - ) - commits_30d = _gauge( - "jq_local_commits_30d", - "Commits on the default branch in the last 30 days.", - ["repo"], - ) - since_release = _gauge( - "jq_local_commits_since_release", - "Commits on the default branch since the newest tag. Absent when the clone has no tags.", - ["repo"], - ) - release_info = _gauge( - "jq_local_last_release_info", - "Always 1; the label carries the newest tag reachable in the clone.", - ["repo", "ref"], - ) + # -- pull requests --------------------------------------------------- + self.pr_count = _gauge("jq_open_pull_requests", "Open pull requests.", ["repo"]) + self.issue_count = _gauge( + "jq_open_issues", + "Open issues, excluding pull requests.", + ["repo"], + ) + self.pr_failing = _gauge( + "jq_open_pull_requests_failing", + "Open pull requests whose checks are red.", + ["repo"], + ) + self.pr_info = _gauge( + "jq_pull_request_info", + "Always 1; one series per open pull request.", + ["repo", "number", "title", "author", "checks", "draft", "url"], + ) + self.merged_at = _gauge( + "jq_merged_pull_request_timestamp_seconds", + "Unix time a pull request was merged. topk() over this gives the newest.", + ["repo", "number", "title", "author"], + ) + # The URL rides on its own family rather than on the timestamp above, + # because that timestamp *is* the merged-PR timeline - the history the + # "Recently merged" panel reads. A label there would have orphaned every + # existing series and, until they went stale, shown each merged PR twice. + self.merged_info = _gauge( + "jq_merged_pull_request_info", + "Always 1; the label carries the merged pull request's URL.", + ["repo", "number", "url"], + ) + self.pr_created = _gauge( + "jq_pull_request_created_timestamp_seconds", + "Unix time the pull request was opened.", + ["repo", "number"], + ) - for key in keys: - remote = snap.remote.get(key) - local = snap.local.get(key) - # 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] - ident = [key] + # -- local working copies -------------------------------------------- + self.local_branch = _gauge( + "jq_local_branch_info", + "Always 1; the label carries the checked-out branch.", + ["repo", "branch"], + ) + self.on_default = _gauge( + "jq_local_on_default_branch", + "1 if the clone sits on its default branch.", + ["repo"], + ) + self.dirty = _gauge( + "jq_local_dirty_files", + "Tracked files with uncommitted changes.", + ["repo"], + ) + self.untracked = _gauge( + "jq_local_untracked_files", + "Untracked files in the working copy.", + ["repo"], + ) + self.ahead = _gauge( + "jq_local_ahead_commits", + "Commits ahead of upstream, as of the last fetch.", + ["repo"], + ) + self.behind_local = _gauge( + "jq_local_behind_commits", + "Commits behind upstream, as of the last fetch.", + ["repo"], + ) + self.stashes = _gauge("jq_local_stash_entries", "Stash entries.", ["repo"]) + self.last_commit = _gauge( + "jq_local_last_commit_timestamp_seconds", + "Unix time of HEAD's commit.", + ["repo"], + ) + self.fetch_age = _gauge( + "jq_local_fetch_age_seconds", + "Seconds since this clone last fetched. Read the ahead/behind counts against this.", + ["repo"], + ) + self.local_ref = _gauge( + "jq_local_template_ref_info", + "Always 1; the ref pinned in the *clone's* pointer. May lag the repo's.", + ["repo", "ref"], + ) + self.synced = _gauge( + "jq_local_default_branch_synced", + "1 if the local default branch is the same commit GitHub reports. Fetch-independent.", + ["repo"], + ) - default_branch = remote.default_branch if remote else "main" - repo_info.add_metric( + # -- size and cadence ------------------------------------------------- + # Lines are counted in the working copy, commits on the default branch. + # Re-measured only when the clone moves, so these are flat between commits + # by design rather than by a stuck collector. + self.code_lines = _gauge( + "jq_local_code_lines", + "Lines of tracked source outside the test tree, in the working copy.", + ["repo"], + ) + self.test_lines = _gauge( + "jq_local_test_lines", + "Lines of tracked source under the test tree, in the working copy.", + ["repo"], + ) + self.commits_30d = _gauge( + "jq_local_commits_30d", + "Commits on the default branch in the last 30 days.", + ["repo"], + ) + self.since_release = _gauge( + "jq_local_commits_since_release", + "Commits on the default branch since the newest tag. Absent when the clone has no tags.", + ["repo"], + ) + self.release_info = _gauge( + "jq_local_last_release_info", + "Always 1; the label carries the newest tag reachable in the clone.", + ["repo", "ref"], + ) + + def in_exposition_order(self) -> tuple[GaugeMetricFamily, ...]: + return ( + self.repo_info, + self.cloned, + self.pushed, + self.managed, + self.protected, + self.required_reviews, + self.force_push, + self.alerts_enabled, + self.alerts, + self.ref_info, + self.behind, + self.ci_info, + self.ci_ok, + self.ci_at, + self.ci_dur, + self.wf_ok, + self.wf_at, + self.wf_info, + self.wf_failing, + self.coverage, + self.coverage_lines, + self.pr_count, + self.issue_count, + self.pr_failing, + self.pr_info, + self.pr_created, + self.merged_at, + self.merged_info, + self.local_branch, + self.on_default, + self.dirty, + self.untracked, + self.ahead, + self.behind_local, + self.stashes, + self.last_commit, + self.fetch_age, + self.local_ref, + self.synced, + self.code_lines, + self.test_lines, + self.commits_30d, + self.since_release, + self.release_info, + ) + + +def _add_identity( + f: _Families, key: str, remote: RemoteRepo | None, local: LocalRepo | None +) -> None: + # 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] + f.repo_info.add_metric( + [ + key, + owner, + _default_branch(remote), + remote.visibility if remote else "unknown", + remote.forge if remote else "github", + remote.url if remote else "", + remote.pulls_url if remote else "", + ], + 1, + ) + f.cloned.add_metric([key], 1 if local else 0) + + +def _default_branch(remote: RemoteRepo | None) -> str: + return remote.default_branch if remote else "main" + + +def _add_remote(f: _Families, ident: list[str], remote: RemoteRepo) -> None: + f.pushed.add_metric(ident, remote.pushed_at) + _add_protection(f, ident, remote) + _add_alerts(f, ident, remote) + _add_drift(f, ident, remote) + _add_ci(f, ident, remote) + _add_pulls(f, ident, remote) + _add_merged(f, ident, remote) + + +def _add_protection(f: _Families, ident: list[str], remote: RemoteRepo) -> None: + 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: + 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 + # cleared its criticals reads as 0 rather than dropping out of + # the query and leaving the last non-zero value on the graph. + counts = dict(remote.alerts) + for severity in ("critical", "high", "medium", "low"): + f.alerts.add_metric([*ident, severity], counts.pop(severity, 0)) + for severity, count in sorted(counts.items()): + f.alerts.add_metric([*ident, severity], count) + + +def _add_drift(f: _Families, ident: list[str], remote: RemoteRepo) -> None: + 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) + if remote.rhiza_behind is not None: + f.behind.add_metric(ident, remote.rhiza_behind) + + +def _latest_per_workflow(workflows: tuple[WorkflowRun, ...]) -> dict[str, WorkflowRun]: + # 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 + # ("samples with different value but same timestamp"), which cost 16 + # samples a scrape when the invariant was last broken upstream. + unique: dict[str, WorkflowRun] = {} + for wf in sorted(workflows, key=lambda w: w.finished_at, reverse=True): + if wf.conclusion in _INCONCLUSIVE_CONCLUSIONS: + continue + if wf.conclusion and wf.name not in unique: + unique[wf.name] = wf + return unique + + +def _add_ci(f: _Families, ident: list[str], remote: RemoteRepo) -> None: + bad = 0 + for wf in _latest_per_workflow(remote.workflows).values(): + good = wf.conclusion in _GOOD_CONCLUSIONS + bad += 0 if good else 1 + f.wf_ok.add_metric([*ident, wf.name], 1 if good else 0) + f.wf_at.add_metric([*ident, wf.name], wf.finished_at) + f.wf_info.add_metric([*ident, wf.name, wf.url], 1) + + if remote.ci_conclusion and remote.ci_conclusion not in _INCONCLUSIVE_CONCLUSIONS: + f.ci_info.add_metric([*ident, remote.ci_conclusion, remote.ci_workflow, remote.ci_url], 1) + # Green only when no workflow is red. Deriving this from a single + # run made a repo look green whenever some other workflow had run + # more recently than the failing one. + f.ci_ok.add_metric(ident, 1 if bad == 0 else 0) + f.ci_at.add_metric(ident, remote.ci_finished_at) + f.ci_dur.add_metric(ident, remote.ci_duration) + f.wf_failing.add_metric(ident, bad) + + # Absent, not zero, when there is no report. Zero would read as + # "nothing is covered", which is a finding; "nobody publishes a + # report here" is not one. + if remote.coverage is not None: + f.coverage.add_metric(ident, remote.coverage) + f.coverage_lines.add_metric(ident, remote.coverage_lines) + + +def _add_pulls(f: _Families, ident: list[str], remote: RemoteRepo) -> None: + 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 + # default branch follows - so it does not make a pull request count + # as failing; the PR table still shows the word in its checks column. + f.pr_failing.add_metric(ident, sum(1 for p in remote.pulls if p.checks == "failure")) + for pull in remote.pulls: + number = str(pull.number) + f.pr_info.add_metric( [ - key, - owner, - default_branch, - remote.visibility if remote else "unknown", - remote.forge if remote else "github", - remote.url if remote else "", - remote.pulls_url if remote else "", + *ident, + number, + pull.title, + pull.author, + pull.checks, + str(pull.draft).lower(), + pull.url, ], 1, ) - cloned.add_metric(ident, 1 if local else 0) - - if remote is not None: - pushed.add_metric(ident, remote.pushed_at) - if remote.protected is not None: - protected.add_metric(ident, 1 if remote.protected else 0) - required_reviews.add_metric(ident, remote.required_reviews) - force_push.add_metric(ident, 1 if remote.allows_force_push else 0) - - 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 - # cleared its criticals reads as 0 rather than dropping out of - # the query and leaving the last non-zero value on the graph. - counts = dict(remote.alerts) - for severity in ("critical", "high", "medium", "low"): - alerts.add_metric([*ident, severity], counts.pop(severity, 0)) - for severity, count in sorted(counts.items()): - alerts.add_metric([*ident, severity], count) - - managed.add_metric(ident, 1 if remote.rhiza_managed else 0) - if remote.rhiza_ref: - ref_info.add_metric([*ident, remote.rhiza_ref], 1) - if remote.rhiza_behind is not None: - behind.add_metric(ident, remote.rhiza_behind) - - # 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 - # ("samples with different value but same timestamp"), which cost 16 - # samples a scrape when the invariant was last broken upstream. - unique: dict[str, WorkflowRun] = {} - for wf in sorted(remote.workflows, key=lambda w: w.finished_at, reverse=True): - if wf.conclusion in _INCONCLUSIVE_CONCLUSIONS: - continue - if wf.conclusion and wf.name not in unique: - unique[wf.name] = wf - - bad = 0 - for wf in unique.values(): - good = wf.conclusion in _GOOD_CONCLUSIONS - bad += 0 if good else 1 - wf_ok.add_metric([*ident, wf.name], 1 if good else 0) - wf_at.add_metric([*ident, wf.name], wf.finished_at) - wf_info.add_metric([*ident, wf.name, wf.url], 1) - - if remote.ci_conclusion and remote.ci_conclusion not in _INCONCLUSIVE_CONCLUSIONS: - ci_info.add_metric( - [*ident, remote.ci_conclusion, remote.ci_workflow, remote.ci_url], 1 - ) - # Green only when no workflow is red. Deriving this from a single - # run made a repo look green whenever some other workflow had run - # more recently than the failing one. - ci_ok.add_metric(ident, 1 if bad == 0 else 0) - ci_at.add_metric(ident, remote.ci_finished_at) - ci_dur.add_metric(ident, remote.ci_duration) - wf_failing.add_metric(ident, bad) - - # Absent, not zero, when there is no report. Zero would read as - # "nothing is covered", which is a finding; "nobody publishes a - # report here" is not one. - if remote.coverage is not None: - coverage.add_metric(ident, remote.coverage) - coverage_lines.add_metric(ident, remote.coverage_lines) - - pr_count.add_metric(ident, remote.open_pulls_total) - issue_count.add_metric(ident, remote.open_issues) - # Red means red. A cancelled check is no verdict - the same rule the - # default branch follows - so it does not make a pull request count - # as failing; the PR table still shows the word in its checks column. - pr_failing.add_metric(ident, sum(1 for p in remote.pulls if p.checks == "failure")) - for pull in remote.pulls: - number = str(pull.number) - pr_info.add_metric( - [ - *ident, - number, - pull.title, - pull.author, - pull.checks, - str(pull.draft).lower(), - pull.url, - ], - 1, - ) - pr_created.add_metric([*ident, number], pull.created_at) - - # 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 - # reports the same PR twice across a page boundary. - seen: set[int] = set() - for m in remote.merged: - if m.number in seen: - continue - seen.add(m.number) - merged_at.add_metric([*ident, str(m.number), m.title, m.author], m.merged_at) - merged_info.add_metric([*ident, str(m.number), m.url], 1) - - if local is not None: - local_branch.add_metric([*ident, local.branch or "unknown"], 1) - on_default.add_metric(ident, 1 if local.branch == default_branch else 0) - dirty.add_metric(ident, local.dirty_files) - untracked.add_metric(ident, local.untracked_files) - if local.ahead is not None: - ahead.add_metric(ident, local.ahead) - if local.behind is not None: - behind_local.add_metric(ident, local.behind) - if local.rhiza_ref: - local_ref.add_metric([*ident, local.rhiza_ref], 1) - stashes.add_metric(ident, local.stashes) - last_commit.add_metric(ident, local.last_commit_at) - if local.fetch_age is not None: - fetch_age.add_metric(ident, local.fetch_age) - if local.default_branch_sha and remote and remote.head_sha: - synced.add_metric(ident, 1 if local.default_branch_sha == remote.head_sha else 0) - - code_lines.add_metric(ident, local.code_lines) - test_lines.add_metric(ident, local.test_lines) - commits_30d.add_metric(ident, local.commits_30d) - # Absent, not zero, when the repo has never been tagged. Zero here - # would read as "nothing unreleased", the opposite of the truth. - if local.commits_since_release is not None: - since_release.add_metric(ident, local.commits_since_release) - if local.last_release: - release_info.add_metric([*ident, local.last_release], 1) - - yield from ( - repo_info, - cloned, - pushed, - managed, - protected, - required_reviews, - force_push, - alerts_enabled, - alerts, - ref_info, - behind, - ci_info, - ci_ok, - ci_at, - ci_dur, - wf_ok, - wf_at, - wf_info, - wf_failing, - coverage, - coverage_lines, - pr_count, - issue_count, - pr_failing, - pr_info, - pr_created, - merged_at, - merged_info, - local_branch, - on_default, - dirty, - untracked, - ahead, - behind_local, - stashes, - last_commit, - fetch_age, - local_ref, - synced, - code_lines, - test_lines, - commits_30d, - since_release, - release_info, - ) + f.pr_created.add_metric([*ident, number], pull.created_at) + + +def _add_merged(f: _Families, ident: list[str], remote: RemoteRepo) -> None: + # 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 + # reports the same PR twice across a page boundary. + seen: set[int] = set() + for m in remote.merged: + if m.number in seen: + continue + seen.add(m.number) + f.merged_at.add_metric([*ident, str(m.number), m.title, m.author], m.merged_at) + f.merged_info.add_metric([*ident, str(m.number), m.url], 1) + + +def _add_local(f: _Families, ident: list[str], local: LocalRepo, remote: RemoteRepo | None) -> None: + f.local_branch.add_metric([*ident, local.branch or "unknown"], 1) + f.on_default.add_metric(ident, 1 if local.branch == _default_branch(remote) else 0) + f.dirty.add_metric(ident, local.dirty_files) + f.untracked.add_metric(ident, local.untracked_files) + if local.ahead is not None: + f.ahead.add_metric(ident, local.ahead) + if local.behind is not None: + f.behind_local.add_metric(ident, local.behind) + if local.rhiza_ref: + f.local_ref.add_metric([*ident, local.rhiza_ref], 1) + f.stashes.add_metric(ident, local.stashes) + f.last_commit.add_metric(ident, local.last_commit_at) + if local.fetch_age is not None: + f.fetch_age.add_metric(ident, local.fetch_age) + if local.default_branch_sha and remote and remote.head_sha: + f.synced.add_metric(ident, 1 if local.default_branch_sha == remote.head_sha else 0) + _add_size(f, ident, local) + + +def _add_size(f: _Families, ident: list[str], local: LocalRepo) -> None: + 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) + # Absent, not zero, when the repo has never been tagged. Zero here + # would read as "nothing unreleased", the opposite of the truth. + if local.commits_since_release is not None: + f.since_release.add_metric(ident, local.commits_since_release) + if local.last_release: + f.release_info.add_metric([*ident, local.last_release], 1) class FleetCollector: diff --git a/collector/jq_collector/repos.py b/collector/jq_collector/repos.py index 4367065..bf2f60b 100644 --- a/collector/jq_collector/repos.py +++ b/collector/jq_collector/repos.py @@ -311,15 +311,8 @@ def _entry(item: Any, index: int, host_root: str) -> tuple[str, str | None, str] return parsed.full_name, path, declared or parsed.forge -def load( - source: str, host_root: str = "" -) -> tuple[tuple[str, ...], dict[str, str], dict[str, str]]: - """Read ``repos.yml`` into ``(fleet, checkout paths, forge per repo)``. - - The fleet is every listed repo as ``namespace/name``; the paths map holds - only those with a checkout this machine can actually read; the forge map - says which API each one is read through. - """ +def _read_entries(source: str) -> list: + """The non-empty ``repos:`` list of ``source``, or a FleetError saying why not.""" import yaml try: @@ -336,6 +329,33 @@ def load( entries = data.get("repos") if isinstance(data, dict) else None if not isinstance(entries, list) or not entries: raise FleetError(f"{source} lists no repos under a top-level `repos:` key") + return entries + + +def _listed_twice( + full_name: str, first: tuple[str, str | None], second: tuple[str, str | None] +) -> FleetError: + """The refusal for a repo two entries both name, as ``(forge, path)`` each.""" + (clash, first_path), (forge, path) = first, second + if clash != forge: + detail = f" - on {clash} and on {forge}" + elif first_path and path and first_path != path: + detail = f" - checked out at {first_path} and at {path}" + else: + detail = "" + return FleetError(f"{full_name} is listed twice{detail}") + + +def load( + source: str, host_root: str = "" +) -> tuple[tuple[str, ...], dict[str, str], dict[str, str]]: + """Read ``repos.yml`` into ``(fleet, checkout paths, forge per repo)``. + + The fleet is every listed repo as ``namespace/name``; the paths map holds + only those with a checkout this machine can actually read; the forge map + says which API each one is read through. + """ + entries = _read_entries(source) fleet: list[str] = [] paths: dict[str, str] = {} @@ -387,14 +407,9 @@ def load( # is the same choice the duplicate case has always made: a merged # pair would report one repo's CI under the other's name, and read # as a working board while doing it. - clash, first = forges[full_name], paths.get(full_name) - if clash != forge: - detail = f" - on {clash} and on {forge}" - elif first and path and first != path: - detail = f" - checked out at {first} and at {path}" - else: - detail = "" - raise FleetError(f"{full_name} is listed twice{detail}") + raise _listed_twice( + full_name, (forges[full_name], paths.get(full_name)), (forge, path) + ) fleet.append(full_name) forges[full_name] = forge diff --git a/collector/tests/golden/metrics.prom b/collector/tests/golden/metrics.prom new file mode 100644 index 0000000..ac31516 --- /dev/null +++ b/collector/tests/golden/metrics.prom @@ -0,0 +1,223 @@ +# HELP jq_collector_last_success_timestamp_seconds Unix time of the last successful refresh, per source. +# TYPE jq_collector_last_success_timestamp_seconds gauge +jq_collector_last_success_timestamp_seconds{source="github"} 1.0 +jq_collector_last_success_timestamp_seconds{source="local"} 3.0 +# HELP jq_collector_refresh_duration_seconds Wall-clock seconds the last refresh took, per source. +# TYPE jq_collector_refresh_duration_seconds gauge +jq_collector_refresh_duration_seconds{source="github"} 2.0 +jq_collector_refresh_duration_seconds{source="local"} 0.5 +# HELP jq_collector_errors_total Refreshes that raised, per source. +# TYPE jq_collector_errors_total counter +jq_collector_errors_total{source="github"} 3.0 +jq_collector_errors_total{source="local"} 0.0 +# HELP jq_github_rate_limit_remaining GitHub REST calls left in the current window. +# TYPE jq_github_rate_limit_remaining gauge +jq_github_rate_limit_remaining 4000.0 +# HELP jq_github_rate_limit_limit GitHub REST calls allowed per window. +# TYPE jq_github_rate_limit_limit gauge +jq_github_rate_limit_limit 5000.0 +# HELP jq_github_rate_limit_reset_timestamp_seconds Unix time the rate window resets. +# TYPE jq_github_rate_limit_reset_timestamp_seconds gauge +jq_github_rate_limit_reset_timestamp_seconds 1234.0 +# HELP jq_template_latest_release_info Always 1; the label carries the newest published template release. +# TYPE jq_template_latest_release_info gauge +jq_template_latest_release_info{ref="v1.7.1"} 1.0 +# HELP jq_repo_info Always 1; labels carry the repo's identity for joining. +# TYPE jq_repo_info gauge +jq_repo_info{default_branch="main",forge="github",owner="Jebel-Quant",pulls_url="",repo="Jebel-Quant/rhiza",repo_url="",visibility="public"} 1.0 +jq_repo_info{default_branch="trunk",forge="gitlab",owner="acme/platform",pulls_url="https://gitlab.com/acme/platform/web/-/merge_requests",repo="acme/platform/web",repo_url="https://gitlab.com/acme/platform/web",visibility="private"} 1.0 +jq_repo_info{default_branch="main",forge="github",owner="o",pulls_url="",repo="o/loose",repo_url="",visibility="unknown"} 1.0 +jq_repo_info{default_branch="main",forge="github",owner="o",pulls_url="",repo="o/solo",repo_url="",visibility="unknown"} 1.0 +# HELP jq_repo_cloned 1 if the repo has a working copy on this machine. +# TYPE jq_repo_cloned gauge +jq_repo_cloned{repo="Jebel-Quant/rhiza"} 1.0 +jq_repo_cloned{repo="acme/platform/web"} 0.0 +jq_repo_cloned{repo="o/loose"} 1.0 +jq_repo_cloned{repo="o/solo"} 1.0 +# HELP jq_repo_last_push_timestamp_seconds Unix time of the last push to GitHub. +# TYPE jq_repo_last_push_timestamp_seconds gauge +jq_repo_last_push_timestamp_seconds{repo="Jebel-Quant/rhiza"} 1000.0 +jq_repo_last_push_timestamp_seconds{repo="acme/platform/web"} 0.0 +jq_repo_last_push_timestamp_seconds{repo="o/loose"} 0.0 +# HELP jq_rhiza_managed 1 if the repo carries a template pointer. +# TYPE jq_rhiza_managed gauge +jq_rhiza_managed{repo="Jebel-Quant/rhiza"} 1.0 +jq_rhiza_managed{repo="acme/platform/web"} 0.0 +jq_rhiza_managed{repo="o/loose"} 0.0 +# HELP jq_branch_protected 1 if the default branch is protected. Absent when the token cannot see protection. +# TYPE jq_branch_protected gauge +jq_branch_protected{repo="Jebel-Quant/rhiza"} 1.0 +jq_branch_protected{repo="o/loose"} 0.0 +# HELP jq_branch_required_reviews Approving reviews required to merge into the default branch. +# TYPE jq_branch_required_reviews gauge +jq_branch_required_reviews{repo="Jebel-Quant/rhiza"} 2.0 +jq_branch_required_reviews{repo="o/loose"} 0.0 +# HELP jq_branch_allows_force_push 1 if the protected default branch still allows force pushes. +# TYPE jq_branch_allows_force_push gauge +jq_branch_allows_force_push{repo="Jebel-Quant/rhiza"} 0.0 +jq_branch_allows_force_push{repo="o/loose"} 0.0 +# HELP jq_dependabot_alerts_enabled 1 if Dependabot alerts are on for the repo. +# TYPE jq_dependabot_alerts_enabled gauge +jq_dependabot_alerts_enabled{repo="Jebel-Quant/rhiza"} 1.0 +jq_dependabot_alerts_enabled{repo="acme/platform/web"} 0.0 +jq_dependabot_alerts_enabled{repo="o/loose"} 0.0 +# HELP jq_dependabot_open_alerts Open Dependabot alerts by severity. Absent when alerts are disabled. +# TYPE jq_dependabot_open_alerts gauge +jq_dependabot_open_alerts{repo="Jebel-Quant/rhiza",severity="critical"} 1.0 +jq_dependabot_open_alerts{repo="Jebel-Quant/rhiza",severity="high"} 0.0 +jq_dependabot_open_alerts{repo="Jebel-Quant/rhiza",severity="medium"} 0.0 +jq_dependabot_open_alerts{repo="Jebel-Quant/rhiza",severity="low"} 0.0 +jq_dependabot_open_alerts{repo="Jebel-Quant/rhiza",severity="moderate"} 3.0 +# HELP jq_rhiza_template_ref_info Always 1; the label carries the pinned template ref. +# TYPE jq_rhiza_template_ref_info gauge +jq_rhiza_template_ref_info{ref="v1.7.1",repo="Jebel-Quant/rhiza"} 1.0 +# HELP jq_rhiza_releases_behind Template releases published after the pinned ref. Absent when the ref is not a release. +# TYPE jq_rhiza_releases_behind gauge +jq_rhiza_releases_behind{repo="Jebel-Quant/rhiza"} 2.0 +# HELP jq_ci_last_run_info Always 1; labels carry the last completed default-branch run. +# TYPE jq_ci_last_run_info gauge +jq_ci_last_run_info{conclusion="success",repo="Jebel-Quant/rhiza",url="",workflow="ci"} 1.0 +jq_ci_last_run_info{conclusion="failure",repo="o/loose",url="",workflow="ci"} 1.0 +# HELP jq_ci_last_run_success 1 if the last completed default-branch run passed. +# TYPE jq_ci_last_run_success gauge +jq_ci_last_run_success{repo="Jebel-Quant/rhiza"} 0.0 +jq_ci_last_run_success{repo="o/loose"} 0.0 +# HELP jq_ci_last_run_timestamp_seconds Unix time that run finished. +# TYPE jq_ci_last_run_timestamp_seconds gauge +jq_ci_last_run_timestamp_seconds{repo="Jebel-Quant/rhiza"} 900.0 +jq_ci_last_run_timestamp_seconds{repo="o/loose"} 0.0 +# HELP jq_ci_last_run_duration_seconds How long that run took. +# TYPE jq_ci_last_run_duration_seconds gauge +jq_ci_last_run_duration_seconds{repo="Jebel-Quant/rhiza"} 42.0 +jq_ci_last_run_duration_seconds{repo="o/loose"} 0.0 +# HELP jq_ci_workflow_success Per workflow: 1 if its latest completed default-branch run passed. +# TYPE jq_ci_workflow_success gauge +jq_ci_workflow_success{repo="Jebel-Quant/rhiza",workflow="ci"} 1.0 +jq_ci_workflow_success{repo="Jebel-Quant/rhiza",workflow="docs"} 0.0 +jq_ci_workflow_success{repo="o/loose",workflow="ci"} 0.0 +# HELP jq_ci_workflow_timestamp_seconds Per workflow: when that run finished. +# TYPE jq_ci_workflow_timestamp_seconds gauge +jq_ci_workflow_timestamp_seconds{repo="Jebel-Quant/rhiza",workflow="ci"} 900.0 +jq_ci_workflow_timestamp_seconds{repo="Jebel-Quant/rhiza",workflow="docs"} 800.0 +jq_ci_workflow_timestamp_seconds{repo="o/loose",workflow="ci"} 1.0 +# HELP jq_ci_workflow_info Always 1; the label carries the URL of that workflow's latest run. +# TYPE jq_ci_workflow_info gauge +jq_ci_workflow_info{repo="Jebel-Quant/rhiza",url="u1",workflow="ci"} 1.0 +jq_ci_workflow_info{repo="Jebel-Quant/rhiza",url="u2",workflow="docs"} 1.0 +jq_ci_workflow_info{repo="o/loose",url="",workflow="ci"} 1.0 +# HELP jq_ci_workflows_failing How many of the repo's workflows are red on the default branch. +# TYPE jq_ci_workflows_failing gauge +jq_ci_workflows_failing{repo="Jebel-Quant/rhiza"} 1.0 +jq_ci_workflows_failing{repo="o/loose"} 1.0 +# HELP jq_ci_coverage_percent Line coverage from the newest default-branch coverage-report artifact. Absent when the repo publishes none. +# TYPE jq_ci_coverage_percent gauge +jq_ci_coverage_percent{repo="Jebel-Quant/rhiza"} 87.3 +# HELP jq_ci_coverage_lines Lines CI measured for that coverage figure. The percentage is not interpretable without it, and its denominator is not jq_local_code_lines. +# TYPE jq_ci_coverage_lines gauge +jq_ci_coverage_lines{repo="Jebel-Quant/rhiza"} 472.0 +# HELP jq_open_pull_requests Open pull requests. +# TYPE jq_open_pull_requests gauge +jq_open_pull_requests{repo="Jebel-Quant/rhiza"} 3.0 +jq_open_pull_requests{repo="acme/platform/web"} 0.0 +jq_open_pull_requests{repo="o/loose"} 0.0 +# HELP jq_open_issues Open issues, excluding pull requests. +# TYPE jq_open_issues gauge +jq_open_issues{repo="Jebel-Quant/rhiza"} 4.0 +jq_open_issues{repo="acme/platform/web"} 0.0 +jq_open_issues{repo="o/loose"} 0.0 +# HELP jq_open_pull_requests_failing Open pull requests whose checks are red. +# TYPE jq_open_pull_requests_failing gauge +jq_open_pull_requests_failing{repo="Jebel-Quant/rhiza"} 1.0 +jq_open_pull_requests_failing{repo="acme/platform/web"} 0.0 +jq_open_pull_requests_failing{repo="o/loose"} 0.0 +# HELP jq_pull_request_info Always 1; one series per open pull request. +# TYPE jq_pull_request_info gauge +jq_pull_request_info{author="u",checks="success",draft="false",number="7",repo="Jebel-Quant/rhiza",title="pr 7",url=""} 1.0 +jq_pull_request_info{author="u",checks="failure",draft="false",number="8",repo="Jebel-Quant/rhiza",title="pr 8",url=""} 1.0 +jq_pull_request_info{author="u",checks="cancelled",draft="true",number="9",repo="Jebel-Quant/rhiza",title="pr 9",url=""} 1.0 +# HELP jq_pull_request_created_timestamp_seconds Unix time the pull request was opened. +# TYPE jq_pull_request_created_timestamp_seconds gauge +jq_pull_request_created_timestamp_seconds{number="7",repo="Jebel-Quant/rhiza"} 1.0 +jq_pull_request_created_timestamp_seconds{number="8",repo="Jebel-Quant/rhiza"} 1.0 +jq_pull_request_created_timestamp_seconds{number="9",repo="Jebel-Quant/rhiza"} 1.0 +# HELP jq_merged_pull_request_timestamp_seconds Unix time a pull request was merged. topk() over this gives the newest. +# TYPE jq_merged_pull_request_timestamp_seconds gauge +jq_merged_pull_request_timestamp_seconds{author="a",number="5",repo="Jebel-Quant/rhiza",title="five"} 500.0 +jq_merged_pull_request_timestamp_seconds{author="b",number="4",repo="Jebel-Quant/rhiza",title="four"} 400.0 +# HELP jq_merged_pull_request_info Always 1; the label carries the merged pull request's URL. +# TYPE jq_merged_pull_request_info gauge +jq_merged_pull_request_info{number="5",repo="Jebel-Quant/rhiza",url="m5"} 1.0 +jq_merged_pull_request_info{number="4",repo="Jebel-Quant/rhiza",url="m4"} 1.0 +# HELP jq_local_branch_info Always 1; the label carries the checked-out branch. +# TYPE jq_local_branch_info gauge +jq_local_branch_info{branch="main",repo="Jebel-Quant/rhiza"} 1.0 +jq_local_branch_info{branch="feature",repo="o/loose"} 1.0 +jq_local_branch_info{branch="unknown",repo="o/solo"} 1.0 +# HELP jq_local_on_default_branch 1 if the clone sits on its default branch. +# TYPE jq_local_on_default_branch gauge +jq_local_on_default_branch{repo="Jebel-Quant/rhiza"} 1.0 +jq_local_on_default_branch{repo="o/loose"} 0.0 +jq_local_on_default_branch{repo="o/solo"} 0.0 +# HELP jq_local_dirty_files Tracked files with uncommitted changes. +# TYPE jq_local_dirty_files gauge +jq_local_dirty_files{repo="Jebel-Quant/rhiza"} 1.0 +jq_local_dirty_files{repo="o/loose"} 1.0 +jq_local_dirty_files{repo="o/solo"} 0.0 +# HELP jq_local_untracked_files Untracked files in the working copy. +# TYPE jq_local_untracked_files gauge +jq_local_untracked_files{repo="Jebel-Quant/rhiza"} 2.0 +jq_local_untracked_files{repo="o/loose"} 2.0 +jq_local_untracked_files{repo="o/solo"} 0.0 +# HELP jq_local_ahead_commits Commits ahead of upstream, as of the last fetch. +# TYPE jq_local_ahead_commits gauge +jq_local_ahead_commits{repo="Jebel-Quant/rhiza"} 3.0 +jq_local_ahead_commits{repo="o/loose"} 3.0 +# HELP jq_local_behind_commits Commits behind upstream, as of the last fetch. +# TYPE jq_local_behind_commits gauge +jq_local_behind_commits{repo="Jebel-Quant/rhiza"} 4.0 +jq_local_behind_commits{repo="o/loose"} 4.0 +# HELP jq_local_stash_entries Stash entries. +# TYPE jq_local_stash_entries gauge +jq_local_stash_entries{repo="Jebel-Quant/rhiza"} 5.0 +jq_local_stash_entries{repo="o/loose"} 5.0 +jq_local_stash_entries{repo="o/solo"} 0.0 +# HELP jq_local_last_commit_timestamp_seconds Unix time of HEAD's commit. +# TYPE jq_local_last_commit_timestamp_seconds gauge +jq_local_last_commit_timestamp_seconds{repo="Jebel-Quant/rhiza"} 800.0 +jq_local_last_commit_timestamp_seconds{repo="o/loose"} 800.0 +jq_local_last_commit_timestamp_seconds{repo="o/solo"} 0.0 +# HELP jq_local_fetch_age_seconds Seconds since this clone last fetched. Read the ahead/behind counts against this. +# TYPE jq_local_fetch_age_seconds gauge +jq_local_fetch_age_seconds{repo="Jebel-Quant/rhiza"} 60.0 +jq_local_fetch_age_seconds{repo="o/loose"} 60.0 +# HELP jq_local_template_ref_info Always 1; the ref pinned in the *clone's* pointer. May lag the repo's. +# TYPE jq_local_template_ref_info gauge +jq_local_template_ref_info{ref="v1.7.0",repo="Jebel-Quant/rhiza"} 1.0 +jq_local_template_ref_info{ref="v1.7.0",repo="o/loose"} 1.0 +# HELP jq_local_default_branch_synced 1 if the local default branch is the same commit GitHub reports. Fetch-independent. +# TYPE jq_local_default_branch_synced gauge +jq_local_default_branch_synced{repo="Jebel-Quant/rhiza"} 1.0 +jq_local_default_branch_synced{repo="o/loose"} 0.0 +# HELP jq_local_code_lines Lines of tracked source outside the test tree, in the working copy. +# TYPE jq_local_code_lines gauge +jq_local_code_lines{repo="Jebel-Quant/rhiza"} 1477.0 +jq_local_code_lines{repo="o/loose"} 1477.0 +jq_local_code_lines{repo="o/solo"} 0.0 +# HELP jq_local_test_lines Lines of tracked source under the test tree, in the working copy. +# TYPE jq_local_test_lines gauge +jq_local_test_lines{repo="Jebel-Quant/rhiza"} 15186.0 +jq_local_test_lines{repo="o/loose"} 15186.0 +jq_local_test_lines{repo="o/solo"} 0.0 +# HELP jq_local_commits_30d Commits on the default branch in the last 30 days. +# TYPE jq_local_commits_30d gauge +jq_local_commits_30d{repo="Jebel-Quant/rhiza"} 124.0 +jq_local_commits_30d{repo="o/loose"} 124.0 +jq_local_commits_30d{repo="o/solo"} 0.0 +# HELP jq_local_commits_since_release Commits on the default branch since the newest tag. Absent when the clone has no tags. +# TYPE jq_local_commits_since_release gauge +jq_local_commits_since_release{repo="Jebel-Quant/rhiza"} 0.0 +jq_local_commits_since_release{repo="o/loose"} 0.0 +# HELP jq_local_last_release_info Always 1; the label carries the newest tag reachable in the clone. +# TYPE jq_local_last_release_info gauge +jq_local_last_release_info{ref="v1.7.1",repo="Jebel-Quant/rhiza"} 1.0 +jq_local_last_release_info{ref="v1.7.1",repo="o/loose"} 1.0 diff --git a/collector/tests/test_metrics_golden.py b/collector/tests/test_metrics_golden.py new file mode 100644 index 0000000..23dfe41 --- /dev/null +++ b/collector/tests/test_metrics_golden.py @@ -0,0 +1,134 @@ +"""The whole exposition, byte for byte, for one fleet that takes every branch. + +The other metric tests assert one property each. This one pins everything at +once - family order, HELP text, label order, which series are absent - so that +restructuring ``render`` cannot change what Prometheus scrapes without saying +so here. A deliberate change to the exposition regenerates the golden file: + + uv run python -c "import test_metrics_golden as t; t.write_golden()" + +run from ``collector/tests``, and the diff is then the review. +""" + +from __future__ import annotations + +import dataclasses +import pathlib + +from prometheus_client import CollectorRegistry, generate_latest +from test_metrics import full_local, full_remote, pull, wf + +from jq_collector.metrics import FleetCollector +from jq_collector.state import ( + LocalRepo, + MergedPull, + RemoteRepo, + Snapshot, + SourceHealth, + Store, + WorkflowRun, +) + +GOLDEN = pathlib.Path(__file__).parent / "golden" / "metrics.prom" + + +def fleet() -> Snapshot: + full = dataclasses.replace( + full_remote(), + workflows=( + WorkflowRun(name="ci", conclusion="success", finished_at=900.0, duration=1.0, url="u1"), + # Older run of the same name: collapsed away, newest wins. + WorkflowRun(name="ci", conclusion="failure", finished_at=100.0, duration=1.0, url="u0"), + WorkflowRun( + name="docs", conclusion="failure", finished_at=800.0, duration=1.0, url="u2" + ), + WorkflowRun( + name="nightly", conclusion="cancelled", finished_at=950.0, duration=1.0, url="" + ), + WorkflowRun(name="empty", conclusion="", finished_at=10.0, duration=1.0, url=""), + ), + pulls=( + pull(7, "success"), + pull(8, "failure"), + dataclasses.replace(pull(9, "cancelled"), draft=True), + ), + open_pulls_total=3, + merged=( + MergedPull(number=5, title="five", author="a", merged_at=500.0, url="m5"), + MergedPull(number=5, title="five", author="a", merged_at=500.0, url="m5"), + MergedPull(number=4, title="four", author="b", merged_at=400.0, url="m4"), + ), + ) + gitlab = RemoteRepo( + name="web", + owner="acme/platform", + forge="gitlab", + default_branch="trunk", + visibility="private", + url="https://gitlab.com/acme/platform/web", + pulls_url="https://gitlab.com/acme/platform/web/-/merge_requests", + protected=None, + alerts_enabled=False, + ci_conclusion="cancelled", + workflows=(wf("build", "cancelled"),), + ) + unprotected = RemoteRepo( + name="loose", + owner="o", + head_sha="new", + protected=False, + ci_conclusion="failure", + ci_workflow="ci", + workflows=(wf("ci", "failure"),), + rhiza_managed=False, + ) + detached = LocalRepo( + name="solo", + path="/repos/o/solo", + owner="o", + branch="", + ahead=None, + behind=None, + fetch_age=None, + commits_since_release=None, + ) + stale_clone = dataclasses.replace(full_local(), branch="feature", default_branch_sha="old") + return Snapshot( + remote={ + "Jebel-Quant/rhiza": full, + "acme/platform/web": gitlab, + "o/loose": unprotected, + "o/gone": full_remote(), + }, + local={ + "Jebel-Quant/rhiza": full_local(), + "o/solo": detached, + "o/loose": stale_clone, + }, + excluded=frozenset({"o/gone"}), + latest_template_ref="v1.7.1", + rate_limit_remaining=4000.0, + rate_limit_limit=5000.0, + rate_limit_reset=1234.0, + health={ + "local": SourceHealth(last_success=3.0, last_duration=0.5, errors=0), + "github": SourceHealth(last_success=1.0, last_duration=2.0, errors=3), + }, + ) + + +def exposition(snapshot: Snapshot) -> str: + store = Store() + store.update(**{f.name: getattr(snapshot, f.name) for f in dataclasses.fields(snapshot)}) + reg = CollectorRegistry() + reg.register(FleetCollector(store)) + return generate_latest(reg).decode() + + +def write_golden() -> None: + GOLDEN.parent.mkdir(exist_ok=True) + GOLDEN.write_text(exposition(fleet())) + + +def test_the_exposition_is_unchanged(): + assert exposition(fleet()) == GOLDEN.read_text()