security-tracker-stats-dashboard
Capability: substrate:analytics
Harness: agnostic
Generate a self-contained HTML dashboard of <tracker> repository
statistics — issue-lifecycle bands (untriaged / triaged / PR-merged /
fixed-released / closed-other), opened-vs-untriaged backlog, cumulative
opened/closed, and mean-time-to-triage / first-response / PR-open /
PR-merge / advisory-announced.
All charts are line / area (no bars) with connectgaps: true. Plotly
loaded via CDN — the output HTML is self-contained but viewing it
requires network access for the chart library.
The tool is read-only on GitHub — it does not create or modify
issues, comments, labels, or PRs. It only fetches data via gh and
renders an HTML file.
The companion agentic skill at
.claude/skills/security-tracker-stats-dashboard/SKILL.md
wraps this tool and surfaces it through Claude Code’s slash-command
interface; both routes (script-only and skill-driven) run the same
fetch + render pipeline.
Layout
tools/security-tracker-stats-dashboard/
├── README.md (this file)
├── default-config.yaml (config schema + adopter-overridable defaults)
├── render.py (renders cached data to HTML; reads config)
├── fetch_issues.py (gh issue list, incl. body + closedByPRs -> issues.json)
├── fetch_roster.py (gh api collaborators -> roster.txt)
├── fetch_bodies.py (body + closedByPRs from issues.json -> issue_extra.json)
├── fetch_events.py (per-issue label history -> events/<N>.json)
├── fetch_prs.py (per-PR metadata from <upstream> -> prs.json)
└── run.sh (orchestrator)
Invocation
bash <framework>/tools/security-tracker-stats-dashboard/run.sh [<output-path>]
Env knobs (all optional):
| Var | Default | Notes |
|---|---|---|
TRACKER_STATS_REPO |
(e.g. airflow-s/airflow-s) |
<tracker> repo slug |
TRACKER_STATS_OUT |
/tmp/airflow_s_monthly.html |
output HTML path |
TRACKER_STATS_CACHE |
/tmp/tracker-stats-cache |
fetch cache dir |
TRACKER_STATS_CONFIG |
(unset) | path to a YAML overlay file |
TRACKER_STATS_BUCKETS |
(from config: monthly) |
monthly or quarterly |
TRACKER_STATS_START |
(from config: null) |
YYYY-MM or YYYY-Qn |
TRACKER_STATS_UPSTREAM_REPO |
(from config; e.g. apache/airflow) |
<upstream> repo slug; none skips PR charts |
Resume behaviour
Each fetch script resumes from cache, so re-running after a partial failure (rate limit, transient HTTP error) only re-fetches what is missing. Delete the cache dir to force a fresh full fetch.
Fetches are parallelised (ThreadPoolExecutor, ~10 workers). A fresh
run is ~5–10 minutes on a 250-issue tracker; incremental re-renders
(cache warm) are ~30 seconds.
Configuration
render.py loads configuration in this order, highest priority last:
default-config.yaml(in this directory).$TRACKER_STATS_CONFIGoverlay YAML, when set (typically<adopter-repo>/.apache-magpie-overrides/security-tracker-stats.yaml). Deep-merged with the default. Every list is REPLACED entirely (not concatenated) —milestones,categories,bot_prefixes,keywords, and any other list-valued key. Overlaying a single entry requires re-stating the whole list, otherwise the defaults are silently dropped.- Env-var quick overrides for the most common knobs:
TRACKER_STATS_BUCKETS,TRACKER_STATS_START,TRACKER_STATS_UPSTREAM_REPO.
See default-config.yaml for the full schema
with inline documentation of every predicate key.
Categories (lifecycle bands)
Mutually-exclusive states per tracker at each bucket-end snapshot,
evaluated top-to-bottom with first-match-wins. Multiple rules can
share a name to express disjoint branches of the same final
category — the default set uses this for the open / closed
fork on fixed_released. The set of distinct names defines the
stack order in the lifecycle chart (overridable via the
stack_order: config key).
Supported predicate keys:
| Key | Meaning |
|---|---|
state |
open / closed |
state_reason |
COMPLETED / NOT_PLANNED / REOPENED / null |
any_label |
at least one of the listed labels is present |
all_labels |
every label in the list is present |
not_label |
the named label must NOT be present |
not_any_label |
none of the listed labels present |
no_scope_label (true/false) |
tracker carries none of scope_labels |
has_scope_label (true/false) |
tracker carries at least one of scope_labels |
pr_merged_by_snapshot (true/false) |
a linked <upstream> PR is merged by the snapshot timestamp |
any_of / all_of |
logical combinators (nestable) |
Snapshot reconstruction replays each tracker’s event stream
(labeled / unlabeled / closed / reopened) chronologically from
{labels: [], state: OPEN} at createdAt, evaluated at the
bucket-end timestamp (Mar 31 / Jun 30 / Sep 30 / Dec 31 at 23:59:59 UTC
for quarterly; calendar-month last day for monthly).
Time-to-triage signal
First tracker comment whose author is on the roster (from
fetch_roster.py) AND whose body matches any
triage.keywords[] regex (case-insensitive). Falls back to
the first non-bot roster comment when no keyword matches
(useful for older trackers that predate the team’s triage-comment
convention). The triage.bot_prefixes[] list skips automated
rollup / sync / import comments.
Milestones (vertical annotations)
milestones[] produces a vertical dashed line + top-label annotation
on every time-axis chart. Each entry needs date: YYYY-MM-DD (mapped
onto the bucket axis) and label. Set milestones: [] in an overlay
to remove them entirely.
Current-bucket projection (projection)
projection:
enabled: true
min_elapsed_fraction: 0.1
The last bucket on the axis is always cut short by “now”, so every count in it reads low against complete buckets — regenerate the dashboard on the 8th of a month and September looks like a collapse in reports rather than a month that is 23 % over. The projection extrapolates that final bucket to where it is on course to end.
Two kinds of series need two different extrapolations:
| Kind | Series | Formula |
|---|---|---|
| rate — accumulates from zero inside the bucket | opened / rejected / reported in bucket | observed / elapsed |
| level — carries over from the previous bucket | cumulative opened / closed / rejected / reported, every lifecycle band, the untriaged backlog | prev + (observed - prev) / elapsed |
Only the movement inside the bucket is scaled for a level series — scaling the level itself would multiply years of accumulated history by four. A falling level (a backlog being worked down) projects further down, floored at zero. A rate projection is never below what has already happened.
Projections are drawn on every chart that carries a projectable series: the lifecycle bands, Reported vs. opened vs. untriaged, the cumulative chart, and the rejections chart. Each is a dotted two-point segment from the last complete bucket’s actual value to the projected end-of-bucket value, so a forecast can never be mistaken for a measurement. On the stacked lifecycle chart each band’s forecast is drawn at its projected position in the stack, with the band’s own projected count in the hover (legend entries suppressed — five extra rows would double the legend). Since the bands partition every tracker, their projected values sum to the projected cumulative opened, which makes an easy sanity check.
The mean-time charts are deliberately not projected. A mean over the items seen so far is already an estimate of the bucket’s mean, not a partial accumulation — scaling it by elapsed time would be meaningless.
An HTML header banner carries the operational headline (reported, opened, and the projected untriaged backlog); stdout lists every projected series:
Current-bucket projection (2026-09, 23% elapsed, now -> month-end):
opened 9 -> 38
rejected 3 -> 13
reported 12 -> 51
cum_opened 355 -> 384
cum_closed 313 -> 323
band:open_untriaged 14 -> 7
band:open_triaged 16 -> 39
min_elapsed_fraction suppresses the projection early in a bucket,
where the extrapolation is noise: two days into a month a single
report projects to fifteen. Below the threshold the banner and traces
are omitted and stdout says why. The projection is also skipped when
the axis holds a single bucket — the level series have no baseline to
project from. enabled: false switches the whole stat off.
Rejected-without-tracker ledger (rejections_ledger_label)
rejections_ledger_label: rejections-ledger # null to disable
The security-issue-import skill sometimes rejects a report with a
canned reply without creating a tracker — the disposition lives
only on the mail thread, so it is invisible to every other stat on
this dashboard. To make those rejections countable the team records
each one as a comment on a single dedicated ledger issue in the
<tracker> repo: one open issue, labelled with
rejections_ledger_label (default rejections-ledger) and not
carrying the security-marker label.
fetch_issues.py already pulls every issue’s labels + comments,
so the ledger and its comments arrive in issues.json with no extra
fetch. render.py:
- identifies any issue whose labels include
rejections_ledger_labelas a ledger issue; - excludes the ledger issue from all tracker classification — it never appears in the lifecycle bands, open/closed KPIs, triage medians, or any per-bucket count;
- parses the ledger’s comments for the rejection markers below and renders a rejected (no tracker) count.
Each per-rejection comment carries a machine-parseable block:
<!-- rejection v1 -->
date: YYYY-MM-DD
reporter: <email/name>
canned: <canned-response-slug>
thread: <url-or-threadid>
summary: <one line>
A one-time historical backfill is recorded as a single comment (count only, no per-entry dates):
<!-- rejection-backfill v1 count: N -->
Dated rejections are bucketed by their date: into the same
monthly / quarterly axis as the trackers and rendered as a
rejected (no tracker) area series (c_rejected). The undated
backfill count: N is kept as a separate historical (pre-ledger)
headline number rather than smeared across buckets, so the per-bucket
series only ever shows real dated rejections. Both the stdout summary
and an HTML header banner print <dated> + <historical> = <total>.
The parse is defensive: comments without a marker are ignored,
malformed date: lines are skipped, and an absent ledger issue (or
rejections_ledger_label: null) simply omits the stat / shows 0.
When upstream_repo is null
The c_prc / c_prm / c_rel PR-driven mean-time charts are
omitted, the fetch_prs.py stage is a silent no-op, and the
pr_merged_by_snapshot predicate is always false (so the
open_pr_merged snapshot back-fill rule is disabled). The
remaining charts still render.
Prerequisites
- Runtime: Python 3.11+ (
python3). Run each stage aspython3 fetch_events.py, etc. Optional:pyyaml— when missing,render.pyuses a bundled minimal YAML subset parser sufficient fordefault-config.yamland typical overlays; setTRACKER_STATS_PY=uv-yamlto pin clean PyYAML invocations viauv run --with pyyaml. - CLIs:
gh(authenticated with read access to<tracker>, and to<upstream>when PR metadata is enabled);jq(used by fetch scripts viagh --jq). - Credentials / auth:
gh auth statusmust show a logged-in account with read access to<tracker>.<upstream>access is required only whenpr_repois configured. - Network:
api.github.com(REST) for event and PR fetch; Plotly CDN (cdn.plot.ly) when viewing the generated HTML dashboards in a browser.
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
events/<N>.json missing for some N |
gh transient failure during paginate | Re-run run.sh; fetch_events.py resumes from cache |
prs.json has {"error": ...} entries |
False-positive body parse (PR# doesn’t exist) | Silently filtered at render; safe to ignore |
c_rel median jumps after re-fetch |
New advisory shipped since last run | Expected — re-render is correct |
| No projection banner / dotted trace | Bucket below min_elapsed_fraction, or projection.enabled: false |
Expected — stdout prints the skip reason |
Empty c_prc / c_prm / c_rel early buckets |
No linked PR in those tracker buckets | Expected — not all early trackers had a fix PR |
ModuleNotFoundError: yaml |
PyYAML missing | The bundled fallback parser handles default-config.yaml; for richer overlays install pyyaml or use TRACKER_STATS_PY=uv-yaml |