diff --git a/collector/jq_collector/github.py b/collector/jq_collector/github.py index cb2ed56..1a4690f 100644 --- a/collector/jq_collector/github.py +++ b/collector/jq_collector/github.py @@ -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 @@ -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") @@ -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 @@ -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), diff --git a/collector/jq_collector/metrics.py b/collector/jq_collector/metrics.py index 137a8b8..96574c0 100644 --- a/collector/jq_collector/metrics.py +++ b/collector/jq_collector/metrics.py @@ -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", @@ -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) @@ -331,6 +378,11 @@ def render(snap: Snapshot): cloned, pushed, managed, + protected, + required_reviews, + force_push, + alerts_enabled, + alerts, ref_info, behind, ci_info, diff --git a/collector/jq_collector/state.py b/collector/jq_collector/state.py index 41b07db..fec1e4c 100644 --- a/collector/jq_collector/state.py +++ b/collector/jq_collector/state.py @@ -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), diff --git a/collector/tests/test_metrics.py b/collector/tests/test_metrics.py index 8188159..49ab4dd 100644 --- a/collector/tests/test_metrics.py +++ b/collector/tests/test_metrics.py @@ -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 diff --git a/grafana/dashboards/fleet-public.json b/grafana/dashboards/fleet-public.json index be3dc52..dd1690f 100644 --- a/grafana/dashboards/fleet-public.json +++ b/grafana/dashboards/fleet-public.json @@ -357,6 +357,226 @@ }, "id": 19 }, + { + "type": "stat", + "title": "Default branches unprotected", + "description": "Repos GitHub reports as having no protection on their default branch. Repos whose protection is not visible to the token are excluded, so this never counts a permission gap as a finding.", + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 9 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_branch_protected == bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "id": 533 + }, + { + "type": "stat", + "title": "Protected but force-pushable", + "description": "Protected default branches that still allow force pushes - the history can be rewritten despite the protection, which a green 'protected' tile hides.", + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 9 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_branch_allows_force_push == bool 1)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "id": 534 + }, + { + "type": "stat", + "title": "Dependabot off", + "description": "Repos with Dependabot alerts disabled. Not the same as having no alerts: nothing is looking, so the zero on the next tile says nothing about them.", + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 9 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_dependabot_alerts_enabled == bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "id": 535 + }, + { + "type": "stat", + "title": "Open Dependabot alerts", + "description": "Open alerts across repos that have Dependabot enabled, all severities.", + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 14 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_dependabot_open_alerts)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + } + }, + "overrides": [] + }, + "id": 536 + }, { "type": "row", "title": "Trend and CI history", @@ -364,7 +584,7 @@ "h": 1, "w": 24, "x": 0, - "y": 9 + "y": 19 }, "collapsed": false, "id": 11, @@ -378,7 +598,7 @@ "h": 8, "w": 24, "x": 0, - "y": 10 + "y": 20 }, "datasource": { "type": "prometheus", @@ -446,7 +666,7 @@ "h": 1, "w": 24, "x": 0, - "y": 18 + "y": 28 }, "collapsed": false, "id": 526, @@ -459,7 +679,7 @@ "h": 5, "w": 6, "x": 0, - "y": 19 + "y": 29 }, "datasource": { "type": "prometheus", @@ -516,7 +736,7 @@ "h": 5, "w": 6, "x": 6, - "y": 19 + "y": 29 }, "datasource": { "type": "prometheus", @@ -572,7 +792,7 @@ "h": 5, "w": 12, "x": 12, - "y": 19 + "y": 29 }, "datasource": { "type": "prometheus", @@ -650,7 +870,7 @@ "h": 1, "w": 24, "x": 0, - "y": 24 + "y": 34 }, "collapsed": false, "id": 14, @@ -663,7 +883,7 @@ "h": 14, "w": 24, "x": 0, - "y": 25 + "y": 35 }, "datasource": { "type": "prometheus", @@ -907,7 +1127,7 @@ "h": 12, "w": 24, "x": 0, - "y": 39 + "y": 49 }, "datasource": { "type": "prometheus", @@ -1162,7 +1382,7 @@ "h": 11, "w": 24, "x": 0, - "y": 51 + "y": 61 }, "datasource": { "type": "prometheus", @@ -1306,7 +1526,7 @@ "h": 1, "w": 24, "x": 0, - "y": 62 + "y": 72 }, "collapsed": false, "panels": [] @@ -1320,7 +1540,7 @@ "h": 12, "w": 24, "x": 0, - "y": 63 + "y": 73 }, "datasource": { "type": "prometheus", @@ -1439,7 +1659,7 @@ "h": 12, "w": 24, "x": 0, - "y": 75 + "y": 85 }, "datasource": { "type": "prometheus", @@ -1605,7 +1825,7 @@ "h": 12, "w": 24, "x": 0, - "y": 87 + "y": 97 }, "datasource": { "type": "prometheus", @@ -1708,6 +1928,460 @@ } ] } + }, + { + "id": 108, + "type": "table", + "title": "Repos with an unprotected default branch", + "description": "Reported by GitHub as 'Branch not protected' - a fact, not a permission gap. Repos whose protection the token cannot read are absent from this table rather than listed as unprotected.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 109 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_branch_protected == 0", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "Value": 1 + }, + "renameByName": { + "repo": "Repo", + "Value": "Protected" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Protected" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + }, + { + "id": 109, + "type": "table", + "title": "Open Dependabot alerts by repo", + "description": "Only repos with alerts enabled appear. A repo with Dependabot switched off has no counts to show and is listed on the 'Dependabot off' tile instead.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 121 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_dependabot_open_alerts > 0", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "severity": 1, + "Value": 2 + }, + "renameByName": { + "repo": "Repo", + "Value": "Open alerts", + "severity": "Severity" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Open alerts" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + }, + { + "id": 110, + "type": "table", + "title": "Repos with Dependabot alerts disabled", + "description": "Nothing is scanning these for vulnerable dependencies, so their absence from the open-alerts table means nobody looked - not that there is nothing to find.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 133 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_dependabot_alerts_enabled == 0", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "Value": 1 + }, + "renameByName": { + "repo": "Repo", + "Value": "Alerts enabled" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Alerts enabled" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + }, + { + "id": 111, + "type": "table", + "title": "Protected branches that still allow force pushes", + "description": "These repos protect their default branch but permit rewriting its history, so they do not appear in the unprotected table - the weakness is inside the protection, not the absence of it.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 145 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_branch_allows_force_push == 1", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "Value": 1 + }, + "renameByName": { + "repo": "Repo", + "Value": "Allows force push" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Allows force push" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } } ] } diff --git a/grafana/dashboards/fleet.json b/grafana/dashboards/fleet.json index 45e2729..f32d52b 100644 --- a/grafana/dashboards/fleet.json +++ b/grafana/dashboards/fleet.json @@ -559,6 +559,254 @@ }, "id": 19 }, + { + "type": "stat", + "title": "Default branches unprotected", + "description": "Repos GitHub reports as having no protection on their default branch. Repos whose protection is not visible to the token are excluded, so this never counts a permission gap as a finding.", + "gridPos": { + "h": 5, + "w": 6, + "x": 0, + "y": 11 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_branch_protected{repo=~\"$repo\"} == bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet/jebel-quant-fleet?viewPanel=108&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 533 + }, + { + "type": "stat", + "title": "Protected but force-pushable", + "description": "Protected default branches that still allow force pushes - the history can be rewritten despite the protection, which a green 'protected' tile hides.", + "gridPos": { + "h": 5, + "w": 6, + "x": 6, + "y": 11 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_branch_allows_force_push{repo=~\"$repo\"} == bool 1)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet/jebel-quant-fleet?viewPanel=111&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 534 + }, + { + "type": "stat", + "title": "Dependabot off", + "description": "Repos with Dependabot alerts disabled. Not the same as having no alerts: nothing is looking, so the zero on the next tile says nothing about them.", + "gridPos": { + "h": 5, + "w": 6, + "x": 12, + "y": 11 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_dependabot_alerts_enabled{repo=~\"$repo\"} == bool 0)", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet/jebel-quant-fleet?viewPanel=110&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 535 + }, + { + "type": "stat", + "title": "Open Dependabot alerts", + "description": "Open alerts across repos that have Dependabot enabled, all severities.", + "gridPos": { + "h": 5, + "w": 6, + "x": 18, + "y": 11 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "sum(jq_dependabot_open_alerts{repo=~\"$repo\"})", + "instant": true + } + ], + "options": { + "colorMode": "value", + "graphMode": "area", + "textMode": "value", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + } + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "decimals": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "#0ca30c", + "value": null + }, + { + "color": "#fab219", + "value": 1 + } + ] + }, + "links": [ + { + "title": "Show the repos behind this number", + "url": "/d/jq-fleet/jebel-quant-fleet?viewPanel=109&${__url_time_range}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "id": 536 + }, { "type": "row", "title": "Trend and CI history", @@ -566,7 +814,7 @@ "h": 1, "w": 24, "x": 0, - "y": 11 + "y": 16 }, "collapsed": false, "panels": [], @@ -580,7 +828,7 @@ "h": 8, "w": 24, "x": 0, - "y": 12 + "y": 17 }, "datasource": { "type": "prometheus", @@ -688,7 +936,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 25 }, "collapsed": true, "panels": [ @@ -893,7 +1141,7 @@ "h": 1, "w": 24, "x": 0, - "y": 21 + "y": 26 }, "collapsed": false, "panels": [], @@ -906,7 +1154,7 @@ "h": 14, "w": 24, "x": 0, - "y": 22 + "y": 27 }, "datasource": { "type": "prometheus", @@ -1150,7 +1398,7 @@ "h": 12, "w": 24, "x": 0, - "y": 36 + "y": 41 }, "datasource": { "type": "prometheus", @@ -1405,7 +1653,7 @@ "h": 11, "w": 24, "x": 0, - "y": 48 + "y": 53 }, "datasource": { "type": "prometheus", @@ -1549,7 +1797,7 @@ "h": 16, "w": 24, "x": 0, - "y": 59 + "y": 64 }, "datasource": { "type": "prometheus", @@ -1847,7 +2095,7 @@ "h": 1, "w": 24, "x": 0, - "y": 75 + "y": 80 }, "collapsed": true, "panels": [ @@ -2697,35 +2945,489 @@ } ] } - } - ] - }, - { - "type": "row", - "title": "Template drift", - "gridPos": { - "h": 1, - "w": 24, - "x": 0, - "y": 76 - }, - "collapsed": false, - "panels": [], - "id": 8 - }, - { - "type": "stat", - "title": "Behind template", - "description": "Repos pinned to a template release older than the newest one.", - "gridPos": { - "h": 4, - "w": 24, - "x": 0, - "y": 77 - }, - "datasource": { - "type": "prometheus", - "uid": "jq-prometheus" + }, + { + "id": 108, + "type": "table", + "title": "Repos with an unprotected default branch", + "description": "Reported by GitHub as 'Branch not protected' - a fact, not a permission gap. Repos whose protection the token cannot read are absent from this table rather than listed as unprotected.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_branch_protected{repo=~\"$repo\"} == 0", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "Value": 1 + }, + "renameByName": { + "repo": "Repo", + "Value": "Protected" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Protected" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + }, + { + "id": 109, + "type": "table", + "title": "Open Dependabot alerts by repo", + "description": "Only repos with alerts enabled appear. A repo with Dependabot switched off has no counts to show and is listed on the 'Dependabot off' tile instead.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_dependabot_open_alerts{repo=~\"$repo\"} > 0", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "severity": 1, + "Value": 2 + }, + "renameByName": { + "repo": "Repo", + "Value": "Open alerts", + "severity": "Severity" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Open alerts" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + }, + { + "id": 110, + "type": "table", + "title": "Repos with Dependabot alerts disabled", + "description": "Nothing is scanning these for vulnerable dependencies, so their absence from the open-alerts table means nobody looked - not that there is nothing to find.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_dependabot_alerts_enabled{repo=~\"$repo\"} == 0", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "Value": 1 + }, + "renameByName": { + "repo": "Repo", + "Value": "Alerts enabled" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Alerts enabled" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + }, + { + "id": 111, + "type": "table", + "title": "Protected branches that still allow force pushes", + "description": "These repos protect their default branch but permit rewriting its history, so they do not appear in the unprotected table - the weakness is inside the protection, not the absence of it.", + "gridPos": { + "h": 12, + "w": 24, + "x": 0, + "y": 98 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" + }, + "targets": [ + { + "refId": "A", + "expr": "jq_branch_allows_force_push{repo=~\"$repo\"} == 1", + "instant": true, + "format": "table" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "instance": true, + "job": true, + "fleet": true + }, + "indexByName": { + "repo": 0, + "Value": 1 + }, + "renameByName": { + "repo": "Repo", + "Value": "Allows force push" + } + } + }, + { + "id": "sortBy", + "options": { + "sort": [ + { + "field": "Repo", + "desc": false + } + ] + } + } + ], + "options": { + "showHeader": true, + "footer": { + "show": false + } + }, + "fieldConfig": { + "defaults": { + "custom": { + "align": "left", + "cellOptions": { + "type": "auto" + } + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Repo" + }, + "properties": [ + { + "id": "custom.width", + "value": 260 + }, + { + "id": "links", + "value": [ + { + "title": "Open on GitHub", + "url": "https://github.com/${__value.text}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "Allows force push" + }, + "properties": [ + { + "id": "custom.align", + "value": "right" + }, + { + "id": "custom.width", + "value": 150 + } + ] + } + ] + } + } + ] + }, + { + "type": "row", + "title": "Template drift", + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 81 + }, + "collapsed": false, + "panels": [], + "id": 8 + }, + { + "type": "stat", + "title": "Behind template", + "description": "Repos pinned to a template release older than the newest one.", + "gridPos": { + "h": 4, + "w": 24, + "x": 0, + "y": 82 + }, + "datasource": { + "type": "prometheus", + "uid": "jq-prometheus" }, "targets": [ { @@ -2787,7 +3489,7 @@ "h": 13, "w": 10, "x": 0, - "y": 81 + "y": 86 }, "datasource": { "type": "prometheus", @@ -2862,7 +3564,7 @@ "h": 13, "w": 14, "x": 10, - "y": 81 + "y": 86 }, "datasource": { "type": "prometheus",