blob: e3961053abadaa87997c09e9b27f3b19adeb8e34 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Regenerate a tracker-stats dashboard. Reads cached issues+events+PR data
from `$TRACKER_STATS_CACHE` (default `/tmp/tracker-stats-cache`) and writes
a self-contained HTML page to `$TRACKER_STATS_OUT`.
Configuration is loaded from `scripts/default-config.yaml`, optionally
overlaid by a YAML file at `$TRACKER_STATS_CONFIG` (deep-merged; the
`milestones` and `categories` lists are REPLACED entirely, not
concatenated), then overlaid by these env-var quick overrides:
TRACKER_STATS_BUCKETS monthly | quarterly | weekly
TRACKER_STATS_START "YYYY-MM" (monthly), "YYYY-Qn" (quarterly)
or "YYYY-Www" (weekly, ISO week)
TRACKER_STATS_UPSTREAM_REPO upstream repo slug (or "" / "none" to skip PR charts)
TRACKER_STATS_REPO tracker repo slug (operational)
TRACKER_STATS_OUT output path
TRACKER_STATS_CACHE cache dir
TRACKER_STATS_CONFIG path to a YAML overlay file
Defaults match the reference `airflow-s/airflow-s` dashboard byte-for-byte.
Mean-time charts (createdAt -> PR opened, PR opened -> PR merged, PR merged
-> advisory announced) use real PR timestamps from the configured upstream
repo, not the `pr created` / `pr merged` label-add events (which were only
adopted in late 2025 and erased pre-2026 history). When `upstream_repo` is
null, those three charts are omitted and the snapshot back-fill rule is
disabled.
"""
import datetime as dt
import json
import os
import re
import statistics
import sys
from collections import defaultdict
# Make the src/ package importable when render.py is run as a standalone script.
_HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(_HERE, "src"))
from security_tracker_stats_dashboard.core import ( # noqa: E402
_minimal_yaml_load,
build_triage_regex,
deep_merge,
eval_predicate,
is_bot_body,
iter_months,
iter_quarters,
iter_weeks,
js_array,
js_quotes,
mean_or_none,
milestone_x,
month_end,
month_label,
month_of,
parse_dt,
quarter_end,
quarter_label,
quarter_of,
week_end,
week_label,
week_of,
)
# --- YAML loader ----------------------------------------------------
# Prefer pyyaml when available (handles every edge case). When it's not
# installed, fall back to the pure-Python subset parser from core.
try:
import yaml # type: ignore
def yaml_load(text):
return yaml.safe_load(text)
except ImportError:
def yaml_load(text):
return _minimal_yaml_load(text)
# --- Config loading -------------------------------------------------
ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
OUT_PATH = os.environ.get("TRACKER_STATS_OUT", "/tmp/airflow_s_monthly.html")
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_CONFIG_PATH = os.path.join(HERE, "default-config.yaml")
def load_config():
with open(DEFAULT_CONFIG_PATH) as f:
cfg = yaml_load(f.read()) or {}
overlay_path = os.environ.get("TRACKER_STATS_CONFIG")
if overlay_path and os.path.exists(overlay_path):
with open(overlay_path) as f:
overlay = yaml_load(f.read()) or {}
cfg = deep_merge(cfg, overlay)
# Env-var quick overrides.
if os.environ.get("TRACKER_STATS_BUCKETS"):
cfg["buckets"] = os.environ["TRACKER_STATS_BUCKETS"]
if "TRACKER_STATS_START" in os.environ:
v = os.environ["TRACKER_STATS_START"]
cfg["start"] = v if v else None
if "TRACKER_STATS_UPSTREAM_REPO" in os.environ:
v = os.environ["TRACKER_STATS_UPSTREAM_REPO"]
cfg["upstream_repo"] = None if v in ("", "none", "null") else v
return cfg
CONFIG = load_config()
BUCKETS_MODE = CONFIG.get("buckets", "monthly")
if BUCKETS_MODE not in ("monthly", "quarterly", "weekly"):
raise SystemExit(f"buckets must be 'monthly', 'quarterly' or 'weekly', got {BUCKETS_MODE!r}")
START_OVERRIDE = CONFIG.get("start")
UPSTREAM_REPO = CONFIG.get("upstream_repo")
SCOPE_LABELS = set(CONFIG.get("scope_labels") or [])
MILESTONES = CONFIG.get("milestones") or []
CATEGORIES_CFG = CONFIG.get("categories") or []
TRIAGE_KW = CONFIG.get("triage", {}).get("keywords") or []
BOT_PREFIXES = tuple(CONFIG.get("triage", {}).get("bot_prefixes") or [])
# Label identifying the "rejected without tracker" ledger issue. When
# null (or no such issue exists) the rejection stat is omitted / shows 0.
REJECTIONS_LEDGER_LABEL = CONFIG.get("rejections_ledger_label")
# Distinct category names in the order they FIRST appear in CATEGORIES_CFG
# (multiple rules can share a name to express disjoint branches of the
# same final category).
_seen = set()
CATS_DEFAULT_ORDER = []
for c in CATEGORIES_CFG:
if c["name"] not in _seen:
_seen.add(c["name"])
CATS_DEFAULT_ORDER.append(c["name"])
STACK_ORDER = CONFIG.get("stack_order") or CATS_DEFAULT_ORDER
# CATS used for snapshot counting is the distinct-name set. Plotting uses
# STACK_ORDER (which may re-order them for visual layering).
CATS = list(CATS_DEFAULT_ORDER)
CAT_COLORS = {}
for c in CATEGORIES_CFG:
CAT_COLORS.setdefault(c["name"], c.get("color", "#888888"))
# --- Cache load -----------------------------------------------------
with open(f"{ROOT}/issues.json") as f:
all_issues = json.load(f)
with open(f"{ROOT}/roster.txt") as f:
roster = {ln.strip() for ln in f if ln.strip()}
with open(f"{ROOT}/issue_extra.json") as f:
issue_extra = json.load(f)
def _issue_label_names(issue):
return {(lbl.get("name") if isinstance(lbl, dict) else lbl) for lbl in (issue.get("labels") or [])}
# Partition out the "rejected without tracker" ledger issue(s). The ledger
# is NOT a security tracker (it carries the rejections-ledger label and not
# the security-marker label), so it must be excluded from every normal
# tracker classification / count / median below. We keep the full list
# (`all_issues`) only for the rejection-comment parse; `issues` is the
# tracker-only list every downstream loop iterates.
if REJECTIONS_LEDGER_LABEL:
ledger_issues = [i for i in all_issues if REJECTIONS_LEDGER_LABEL in _issue_label_names(i)]
issues = [i for i in all_issues if REJECTIONS_LEDGER_LABEL not in _issue_label_names(i)]
else:
ledger_issues = []
issues = list(all_issues)
prs_cache = {}
if UPSTREAM_REPO:
prs_path = f"{ROOT}/prs.json"
if os.path.exists(prs_path):
with open(prs_path) as f:
prs_cache = json.load(f)
NOW = dt.datetime.now(dt.timezone.utc)
if UPSTREAM_REPO:
# Match the original literal in the body-parse regex so an upstream
# of `apache/airflow` still matches the historical pre-existing
# `apache/airflow#NNN` references byte-for-byte.
repo_re = re.escape(UPSTREAM_REPO)
PR_PAT = re.compile(rf"{repo_re}#(\d+)|https://github\.com/{repo_re}/pull/(\d+)", re.I)
else:
PR_PAT = None
if BUCKETS_MODE == "monthly":
bucket_of = month_of
bucket_label = month_label
bucket_end = month_end
bucket_iter = iter_months
elif BUCKETS_MODE == "weekly":
bucket_of = week_of
bucket_label = week_label
bucket_end = week_end
bucket_iter = iter_weeks
else:
bucket_of = quarter_of
bucket_label = quarter_label
bucket_end = quarter_end
bucket_iter = iter_quarters
# --- index issues + buckets ----------------------------------------
issues_by_n = {i["number"]: i for i in issues}
earliest = min(parse_dt(i["createdAt"]) for i in issues)
if START_OVERRIDE:
if BUCKETS_MODE == "monthly":
y0, m0 = (int(x) for x in START_OVERRIDE.split("-"))
start_key = (y0, m0)
elif BUCKETS_MODE == "weekly":
y_part, w_part = START_OVERRIDE.split("-W")
start_key = (int(y_part), int(w_part))
else:
y_part, q_part = START_OVERRIDE.split("-Q")
start_key = (int(y_part), int(q_part))
else:
start_key = bucket_of(earliest)
end_key = bucket_of(NOW)
buckets = list(bucket_iter(start_key[0], start_key[1], end_key[0], end_key[1]))
bucket_labels = [bucket_label(*b) for b in buckets]
n_buckets = len(buckets)
print(f"earliest createdAt: {earliest.isoformat()} -> starts at {bucket_label(*start_key)}")
print(f"now: {NOW.isoformat()} -> ends at {bucket_label(*end_key)}")
print(f"buckets in range ({BUCKETS_MODE}): {n_buckets}")
# --- rejected-without-tracker ledger -------------------------------
#
# The skill records each report it rejects *without* creating a tracker
# as a comment on a dedicated ledger issue (labelled
# REJECTIONS_LEDGER_LABEL). Each rejection comment carries a
# machine-parseable block:
#
# <!-- rejection v1 -->
# date: YYYY-MM-DD
# reporter: <email/name>
# canned: <slug>
# thread: <url-or-threadid>
# summary: <one line>
#
# A one-time historical backfill is a single comment of the form:
#
# <!-- rejection-backfill v1 count: N -->
#
# Dated rejections are bucketed by their `date:` into the same monthly /
# quarterly axis as the trackers. The backfill count is undated, so we
# keep it as a separate "historical (pre-ledger)" headline number rather
# than smearing it across buckets — that keeps the per-bucket series
# faithful (only real dated rejections appear in it) while still
# surfacing the historical lump in the summary / HTML header.
REJECTION_MARKER = "<!-- rejection v1 -->"
REJECTION_BACKFILL_RE = re.compile(r"<!--\s*rejection-backfill\s+v1\s+count:\s*(\d+)\s*-->", re.I)
REJECTION_DATE_RE = re.compile(r"^\s*date:\s*(\d{4})-(\d{2})-(\d{2})\s*$", re.M)
rejections_by_b = defaultdict(int) # bucket label -> dated rejection count
rejections_dated = [] # every dated rejection datetime (in + out of range)
rejections_dated_total = 0
rejections_backfill_total = 0
for li in ledger_issues:
for c in li.get("comments") or []:
body = c.get("body") or ""
# Historical backfill marker (undated lump).
for m in REJECTION_BACKFILL_RE.finditer(body):
try:
rejections_backfill_total += int(m.group(1))
except ValueError:
continue
# Per-entry rejection markers. A single comment may in principle
# carry more than one block; count each marker that is followed
# by a parseable date line.
if REJECTION_MARKER not in body:
continue
# Split on the marker so a malformed block can't swallow the
# date of the next one. The text before the first marker is
# discarded (it belongs to no rejection block).
for chunk in body.split(REJECTION_MARKER)[1:]:
dm = REJECTION_DATE_RE.search(chunk)
if not dm:
# No / malformed date line — skip this entry defensively.
continue
try:
d = dt.datetime(int(dm.group(1)), int(dm.group(2)), int(dm.group(3)), tzinfo=dt.timezone.utc)
except ValueError:
continue
rejections_dated.append(d)
cb = bucket_of(d)
if cb < buckets[0] or cb > buckets[-1]:
# Dated outside the chart range — still count in the
# headline total, just not in any visible bucket.
rejections_dated_total += 1
continue
rejections_by_b[bucket_label(*cb)] += 1
rejections_dated_total += 1
rejected_series = [rejections_by_b.get(bucket_label(*b), 0) for b in buckets]
rejections_total = rejections_dated_total + rejections_backfill_total
# Per-issue events
events_by_n = {}
for n in issues_by_n:
p = f"{ROOT}/events/{n}.json"
if os.path.exists(p) and os.path.getsize(p) > 0:
with open(p) as f:
events_by_n[n] = json.load(f)
else:
events_by_n[n] = []
# --- tracker -> linked PR list (from body parse + closedBy) --------
def extract_prs_for_issue(n):
if not UPSTREAM_REPO:
return set()
v = issue_extra.get(str(n)) or {}
nums = set()
for ref in v.get("closedByPullRequestsReferences") or []:
if ref.get("repository", {}).get("nameWithOwner") == UPSTREAM_REPO:
nums.add(ref["number"])
body = v.get("body") or ""
if PR_PAT is not None:
for m in PR_PAT.findall(body):
x = m[0] or m[1]
if x:
nums.add(int(x))
return nums
issue_prs = {n: extract_prs_for_issue(n) for n in issues_by_n}
def pr_meta(num):
"""Return dict(createdAt=dt, mergedAt=dt|None, state=str) or None."""
v = prs_cache.get(str(num))
if not v or "error" in v:
return None
return {
"createdAt": parse_dt(v.get("createdAt")),
"mergedAt": parse_dt(v.get("mergedAt")),
"state": v.get("state"),
}
def tracker_pr_signals(n):
earliest_created = None
earliest_created_pr = None
earliest_merged_ts = None
earliest_merged_pr = None
for prn in issue_prs.get(n, []):
meta = pr_meta(prn)
if meta is None:
continue
c = meta["createdAt"]
if c is not None and (earliest_created is None or c < earliest_created):
earliest_created = c
earliest_created_pr = prn
mt = meta["mergedAt"]
if mt is not None and (earliest_merged_ts is None or mt < earliest_merged_ts):
earliest_merged_ts = mt
earliest_merged_pr = prn
return {
"first_pr_created": earliest_created,
"first_pr_created_num": earliest_created_pr,
"first_pr_merged": earliest_merged_ts,
"first_pr_merged_num": earliest_merged_pr,
}
tracker_signals = {n: tracker_pr_signals(n) for n in issues_by_n}
# --- label timeline replay ------------------------------------------
def labels_open_at(issue, ts):
n = issue["number"]
created = parse_dt(issue["createdAt"])
if ts < created:
return None, None
labels = set()
is_open = True
for e in events_by_n.get(n, []):
et = parse_dt(e["created_at"])
if et > ts:
break
if e["event"] == "labeled" and e.get("label"):
labels.add(e["label"])
elif e["event"] == "unlabeled" and e.get("label"):
labels.discard(e["label"])
elif e["event"] == "closed":
is_open = False
elif e["event"] == "reopened":
is_open = True
# The issue-events API can omit the 'closed' event for some (often
# older) issues, which would leave is_open stuck True and miscount a
# closed issue as open. Trust the authoritative closedAt from the
# issue payload as a backstop (the cum_closed series already does).
closed_at = parse_dt(issue.get("closedAt"))
if issue.get("state") == "CLOSED" and closed_at and closed_at <= ts:
is_open = False
return labels, is_open
def classify_per_config(labels, is_open, ts, n):
issue = issues_by_n[n]
state_reason = issue.get("stateReason")
sig = tracker_signals.get(n, {})
fm = sig.get("first_pr_merged")
pr_merged_by_snapshot = bool(UPSTREAM_REPO and fm is not None and fm <= ts)
ctx = {
"labels": labels,
"is_open": is_open,
"state_reason": state_reason,
"pr_merged_by_snapshot": pr_merged_by_snapshot,
"scope_labels": SCOPE_LABELS,
}
for cat in CATEGORIES_CFG:
if eval_predicate(cat["predicate"], ctx):
return cat["name"]
return None
# --- snapshot counts ------------------------------------------------
counts = {cat: [0] * n_buckets for cat in CATS}
backfill_trackers = set()
for bi, b in enumerate(buckets):
be = bucket_end(*b)
ts = NOW if be > NOW else be
for i in issues:
labels, is_open = labels_open_at(i, ts)
if labels is None:
continue
cat = classify_per_config(labels, is_open, ts, i["number"])
if cat is None:
continue
counts[cat][bi] += 1
if cat == "open_pr_merged" and is_open and "pr merged" not in labels:
backfill_trackers.add(i["number"])
# --- cumulative opened / closed ------------------------------------
cum_opened = [0] * n_buckets
cum_closed = [0] * n_buckets
for bi, b in enumerate(buckets):
be = bucket_end(*b)
ts = NOW if be > NOW else be
op = 0
cl = 0
for i in issues:
ca = parse_dt(i["createdAt"])
if ca and ca <= ts:
op += 1
cz = parse_dt(i.get("closedAt"))
if cz and cz <= ts:
cl += 1
cum_opened[bi] = op
cum_closed[bi] = cl
# --- cumulative rejected + reported (opened + rejected) ------------
# Mirror the cumulative-opened computation exactly: at each bucket end,
# count every rejection dated on or before it (so rejections that predate
# the chart window are baselined in, just as pre-window trackers are in
# cum_opened). rejections_dated / rejections_backfill_total come from the
# ledger parse above; both are empty/0 when the stat is disabled, so
# cum_reported degrades to exactly cum_opened. Any legacy undated backfill
# lump still seeds a flat baseline. "reported" = trackers opened + rejected.
cum_rejected = []
for b in buckets:
be = bucket_end(*b)
ts = NOW if be > NOW else be
cum_rejected.append(rejections_backfill_total + sum(1 for rd in rejections_dated if rd <= ts))
cum_reported = [o + r for o, r in zip(cum_opened, cum_rejected)]
# --- Opened-in-bucket vs untriaged-at-bucket-end ------------------
opened_in_b = [0] * n_buckets
untriaged_at_bend = counts.get("open_untriaged", [0] * n_buckets)
for i in issues:
ca = parse_dt(i["createdAt"])
if ca is None:
continue
cb = bucket_of(ca)
if cb < buckets[0] or cb > buckets[-1]:
continue
bi = buckets.index(cb)
opened_in_b[bi] += 1
# Per-bucket reported = trackers opened in the bucket + reports rejected in
# the same bucket (rejected_series). Equals opened_in_b when the rejections
# stat is disabled, so the trace is only drawn when the stat is active.
reported_in_b = [o + r for o, r in zip(opened_in_b, rejected_series)]
# --- triage / response ---------------------------------------------
TRIAGE_RE = build_triage_regex(TRIAGE_KW)
triage_hours_by_b = defaultdict(list)
resp_hours_by_b = defaultdict(list)
n_fallback_triage = 0
n_no_triage = 0
all_triage_hours = []
for i in issues:
created = parse_dt(i["createdAt"])
blbl = bucket_label(*bucket_of(created))
comments = i.get("comments", []) or []
first_roster = None
first_roster_keyword = None
for c in comments:
author = (c.get("author") or {}).get("login")
if not author or author not in roster:
continue
if is_bot_body(c.get("body") or "", BOT_PREFIXES):
continue
ct = parse_dt(c["createdAt"])
if first_roster is None:
first_roster = ct
if first_roster_keyword is None and TRIAGE_RE is not None and TRIAGE_RE.search(c.get("body") or ""):
first_roster_keyword = ct
if first_roster is not None and first_roster_keyword is not None:
break
if first_roster is not None:
hours = (first_roster - created).total_seconds() / 3600
resp_hours_by_b[blbl].append(hours)
triage_ts = first_roster_keyword if first_roster_keyword is not None else first_roster
if triage_ts is None:
n_no_triage += 1
continue
if first_roster_keyword is None:
n_fallback_triage += 1
hours = (triage_ts - created).total_seconds() / 3600
triage_hours_by_b[blbl].append(hours)
all_triage_hours.append(hours)
def per_b_series(by_b):
ys = []
ns = []
for b in buckets:
lbl = bucket_label(*b)
xs = by_b.get(lbl, [])
ys.append(mean_or_none(xs))
ns.append(len(xs))
return ys, ns
triage_ys, triage_ns = per_b_series(triage_hours_by_b)
resp_ys, resp_ns = per_b_series(resp_hours_by_b)
triage_median = round(statistics.median(all_triage_hours), 2) if all_triage_hours else None
triage_mean = round(statistics.mean(all_triage_hours), 2) if all_triage_hours else None
triage_n = len(all_triage_hours)
# --- PR-driven mean-time metrics -----------------------------------
prc_by_b = defaultdict(list)
prm_by_b = defaultdict(list)
rel_by_b = defaultdict(list)
def first_label_time(n, label):
for e in events_by_n.get(n, []):
if e["event"] == "labeled" and e.get("label") == label:
return parse_dt(e["created_at"])
return None
if UPSTREAM_REPO:
for i in issues:
n = i["number"]
created = parse_dt(i["createdAt"])
sig = tracker_signals.get(n, {})
first_pr_c = sig.get("first_pr_created")
first_pr_m = sig.get("first_pr_merged")
if first_pr_c and created and first_pr_c >= created:
days = (first_pr_c - created).total_seconds() / 86400
prc_by_b[bucket_label(*bucket_of(created))].append(days)
if first_pr_m is not None:
prn = sig.get("first_pr_merged_num")
meta = pr_meta(prn) if prn else None
if meta and meta["createdAt"] and meta["mergedAt"] and meta["mergedAt"] >= meta["createdAt"]:
days = (meta["mergedAt"] - meta["createdAt"]).total_seconds() / 86400
prm_by_b[bucket_label(*bucket_of(meta["createdAt"]))].append(days)
if first_pr_m is not None:
announced = first_label_time(n, "announced - emails sent") or first_label_time(n, "announced")
rel_ts = announced
if rel_ts is None:
ca = parse_dt(i.get("closedAt"))
state_reason = i.get("stateReason")
cur_labels = {lb["name"] for lb in i.get("labels", [])}
is_closed_completed = i.get("state") == "CLOSED" and state_reason == "COMPLETED"
has_cve = "cve allocated" in cur_labels
if ca and is_closed_completed and has_cve:
rel_ts = ca
if rel_ts is not None and rel_ts > first_pr_m:
days = (rel_ts - first_pr_m).total_seconds() / 86400
rel_by_b[bucket_label(*bucket_of(first_pr_m))].append(days)
prc_ys, prc_ns = per_b_series(prc_by_b)
prm_ys, prm_ns = per_b_series(prm_by_b)
rel_ys, rel_ns = per_b_series(rel_by_b)
def n_buckets_with_data(by_b):
return sum(1 for k, xs in by_b.items() if xs)
def overall_median(by_b):
flat = [x for xs in by_b.values() for x in xs]
return round(statistics.median(flat), 2) if flat else None
def overall_n(by_b):
return sum(len(xs) for xs in by_b.values())
# --- KPIs ----------------------------------------------------------
total = len(issues)
open_now = sum(1 for i in issues if i.get("state") == "OPEN")
closed_now = total - open_now
def latest(cat):
return counts[cat][-1] if cat in counts else 0
print(f"total trackers: {total}")
print(f"open: {open_now}, closed: {closed_now}")
print(f"fixed_released (latest bucket): {latest('fixed_released')}")
print(
f"open_untriaged: {latest('open_untriaged')}, open_triaged: {latest('open_triaged')}, "
f"open_pr_merged: {latest('open_pr_merged')}, closed_other: {latest('closed_other')}"
)
print(
f"triage median {triage_median}h, mean {triage_mean}h, n={triage_n} "
f"(fallback={n_fallback_triage}, none={n_no_triage})"
)
if REJECTIONS_LEDGER_LABEL:
print(
f"rejected without tracker: {rejections_dated_total} dated + "
f"{rejections_backfill_total} historical = {rejections_total} "
f"(ledger issues: {len(ledger_issues)})"
)
if UPSTREAM_REPO:
print()
print("PR-driven mean-time series:")
for name, by_b in [
("c_prc", prc_by_b),
("c_prm", prm_by_b),
("c_rel", rel_by_b),
]:
print(
f" {name}: n={overall_n(by_b)} median={overall_median(by_b)} "
f"buckets_with_data={n_buckets_with_data(by_b)}"
)
print()
print(
f"open_pr_merged back-fill: {len(backfill_trackers)} trackers were re-classified "
f"from open_triaged -> open_pr_merged in at least one historical bucket "
f"because of the PR-merge-date rule"
)
print()
print(
f"Latest bucket ({bucket_labels[-1]}) opened-vs-untriaged: "
f"opened_in_b={opened_in_b[-1]}, untriaged_at_bend={untriaged_at_bend[-1]}"
)
# --- Render HTML ---------------------------------------------------
# Title prefix differs between bucket modes for clarity.
bucket_word = {"monthly": "month", "weekly": "week"}.get(BUCKETS_MODE, "quarter")
# Build stacked-band traces in STACK_ORDER. With the default config that
# resolves to `fixed_released, open_pr_merged, open_triaged,
# open_untriaged, closed_other` — matching the reference dashboard.
stacked_traces = []
for cat in STACK_ORDER:
if cat not in counts:
continue
color = CAT_COLORS.get(cat, "#888888")
ys = js_array(counts[cat])
stacked_traces.append(
f" {{x: buckets, y: {ys}, name: '{cat}', stackgroup: 'one', "
f"type: 'scatter', mode: 'lines', line: {{color: '{color}', width: 0}}, "
f"fillcolor: '{color}', hoveron: 'points+fills'}}"
)
stacked_block = ",\n".join(stacked_traces)
# Milestone shapes + annotations (multi-milestone capable).
ms_shapes = []
ms_annots = []
for ms in MILESTONES:
ms_date = ms.get("date")
ms_label = ms.get("label") or "milestone"
if not ms_date:
continue
x_val = milestone_x(str(ms_date), BUCKETS_MODE)
ms_shapes.append(
"{type: 'line', xref: 'x', yref: 'paper', x0: '"
+ x_val
+ "', x1: '"
+ x_val
+ "', y0: 0, y1: 1, line: {color: '#888', width: 1.5, dash: 'dash'}}"
)
ms_annots.append(
"{xref: 'x', yref: 'paper', x: '"
+ x_val
+ "', y: 1.04, xanchor: 'left', text: '↓ "
+ ms_label
+ " ("
+ str(ms_date)
+ ")', "
+ "showarrow: false, font: {size: 11, color: '#666'}}"
)
shapes_js = "[" + ", ".join(ms_shapes) + "]"
annots_js = "[" + ", ".join(ms_annots) + "]"
# Build the optional PR-charts HTML and JS sections.
if UPSTREAM_REPO:
pr_cards_html = (
'<div class="card"><div id="c_prc"></div></div>\n'
'<div class="card"><div id="c_prm"></div></div>\n'
'<div class="card"><div id="c_rel"></div></div>\n'
)
pr_charts_js = (
f"meanChart('c_prc', 'Mean time createdAt → PR opened (days)', "
f"{js_array(prc_ys)}, {js_array(prc_ns)}, 'd', '#16a085');\n"
f"meanChart('c_prm', 'Mean time PR-open → PR-merged (days)', "
f"{js_array(prm_ys)}, {js_array(prm_ns)}, 'd', '#2980b9');\n"
f"meanChart('c_rel', 'Mean time PR-merged → advisory announced (days)', "
f"{js_array(rel_ys)}, {js_array(rel_ns)}, 'd', '#d35400');"
)
else:
pr_cards_html = ""
pr_charts_js = ""
# Build the optional "rejected (no tracker)" header banner, chart card,
# and JS. Omitted entirely when the rejections-ledger stat is disabled
# (null label) or there is no ledger issue and nothing was parsed.
if REJECTIONS_LEDGER_LABEL and (rejections_total or ledger_issues):
rej_header_html = (
'<div class="banner">Reports rejected without a tracker: '
f"<strong>{rejections_total}</strong> "
f"({rejections_dated_total} dated + {rejections_backfill_total} historical)</div>\n"
)
rej_cards_html = '<div class="card full"><div id="c_rejected"></div></div>\n'
rej_chart_js = (
f"Plotly.newPlot('c_rejected', [\n"
f" {{x: buckets, y: {js_array(rejected_series)}, "
f"name: 'rejected (no tracker)', type: 'scatter', "
f"mode: 'lines+markers', connectgaps: true, line: {{color: '#7f8c8d'}}, "
f"fill: 'tozeroy'}}\n"
f"], {{\n"
f" ...MILESTONES_LAYOUT,\n"
f" title: 'Reports rejected without a tracker (per {bucket_word}, dated; "
f"{rejections_backfill_total} historical pre-ledger not shown)',\n"
f" yaxis: {{title: 'count', rangemode: 'tozero'}},\n"
f" legend: {{orientation: 'h'}}\n"
f"}});"
)
else:
rej_header_html = ""
rej_cards_html = ""
rej_chart_js = ""
# Extra cumulative traces (rejected + reported) appended to the c_cum
# chart. Only emitted when the rejections stat is active; otherwise an
# empty string leaves the cumulative chart with its original two lines
# for projects without a ledger. Plain single-brace JS — inserted
# verbatim into the HTML f-string via {cum_rej_traces}.
if REJECTIONS_LEDGER_LABEL and (rejections_total or ledger_issues):
cum_rej_traces = (
",\n {x: buckets, y: "
+ js_array(cum_rejected)
+ ", name: 'cumulative rejected (no tracker)', type: 'scatter', "
"mode: 'lines+markers', connectgaps: true, "
"line: {color: '#7f8c8d', dash: 'dot'}},\n"
" {x: buckets, y: "
+ js_array(cum_reported)
+ ", name: 'reported (opened + rejected)', type: 'scatter', "
"mode: 'lines+markers', connectgaps: true, "
"line: {color: '#9467bd', width: 3}}"
)
else:
cum_rej_traces = ""
# Extra "reported" trace (opened + rejected per bucket) appended to the
# opened-vs-untriaged chart. Same activation rule as cum_rej_traces so it
# is omitted (and the chart keeps its two original lines) when no ledger.
if REJECTIONS_LEDGER_LABEL and (rejections_total or ledger_issues):
rep_ou_trace = (
",\n {x: buckets, y: "
+ js_array(reported_in_b)
+ ", name: 'reported in "
+ bucket_word
+ " (opened + rejected)', "
"type: 'scatter', mode: 'lines+markers', connectgaps: true, "
"line: {color: '#9467bd', width: 3}}"
)
else:
rep_ou_trace = ""
HTML = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>tracker {bucket_word}ly statistics</title>
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0 auto; padding: 16px; color: #222; max-width: 1400px; }}
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
.card {{ border: 1px solid #e0e0e0; border-radius: 8px; padding: 8px; background: #fafafa; }}
.card.full {{ grid-column: 1 / -1; }}
.banner {{ border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px 14px; background: #f4f6f6; margin-bottom: 16px; color: #444; }}
</style>
</head>
<body>
{rej_header_html}
<div class="grid">
<div class="card full"><div id="c_states"></div></div>
<div class="card full"><div id="c_open_vs_untriaged"></div></div>
<div class="card full"><div id="c_cum"></div></div>
{rej_cards_html}<div class="card"><div id="c_triage"></div></div>
<div class="card"><div id="c_resp"></div></div>
{pr_cards_html}
</div>
<script>
const buckets = {js_quotes(bucket_labels)};
function lineOpts() {{ return {{ type: 'scatter', mode: 'lines+markers', connectgaps: true }}; }}
// Milestone markers (config-driven).
const milestoneShapes = {shapes_js};
const milestoneAnnotations = {annots_js};
const MILESTONES_LAYOUT = {{shapes: milestoneShapes, annotations: milestoneAnnotations}};
// Stacked-line lifecycle bands
Plotly.newPlot('c_states', [
{stacked_block}
], {{
...MILESTONES_LAYOUT,
title: 'Issue lifecycle bands (stacked, end-of-{bucket_word} snapshots)',
yaxis: {{title: 'tracker count'}},
legend: {{orientation: 'h'}},
hovermode: 'x unified'
}});
// Opened-in-bucket vs untriaged-at-bucket-end
Plotly.newPlot('c_open_vs_untriaged', [
{{x: buckets, y: {js_array(opened_in_b)}, name: 'opened in {bucket_word}',
type: 'scatter', mode: 'lines+markers', connectgaps: true,
line: {{color: '#1f77b4'}}}},
{{x: buckets, y: {js_array(untriaged_at_bend)}, name: 'untriaged at {bucket_word}-end',
type: 'scatter', mode: 'lines+markers', connectgaps: true,
line: {{color: '#d62728'}}}}{rep_ou_trace}
], {{
...MILESTONES_LAYOUT,
title: 'Reported vs. opened vs. untriaged backlog (per {bucket_word})',
yaxis: {{title: 'count'}},
legend: {{orientation: 'h'}}
}});
Plotly.newPlot('c_cum', [
{{x: buckets, y: {js_array(cum_opened)}, name: 'cumulative opened',
type: 'scatter', mode: 'lines+markers', connectgaps: true,
line: {{color: '#1f77b4'}}, fill: 'tozeroy'}},
{{x: buckets, y: {js_array(cum_closed)}, name: 'cumulative closed',
type: 'scatter', mode: 'lines+markers', connectgaps: true,
line: {{color: '#2ca02c'}}, fill: 'tozeroy'}}{cum_rej_traces}
], {{
...MILESTONES_LAYOUT,
title: 'Cumulative reported (opened + rejected) vs. opened vs. closed (gap = open backlog)',
yaxis: {{title: 'count'}},
legend: {{orientation: 'h'}}
}});
{rej_chart_js}
function meanChart(divId, title, ys, ns, unit, color) {{
Plotly.newPlot(divId, [{{
x: buckets, y: ys,
type: 'scatter', mode: 'lines+markers', connectgaps: true,
text: ns.map(n => 'n=' + n),
hovertemplate: '%{{x}}<br>mean: %{{y:.2f}} ' + unit + '<br>%{{text}}<extra></extra>',
line: {{color: color}}
}}], {{
...MILESTONES_LAYOUT,
title: title,
yaxis: {{title: 'mean ' + unit, rangemode: 'tozero'}}
}});
}}
meanChart('c_triage', 'Mean time to triage (hours)', {js_array(triage_ys)}, {js_array(triage_ns)}, 'h', '#c0392b');
meanChart('c_resp', 'Mean time to first response (hours)', {js_array(resp_ys)}, {js_array(resp_ns)}, 'h', '#8e44ad');
{pr_charts_js}
</script>
</body>
</html>
"""
with open(OUT_PATH, "w") as f:
f.write(HTML)
print(f"\nWrote {OUT_PATH} ({len(HTML)} bytes)")