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
65 changes: 60 additions & 5 deletions collector/jq_collector/github.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Read the state of the repos as GitHub sees them.

Per refresh this costs roughly ``3 * repos + open_pull_requests`` REST calls
(one branch, one workflow-run and one pull listing each, plus a check-run
listing per open PR), which at the default five-minute cadence sits well inside
the authenticated 5000/hour budget. ``jq_github_rate_limit_remaining`` is
exported so the headroom is visible rather than assumed.
Per refresh this costs roughly ``5 * repos + workflows + open_pull_requests``
REST calls (one branch, one protection, one alert listing, one workflow-run and
one pull listing each, plus a check-run listing per open PR). There are no
conditional requests, so the cost scales with the fleet and does not fall when
nothing has changed - see JQ_GITHUB_INTERVAL before growing either.
``jq_github_rate_limit_remaining`` is exported so the headroom is visible
rather than assumed.
"""

from __future__ import annotations
Expand Down Expand Up @@ -131,6 +133,48 @@ def list_repos(self) -> list[dict]:

return repos

def branch_protection(self, full_name: str, branch: str) -> tuple[dict | None, bool]:
"""``(protection, known)`` for one branch.

The endpoint 404s both for an unprotected branch and for a token
without admin, but the bodies differ: an unprotected branch says
"Branch not protected", where a permission gap does not. Reading the
message is what lets an unprotected branch be reported as a fact
rather than as a gap - and a fleet where nothing is protected is
exactly the case this metric exists to show.

``known`` is False only when GitHub genuinely would not say, so the
caller never has to turn "we cannot see" into "it is unprotected".
"""
response = self._get(f"/repos/{full_name}/branches/{branch}/protection")
if response.status_code == 200:
return response.json(), True
if response.status_code == 404:
try:
message = str((response.json() or {}).get("message", ""))
except ValueError:
message = ""
if "not protected" in message.lower():
return None, True
log.info("%s protection unreadable: %s", full_name, message or "404")
return None, False

def open_alerts(self, full_name: str) -> dict[str, int] | None:
"""Open Dependabot alerts by severity, or None when the feature is off.

A repo with alerts disabled 404s exactly like one with none open, so
None and {} are kept distinct all the way to the exposition.
"""
raw = self._json(f"/repos/{full_name}/dependabot/alerts", state="open", per_page=100)
if not isinstance(raw, list):
return None
counts: dict[str, int] = {}
for alert in raw:
advisory = alert.get("security_advisory") or {}
severity = str(advisory.get("severity") or "unknown").lower()
counts[severity] = counts.get(severity, 0) + 1
return counts

def release_tags(self, full_name: str) -> list[str]:
"""Published release tags for a repo, newest first."""
releases = self._paginate(f"/repos/{full_name}/releases")
Expand Down Expand Up @@ -420,6 +464,12 @@ def one(raw: dict) -> RemoteRepo:
rest = sorted(workflows, key=lambda w: w.finished_at, reverse=True)
representative = failing[0] if failing else (rest[0] if rest else None)

protection, protection_known = api.branch_protection(full_name, branch)
reviews = (protection or {}).get("required_pull_request_reviews") or {}
force = (protection or {}).get("allow_force_pushes") or {}

alerts = api.open_alerts(full_name)

pulls_total, pulls = api.open_pulls(full_name)
merged = api.recent_merges(full_name, cfg.recent_merges_per_repo)
# GitHub's open_issues_count includes pull requests; subtract them to
Expand All @@ -434,6 +484,11 @@ def one(raw: dict) -> RemoteRepo:
archived=bool(raw.get("archived")),
head_sha=head_sha,
pushed_at=_ts(raw.get("pushed_at")),
protected=(protection is not None) if protection_known else None,
required_reviews=int(reviews.get("required_approving_review_count") or 0),
allows_force_push=bool(force.get("enabled")),
alerts_enabled=alerts is not None,
alerts=tuple(sorted((alerts or {}).items())),
rhiza_managed=bool(ref),
rhiza_ref=ref,
rhiza_behind=_behind_count(tags, ref),
Expand Down
52 changes: 52 additions & 0 deletions collector/jq_collector/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,37 @@ def render(snap: Snapshot):
["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.
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"],
)

# -- 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"],
)

# -- CI on the default branch ----------------------------------------
ci_info = _gauge(
"jq_ci_last_run_info",
Expand Down Expand Up @@ -243,6 +274,22 @@ def render(snap: Snapshot):

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)
Expand Down Expand Up @@ -331,6 +378,11 @@ def render(snap: Snapshot):
cloned,
pushed,
managed,
protected,
required_reviews,
force_push,
alerts_enabled,
alerts,
ref_info,
behind,
ci_info,
Expand Down
15 changes: 15 additions & 0 deletions collector/jq_collector/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,21 @@ class RemoteRepo:
head_sha: str = ""
pushed_at: float = 0.0

# Default-branch protection. None means "GitHub would not tell us" - the
# protection endpoint needs admin on the repo, and a token without it gets
# the same 404 as a genuinely unprotected branch. Reporting that as
# "unprotected" would invent a finding; the dashboard shows it as unknown.
protected: bool | None = None
required_reviews: int = 0
allows_force_push: bool = False

# Open Dependabot alerts by severity. `alerts_enabled` is False when the
# feature is off for the repo, which GitHub reports as a 404 - the same
# status as "no alerts". Kept apart because "0 open alerts" and "nobody is
# looking" are opposite facts and must not render as the same green tile.
alerts_enabled: bool = False
alerts: tuple[tuple[str, int], ...] = ()

rhiza_managed: bool = False
rhiza_ref: str = ""
# None when the pinned ref is not a published release (a branch or a sha),
Expand Down
61 changes: 61 additions & 0 deletions collector/tests/test_metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,3 +127,64 @@ def test_a_merged_pr_reported_twice_yields_one_series():
)
lines = [x for x in expose(snap) if x.startswith("jq_merged_pull_request_timestamp_seconds")]
assert len(lines) == 1


def test_protection_is_absent_rather_than_zero_when_unknown():
"""A token without admin 404s exactly like an unprotected branch.

Exporting 0 there would put every repo on the "unprotected" list on the
strength of a permission gap, which is a finding the board invented.
"""
lines = expose(Snapshot(remote={"o/r": RemoteRepo(name="r", owner="o", protected=None)}))
assert not [ln for ln in lines if ln.startswith("jq_branch_protected")]


def test_protection_details_are_exported_when_known():
lines = expose(
Snapshot(
remote={
"o/r": RemoteRepo(
name="r",
owner="o",
protected=True,
required_reviews=1,
allows_force_push=True,
)
}
)
)
assert 'jq_branch_protected{repo="o/r"} 1.0' in lines
assert 'jq_branch_required_reviews{repo="o/r"} 1.0' in lines
# Protected but still force-pushable is the interesting case: the tile
# would otherwise read as green on a branch anyone can rewrite.
assert 'jq_branch_allows_force_push{repo="o/r"} 1.0' in lines


def test_disabled_dependabot_is_not_reported_as_zero_alerts():
""" "No alerts" and "nobody is looking" must not render as the same tile."""
lines = expose(Snapshot(remote={"o/r": RemoteRepo(name="r", owner="o", alerts_enabled=False)}))
assert 'jq_dependabot_alerts_enabled{repo="o/r"} 0.0' in lines
assert not [ln for ln in lines if ln.startswith("jq_dependabot_open_alerts")]


def test_open_alerts_zero_fill_the_known_severities():
"""A cleared severity must report 0, not vanish and strand its last value."""
lines = expose(
Snapshot(
remote={
"o/r": RemoteRepo(name="r", owner="o", alerts_enabled=True, alerts=(("high", 3),))
}
)
)
assert 'jq_dependabot_open_alerts{repo="o/r",severity="high"} 3.0' in lines
assert 'jq_dependabot_open_alerts{repo="o/r",severity="critical"} 0.0' in lines


def test_unprotected_is_a_fact_not_a_gap():
"""GitHub says "Branch not protected" outright; that must reach the board.

Reporting it as unknown would leave the unprotected repos - here, nearly
the whole fleet - invisible on the one metric that exists to show them.
"""
lines = expose(Snapshot(remote={"o/r": RemoteRepo(name="r", owner="o", protected=False)}))
assert 'jq_branch_protected{repo="o/r"} 0.0' in lines
Loading
Loading