Table of Contents generated with DocToc
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 -> issues.json)
├── fetch_roster.py (gh api collaborators -> roster.txt)
├── fetch_bodies.py (per-issue body + closedByPRs -> 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. Themilestonesandcategorieslists are REPLACED entirely (not concatenated) — overlaying a single category requires re-stating the whole list.- 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.
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.9+ (
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 |
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 |