This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new f52b9526 feat(dashboard): add pyproject.toml, src/ layout, and pytest
suite to security-tracker-stats-dashboard (#536)
f52b9526 is described below
commit f52b9526c674870c4a3527c2c08e2856212ee666
Author: Justin Mclean <[email protected]>
AuthorDate: Sat Jun 27 10:43:06 2026 +1000
feat(dashboard): add pyproject.toml, src/ layout, and pytest suite to
security-tracker-stats-dashboard (#536)
Extract pure rendering helpers from render.py into
src/security_tracker_stats_dashboard/core.py (parse_dt, bucket
categorisation, eval_predicate, triage helpers, HTML serialisers).
Add pyproject.toml (hatchling, uv, dev group: pytest>=8.0), and a
100-test pytest suite in tests/test_core.py covering bucket
categorisation, time-to-triage computation, and the render pipeline.
The render.py monolith now imports from core and removes all duplicate
local definitions (eval_predicate, bucket helpers, triage regex, etc.);
scope_labels is threaded through ctx instead of reading a global.
Pre-existing fetch_*.py scripts reformatted by ruff for workspace
compliance.
All validation commands pass:
uv run --directory tools/security-tracker-stats-dashboard --group dev
pytest
bash -n tools/security-tracker-stats-dashboard/run.sh
Closes the test-coverage gap named in specs/security-reporting.md Known
Gaps. Adds the project to the root uv workspace members.
Generated-by: Claude (Opus 4.7)
---
pyproject.toml | 1 +
.../fetch_bodies.py | 34 +-
.../fetch_events.py | 49 +-
.../fetch_issues.py | 25 +-
.../security-tracker-stats-dashboard/fetch_prs.py | 41 +-
.../fetch_roster.py | 14 +-
.../pyproject.toml | 57 ++
tools/security-tracker-stats-dashboard/render.py | 887 ++++++---------------
.../security_tracker_stats_dashboard/__init__.py | 16 +
.../src/security_tracker_stats_dashboard/core.py | 525 ++++++++++++
.../tests/__init__.py | 16 +
.../tests/test_core.py | 619 ++++++++++++++
tools/security-tracker-stats-dashboard/uv.lock | 204 +++++
tools/spec-loop/specs/security-reporting.md | 4 +-
uv.lock | 16 +
15 files changed, 1789 insertions(+), 719 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index a6f83f13..83aeb815 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -109,6 +109,7 @@ members = [
"tools/privacy-llm/checker",
"tools/privacy-llm/redactor",
"tools/sandbox-lint",
+ "tools/security-tracker-stats-dashboard",
"tools/skill-and-tool-validator",
"tools/skill-evals",
"tools/spec-status-index",
diff --git a/tools/security-tracker-stats-dashboard/fetch_bodies.py
b/tools/security-tracker-stats-dashboard/fetch_bodies.py
index 6e346afd..d8497442 100644
--- a/tools/security-tracker-stats-dashboard/fetch_bodies.py
+++ b/tools/security-tracker-stats-dashboard/fetch_bodies.py
@@ -25,11 +25,11 @@ import os
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
-ROOT = os.environ.get('TRACKER_STATS_CACHE', '/tmp/tracker-stats-cache')
-REPO = os.environ.get('TRACKER_STATS_REPO', 'airflow-s/airflow-s')
-OUT = f'{ROOT}/issue_extra.json'
+ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
+REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")
+OUT = f"{ROOT}/issue_extra.json"
-with open(f'{ROOT}/issues.json') as f:
+with open(f"{ROOT}/issues.json") as f:
issues = json.load(f)
# Resume support
@@ -39,22 +39,32 @@ if os.path.exists(OUT):
cache = json.load(f)
print(f"resume: {len(cache)} cached")
-todo = [i['number'] for i in issues if str(i['number']) not in cache]
+todo = [i["number"] for i in issues if str(i["number"]) not in cache]
print(f"to fetch: {len(todo)}")
def fetch(n):
try:
r = subprocess.run(
- ['gh', 'issue', 'view', str(n), '--repo', REPO,
- '--json', 'number,body,closedByPullRequestsReferences'],
- capture_output=True, text=True, timeout=60,
+ [
+ "gh",
+ "issue",
+ "view",
+ str(n),
+ "--repo",
+ REPO,
+ "--json",
+ "number,body,closedByPullRequestsReferences",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
)
if r.returncode != 0:
- return n, {'error': r.stderr.strip()}
+ return n, {"error": r.stderr.strip()}
return n, json.loads(r.stdout)
except Exception as e:
- return n, {'error': str(e)}
+ return n, {"error": str(e)}
done = 0
@@ -65,10 +75,10 @@ with ThreadPoolExecutor(max_workers=10) as ex:
cache[str(n)] = data
done += 1
if done % 25 == 0:
- with open(OUT, 'w') as f:
+ with open(OUT, "w") as f:
json.dump(cache, f)
print(f" {done}/{len(todo)}")
-with open(OUT, 'w') as f:
+with open(OUT, "w") as f:
json.dump(cache, f)
print(f"done: cached {len(cache)} → {OUT}")
diff --git a/tools/security-tracker-stats-dashboard/fetch_events.py
b/tools/security-tracker-stats-dashboard/fetch_events.py
index 3d4bdc9b..9bd9f673 100644
--- a/tools/security-tracker-stats-dashboard/fetch_events.py
+++ b/tools/security-tracker-stats-dashboard/fetch_events.py
@@ -19,48 +19,60 @@
"""Fetch per-issue label-history events. Resumes from cache."""
-import json
-import subprocess
import concurrent.futures
import datetime as dt
+import json
import os
+import subprocess
-ROOT = os.environ.get('TRACKER_STATS_CACHE', '/tmp/tracker-stats-cache')
-REPO = os.environ.get('TRACKER_STATS_REPO', 'airflow-s/airflow-s')
-EVENTS_DIR = f'{ROOT}/events'
+ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
+REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")
+EVENTS_DIR = f"{ROOT}/events"
-with open(f'{ROOT}/issues.json') as f:
+with open(f"{ROOT}/issues.json") as f:
issues = json.load(f)
-numbers = [i['number'] for i in issues]
+numbers = [i["number"] for i in issues]
+
+
# Map issue number -> last-updated epoch so the cache can be refreshed when an
# issue was relabeled / closed after its events were last fetched.
def _iso_to_epoch(s):
if not s:
return None
try:
- return dt.datetime.fromisoformat(s.replace('Z', '+00:00')).timestamp()
+ return dt.datetime.fromisoformat(s.replace("Z", "+00:00")).timestamp()
except ValueError:
return None
-UPDATED_AT = {i['number']: _iso_to_epoch(i.get('updatedAt')) for i in issues}
+
+
+UPDATED_AT = {i["number"]: _iso_to_epoch(i.get("updatedAt")) for i in issues}
print(f"Fetching events for {len(numbers)} issues...")
os.makedirs(EVENTS_DIR, exist_ok=True)
+
def fetch_one(n):
- out_path = f'{EVENTS_DIR}/{n}.json'
+ out_path = f"{EVENTS_DIR}/{n}.json"
if os.path.exists(out_path) and os.path.getsize(out_path) > 0:
# Trust the cache only if it was written after the issue was last
# updated; otherwise the issue changed since and the events are stale.
upd = UPDATED_AT.get(n)
if upd is None or os.path.getmtime(out_path) >= upd:
- return (n, True, 'cached')
+ return (n, True, "cached")
try:
r = subprocess.run(
- ['gh', 'api', f'repos/{REPO}/issues/{n}/events',
- '--paginate',
- '--jq', '[.[] | select(.event == "labeled" or .event ==
"unlabeled" or .event == "closed" or .event == "reopened") | {event, label:
(.label.name // null), created_at}]'],
- capture_output=True, text=True, timeout=60
+ [
+ "gh",
+ "api",
+ f"repos/{REPO}/issues/{n}/events",
+ "--paginate",
+ "--jq",
+ '[.[] | select(.event == "labeled" or .event == "unlabeled" or
.event == "closed" or .event == "reopened") | {event, label: (.label.name //
null), created_at}]',
+ ],
+ capture_output=True,
+ text=True,
+ timeout=60,
)
if r.returncode != 0:
return (n, False, r.stderr[:200])
@@ -69,19 +81,20 @@ def fetch_one(n):
idx = 0
merged = []
while idx < len(out):
- while idx < len(out) and out[idx] in ' \n\r\t':
+ while idx < len(out) and out[idx] in " \n\r\t":
idx += 1
if idx >= len(out):
break
obj, n2 = decoder.raw_decode(out, idx)
merged.extend(obj)
idx = n2
- with open(out_path, 'w') as f:
+ with open(out_path, "w") as f:
json.dump(merged, f)
- return (n, True, f'{len(merged)} events')
+ return (n, True, f"{len(merged)} events")
except Exception as e:
return (n, False, str(e)[:200])
+
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as ex:
results = list(ex.map(fetch_one, numbers))
diff --git a/tools/security-tracker-stats-dashboard/fetch_issues.py
b/tools/security-tracker-stats-dashboard/fetch_issues.py
index f71de349..b32460b1 100644
--- a/tools/security-tracker-stats-dashboard/fetch_issues.py
+++ b/tools/security-tracker-stats-dashboard/fetch_issues.py
@@ -23,22 +23,35 @@ import json
import os
import subprocess
-ROOT = os.environ.get('TRACKER_STATS_CACHE', '/tmp/tracker-stats-cache')
-REPO = os.environ.get('TRACKER_STATS_REPO', 'airflow-s/airflow-s')
+ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
+REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")
os.makedirs(ROOT, exist_ok=True)
print(f"Fetching issue list from {REPO} (state=all, limit 1000) ...")
r = subprocess.run(
- ['gh', 'issue', 'list', '--repo', REPO, '--state', 'all', '--limit',
'1000',
- '--json',
'number,title,state,stateReason,createdAt,closedAt,labels,comments'],
- capture_output=True, text=True, timeout=300,
+ [
+ "gh",
+ "issue",
+ "list",
+ "--repo",
+ REPO,
+ "--state",
+ "all",
+ "--limit",
+ "1000",
+ "--json",
+ "number,title,state,stateReason,createdAt,closedAt,labels,comments",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=300,
)
if r.returncode != 0:
raise SystemExit(f"gh failed: {r.stderr}")
issues = json.loads(r.stdout)
-with open(f'{ROOT}/issues.json', 'w') as f:
+with open(f"{ROOT}/issues.json", "w") as f:
json.dump(issues, f)
print(f"Wrote {len(issues)} issues to {ROOT}/issues.json")
diff --git a/tools/security-tracker-stats-dashboard/fetch_prs.py
b/tools/security-tracker-stats-dashboard/fetch_prs.py
index 4b545397..a371cb87 100644
--- a/tools/security-tracker-stats-dashboard/fetch_prs.py
+++ b/tools/security-tracker-stats-dashboard/fetch_prs.py
@@ -30,31 +30,31 @@ import re
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed
-ROOT = os.environ.get('TRACKER_STATS_CACHE', '/tmp/tracker-stats-cache')
-UPSTREAM = os.environ.get('TRACKER_STATS_UPSTREAM_REPO', 'apache/airflow')
-if UPSTREAM in ('', 'none', 'null'):
- print('TRACKER_STATS_UPSTREAM_REPO is empty/none - skipping PR fetch.')
+ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
+UPSTREAM = os.environ.get("TRACKER_STATS_UPSTREAM_REPO", "apache/airflow")
+if UPSTREAM in ("", "none", "null"):
+ print("TRACKER_STATS_UPSTREAM_REPO is empty/none - skipping PR fetch.")
raise SystemExit(0)
-EXTRA = f'{ROOT}/issue_extra.json'
-OUT = f'{ROOT}/prs.json'
+EXTRA = f"{ROOT}/issue_extra.json"
+OUT = f"{ROOT}/prs.json"
with open(EXTRA) as f:
extra = json.load(f)
PR_PAT = re.compile(
-
rf'{re.escape(UPSTREAM)}#(\d+)|https://github\.com/{re.escape(UPSTREAM)}/pull/(\d+)',
+
rf"{re.escape(UPSTREAM)}#(\d+)|https://github\.com/{re.escape(UPSTREAM)}/pull/(\d+)",
re.I,
)
def extract_prs(v):
nums = set()
- cb = v.get('closedByPullRequestsReferences') or []
+ cb = v.get("closedByPullRequestsReferences") or []
for ref in cb:
- if ref.get('repository', {}).get('nameWithOwner') == UPSTREAM:
- nums.add(ref['number'])
- body = v.get('body') or ''
+ if ref.get("repository", {}).get("nameWithOwner") == UPSTREAM:
+ nums.add(ref["number"])
+ body = v.get("body") or ""
# Only parse the "PR with the fix" field portion if we can find it,
# but also accept apache/airflow PR mentions anywhere in the body
# (the spec allows either).
@@ -74,7 +74,7 @@ for issue_n, v in extra.items():
all_prs.update(prs)
# Save the issue_to_prs linkage map alongside
-with open(f'{ROOT}/issue_to_prs.json', 'w') as f:
+with open(f"{ROOT}/issue_to_prs.json", "w") as f:
json.dump(issue_to_prs, f)
print(f"unique {UPSTREAM} PRs to fetch: {len(all_prs)}")
@@ -92,15 +92,16 @@ print(f"to fetch: {len(todo)}")
def fetch(n):
try:
r = subprocess.run(
- ['gh', 'pr', 'view', str(n), '--repo', UPSTREAM,
- '--json', 'number,createdAt,mergedAt,state'],
- capture_output=True, text=True, timeout=60,
+ ["gh", "pr", "view", str(n), "--repo", UPSTREAM, "--json",
"number,createdAt,mergedAt,state"],
+ capture_output=True,
+ text=True,
+ timeout=60,
)
if r.returncode != 0:
- return n, {'error': r.stderr.strip()}
+ return n, {"error": r.stderr.strip()}
return n, json.loads(r.stdout)
except Exception as e:
- return n, {'error': str(e)}
+ return n, {"error": str(e)}
done = 0
@@ -111,11 +112,11 @@ with ThreadPoolExecutor(max_workers=12) as ex:
cache[str(n)] = data
done += 1
if done % 25 == 0:
- with open(OUT, 'w') as f:
+ with open(OUT, "w") as f:
json.dump(cache, f)
print(f" {done}/{len(todo)}")
-with open(OUT, 'w') as f:
+with open(OUT, "w") as f:
json.dump(cache, f)
-errs = sum(1 for v in cache.values() if 'error' in v)
+errs = sum(1 for v in cache.values() if "error" in v)
print(f"done: cached {len(cache)} PRs ({errs} errors) → {OUT}")
diff --git a/tools/security-tracker-stats-dashboard/fetch_roster.py
b/tools/security-tracker-stats-dashboard/fetch_roster.py
index 9d3aef25..7375cc60 100644
--- a/tools/security-tracker-stats-dashboard/fetch_roster.py
+++ b/tools/security-tracker-stats-dashboard/fetch_roster.py
@@ -22,21 +22,23 @@
import os
import subprocess
-ROOT = os.environ.get('TRACKER_STATS_CACHE', '/tmp/tracker-stats-cache')
-REPO = os.environ.get('TRACKER_STATS_REPO', 'airflow-s/airflow-s')
+ROOT = os.environ.get("TRACKER_STATS_CACHE", "/tmp/tracker-stats-cache")
+REPO = os.environ.get("TRACKER_STATS_REPO", "airflow-s/airflow-s")
os.makedirs(ROOT, exist_ok=True)
r = subprocess.run(
- ['gh', 'api', f'repos/{REPO}/collaborators', '--jq', '.[].login',
'--paginate'],
- capture_output=True, text=True, timeout=60,
+ ["gh", "api", f"repos/{REPO}/collaborators", "--jq", ".[].login",
"--paginate"],
+ capture_output=True,
+ text=True,
+ timeout=60,
)
if r.returncode != 0:
raise SystemExit(f"gh failed: {r.stderr}")
logins = [ln.strip() for ln in r.stdout.splitlines() if ln.strip()]
-with open(f'{ROOT}/roster.txt', 'w') as f:
+with open(f"{ROOT}/roster.txt", "w") as f:
for ln in sorted(set(logins)):
- f.write(ln + '\n')
+ f.write(ln + "\n")
print(f"Wrote {len(set(logins))} roster handles to {ROOT}/roster.txt")
diff --git a/tools/security-tracker-stats-dashboard/pyproject.toml
b/tools/security-tracker-stats-dashboard/pyproject.toml
new file mode 100644
index 00000000..4ba1154f
--- /dev/null
+++ b/tools/security-tracker-stats-dashboard/pyproject.toml
@@ -0,0 +1,57 @@
+# 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.
+
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "security-tracker-stats-dashboard"
+version = "0.1.0"
+description = "Rendering helpers for the security-tracker statistics
dashboard."
+readme = "README.md"
+requires-python = ">=3.9"
+license = { text = "Apache-2.0" }
+dependencies = []
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/security_tracker_stats_dashboard"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py39"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = ["E", "W", "F", "I", "B", "UP", "SIM", "C4", "RUF"]
+ignore = ["E501"]
+
+[tool.mypy]
+python_version = "3.10"
+files = ["src", "tests"]
+check_untyped_defs = true
+no_implicit_optional = true
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
+
+[dependency-groups]
+dev = [
+ "pytest>=8.0",
+]
diff --git a/tools/security-tracker-stats-dashboard/render.py
b/tools/security-tracker-stats-dashboard/render.py
index 3112c03e..e3961053 100644
--- a/tools/security-tracker-stats-dashboard/render.py
+++ b/tools/security-tracker-stats-dashboard/render.py
@@ -46,18 +46,46 @@ null, those three charts are omitted and the snapshot
back-fill rule is
disabled.
"""
-import calendar
+import datetime as dt
import json
import os
import re
import statistics
-import datetime as dt
+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 a tiny subset parser that covers the schema in
-# default-config.yaml only.
+# installed, fall back to the pure-Python subset parser from core.
try:
import yaml # type: ignore
@@ -65,311 +93,55 @@ try:
return yaml.safe_load(text)
except ImportError:
+
def yaml_load(text):
return _minimal_yaml_load(text)
-def _minimal_yaml_load(text):
- """Tiny YAML subset parser sufficient for default-config.yaml.
-
- Supports: nested block mappings, block sequences (`- ...`), inline
- flow lists `[a, b, "c d"]`, string scalars (with optional quotes),
- integers, floats, booleans, null. Comments start at `#` outside of
- quoted strings. No anchors, no merge keys, no flow mappings.
- """
- lines = []
- for raw in text.splitlines():
- # Strip comments outside of quotes.
- in_q = None
- out = []
- i = 0
- while i < len(raw):
- ch = raw[i]
- if in_q:
- out.append(ch)
- if ch == '\\' and i + 1 < len(raw):
- out.append(raw[i + 1])
- i += 2
- continue
- if ch == in_q:
- in_q = None
- i += 1
- continue
- if ch in ('"', "'"):
- in_q = ch
- out.append(ch)
- i += 1
- continue
- if ch == '#':
- break
- out.append(ch)
- i += 1
- line = ''.join(out).rstrip()
- if line.strip():
- lines.append(line)
-
- # Parse using indentation stack.
- def indent_of(s):
- return len(s) - len(s.lstrip(' '))
-
- def scalar(s):
- s = s.strip()
- if not s:
- return None
- if s.lower() in ('null', '~'):
- return None
- if s.lower() == 'true':
- return True
- if s.lower() == 'false':
- return False
- if s.startswith('"') and s.endswith('"') and len(s) >= 2:
- return s[1:-1].encode().decode('unicode_escape')
- if s.startswith("'") and s.endswith("'") and len(s) >= 2:
- return s[1:-1]
- if s.startswith('[') and s.endswith(']'):
- inner = s[1:-1].strip()
- if not inner:
- return []
- return [scalar(x) for x in _split_flow_list(inner)]
- try:
- if '.' in s or 'e' in s or 'E' in s:
- return float(s)
- return int(s)
- except ValueError:
- return s
-
- def _split_flow_list(inner):
- parts = []
- cur = []
- in_q = None
- depth = 0
- for ch in inner:
- if in_q:
- cur.append(ch)
- if ch == in_q:
- in_q = None
- continue
- if ch in ('"', "'"):
- in_q = ch
- cur.append(ch)
- continue
- if ch == '[':
- depth += 1
- cur.append(ch)
- continue
- if ch == ']':
- depth -= 1
- cur.append(ch)
- continue
- if ch == ',' and depth == 0:
- parts.append(''.join(cur).strip())
- cur = []
- continue
- cur.append(ch)
- if cur:
- parts.append(''.join(cur).strip())
- return parts
-
- def parse_block(idx, base_indent):
- # Returns (value, next_idx). Inspects the first non-empty line
- # at >= base_indent to decide mapping vs. sequence.
- if idx >= len(lines):
- return None, idx
- first = lines[idx]
- ind = indent_of(first)
- if ind < base_indent:
- return None, idx
- if first.lstrip().startswith('- '):
- return parse_seq(idx, ind)
- return parse_map(idx, ind)
-
- def parse_map(idx, base_indent):
- out = {}
- while idx < len(lines):
- line = lines[idx]
- ind = indent_of(line)
- if ind < base_indent:
- break
- if ind > base_indent:
- # Shouldn't happen at top of map.
- break
- stripped = line.strip()
- if stripped.startswith('- '):
- break
- # key: value or key:
- if ':' not in stripped:
- idx += 1
- continue
- # Split on the first ':' that isn't inside quotes.
- key, _, rest = _split_key_value(stripped)
- rest = rest.strip()
- idx += 1
- if rest == '' or rest is None:
- # Block child.
- if idx < len(lines) and indent_of(lines[idx]) > base_indent:
- child, idx = parse_block(idx, indent_of(lines[idx]))
- out[key] = child
- else:
- out[key] = None
- else:
- out[key] = scalar(rest)
- return out, idx
-
- def _split_key_value(stripped):
- in_q = None
- for i, ch in enumerate(stripped):
- if in_q:
- if ch == in_q:
- in_q = None
- continue
- if ch in ('"', "'"):
- in_q = ch
- continue
- if ch == ':':
- key = stripped[:i].strip()
- rest = stripped[i + 1 :]
- # Unquote key.
- if (key.startswith('"') and key.endswith('"')) or (
- key.startswith("'") and key.endswith("'")
- ):
- key = key[1:-1]
- return key, ':', rest
- return stripped, None, ''
-
- def parse_seq(idx, base_indent):
- out = []
- while idx < len(lines):
- line = lines[idx]
- ind = indent_of(line)
- if ind < base_indent:
- break
- if ind > base_indent:
- break
- stripped = line.strip()
- if not stripped.startswith('- '):
- break
- after_dash = stripped[2:].rstrip()
- # Item indent = base_indent + 2 (for "- ")
- item_inner_indent = base_indent + 2
- idx += 1
- if after_dash == '':
- # Block item, child lines.
- if idx < len(lines) and indent_of(lines[idx]) > base_indent:
- child, idx = parse_block(idx, indent_of(lines[idx]))
- out.append(child)
- else:
- out.append(None)
- continue
- if ':' in after_dash and not (
- after_dash.startswith('"') or after_dash.startswith("'")
- ):
- # Inline first key-value of a mapping item. Treat the "- "
- # as introducing a mapping whose first key is on this line.
- key, _, rest = _split_key_value(after_dash)
- rest = rest.strip()
- item = {}
- if rest == '':
- if idx < len(lines) and indent_of(lines[idx]) >
item_inner_indent:
- child, idx = parse_block(idx, indent_of(lines[idx]))
- item[key] = child
- else:
- item[key] = None
- else:
- item[key] = scalar(rest)
- # Continue absorbing further keys at item_inner_indent.
- while idx < len(lines):
- nline = lines[idx]
- nind = indent_of(nline)
- if nind < item_inner_indent:
- break
- if nind > item_inner_indent:
- break
- nstripped = nline.strip()
- if nstripped.startswith('- '):
- break
- if ':' not in nstripped:
- idx += 1
- continue
- nkey, _, nrest = _split_key_value(nstripped)
- nrest = nrest.strip()
- idx += 1
- if nrest == '':
- if idx < len(lines) and indent_of(lines[idx]) >
item_inner_indent:
- child, idx = parse_block(idx,
indent_of(lines[idx]))
- item[nkey] = child
- else:
- item[nkey] = None
- else:
- item[nkey] = scalar(nrest)
- out.append(item)
- else:
- out.append(scalar(after_dash))
- return out, idx
-
- val, _ = parse_block(0, 0)
- return val
-
-
# --- 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')
+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 deep_merge(base, overlay):
- """Deep-merge overlay into base. Lists are REPLACED (not concatenated)."""
- if overlay is None:
- return base
- if not isinstance(base, dict) or not isinstance(overlay, dict):
- return overlay
- out = dict(base)
- for k, v in overlay.items():
- if k in out and isinstance(out[k], dict) and isinstance(v, dict):
- out[k] = deep_merge(out[k], v)
- else:
- out[k] = v
- return out
+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')
+ 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
+ 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 [])
+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')
+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
@@ -377,30 +149,30 @@ REJECTIONS_LEDGER_LABEL =
CONFIG.get('rejections_ledger_label')
_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
+ 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'))
+ CAT_COLORS.setdefault(c["name"], c.get("color", "#888888"))
# --- Cache load -----------------------------------------------------
-with open(f'{ROOT}/issues.json') as f:
+with open(f"{ROOT}/issues.json") as f:
all_issues = json.load(f)
-with open(f'{ROOT}/roster.txt') as 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:
+with open(f"{ROOT}/issue_extra.json") as f:
issue_extra = json.load(f)
def _issue_label_names(issue):
- return {(l.get('name') if isinstance(l, dict) else l) for l in
(issue.get('labels') or [])}
+ 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
@@ -410,17 +182,15 @@ def _issue_label_names(issue):
# (`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)]
+ 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'
+ prs_path = f"{ROOT}/prs.json"
if os.path.exists(prs_path):
with open(prs_path) as f:
prs_cache = json.load(f)
@@ -432,108 +202,17 @@ if UPSTREAM_REPO:
# 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
- )
+ PR_PAT =
re.compile(rf"{repo_re}#(\d+)|https://github\.com/{repo_re}/pull/(\d+)", re.I)
else:
PR_PAT = None
-# --- helpers --------------------------------------------------------
-
-def parse_dt(s):
- if not s:
- return None
- return dt.datetime.fromisoformat(s.replace('Z', '+00:00'))
-
-
-# --- Bucket abstraction --------------------------------------------
-
-def month_of(d):
- return d.year, d.month
-
-
-def quarter_of(d):
- return d.year, (d.month - 1) // 3 + 1
-
-
-def month_label(y, m):
- return f"{y}-{m:02d}"
-
-
-def quarter_label(y, q):
- return f"{y}-Q{q}"
-
-
-def month_end(y, m):
- last_day = calendar.monthrange(y, m)[1]
- return dt.datetime(y, m, last_day, 23, 59, 59, tzinfo=dt.timezone.utc)
-
-
-def quarter_end(y, q):
- # q in {1,2,3,4}
- last_month = q * 3
- last_day = calendar.monthrange(y, last_month)[1]
- return dt.datetime(y, last_month, last_day, 23, 59, 59,
tzinfo=dt.timezone.utc)
-
-
-def iter_months(y0, m0, y1, m1):
- y, m = y0, m0
- while (y, m) <= (y1, m1):
- yield y, m
- m += 1
- if m == 13:
- m = 1
- y += 1
-
-
-def iter_quarters(y0, q0, y1, q1):
- y, q = y0, q0
- while (y, q) <= (y1, q1):
- yield y, q
- q += 1
- if q == 5:
- q = 1
- y += 1
-
-
-# Weekly buckets key on ISO (year, week) — note the ISO year can differ from
-# the calendar year in late December / early January, but (iso_year, iso_week)
-# tuples remain chronologically ordered, so the existing <=-based iteration
-# and bucketing carry over unchanged.
-def week_of(d):
- iso = d.isocalendar()
- return iso[0], iso[1]
-
-
-def week_label(y, w):
- return f"{y}-W{w:02d}"
-
-
-def week_end(y, w):
- # ISO weeks run Monday (day 1) .. Sunday (day 7); end at Sunday 23:59:59.
- sunday = dt.date.fromisocalendar(y, w, 7)
- return dt.datetime(sunday.year, sunday.month, sunday.day,
- 23, 59, 59, tzinfo=dt.timezone.utc)
-
-
-def iter_weeks(y0, w0, y1, w1):
- # Step by 7 days from the Monday of the start week through the end week;
- # ISO years have 52 or 53 weeks, so stepping by date avoids that edge.
- cur = dt.date.fromisocalendar(y0, w0, 1)
- last = dt.date.fromisocalendar(y1, w1, 1)
- while cur <= last:
- iso = cur.isocalendar()
- yield iso[0], iso[1]
- cur += dt.timedelta(days=7)
-
-
-if BUCKETS_MODE == 'monthly':
+if BUCKETS_MODE == "monthly":
bucket_of = month_of
bucket_label = month_label
bucket_end = month_end
bucket_iter = iter_months
-elif BUCKETS_MODE == 'weekly':
+elif BUCKETS_MODE == "weekly":
bucket_of = week_of
bucket_label = week_label
bucket_end = week_end
@@ -547,18 +226,18 @@ else:
# --- index issues + buckets ----------------------------------------
-issues_by_n = {i['number']: i for i in issues}
-earliest = min(parse_dt(i['createdAt']) for i in issues)
+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('-'))
+ 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')
+ 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')
+ y_part, q_part = START_OVERRIDE.split("-Q")
start_key = (int(y_part), int(q_part))
else:
start_key = bucket_of(earliest)
@@ -597,19 +276,18 @@ print(f"buckets in range ({BUCKETS_MODE}): {n_buckets}")
# 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)
+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_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 ''
+ 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:
@@ -630,8 +308,7 @@ for li in ledger_issues:
# 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)
+ 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)
@@ -650,7 +327,7 @@ 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'
+ 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)
@@ -660,15 +337,16 @@ for n in issues_by_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 ''
+ 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]
@@ -683,12 +361,12 @@ 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:
+ 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'),
+ "createdAt": parse_dt(v.get("createdAt")),
+ "mergedAt": parse_dt(v.get("mergedAt")),
+ "state": v.get("state"),
}
@@ -701,21 +379,19 @@ def tracker_pr_signals(n):
meta = pr_meta(prn)
if meta is None:
continue
- c = meta['createdAt']
- if c is not None:
- if earliest_created is None or c < earliest_created:
- earliest_created = c
- earliest_created_pr = prn
- mt = meta['mergedAt']
- if mt is not None:
- if earliest_merged_ts is None or mt < earliest_merged_ts:
- earliest_merged_ts = mt
- earliest_merged_pr = prn
+ 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,
+ "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,
}
@@ -724,116 +400,52 @@ 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'])
+ 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'])
+ 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':
+ 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':
+ 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:
+ 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
-# --- Predicate evaluator -------------------------------------------
-
-def eval_predicate(pred, ctx):
- """Evaluate a category predicate against a snapshot context.
-
- `ctx` keys:
- labels (set), is_open (bool), state_reason (str|None),
- pr_merged_by_snapshot (bool).
- """
- if not isinstance(pred, dict):
- return False
- for key, val in pred.items():
- if key == 'any_of':
- if not any(eval_predicate(p, ctx) for p in val):
- return False
- elif key == 'all_of':
- if isinstance(val, list):
- if not all(eval_predicate(p, ctx) for p in val):
- return False
- elif isinstance(val, dict):
- if not eval_predicate(val, ctx):
- return False
- else:
- return False
- elif key == 'state':
- want_open = (val == 'open')
- if ctx['is_open'] != want_open:
- return False
- elif key == 'state_reason':
- if ctx['state_reason'] != val:
- return False
- elif key == 'any_label':
- if not any(l in ctx['labels'] for l in val):
- return False
- elif key == 'all_labels':
- if not all(l in ctx['labels'] for l in val):
- return False
- elif key == 'not_label':
- if val in ctx['labels']:
- return False
- elif key == 'not_any_label':
- if any(l in ctx['labels'] for l in val):
- return False
- elif key == 'no_scope_label':
- has_scope = bool(ctx['labels'] & SCOPE_LABELS)
- if val and has_scope:
- return False
- if not val and not has_scope:
- return False
- elif key == 'has_scope_label':
- has_scope = bool(ctx['labels'] & SCOPE_LABELS)
- if val and not has_scope:
- return False
- if not val and has_scope:
- return False
- elif key == 'pr_merged_by_snapshot':
- if val and not ctx['pr_merged_by_snapshot']:
- return False
- if not val and ctx['pr_merged_by_snapshot']:
- return False
- else:
- # Unknown key — fail safe.
- return False
- return True
-
-
def classify_per_config(labels, is_open, ts, n):
issue = issues_by_n[n]
- state_reason = issue.get('stateReason')
+ state_reason = issue.get("stateReason")
sig = tracker_signals.get(n, {})
- fm = sig.get('first_pr_merged')
+ 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,
+ "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']
+ if eval_predicate(cat["predicate"], ctx):
+ return cat["name"]
return None
@@ -849,13 +461,13 @@ for bi, b in enumerate(buckets):
labels, is_open = labels_open_at(i, ts)
if labels is None:
continue
- cat = classify_per_config(labels, is_open, ts, i['number'])
+ 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'])
+ if cat == "open_pr_merged" and is_open and "pr merged" not in labels:
+ backfill_trackers.add(i["number"])
# --- cumulative opened / closed ------------------------------------
@@ -867,10 +479,10 @@ for bi, b in enumerate(buckets):
op = 0
cl = 0
for i in issues:
- ca = parse_dt(i['createdAt'])
+ ca = parse_dt(i["createdAt"])
if ca and ca <= ts:
op += 1
- cz = parse_dt(i.get('closedAt'))
+ cz = parse_dt(i.get("closedAt"))
if cz and cz <= ts:
cl += 1
cum_opened[bi] = op
@@ -888,17 +500,16 @@ 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_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)
+untriaged_at_bend = counts.get("open_untriaged", [0] * n_buckets)
for i in issues:
- ca = parse_dt(i['createdAt'])
+ ca = parse_dt(i["createdAt"])
if ca is None:
continue
cb = bucket_of(ca)
@@ -914,28 +525,7 @@ reported_in_b = [o + r for o, r in zip(opened_in_b,
rejected_series)]
# --- triage / response ---------------------------------------------
-# Build the triage regex from config. Keep word-boundary wrapping for
-# the all-caps keywords so they don't match substrings inside other
-# words (mirrors the original handwritten regex).
-_kw_parts = []
-for kw in TRIAGE_KW:
- if kw.isupper() and ' ' not in kw and '-' not in kw:
- _kw_parts.append(rf'\b{re.escape(kw)}\b')
- elif kw.isalpha() and kw.islower() and ' ' not in kw:
- _kw_parts.append(rf'\b{re.escape(kw)}\b')
- else:
- _kw_parts.append(re.escape(kw))
-TRIAGE_RE = re.compile('|'.join(_kw_parts), re.IGNORECASE) if _kw_parts else
None
-
-
-def is_bot_body(body):
- if not body:
- return False
- b = body.lstrip()
- for p in BOT_PREFIXES:
- if b.startswith(p):
- return True
- return False
+TRIAGE_RE = build_triage_regex(TRIAGE_KW)
triage_hours_by_b = defaultdict(list)
@@ -945,26 +535,22 @@ n_no_triage = 0
all_triage_hours = []
for i in issues:
- created = parse_dt(i['createdAt'])
+ created = parse_dt(i["createdAt"])
blbl = bucket_label(*bucket_of(created))
- comments = i.get('comments', []) or []
+ comments = i.get("comments", []) or []
first_roster = None
first_roster_keyword = None
for c in comments:
- author = (c.get('author') or {}).get('login')
+ author = (c.get("author") or {}).get("login")
if not author or author not in roster:
continue
- if is_bot_body(c.get('body') or ''):
+ if is_bot_body(c.get("body") or "", BOT_PREFIXES):
continue
- ct = parse_dt(c['createdAt'])
+ 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 '')
- ):
+ 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
@@ -984,10 +570,6 @@ for i in issues:
all_triage_hours.append(hours)
-def mean_or_none(xs):
- return round(statistics.mean(xs), 2) if xs else None
-
-
def per_b_series(by_b):
ys = []
ns = []
@@ -1016,41 +598,40 @@ 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'])
+ 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'])
+ 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')
+ 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')
+ 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 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'))
+ 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 = {l['name'] for l in i.get('labels', [])}
- is_closed_completed = (i.get('state') == 'CLOSED' and
state_reason == 'COMPLETED')
- has_cve = 'cve allocated' in cur_labels
+ 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:
@@ -1079,9 +660,10 @@ def overall_n(by_b):
# --- KPIs ----------------------------------------------------------
total = len(issues)
-open_now = sum(1 for i in issues if i.get('state') == 'OPEN')
+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
@@ -1089,68 +671,51 @@ def latest(cat):
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})")
+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)})")
+ 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),
+ ("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(
+ 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(
+ 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]}")
+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 ---------------------------------------------------
-def js_array(xs, fmt_null='null'):
- parts = []
- for x in xs:
- if x is None:
- parts.append(fmt_null)
- elif isinstance(x, float):
- parts.append(f"{x:.2f}" if not (x == int(x)) else f"{int(x)}")
- else:
- parts.append(str(x))
- return '[' + ', '.join(parts) + ']'
-
-
-def js_quotes(xs):
- return '[' + ', '.join(f'"{x}"' for x in xs) + ']'
-
-
-def milestone_x(milestone_date):
- """Map a milestone date (YYYY-MM-DD) onto a bucket-axis label."""
- y = int(milestone_date[:4])
- mo = int(milestone_date[5:7])
- if BUCKETS_MODE == 'monthly':
- return f"{y}-{mo:02d}"
- if BUCKETS_MODE == 'weekly':
- d = dt.date(y, mo, int(milestone_date[8:10]))
- iso = d.isocalendar()
- return f"{iso[0]}-W{iso[1]:02d}"
- return f"{y}-Q{(mo - 1) // 3 + 1}"
-
-
# Title prefix differs between bucket modes for clarity.
-bucket_word = {'monthly': 'month', 'weekly': 'week'}.get(BUCKETS_MODE,
'quarter')
+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,
@@ -1159,36 +724,43 @@ stacked_traces = []
for cat in STACK_ORDER:
if cat not in counts:
continue
- color = CAT_COLORS.get(cat, '#888888')
+ 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)
+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'
+ ms_date = ms.get("date")
+ ms_label = ms.get("label") or "milestone"
if not ms_date:
continue
- x_val = milestone_x(str(ms_date))
+ x_val = milestone_x(str(ms_date), BUCKETS_MODE)
ms_shapes.append(
- "{type: 'line', xref: 'x', yref: 'paper', x0: '" + x_val
- + "', x1: '" + x_val
+ "{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) + ")', "
+ "{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) + ']'
+shapes_js = "[" + ", ".join(ms_shapes) + "]"
+annots_js = "[" + ", ".join(ms_annots) + "]"
# Build the optional PR-charts HTML and JS sections.
@@ -1207,8 +779,8 @@ if UPSTREAM_REPO:
f"{js_array(rel_ys)}, {js_array(rel_ns)}, 'd', '#d35400');"
)
else:
- pr_cards_html = ''
- pr_charts_js = ''
+ pr_cards_html = ""
+ pr_charts_js = ""
# Build the optional "rejected (no tracker)" header banner, chart card,
@@ -1217,8 +789,8 @@ else:
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'
+ 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 = (
@@ -1236,9 +808,9 @@ if REJECTIONS_LEDGER_LABEL and (rejections_total or
ledger_issues):
f"}});"
)
else:
- rej_header_html = ''
- rej_cards_html = ''
- rej_chart_js = ''
+ 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
@@ -1247,30 +819,35 @@ else:
# 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', "
+ ",\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', "
+ " {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 = ''
+ 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)', "
+ ",\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 = ''
+ rep_ou_trace = ""
HTML = f"""<!DOCTYPE html>
@@ -1374,7 +951,7 @@ meanChart('c_resp', 'Mean time to first response
(hours)', {js_array(resp_ys)
</html>
"""
-with open(OUT_PATH, 'w') as f:
+with open(OUT_PATH, "w") as f:
f.write(HTML)
print(f"\nWrote {OUT_PATH} ({len(HTML)} bytes)")
diff --git
a/tools/security-tracker-stats-dashboard/src/security_tracker_stats_dashboard/__init__.py
b/tools/security-tracker-stats-dashboard/src/security_tracker_stats_dashboard/__init__.py
new file mode 100644
index 00000000..13a83393
--- /dev/null
+++
b/tools/security-tracker-stats-dashboard/src/security_tracker_stats_dashboard/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git
a/tools/security-tracker-stats-dashboard/src/security_tracker_stats_dashboard/core.py
b/tools/security-tracker-stats-dashboard/src/security_tracker_stats_dashboard/core.py
new file mode 100644
index 00000000..21656c44
--- /dev/null
+++
b/tools/security-tracker-stats-dashboard/src/security_tracker_stats_dashboard/core.py
@@ -0,0 +1,525 @@
+# 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.
+
+"""Pure rendering helpers for the security-tracker statistics dashboard.
+
+All functions here are dependency-free (stdlib only) and have no side
+effects, making them straightforwardly unit-testable without a cache
+directory or live GitHub credentials.
+"""
+
+from __future__ import annotations
+
+import calendar
+import datetime as dt
+import re
+import statistics
+from collections.abc import Iterator
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# Datetime helpers
+# ---------------------------------------------------------------------------
+
+
+def parse_dt(s: str | None) -> dt.datetime | None:
+ """Parse an ISO-8601 timestamp string to an aware datetime, or None."""
+ if not s:
+ return None
+ return dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
+
+
+# ---------------------------------------------------------------------------
+# Bucket abstraction (monthly / quarterly)
+# ---------------------------------------------------------------------------
+
+
+def month_of(d: dt.datetime) -> tuple[int, int]:
+ """Return (year, month) bucket key for a datetime."""
+ return d.year, d.month
+
+
+def quarter_of(d: dt.datetime) -> tuple[int, int]:
+ """Return (year, quarter) bucket key for a datetime. Quarter in 1..4."""
+ return d.year, (d.month - 1) // 3 + 1
+
+
+def month_label(y: int, m: int) -> str:
+ """Format a monthly bucket key as "YYYY-MM"."""
+ return f"{y}-{m:02d}"
+
+
+def quarter_label(y: int, q: int) -> str:
+ """Format a quarterly bucket key as "YYYY-Qn"."""
+ return f"{y}-Q{q}"
+
+
+def month_end(y: int, m: int) -> dt.datetime:
+ """Return the last instant of the given calendar month (UTC)."""
+ last_day = calendar.monthrange(y, m)[1]
+ return dt.datetime(y, m, last_day, 23, 59, 59, tzinfo=dt.timezone.utc)
+
+
+def quarter_end(y: int, q: int) -> dt.datetime:
+ """Return the last instant of the given calendar quarter (UTC)."""
+ last_month = q * 3
+ last_day = calendar.monthrange(y, last_month)[1]
+ return dt.datetime(y, last_month, last_day, 23, 59, 59,
tzinfo=dt.timezone.utc)
+
+
+def iter_months(y0: int, m0: int, y1: int, m1: int) -> Iterator[tuple[int,
int]]:
+ """Yield (year, month) tuples from (y0, m0) to (y1, m1) inclusive."""
+ y, m = y0, m0
+ while (y, m) <= (y1, m1):
+ yield y, m
+ m += 1
+ if m == 13:
+ m = 1
+ y += 1
+
+
+def iter_quarters(y0: int, q0: int, y1: int, q1: int) -> Iterator[tuple[int,
int]]:
+ """Yield (year, quarter) tuples from (y0, q0) to (y1, q1) inclusive."""
+ y, q = y0, q0
+ while (y, q) <= (y1, q1):
+ yield y, q
+ q += 1
+ if q == 5:
+ q = 1
+ y += 1
+
+
+# Weekly buckets key on ISO (year, week) — note the ISO year can differ from
+# the calendar year in late December / early January, but (iso_year, iso_week)
+# tuples remain chronologically ordered, so the existing <=-based iteration
+# and bucketing carry over unchanged.
+def week_of(d: dt.datetime) -> tuple[int, int]:
+ iso = d.isocalendar()
+ return iso[0], iso[1]
+
+
+def week_label(y: int, w: int) -> str:
+ return f"{y}-W{w:02d}"
+
+
+def week_end(y: int, w: int) -> dt.datetime:
+ # ISO weeks run Monday (day 1) .. Sunday (day 7); end at Sunday 23:59:59.
+ sunday = dt.date.fromisocalendar(y, w, 7)
+ return dt.datetime(sunday.year, sunday.month, sunday.day, 23, 59, 59,
tzinfo=dt.timezone.utc)
+
+
+def iter_weeks(y0: int, w0: int, y1: int, w1: int) -> Iterator[tuple[int,
int]]:
+ # Step by 7 days from the Monday of the start week through the end week;
+ # ISO years have 52 or 53 weeks, so stepping by date avoids that edge.
+ cur = dt.date.fromisocalendar(y0, w0, 1)
+ last = dt.date.fromisocalendar(y1, w1, 1)
+ while cur <= last:
+ iso = cur.isocalendar()
+ yield iso[0], iso[1]
+ cur += dt.timedelta(days=7)
+
+
+def milestone_x(milestone_date: str, buckets_mode: str = "monthly") -> str:
+ """Map a milestone date string (YYYY-MM-DD) to a bucket-axis label."""
+ y = int(milestone_date[:4])
+ mo = int(milestone_date[5:7])
+ if buckets_mode == "monthly":
+ return f"{y}-{mo:02d}"
+ if buckets_mode == "weekly":
+ d = dt.date(y, mo, int(milestone_date[8:10]))
+ iso = d.isocalendar()
+ return f"{iso[0]}-W{iso[1]:02d}"
+ return f"{y}-Q{(mo - 1) // 3 + 1}"
+
+
+# ---------------------------------------------------------------------------
+# Configuration helpers
+# ---------------------------------------------------------------------------
+
+
+def deep_merge(base: Any, overlay: Any) -> Any:
+ """Deep-merge *overlay* into *base*. Lists are REPLACED, not
concatenated."""
+ if overlay is None:
+ return base
+ if not isinstance(base, dict) or not isinstance(overlay, dict):
+ return overlay
+ out = dict(base)
+ for k, v in overlay.items():
+ if k in out and isinstance(out[k], dict) and isinstance(v, dict):
+ out[k] = deep_merge(out[k], v)
+ else:
+ out[k] = v
+ return out
+
+
+def _minimal_yaml_load(text: str) -> Any:
+ """Tiny YAML subset parser sufficient for default-config.yaml.
+
+ Supports: nested block mappings, block sequences (``- ...``), inline
+ flow lists ``[a, b, "c d"]``, string scalars (with optional quotes),
+ integers, floats, booleans, null. Comments start at ``#`` outside
+ quoted strings. No anchors, no merge keys, no flow mappings.
+ """
+ lines: list[str] = []
+ for raw in text.splitlines():
+ in_q = None
+ out: list[str] = []
+ i = 0
+ while i < len(raw):
+ ch = raw[i]
+ if in_q:
+ out.append(ch)
+ if ch == "\\" and i + 1 < len(raw):
+ out.append(raw[i + 1])
+ i += 2
+ continue
+ if ch == in_q:
+ in_q = None
+ i += 1
+ continue
+ if ch in ('"', "'"):
+ in_q = ch
+ out.append(ch)
+ i += 1
+ continue
+ if ch == "#":
+ break
+ out.append(ch)
+ i += 1
+ line = "".join(out).rstrip()
+ if line.strip():
+ lines.append(line)
+
+ def indent_of(s: str) -> int:
+ return len(s) - len(s.lstrip(" "))
+
+ def scalar(s: str) -> Any:
+ s = s.strip()
+ if not s:
+ return None
+ if s.lower() in ("null", "~"):
+ return None
+ if s.lower() == "true":
+ return True
+ if s.lower() == "false":
+ return False
+ if s.startswith('"') and s.endswith('"') and len(s) >= 2:
+ return s[1:-1].encode().decode("unicode_escape")
+ if s.startswith("'") and s.endswith("'") and len(s) >= 2:
+ return s[1:-1]
+ if s.startswith("[") and s.endswith("]"):
+ inner = s[1:-1].strip()
+ if not inner:
+ return []
+ return [scalar(x) for x in _split_flow_list(inner)]
+ try:
+ if "." in s or "e" in s or "E" in s:
+ return float(s)
+ return int(s)
+ except ValueError:
+ return s
+
+ def _split_flow_list(inner: str) -> list[str]:
+ parts: list[str] = []
+ cur: list[str] = []
+ in_q2 = None
+ depth = 0
+ for ch in inner:
+ if in_q2:
+ cur.append(ch)
+ if ch == in_q2:
+ in_q2 = None
+ continue
+ if ch in ('"', "'"):
+ in_q2 = ch
+ cur.append(ch)
+ continue
+ if ch == "[":
+ depth += 1
+ cur.append(ch)
+ continue
+ if ch == "]":
+ depth -= 1
+ cur.append(ch)
+ continue
+ if ch == "," and depth == 0:
+ parts.append("".join(cur).strip())
+ cur = []
+ continue
+ cur.append(ch)
+ if cur:
+ parts.append("".join(cur).strip())
+ return parts
+
+ def parse_block(idx: int, base_indent: int) -> tuple[Any, int]:
+ if idx >= len(lines):
+ return None, idx
+ first = lines[idx]
+ ind = indent_of(first)
+ if ind < base_indent:
+ return None, idx
+ if first.lstrip().startswith("- "):
+ return parse_seq(idx, ind)
+ return parse_map(idx, ind)
+
+ def _split_key_value(stripped: str) -> tuple[str, str, str]:
+ in_q3 = None
+ for i, ch in enumerate(stripped):
+ if in_q3:
+ if ch == in_q3:
+ in_q3 = None
+ continue
+ if ch in ('"', "'"):
+ in_q3 = ch
+ continue
+ if ch == ":":
+ key = stripped[:i].strip()
+ rest = stripped[i + 1 :]
+ if (key.startswith('"') and key.endswith('"')) or
(key.startswith("'") and key.endswith("'")):
+ key = key[1:-1]
+ return key, ":", rest
+ return stripped, "", ""
+
+ def parse_map(idx: int, base_indent: int) -> tuple[dict, int]:
+ out: dict[str, Any] = {}
+ while idx < len(lines):
+ line = lines[idx]
+ ind = indent_of(line)
+ if ind < base_indent:
+ break
+ if ind > base_indent:
+ break
+ stripped = line.strip()
+ if stripped.startswith("- "):
+ break
+ if ":" not in stripped:
+ idx += 1
+ continue
+ key, _, rest = _split_key_value(stripped)
+ rest = rest.strip()
+ idx += 1
+ if rest == "" or rest is None:
+ if idx < len(lines) and indent_of(lines[idx]) > base_indent:
+ child, idx = parse_block(idx, indent_of(lines[idx]))
+ out[key] = child
+ else:
+ out[key] = None
+ else:
+ out[key] = scalar(rest)
+ return out, idx
+
+ def parse_seq(idx: int, base_indent: int) -> tuple[list, int]:
+ out: list[Any] = []
+ while idx < len(lines):
+ line = lines[idx]
+ ind = indent_of(line)
+ if ind < base_indent:
+ break
+ if ind > base_indent:
+ break
+ stripped = line.strip()
+ if not stripped.startswith("- "):
+ break
+ after_dash = stripped[2:].rstrip()
+ item_inner_indent = base_indent + 2
+ idx += 1
+ if after_dash == "":
+ if idx < len(lines) and indent_of(lines[idx]) > base_indent:
+ child, idx = parse_block(idx, indent_of(lines[idx]))
+ out.append(child)
+ else:
+ out.append(None)
+ continue
+ if ":" in after_dash and not (after_dash.startswith('"') or
after_dash.startswith("'")):
+ key, _, rest = _split_key_value(after_dash)
+ rest = rest.strip()
+ item: dict[str, Any] = {}
+ if rest == "":
+ if idx < len(lines) and indent_of(lines[idx]) >
item_inner_indent:
+ child, idx = parse_block(idx, indent_of(lines[idx]))
+ item[key] = child
+ else:
+ item[key] = None
+ else:
+ item[key] = scalar(rest)
+ while idx < len(lines):
+ nline = lines[idx]
+ nind = indent_of(nline)
+ if nind < item_inner_indent:
+ break
+ if nind > item_inner_indent:
+ break
+ nstripped = nline.strip()
+ if nstripped.startswith("- "):
+ break
+ if ":" not in nstripped:
+ idx += 1
+ continue
+ nkey, _, nrest = _split_key_value(nstripped)
+ nrest = nrest.strip()
+ idx += 1
+ if nrest == "":
+ if idx < len(lines) and indent_of(lines[idx]) >
item_inner_indent:
+ child, idx = parse_block(idx,
indent_of(lines[idx]))
+ item[nkey] = child
+ else:
+ item[nkey] = None
+ else:
+ item[nkey] = scalar(nrest)
+ out.append(item)
+ else:
+ out.append(scalar(after_dash))
+ return out, idx
+
+ val, _ = parse_block(0, 0)
+ return val
+
+
+# ---------------------------------------------------------------------------
+# Category predicate evaluator
+# ---------------------------------------------------------------------------
+
+
+def eval_predicate(pred: Any, ctx: dict[str, Any]) -> bool:
+ """Evaluate a category predicate against a snapshot context dict.
+
+ Expected *ctx* keys:
+ - ``labels`` : set of label strings present at snapshot
time
+ - ``is_open`` : bool — tracker is open at snapshot time
+ - ``state_reason`` : str | None — GitHub stateReason
+ - ``pr_merged_by_snapshot``: bool — a linked upstream PR is merged by
snapshot
+ - ``scope_labels`` : set of label strings that are scope labels
+ (optional; defaults to empty set)
+
+ Returns True when the predicate matches the context.
+ """
+ if not isinstance(pred, dict):
+ return False
+ scope_labels: set[str] = ctx.get("scope_labels") or set() # type:
ignore[assignment]
+ for key, val in pred.items():
+ if key == "any_of":
+ if not any(eval_predicate(p, ctx) for p in val):
+ return False
+ elif key == "all_of":
+ if isinstance(val, list):
+ if not all(eval_predicate(p, ctx) for p in val):
+ return False
+ elif isinstance(val, dict):
+ if not eval_predicate(val, ctx):
+ return False
+ else:
+ return False
+ elif key == "state":
+ want_open = val == "open"
+ if ctx["is_open"] != want_open:
+ return False
+ elif key == "state_reason":
+ if ctx["state_reason"] != val:
+ return False
+ elif key == "any_label":
+ if not any(lb in ctx["labels"] for lb in val):
+ return False
+ elif key == "all_labels":
+ if not all(lb in ctx["labels"] for lb in val):
+ return False
+ elif key == "not_label":
+ if val in ctx["labels"]:
+ return False
+ elif key == "not_any_label":
+ if any(lb in ctx["labels"] for lb in val):
+ return False
+ elif key == "no_scope_label":
+ has_scope = bool(ctx["labels"] & scope_labels)
+ if val and has_scope:
+ return False
+ if not val and not has_scope:
+ return False
+ elif key == "has_scope_label":
+ has_scope = bool(ctx["labels"] & scope_labels)
+ if val and not has_scope:
+ return False
+ if not val and has_scope:
+ return False
+ elif key == "pr_merged_by_snapshot":
+ if val and not ctx["pr_merged_by_snapshot"]:
+ return False
+ if not val and ctx["pr_merged_by_snapshot"]:
+ return False
+ else:
+ return False
+ return True
+
+
+# ---------------------------------------------------------------------------
+# Statistics helpers
+# ---------------------------------------------------------------------------
+
+
+def mean_or_none(xs: list[float]) -> float | None:
+ """Return the mean of *xs* rounded to 2 decimal places, or None if
empty."""
+ return round(statistics.mean(xs), 2) if xs else None
+
+
+# ---------------------------------------------------------------------------
+# HTML / JS serialisation helpers
+# ---------------------------------------------------------------------------
+
+
+def js_array(xs: list[Any], fmt_null: str = "null") -> str:
+ """Serialise a Python list to a JavaScript array literal string."""
+ parts = []
+ for x in xs:
+ if x is None:
+ parts.append(fmt_null)
+ elif isinstance(x, float):
+ parts.append(f"{x:.2f}" if x != int(x) else f"{int(x)}")
+ else:
+ parts.append(str(x))
+ return "[" + ", ".join(parts) + "]"
+
+
+def js_quotes(xs: list[str]) -> str:
+ """Serialise a list of strings to a JavaScript array of quoted string
literals."""
+ return "[" + ", ".join(f'"{x}"' for x in xs) + "]"
+
+
+# ---------------------------------------------------------------------------
+# Triage helpers
+# ---------------------------------------------------------------------------
+
+
+def build_triage_regex(keywords: list[str]) -> re.Pattern[str] | None:
+ """Compile a triage keyword list into a single OR-pattern regex."""
+ if not keywords:
+ return None
+ parts = []
+ for kw in keywords:
+ if (kw.isupper() and " " not in kw and "-" not in kw) or (
+ kw.isalpha() and kw.islower() and " " not in kw
+ ):
+ parts.append(rf"\b{re.escape(kw)}\b")
+ else:
+ parts.append(re.escape(kw))
+ return re.compile("|".join(parts), re.IGNORECASE)
+
+
+def is_bot_body(body: str | None, bot_prefixes: tuple[str, ...] = ()) -> bool:
+ """Return True when *body* begins with one of the known bot comment
prefixes."""
+ if not body:
+ return False
+ b = body.lstrip()
+ return any(b.startswith(p) for p in bot_prefixes)
diff --git a/tools/security-tracker-stats-dashboard/tests/__init__.py
b/tools/security-tracker-stats-dashboard/tests/__init__.py
new file mode 100644
index 00000000..13a83393
--- /dev/null
+++ b/tools/security-tracker-stats-dashboard/tests/__init__.py
@@ -0,0 +1,16 @@
+# 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.
diff --git a/tools/security-tracker-stats-dashboard/tests/test_core.py
b/tools/security-tracker-stats-dashboard/tests/test_core.py
new file mode 100644
index 00000000..5374ace5
--- /dev/null
+++ b/tools/security-tracker-stats-dashboard/tests/test_core.py
@@ -0,0 +1,619 @@
+# 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.
+
+import datetime as dt
+
+import pytest
+
+from security_tracker_stats_dashboard.core import (
+ _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,
+)
+
+# ---------------------------------------------------------------------------
+# parse_dt
+# ---------------------------------------------------------------------------
+
+
+class TestParseDt:
+ def test_none_returns_none(self):
+ assert parse_dt(None) is None
+
+ def test_empty_returns_none(self):
+ assert parse_dt("") is None
+
+ def test_z_suffix(self):
+ result = parse_dt("2024-03-15T10:30:00Z")
+ assert result == dt.datetime(2024, 3, 15, 10, 30, 0,
tzinfo=dt.timezone.utc)
+
+ def test_plus_offset(self):
+ result = parse_dt("2024-03-15T10:30:00+00:00")
+ assert result == dt.datetime(2024, 3, 15, 10, 30, 0,
tzinfo=dt.timezone.utc)
+
+ def test_aware(self):
+ result = parse_dt("2025-01-01T00:00:00Z")
+ assert result is not None
+ assert result.tzinfo is not None
+
+
+# ---------------------------------------------------------------------------
+# Bucket categorisation — monthly
+# ---------------------------------------------------------------------------
+
+
+class TestMonthOf:
+ def test_basic(self):
+ d = dt.datetime(2024, 6, 15, tzinfo=dt.timezone.utc)
+ assert month_of(d) == (2024, 6)
+
+ def test_january(self):
+ d = dt.datetime(2025, 1, 1, tzinfo=dt.timezone.utc)
+ assert month_of(d) == (2025, 1)
+
+ def test_december(self):
+ d = dt.datetime(2024, 12, 31, tzinfo=dt.timezone.utc)
+ assert month_of(d) == (2024, 12)
+
+
+class TestQuarterOf:
+ def test_q1(self):
+ d = dt.datetime(2024, 2, 15, tzinfo=dt.timezone.utc)
+ assert quarter_of(d) == (2024, 1)
+
+ def test_q2(self):
+ d = dt.datetime(2024, 4, 1, tzinfo=dt.timezone.utc)
+ assert quarter_of(d) == (2024, 2)
+
+ def test_q3(self):
+ d = dt.datetime(2024, 9, 30, tzinfo=dt.timezone.utc)
+ assert quarter_of(d) == (2024, 3)
+
+ def test_q4(self):
+ d = dt.datetime(2024, 12, 1, tzinfo=dt.timezone.utc)
+ assert quarter_of(d) == (2024, 4)
+
+ def test_boundary_month_3_is_q1(self):
+ d = dt.datetime(2024, 3, 31, tzinfo=dt.timezone.utc)
+ assert quarter_of(d) == (2024, 1)
+
+ def test_boundary_month_4_is_q2(self):
+ d = dt.datetime(2024, 4, 1, tzinfo=dt.timezone.utc)
+ assert quarter_of(d) == (2024, 2)
+
+
+class TestMonthLabel:
+ def test_basic(self):
+ assert month_label(2024, 3) == "2024-03"
+
+ def test_two_digit_month(self):
+ assert month_label(2024, 11) == "2024-11"
+
+ def test_january_zero_padded(self):
+ assert month_label(2025, 1) == "2025-01"
+
+
+class TestQuarterLabel:
+ def test_q1(self):
+ assert quarter_label(2024, 1) == "2024-Q1"
+
+ def test_q4(self):
+ assert quarter_label(2023, 4) == "2023-Q4"
+
+
+class TestMonthEnd:
+ def test_january(self):
+ result = month_end(2024, 1)
+ assert result.day == 31
+ assert result.hour == 23
+ assert result.minute == 59
+ assert result.tzinfo == dt.timezone.utc
+
+ def test_february_leap(self):
+ result = month_end(2024, 2)
+ assert result.day == 29
+
+ def test_february_nonleap(self):
+ result = month_end(2023, 2)
+ assert result.day == 28
+
+ def test_april(self):
+ result = month_end(2024, 4)
+ assert result.day == 30
+
+
+class TestQuarterEnd:
+ def test_q1(self):
+ result = quarter_end(2024, 1)
+ assert result.month == 3
+ assert result.day == 31
+
+ def test_q2(self):
+ result = quarter_end(2024, 2)
+ assert result.month == 6
+ assert result.day == 30
+
+ def test_q3(self):
+ result = quarter_end(2024, 3)
+ assert result.month == 9
+ assert result.day == 30
+
+ def test_q4(self):
+ result = quarter_end(2024, 4)
+ assert result.month == 12
+ assert result.day == 31
+
+ def test_aware(self):
+ assert quarter_end(2024, 1).tzinfo == dt.timezone.utc
+
+
+# ---------------------------------------------------------------------------
+# iter_months / iter_quarters
+# ---------------------------------------------------------------------------
+
+
+class TestIterMonths:
+ def test_single(self):
+ assert list(iter_months(2024, 3, 2024, 3)) == [(2024, 3)]
+
+ def test_spans_year(self):
+ result = list(iter_months(2024, 11, 2025, 2))
+ assert result == [(2024, 11), (2024, 12), (2025, 1), (2025, 2)]
+
+ def test_empty_when_start_after_end(self):
+ assert list(iter_months(2025, 1, 2024, 12)) == []
+
+ def test_full_year(self):
+ result = list(iter_months(2024, 1, 2024, 12))
+ assert len(result) == 12
+ assert result[0] == (2024, 1)
+ assert result[-1] == (2024, 12)
+
+
+class TestIterQuarters:
+ def test_single(self):
+ assert list(iter_quarters(2024, 2, 2024, 2)) == [(2024, 2)]
+
+ def test_spans_year(self):
+ result = list(iter_quarters(2024, 3, 2025, 2))
+ assert result == [(2024, 3), (2024, 4), (2025, 1), (2025, 2)]
+
+ def test_empty_when_start_after_end(self):
+ assert list(iter_quarters(2025, 1, 2024, 4)) == []
+
+ def test_full_year(self):
+ result = list(iter_quarters(2024, 1, 2024, 4))
+ assert len(result) == 4
+
+
+# ---------------------------------------------------------------------------
+# week_of / week_label / week_end / iter_weeks (ISO-week buckets)
+# ---------------------------------------------------------------------------
+
+
+class TestWeekOf:
+ def test_basic(self):
+ # 2026-01-01 is a Thursday -> ISO week 1 of 2026.
+ assert week_of(dt.datetime(2026, 1, 1)) == (2026, 1)
+
+ def test_iso_year_behind_calendar_year(self):
+ # 2023-01-01 is a Sunday -> still ISO week 52 of 2022.
+ assert week_of(dt.datetime(2023, 1, 1)) == (2022, 52)
+
+ def test_iso_year_ahead_of_calendar_year(self):
+ # 2024-12-30 is a Monday -> ISO week 1 of 2025.
+ assert week_of(dt.datetime(2024, 12, 30)) == (2025, 1)
+
+
+class TestWeekLabel:
+ def test_zero_padded(self):
+ assert week_label(2026, 3) == "2026-W03"
+
+ def test_two_digit_week(self):
+ assert week_label(2025, 52) == "2025-W52"
+
+
+class TestWeekEnd:
+ def test_sunday_end_of_week(self):
+ # ISO 2026-W01 runs Mon 2025-12-29 .. Sun 2026-01-04.
+ result = week_end(2026, 1)
+ assert (result.year, result.month, result.day) == (2026, 1, 4)
+ assert (result.hour, result.minute, result.second) == (23, 59, 59)
+
+ def test_is_utc(self):
+ assert week_end(2025, 52).tzinfo == dt.timezone.utc
+
+ def test_week_52(self):
+ # ISO 2025-W52 ends Sun 2025-12-28.
+ result = week_end(2025, 52)
+ assert (result.year, result.month, result.day) == (2025, 12, 28)
+
+
+class TestIterWeeks:
+ def test_single(self):
+ assert list(iter_weeks(2026, 1, 2026, 1)) == [(2026, 1)]
+
+ def test_spans_iso_year_rollover(self):
+ # 2025-W52 -> 2026-W01 -> 2026-W02; the ISO year flips mid-range.
+ result = list(iter_weeks(2025, 52, 2026, 2))
+ assert result == [(2025, 52), (2026, 1), (2026, 2)]
+
+ def test_empty_when_start_after_end(self):
+ assert list(iter_weeks(2026, 5, 2026, 2)) == []
+
+ def test_consecutive_count(self):
+ result = list(iter_weeks(2026, 1, 2026, 10))
+ assert len(result) == 10
+
+
+# ---------------------------------------------------------------------------
+# milestone_x
+# ---------------------------------------------------------------------------
+
+
+class TestMilestoneX:
+ def test_monthly(self):
+ assert milestone_x("2024-03-15") == "2024-03"
+
+ def test_quarterly_q1(self):
+ assert milestone_x("2024-02-01", buckets_mode="quarterly") == "2024-Q1"
+
+ def test_quarterly_q2(self):
+ assert milestone_x("2024-04-01", buckets_mode="quarterly") == "2024-Q2"
+
+ def test_quarterly_q4(self):
+ assert milestone_x("2024-11-30", buckets_mode="quarterly") == "2024-Q4"
+
+ def test_monthly_default(self):
+ assert milestone_x("2025-01-01") == "2025-01"
+
+ def test_weekly(self):
+ # 2026-01-15 is a Thursday -> ISO week 3 of 2026.
+ assert milestone_x("2026-01-15", buckets_mode="weekly") == "2026-W03"
+
+ def test_weekly_iso_year_rollover(self):
+ # 2024-12-30 is a Monday -> ISO week 1 of 2025.
+ assert milestone_x("2024-12-30", buckets_mode="weekly") == "2025-W01"
+
+
+# ---------------------------------------------------------------------------
+# deep_merge
+# ---------------------------------------------------------------------------
+
+
+class TestDeepMerge:
+ def test_overlay_none_returns_base(self):
+ base = {"a": 1}
+ assert deep_merge(base, None) == {"a": 1}
+
+ def test_non_dict_overlay_replaces(self):
+ assert deep_merge({"a": 1}, "scalar") == "scalar"
+
+ def test_simple_merge(self):
+ result = deep_merge({"a": 1, "b": 2}, {"b": 3, "c": 4})
+ assert result == {"a": 1, "b": 3, "c": 4}
+
+ def test_nested_merge(self):
+ base = {"x": {"a": 1, "b": 2}}
+ overlay = {"x": {"b": 99, "c": 3}}
+ result = deep_merge(base, overlay)
+ assert result == {"x": {"a": 1, "b": 99, "c": 3}}
+
+ def test_list_replaced_not_concatenated(self):
+ base = {"items": [1, 2, 3]}
+ overlay = {"items": [4, 5]}
+ result = deep_merge(base, overlay)
+ assert result["items"] == [4, 5]
+
+ def test_deep_nested(self):
+ base = {"a": {"b": {"c": 1}}}
+ overlay = {"a": {"b": {"d": 2}}}
+ result = deep_merge(base, overlay)
+ assert result == {"a": {"b": {"c": 1, "d": 2}}}
+
+
+# ---------------------------------------------------------------------------
+# _minimal_yaml_load
+# ---------------------------------------------------------------------------
+
+
+class TestMinimalYamlLoad:
+ def test_simple_mapping(self):
+ text = "key: value\nnum: 42\n"
+ result = _minimal_yaml_load(text)
+ assert result == {"key": "value", "num": 42}
+
+ def test_boolean(self):
+ result = _minimal_yaml_load("flag: true\n")
+ assert result == {"flag": True}
+
+ def test_null(self):
+ result = _minimal_yaml_load("x: null\n")
+ assert result == {"x": None}
+
+ def test_nested(self):
+ text = "outer:\n inner: 1\n"
+ result = _minimal_yaml_load(text)
+ assert result == {"outer": {"inner": 1}}
+
+ def test_sequence(self):
+ text = "items:\n - a\n - b\n"
+ result = _minimal_yaml_load(text)
+ assert result == {"items": ["a", "b"]}
+
+ def test_inline_list(self):
+ result = _minimal_yaml_load("tags: [alpha, beta]\n")
+ assert result == {"tags": ["alpha", "beta"]}
+
+ def test_comment_stripped(self):
+ result = _minimal_yaml_load("key: value # comment\n")
+ assert result == {"key": "value"}
+
+ def test_float(self):
+ result = _minimal_yaml_load("v: 3.14\n")
+ assert result == {"v": pytest.approx(3.14)}
+
+ def test_quoted_string(self):
+ result = _minimal_yaml_load("s: 'hello world'\n")
+ assert result == {"s": "hello world"}
+
+ def test_mapping_in_sequence(self):
+ text = "cats:\n - name: foo\n color: red\n"
+ result = _minimal_yaml_load(text)
+ assert result == {"cats": [{"name": "foo", "color": "red"}]}
+
+
+# ---------------------------------------------------------------------------
+# eval_predicate
+# ---------------------------------------------------------------------------
+
+
+def _ctx(labels=None, is_open=True, state_reason=None, pr_merged=False,
scope_labels=None):
+ return {
+ "labels": set(labels or []),
+ "is_open": is_open,
+ "state_reason": state_reason,
+ "pr_merged_by_snapshot": pr_merged,
+ "scope_labels": set(scope_labels or []),
+ }
+
+
+class TestEvalPredicate:
+ def test_non_dict_pred_returns_false(self):
+ assert eval_predicate("bad", _ctx()) is False
+
+ def test_empty_pred_matches_anything(self):
+ assert eval_predicate({}, _ctx()) is True
+
+ def test_state_open(self):
+ assert eval_predicate({"state": "open"}, _ctx(is_open=True)) is True
+ assert eval_predicate({"state": "open"}, _ctx(is_open=False)) is False
+
+ def test_state_closed(self):
+ assert eval_predicate({"state": "closed"}, _ctx(is_open=False)) is True
+
+ def test_state_reason(self):
+ assert eval_predicate({"state_reason": "COMPLETED"},
_ctx(state_reason="COMPLETED")) is True
+ assert eval_predicate({"state_reason": "COMPLETED"},
_ctx(state_reason="NOT_PLANNED")) is False
+
+ def test_any_label(self):
+ assert eval_predicate({"any_label": ["a", "b"]}, _ctx(labels=["b"]))
is True
+ assert eval_predicate({"any_label": ["a", "b"]}, _ctx(labels=["c"]))
is False
+
+ def test_all_labels(self):
+ assert eval_predicate({"all_labels": ["a", "b"]}, _ctx(labels=["a",
"b", "c"])) is True
+ assert eval_predicate({"all_labels": ["a", "b"]}, _ctx(labels=["a"]))
is False
+
+ def test_not_label(self):
+ assert eval_predicate({"not_label": "bad"}, _ctx(labels=["good"])) is
True
+ assert eval_predicate({"not_label": "bad"}, _ctx(labels=["bad"])) is
False
+
+ def test_not_any_label(self):
+ assert eval_predicate({"not_any_label": ["a", "b"]},
_ctx(labels=["c"])) is True
+ assert eval_predicate({"not_any_label": ["a", "b"]},
_ctx(labels=["a"])) is False
+
+ def test_pr_merged_by_snapshot(self):
+ assert eval_predicate({"pr_merged_by_snapshot": True},
_ctx(pr_merged=True)) is True
+ assert eval_predicate({"pr_merged_by_snapshot": True},
_ctx(pr_merged=False)) is False
+
+ def test_no_scope_label_true(self):
+ ctx = _ctx(labels=["bug"], scope_labels=["area/api", "area/core"])
+ assert eval_predicate({"no_scope_label": True}, ctx) is True
+
+ def test_no_scope_label_false_when_scope_present(self):
+ ctx = _ctx(labels=["area/api"], scope_labels=["area/api", "area/core"])
+ assert eval_predicate({"no_scope_label": True}, ctx) is False
+
+ def test_has_scope_label(self):
+ ctx = _ctx(labels=["area/api"], scope_labels=["area/api"])
+ assert eval_predicate({"has_scope_label": True}, ctx) is True
+ ctx2 = _ctx(labels=["bug"], scope_labels=["area/api"])
+ assert eval_predicate({"has_scope_label": True}, ctx2) is False
+
+ def test_any_of(self):
+ pred = {"any_of": [{"state": "open"}, {"any_label": ["wontfix"]}]}
+ assert eval_predicate(pred, _ctx(is_open=True)) is True
+ assert eval_predicate(pred, _ctx(is_open=False, labels=["wontfix"]))
is True
+ assert eval_predicate(pred, _ctx(is_open=False)) is False
+
+ def test_all_of_list(self):
+ pred = {"all_of": [{"state": "closed"}, {"state_reason": "COMPLETED"}]}
+ assert eval_predicate(pred, _ctx(is_open=False,
state_reason="COMPLETED")) is True
+ assert eval_predicate(pred, _ctx(is_open=False,
state_reason="NOT_PLANNED")) is False
+
+ def test_unknown_key_returns_false(self):
+ assert eval_predicate({"unknown_key": "value"}, _ctx()) is False
+
+ def test_composite(self):
+ pred = {
+ "state": "closed",
+ "state_reason": "COMPLETED",
+ "any_label": ["cve allocated"],
+ }
+ ctx = _ctx(is_open=False, state_reason="COMPLETED", labels=["cve
allocated"])
+ assert eval_predicate(pred, ctx) is True
+ ctx2 = _ctx(is_open=False, state_reason="COMPLETED", labels=[])
+ assert eval_predicate(pred, ctx2) is False
+
+
+# ---------------------------------------------------------------------------
+# mean_or_none
+# ---------------------------------------------------------------------------
+
+
+class TestMeanOrNone:
+ def test_empty(self):
+ assert mean_or_none([]) is None
+
+ def test_single(self):
+ assert mean_or_none([5.0]) == 5.0
+
+ def test_multiple(self):
+ assert mean_or_none([1.0, 2.0, 3.0]) == 2.0
+
+ def test_rounds_to_two_places(self):
+ result = mean_or_none([1.0, 2.0])
+ assert result == 1.5
+ result2 = mean_or_none([1.0, 2.0, 4.0])
+ assert result2 == pytest.approx(2.33, abs=0.01)
+
+
+# ---------------------------------------------------------------------------
+# build_triage_regex
+# ---------------------------------------------------------------------------
+
+
+class TestBuildTriageRegex:
+ def test_empty_returns_none(self):
+ assert build_triage_regex([]) is None
+
+ def test_single_keyword(self):
+ pat = build_triage_regex(["triage"])
+ assert pat is not None
+ assert pat.search("please triage this") is not None
+ assert pat.search("TRIAGE this") is not None
+
+ def test_uppercase_word_boundary(self):
+ pat = build_triage_regex(["CVE"])
+ assert pat is not None
+ # \b matches before '-', so "CVE-2024" still triggers
+ assert pat.search("CVE-2024-1234") is not None
+ # Does NOT match inside a longer word token
+ assert pat.search("XCVE") is None
+ assert pat.search("CVEx") is None
+ assert pat.search("is a CVE here") is not None
+
+ def test_phrase_keyword(self):
+ pat = build_triage_regex(["needs triage"])
+ assert pat is not None
+ assert pat.search("this needs triage today") is not None
+
+ def test_multiple_keywords(self):
+ pat = build_triage_regex(["triage", "CVE", "needs review"])
+ assert pat is not None
+ assert pat.search("triage this") is not None
+ assert pat.search("see the CVE today") is not None
+ assert pat.search("needs review please") is not None
+ assert pat.search("nothing here") is None
+
+
+# ---------------------------------------------------------------------------
+# is_bot_body
+# ---------------------------------------------------------------------------
+
+
+class TestIsBotBody:
+ def test_none_returns_false(self):
+ assert is_bot_body(None) is False
+
+ def test_empty_returns_false(self):
+ assert is_bot_body("") is False
+
+ def test_no_prefix_match(self):
+ assert is_bot_body("regular comment", ("<!-- bot -->",)) is False
+
+ def test_prefix_match(self):
+ assert is_bot_body("<!-- bot --> auto generated", ("<!-- bot -->",))
is True
+
+ def test_leading_whitespace_stripped(self):
+ assert is_bot_body(" <!-- bot -->body", ("<!-- bot -->",)) is True
+
+ def test_multiple_prefixes(self):
+ assert is_bot_body("[BOT] hello", ("[BOT]", "<!-- auto -->")) is True
+ assert is_bot_body("<!-- auto --> hi", ("[BOT]", "<!-- auto -->")) is
True
+
+ def test_default_empty_prefixes(self):
+ assert is_bot_body("<!-- bot -->") is False
+
+
+# ---------------------------------------------------------------------------
+# js_array / js_quotes
+# ---------------------------------------------------------------------------
+
+
+class TestJsArray:
+ def test_empty(self):
+ assert js_array([]) == "[]"
+
+ def test_integers(self):
+ assert js_array([1, 2, 3]) == "[1, 2, 3]"
+
+ def test_none_default(self):
+ assert js_array([1, None, 3]) == "[1, null, 3]"
+
+ def test_none_custom(self):
+ assert js_array([None], fmt_null="undefined") == "[undefined]"
+
+ def test_float_with_decimal(self):
+ assert js_array([1.5]) == "[1.50]"
+
+ def test_float_whole_number(self):
+ assert js_array([2.0]) == "[2]"
+
+ def test_mixed(self):
+ result = js_array([1, None, 2.5])
+ assert result == "[1, null, 2.50]"
+
+
+class TestJsQuotes:
+ def test_empty(self):
+ assert js_quotes([]) == "[]"
+
+ def test_single(self):
+ assert js_quotes(["hello"]) == '["hello"]'
+
+ def test_multiple(self):
+ assert js_quotes(["a", "b", "c"]) == '["a", "b", "c"]'
diff --git a/tools/security-tracker-stats-dashboard/uv.lock
b/tools/security-tracker-stats-dashboard/uv.lock
new file mode 100644
index 00000000..cc990e74
--- /dev/null
+++ b/tools/security-tracker-stats-dashboard/uv.lock
@@ -0,0 +1,204 @@
+version = 1
+revision = 3
+requires-python = ">=3.9"
+resolution-markers = [
+ "python_full_version >= '3.10'",
+ "python_full_version < '3.10'",
+]
+
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included
for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz",
hash =
"sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size
= 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl",
hash =
"sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size
= 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "exceptiongroup"
+version = "1.3.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz",
hash =
"sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size
= 30371, upload-time = "2025-11-21T23:01:54.787Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl",
hash =
"sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size
= 16740, upload-time = "2025-11-21T23:01:53.443Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz",
hash =
"sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size
= 4793, upload-time = "2025-03-19T20:09:59.721Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl",
hash =
"sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size
= 6050, upload-time = "2025-03-19T20:10:01.071Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.10'",
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz",
hash =
"sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size
= 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl",
hash =
"sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size
= 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz",
hash =
"sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size
= 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl",
hash =
"sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size
= 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz",
hash =
"sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size
= 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl",
hash =
"sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size
= 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz",
hash =
"sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size
= 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl",
hash =
"sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size
= 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "8.4.2"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version < '3.10'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version < '3.10' and
sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version < '3.10'" },
+ { name = "iniconfig", version = "2.1.0", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "packaging", marker = "python_full_version < '3.10'" },
+ { name = "pluggy", marker = "python_full_version < '3.10'" },
+ { name = "pygments", marker = "python_full_version < '3.10'" },
+ { name = "tomli", marker = "python_full_version < '3.10'" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz",
hash =
"sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size
= 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl",
hash =
"sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size
= 365750, upload-time = "2025-09-04T14:34:20.226Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+resolution-markers = [
+ "python_full_version >= '3.10'",
+]
+dependencies = [
+ { name = "colorama", marker = "python_full_version >= '3.10' and
sys_platform == 'win32'" },
+ { name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
+ { name = "iniconfig", version = "2.3.0", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+ { name = "packaging", marker = "python_full_version >= '3.10'" },
+ { name = "pluggy", marker = "python_full_version >= '3.10'" },
+ { name = "pygments", marker = "python_full_version >= '3.10'" },
+ { name = "tomli", marker = "python_full_version == '3.10.*'" },
+]
+sdist = { url =
"https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz",
hash =
"sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size
= 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl",
hash =
"sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size
= 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "security-tracker-stats-dashboard"
+version = "0.1.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest", version = "8.4.2", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
+ { name = "pytest", version = "9.0.3", source = { registry =
"https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.0" }]
+
+[[package]]
+name = "tomli"
+version = "2.4.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz",
hash =
"sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size
= 17543, upload-time = "2026-03-25T20:22:03.828Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl",
hash =
"sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size
= 154704, upload-time = "2026-03-25T20:21:10.473Z" },
+ { url =
"https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl",
hash =
"sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size
= 149454, upload-time = "2026-03-25T20:21:12.036Z" },
+ { url =
"https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size
= 237561, upload-time = "2026-03-25T20:21:13.098Z" },
+ { url =
"https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size
= 243824, upload-time = "2026-03-25T20:21:14.569Z" },
+ { url =
"https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl",
hash =
"sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size
= 242227, upload-time = "2026-03-25T20:21:15.712Z" },
+ { url =
"https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl",
hash =
"sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size
= 247859, upload-time = "2026-03-25T20:21:17.001Z" },
+ { url =
"https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl",
hash =
"sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size
= 97204, upload-time = "2026-03-25T20:21:18.079Z" },
+ { url =
"https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl",
hash =
"sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size
= 108084, upload-time = "2026-03-25T20:21:18.978Z" },
+ { url =
"https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl",
hash =
"sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size
= 95285, upload-time = "2026-03-25T20:21:20.309Z" },
+ { url =
"https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl",
hash =
"sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size
= 155924, upload-time = "2026-03-25T20:21:21.626Z" },
+ { url =
"https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl",
hash =
"sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size
= 150018, upload-time = "2026-03-25T20:21:23.002Z" },
+ { url =
"https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size
= 244948, upload-time = "2026-03-25T20:21:24.04Z" },
+ { url =
"https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size
= 253341, upload-time = "2026-03-25T20:21:25.177Z" },
+ { url =
"https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl",
hash =
"sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size
= 248159, upload-time = "2026-03-25T20:21:26.364Z" },
+ { url =
"https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl",
hash =
"sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size
= 253290, upload-time = "2026-03-25T20:21:27.46Z" },
+ { url =
"https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl",
hash =
"sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size
= 98141, upload-time = "2026-03-25T20:21:28.492Z" },
+ { url =
"https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl",
hash =
"sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size
= 108847, upload-time = "2026-03-25T20:21:29.386Z" },
+ { url =
"https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl",
hash =
"sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size
= 95088, upload-time = "2026-03-25T20:21:30.677Z" },
+ { url =
"https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl",
hash =
"sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size
= 155866, upload-time = "2026-03-25T20:21:31.65Z" },
+ { url =
"https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl",
hash =
"sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size
= 149887, upload-time = "2026-03-25T20:21:33.028Z" },
+ { url =
"https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size
= 243704, upload-time = "2026-03-25T20:21:34.51Z" },
+ { url =
"https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size
= 251628, upload-time = "2026-03-25T20:21:36.012Z" },
+ { url =
"https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl",
hash =
"sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size
= 247180, upload-time = "2026-03-25T20:21:37.136Z" },
+ { url =
"https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl",
hash =
"sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size
= 251674, upload-time = "2026-03-25T20:21:38.298Z" },
+ { url =
"https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl",
hash =
"sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size
= 97976, upload-time = "2026-03-25T20:21:39.316Z" },
+ { url =
"https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl",
hash =
"sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size
= 108755, upload-time = "2026-03-25T20:21:40.248Z" },
+ { url =
"https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl",
hash =
"sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size
= 95265, upload-time = "2026-03-25T20:21:41.219Z" },
+ { url =
"https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl",
hash =
"sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size
= 155726, upload-time = "2026-03-25T20:21:42.23Z" },
+ { url =
"https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl",
hash =
"sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size
= 149859, upload-time = "2026-03-25T20:21:43.386Z" },
+ { url =
"https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size
= 244713, upload-time = "2026-03-25T20:21:44.474Z" },
+ { url =
"https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size
= 252084, upload-time = "2026-03-25T20:21:45.62Z" },
+ { url =
"https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl",
hash =
"sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size
= 247973, upload-time = "2026-03-25T20:21:46.937Z" },
+ { url =
"https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl",
hash =
"sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size
= 256223, upload-time = "2026-03-25T20:21:48.467Z" },
+ { url =
"https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl",
hash =
"sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size
= 98973, upload-time = "2026-03-25T20:21:49.526Z" },
+ { url =
"https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl",
hash =
"sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size
= 109082, upload-time = "2026-03-25T20:21:50.506Z" },
+ { url =
"https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl",
hash =
"sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size
= 96490, upload-time = "2026-03-25T20:21:51.474Z" },
+ { url =
"https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl",
hash =
"sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size
= 164263, upload-time = "2026-03-25T20:21:52.543Z" },
+ { url =
"https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl",
hash =
"sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size
= 160736, upload-time = "2026-03-25T20:21:53.674Z" },
+ { url =
"https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl",
hash =
"sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size
= 270717, upload-time = "2026-03-25T20:21:55.129Z" },
+ { url =
"https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl",
hash =
"sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size
= 278461, upload-time = "2026-03-25T20:21:56.228Z" },
+ { url =
"https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl",
hash =
"sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size
= 274855, upload-time = "2026-03-25T20:21:57.653Z" },
+ { url =
"https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl",
hash =
"sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size
= 283144, upload-time = "2026-03-25T20:21:59.089Z" },
+ { url =
"https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl",
hash =
"sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size
= 108683, upload-time = "2026-03-25T20:22:00.214Z" },
+ { url =
"https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl",
hash =
"sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size
= 121196, upload-time = "2026-03-25T20:22:01.169Z" },
+ { url =
"https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl",
hash =
"sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size
= 100393, upload-time = "2026-03-25T20:22:02.137Z" },
+ { url =
"https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl",
hash =
"sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size
= 14583, upload-time = "2026-03-25T20:22:03.012Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url =
"https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz",
hash =
"sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size
= 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+ { url =
"https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl",
hash =
"sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size
= 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
diff --git a/tools/spec-loop/specs/security-reporting.md
b/tools/spec-loop/specs/security-reporting.md
index 43227e57..7ef9ca82 100644
--- a/tools/spec-loop/specs/security-reporting.md
+++ b/tools/spec-loop/specs/security-reporting.md
@@ -66,6 +66,7 @@ health without navigating the tracker issue-by-issue.
## Validation
```bash
+uv run --directory tools/security-tracker-stats-dashboard --group dev pytest
bash -n tools/security-tracker-stats-dashboard/run.sh
shellcheck tools/security-tracker-stats-dashboard/run.sh
```
@@ -73,5 +74,4 @@ shellcheck tools/security-tracker-stats-dashboard/run.sh
## Known gaps
- `experimental` — no adopter pilot has run the dashboard end-to-end.
- The tool's test coverage and CI integration are tracked as follow-on
- work items.
+- CI integration is a follow-on item.
diff --git a/uv.lock b/uv.lock
index 9148330f..2b82e2b7 100644
--- a/uv.lock
+++ b/uv.lock
@@ -27,6 +27,7 @@ members = [
"preflight-audit",
"redactor",
"sandbox-lint",
+ "security-tracker-stats-dashboard",
"skill-and-tool-validator",
"skill-evals",
"spec-status-index",
@@ -783,6 +784,21 @@ name = "sandbox-lint"
version = "0.1.0"
source = { editable = "tools/sandbox-lint" }
+[[package]]
+name = "security-tracker-stats-dashboard"
+version = "0.1.0"
+source = { editable = "tools/security-tracker-stats-dashboard" }
+
+[package.dev-dependencies]
+dev = [
+ { name = "pytest" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [{ name = "pytest", specifier = ">=8.0" }]
+
[[package]]
name = "skill-and-tool-validator"
version = "0.1.0"