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 2828d51a feat(dashboard): count reports rejected without a tracker
(#548)
2828d51a is described below
commit 2828d51a5c510d55e0b21bfadb2a5f5aab9b54db
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu Jun 25 04:17:01 2026 -0400
feat(dashboard): count reports rejected without a tracker (#548)
Reports rejected on the security list with a canned reply but no
tracker were invisible to the stats dashboard. Record each as a comment
on a dedicated, label-identified ledger issue (rejections-ledger,
deliberately not the security-marker label) and surface the count.
- security-issue-import: on every reject-without-tracker disposition,
append a <!-- rejection v1 --> comment (date/reporter/canned/thread/
summary) to the ledger issue. Excludes spam / cve-tool-bookkeeping and
security-issue-invalidate (tracked closes, already counted).
- dashboard render.py: parse the ledger issue's comments (dated markers
bucketed on the time axis + a one-time backfill lump), add a
'rejected (no tracker)' headline + chart, and exclude the ledger issue
from all tracker classification/counts. New rejections_ledger_label
config knob (null disables).
- security-issue-sync bulk-mode: drop rejections-ledger issues from
set-valued selectors so the ledger is never dispatched a sync subagent.
- project + tracker-stats templates: document the convention + knob.
---
projects/_template/project.md | 35 ++++++
projects/_template/security-tracker-stats.md | 27 ++++
skills/security-issue-import/SKILL.md | 63 ++++++++++
skills/security-issue-sync/bulk-mode.md | 13 +-
tools/security-tracker-stats-dashboard/README.md | 58 +++++++++
.../default-config.yaml | 5 +
tools/security-tracker-stats-dashboard/render.py | 138 ++++++++++++++++++++-
7 files changed, 335 insertions(+), 4 deletions(-)
diff --git a/projects/_template/project.md b/projects/_template/project.md
index 9b227024..c1db816d 100644
--- a/projects/_template/project.md
+++ b/projects/_template/project.md
@@ -685,8 +685,43 @@ tracker:
pr_merged: "pr merged"
cve_allocated: "cve allocated"
not_cve_worthy: "not cve worthy"
+ # Label on the single open "rejected without tracker" ledger issue
+ # — see the rejections-ledger note below. NOT the security_marker
+ # label, so the ledger is never treated as a tracker.
+ rejections_ledger: "rejections-ledger"
```
+#### Rejected-without-tracker ledger
+
+The `security-issue-import` skill sometimes rejects a report with a
+canned reply **without creating a tracker** (the disposition lives
+only on the mail thread). To keep those rejections countable, the
+team records each one as a comment on a single dedicated **ledger
+issue** in `tracker_repo`: one **open** issue, labelled with the
+`tracker.labels.rejections_ledger` value (ASF default
+`rejections-ledger`) and **not** carrying the security-marker label.
+
+Adopters who want the *rejected without tracker* dashboard stat must:
+
+1. **Create the ledger issue** once in `tracker_repo` and label it
+ `rejections-ledger` (keep it open; the skills resolve it via
+ `gh issue list --repo <tracker> --state open --label
+ rejections-ledger`).
+2. **Set the dashboard knob.** Point
+ `security-tracker-stats.md → rejections_ledger_label` (or the
+ `rejections_ledger_label:` key in the renderer's YAML overlay) at
+ the same label. Set it to `null` to disable the stat — then no
+ ledger issue is needed.
+
+Each rejection comment carries a machine-parseable block
+(`<!-- rejection v1 -->` with `date:` / `reporter:` / `canned:` /
+`thread:` / `summary:` lines); a one-time historical backfill is a
+single `<!-- rejection-backfill v1 count: N -->` comment. The
+`security-tracker-stats-dashboard` renderer parses these and excludes
+the ledger issue from all tracker classification. Closes handled by
+`security-issue-invalidate` are **not** ledger entries — those are
+tracked closes already counted in the closed buckets.
+
### Scope detection
```yaml
diff --git a/projects/_template/security-tracker-stats.md
b/projects/_template/security-tracker-stats.md
index 6556a7d9..302a400a 100644
--- a/projects/_template/security-tracker-stats.md
+++ b/projects/_template/security-tracker-stats.md
@@ -7,6 +7,7 @@
- [Default output path](#default-output-path)
- [Cache directory](#cache-directory)
- [Refresh cadence](#refresh-cadence)
+ - [Rejected-without-tracker ledger](#rejected-without-tracker-ledger)
- [Example overlay
(`security-tracker-stats.yaml`)](#example-overlay-security-tracker-statsyaml)
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
@@ -73,6 +74,30 @@ this many hours, and proposes a refresh before re-rendering.
Lower
this for fast-moving trackers; raise it for trackers where the
dashboard is reviewed weekly or monthly.
+## Rejected-without-tracker ledger
+
+```yaml
+rejections_ledger_label: rejections-ledger
+```
+
+Lives in the renderer's YAML overlay (the file pointed at by
+`tracker_stats_config:` above), not in `project.md`. Names the label
+on the single open **ledger issue** that records reports the
+`security-issue-import` skill rejected with a canned reply **without
+creating a tracker** — see the *Rejected-without-tracker ledger* note
+in [`project.md`](project.md) for the convention. The renderer
+identifies any issue carrying this label as the ledger, excludes it
+from all tracker classification, and parses its comments for the
+`<!-- rejection v1 -->` / `<!-- rejection-backfill v1 count: N -->`
+markers to produce a *rejected (no tracker)* count (a per-bucket area
+series plus a `<dated> + <historical> = <total>` headline).
+
+Set to `null` to disable the stat entirely (no ledger issue needed).
+The ASF/Airflow default is `rejections-ledger`, matching
+`tracker.labels.rejections_ledger` in `project.md`. To turn the stat
+on, create the open ledger issue, label it, and keep this knob
+pointed at the same label.
+
## Example overlay (`security-tracker-stats.yaml`)
A minimal overlay that swaps to quarterly buckets and adds a
@@ -81,6 +106,8 @@ project-specific milestone:
```yaml
buckets: quarterly
+rejections_ledger_label: rejections-ledger
+
milestones:
- date: 2026-04-20
label: skill adoption
diff --git a/skills/security-issue-import/SKILL.md
b/skills/security-issue-import/SKILL.md
index c5b19927..c33aeaf6 100644
--- a/skills/security-issue-import/SKILL.md
+++ b/skills/security-issue-import/SKILL.md
@@ -1864,6 +1864,59 @@ media / cross-thread-followup / fix-already-public):
[`security-issue-import-from-pr`'s no-outreach
rule](../security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports);
revealing that a security report came in about the PR would
leak private-channel content into a public surface.
+4. **Record the rejection on the rejections ledger** so the
+ tracker-stats dashboard can count it. A reject-without-tracker
+ disposition leaves no tracker, so without this step it is
+ invisible to every stat. After the Gmail draft is created, append
+ a `<!-- rejection v1 -->` comment to the single open issue
+ labelled `rejections-ledger` in `<tracker>`. This applies to
+ **every reject-without-tracker disposition**:
+
+ - `skip NN` with a canned reply,
+ `NN:reject-with-canned <name>`, `NN:reject-with-public-fix
+ <PR-URL>`;
+ - a confirmed `automated-scanner` / `consolidated-multi-issue`
+ / `media-request` canned reply.
+
+ It does **not** apply to `spam` or `cve-tool-bookkeeping` (those
+ are dropped silently — no disposition to record), and it
+ **never** creates a security tracker.
+
+ Resolve the ledger issue number, then append the comment (the
+ `summary` text is attacker-derived, so write it to a tempfile
+ with the Write tool and pass via `-F`, per the injection guard
+ used elsewhere in this skill):
+
+ ```bash
+ LEDGER=$(gh issue list --repo <tracker> --state open \
+ --label rejections-ledger --json number --jq '.[0].number')
+ ```
+
+ *Write tool call:* `file_path: /tmp/rejection-<threadId>.md`,
+ `content:`
+ ```text
+ <!-- rejection v1 -->
+ date: <YYYY-MM-DD>
+ reporter: <reporter email or display name>
+ canned: <canned-response-slug>
+ thread: <Gmail/PonyMail thread URL or threadId>
+ summary: <one-line disposition>
+ ```
+
+ ```bash
+ gh api repos/<tracker>/issues/$LEDGER/comments \
+ -F body=@/tmp/rejection-<threadId>.md --jq '.id'
+ ```
+
+ If the resolution returns no number (no ledger issue exists yet),
+ surface a one-line note in the recap (*"no `rejections-ledger`
+ issue found — rejection not recorded; create the ledger issue to
+ enable the stat"*) and continue — never fall back to creating a
+ tracker. **Note:** closes handled by
+ [`security-issue-invalidate`](../security-issue-invalidate/SKILL.md)
+ are **not** ledger entries — those are *tracked* closes already
+ counted in the dashboard's closed buckets, so adding them here
+ would double-count.
Apply sequentially (not in parallel): one `gh issue create` per
confirmed candidate, one draft per reply. If any step fails, stop and
@@ -1935,6 +1988,16 @@ before presenting.
separate commit to the canned-responses file, not in a one-off
draft. See the *"Canned-response discipline for negative-response
drafts"* subsection of Step 5.
+- **Record every reject-without-tracker disposition on the
+ `rejections-ledger` issue** (Step 7, non-import path, item 4) so
+ the tracker-stats dashboard can count it — `skip NN` with a canned
+ reply, `NN:reject-with-canned`, `NN:reject-with-public-fix`, and
+ confirmed `automated-scanner` / `consolidated-multi-issue` /
+ `media-request` canned replies. Never for `spam` /
+ `cve-tool-bookkeeping` (dropped silently) and never for closes
+ handled by `security-issue-invalidate` (tracked closes — already
+ counted, recording here would double-count). The ledger comment
+ never creates a tracker.
- **Never present a draft that contradicts the report.** The
coherence check in Step 5 is mandatory before a negative-response
draft appears in the proposal: the draft must accurately
diff --git a/skills/security-issue-sync/bulk-mode.md
b/skills/security-issue-sync/bulk-mode.md
index 86974312..68e9fb2b 100644
--- a/skills/security-issue-sync/bulk-mode.md
+++ b/skills/security-issue-sync/bulk-mode.md
@@ -34,8 +34,8 @@ concurrently, which is exactly what the sync needs.
| User input | Resolves to |
|---|---|
- | `sync all` | every open issue in `<tracker>` **plus recently-closed
trackers still awaiting a post-close cve.org publication check**. Resolve as:
`gh issue list --repo <tracker> --state open --limit 100 --json
number,title,labels` ∪ `gh issue list --repo <tracker> --state closed --label
"announced" --limit 50 --json number,title,labels,closedAt --jq '[.[] \|
select(.closedAt > (now - 90*86400 \| todate))]'`. The closed bucket is limited
to the last 90 days and to trackers carrying t [...]
- | `sync all open` | explicit open-only variant — `gh issue list --repo
<tracker> --state open --limit 100 --json number,title,labels`. No closed
trackers. Use when you want the classic open-only sweep and nothing else. |
+ | `sync all` | every open issue in `<tracker>` **plus recently-closed
trackers still awaiting a post-close cve.org publication check**. Resolve as:
`gh issue list --repo <tracker> --state open --limit 100 --json
number,title,labels` ∪ `gh issue list --repo <tracker> --state closed --label
"announced" --limit 50 --json number,title,labels,closedAt --jq '[.[] \|
select(.closedAt > (now - 90*86400 \| todate))]'`, then **drop any issue
labelled `rejections-ledger`** (the rejected-without- [...]
+ | `sync all open` | explicit open-only variant — `gh issue list --repo
<tracker> --state open --limit 100 --json number,title,labels`, then **drop any
issue labelled `rejections-ledger`**. No closed trackers. Use when you want the
classic open-only sweep and nothing else. |
| `sync #212`, `sync 212`, `sync #212, #214, #218`, `sync #212-#218` | the
issue number(s) verbatim — no resolution needed. Works on open and closed
trackers alike (the closed-issue sub-steps run when the tracker is closed with
`announced`). |
| `sync CVE-2026-40913` or `sync CVE-2026-40913, CVE-2026-40690` |
regex-validate each token against `^CVE-\d{4}-\d{4,7}$` first (anything that
does not match is a hard error — *never* interpolate an unvalidated free-form
string into the search arg, which is in double quotes and would expand
`$(...)`); then look up each validated CVE ID with `gh search issues
"CVE-YYYY-NNNNN" --repo <tracker> --json number,title,body --jq '.[] |
select(.body \| contains("CVE-YYYY-NNNNN")) \| .number'` [...]
| `sync <free-text>` (e.g. `sync JWT`, `sync KubernetesExecutor`) |
title-substring match — run `gh issue list --repo <tracker> --state open
--search "<free-text> in:title" --limit 100 --json number,title` and surface
the matches back to the user for confirmation before dispatching (title matches
are the fuzziest selector — always confirm, never auto-dispatch). |
@@ -59,6 +59,15 @@ concurrently, which is exactly what the sync needs.
When the selector resolves to zero issues, tell the user and stop
— do not fall back to `sync all`.
+ **Exclude the rejections ledger.** The single open issue labelled
+ `rejections-ledger` (the rejected-without-tracker ledger written
+ by `security-issue-import`) is **not** a security tracker — it
+ carries no scope label and holds only rejection-record comments.
+ Drop it from every set-valued selector (`sync all`, `sync all
+ open`, label/title sweeps) so it is never dispatched a sync
+ subagent. An explicitly-named number (`sync #99`) still works if
+ the operator really means it.
+
1b. **Pre-flight no-op classifier — skip trackers that obviously need no
work.**
Before spawning subagents, do one batched read to fetch lightweight
state for every resolved tracker, classify each as
diff --git a/tools/security-tracker-stats-dashboard/README.md
b/tools/security-tracker-stats-dashboard/README.md
index 11f47ee3..07d43cb9 100644
--- a/tools/security-tracker-stats-dashboard/README.md
+++ b/tools/security-tracker-stats-dashboard/README.md
@@ -10,6 +10,7 @@
- [Categories (lifecycle bands)](#categories-lifecycle-bands)
- [Time-to-triage signal](#time-to-triage-signal)
- [Milestones (vertical annotations)](#milestones-vertical-annotations)
+ - [Rejected-without-tracker ledger
(`rejections_ledger_label`)](#rejected-without-tracker-ledger-rejections_ledger_label)
- [When `upstream_repo` is null](#when-upstream_repo-is-null)
- [Prerequisites](#prerequisites)
- [Failure modes](#failure-modes)
@@ -151,6 +152,63 @@ on every time-axis chart. Each entry needs `date:
YYYY-MM-DD` (mapped
onto the bucket axis) and `label`. Set `milestones: []` in an overlay
to remove them entirely.
+### Rejected-without-tracker ledger (`rejections_ledger_label`)
+
+```yaml
+rejections_ledger_label: rejections-ledger # null to disable
+```
+
+The `security-issue-import` skill sometimes rejects a report with a
+canned reply **without creating a tracker** — the disposition lives
+only on the mail thread, so it is invisible to every other stat on
+this dashboard. To make those rejections countable the team records
+each one as a comment on a single dedicated **ledger issue** in the
+`<tracker>` repo: one open issue, labelled with
+`rejections_ledger_label` (default `rejections-ledger`) and **not**
+carrying the security-marker label.
+
+`fetch_issues.py` already pulls every issue's `labels` + `comments`,
+so the ledger and its comments arrive in `issues.json` with no extra
+fetch. `render.py`:
+
+- **identifies** any issue whose labels include
+ `rejections_ledger_label` as a ledger issue;
+- **excludes** the ledger issue from all tracker classification —
+ it never appears in the lifecycle bands, open/closed KPIs, triage
+ medians, or any per-bucket count;
+- **parses** the ledger's comments for the rejection markers below
+ and renders a *rejected (no tracker)* count.
+
+Each per-rejection comment carries a machine-parseable block:
+
+```text
+<!-- rejection v1 -->
+date: YYYY-MM-DD
+reporter: <email/name>
+canned: <canned-response-slug>
+thread: <url-or-threadid>
+summary: <one line>
+```
+
+A one-time historical backfill is recorded as a single comment
+(count only, no per-entry dates):
+
+```text
+<!-- rejection-backfill v1 count: N -->
+```
+
+Dated rejections are bucketed by their `date:` into the same
+monthly / quarterly axis as the trackers and rendered as a
+*rejected (no tracker)* area series (`c_rejected`). The undated
+backfill `count: N` is kept as a separate **historical (pre-ledger)**
+headline number rather than smeared across buckets, so the per-bucket
+series only ever shows real dated rejections. Both the stdout summary
+and an HTML header banner print `<dated> + <historical> = <total>`.
+
+The parse is defensive: comments without a marker are ignored,
+malformed `date:` lines are skipped, and an absent ledger issue (or
+`rejections_ledger_label: null`) simply omits the stat / shows 0.
+
### When `upstream_repo` is null
The `c_prc` / `c_prm` / `c_rel` PR-driven mean-time charts are
diff --git a/tools/security-tracker-stats-dashboard/default-config.yaml
b/tools/security-tracker-stats-dashboard/default-config.yaml
index b98e88b7..4f47dd27 100644
--- a/tools/security-tracker-stats-dashboard/default-config.yaml
+++ b/tools/security-tracker-stats-dashboard/default-config.yaml
@@ -17,6 +17,11 @@
buckets: monthly # monthly | quarterly
start: null # null = first tracker createdAt; else
"YYYY-MM" (monthly) or "YYYY-Qn" (quarterly)
+rejections_ledger_label: rejections-ledger # label identifying the "rejected
without tracker" ledger issue; set to null to disable the stat
+ # The ledger is a single open issue (labelled
with this value, NOT the security-marker
+ # label) whose comments record reports
rejected on-thread without ever creating a
+ # tracker. The renderer parses those comments
for "rejected (no tracker)" counts and
+ # excludes the ledger issue itself from all
tracker classification. null -> stat omitted.
upstream_repo: apache/airflow # null -> skip c_prc/c_prm/c_rel charts and
the back-fill rule
# Mirrors <project-config>/project.md ->
`upstream_repo`; ASF default is the airflow-s adopter's
# value. Override in the overlay if the
renderer should chart a different repo (or null).
diff --git a/tools/security-tracker-stats-dashboard/render.py
b/tools/security-tracker-stats-dashboard/render.py
index 26c21680..d1bc6f04 100644
--- a/tools/security-tracker-stats-dashboard/render.py
+++ b/tools/security-tracker-stats-dashboard/render.py
@@ -365,6 +365,9 @@ MILESTONES = CONFIG.get('milestones') or []
CATEGORIES_CFG = CONFIG.get('categories') or []
TRIAGE_KW = CONFIG.get('triage', {}).get('keywords') or []
BOT_PREFIXES = tuple(CONFIG.get('triage', {}).get('bot_prefixes') or [])
+# Label identifying the "rejected without tracker" ledger issue. When
+# null (or no such issue exists) the rejection stat is omitted / shows 0.
+REJECTIONS_LEDGER_LABEL = CONFIG.get('rejections_ledger_label')
# Distinct category names in the order they FIRST appear in CATEGORIES_CFG
# (multiple rules can share a name to express disjoint branches of the
@@ -387,12 +390,32 @@ for c in CATEGORIES_CFG:
# --- Cache load -----------------------------------------------------
with open(f'{ROOT}/issues.json') as f:
- issues = json.load(f)
+ all_issues = json.load(f)
with open(f'{ROOT}/roster.txt') as f:
roster = {ln.strip() for ln in f if ln.strip()}
with open(f'{ROOT}/issue_extra.json') as f:
issue_extra = json.load(f)
+
+def _issue_label_names(issue):
+ return {(l.get('name') if isinstance(l, dict) else l) for l in
(issue.get('labels') or [])}
+
+
+# Partition out the "rejected without tracker" ledger issue(s). The ledger
+# is NOT a security tracker (it carries the rejections-ledger label and not
+# the security-marker label), so it must be excluded from every normal
+# tracker classification / count / median below. We keep the full list
+# (`all_issues`) only for the rejection-comment parse; `issues` is the
+# tracker-only list every downstream loop iterates.
+if REJECTIONS_LEDGER_LABEL:
+ ledger_issues = [i for i in all_issues
+ if REJECTIONS_LEDGER_LABEL in _issue_label_names(i)]
+ issues = [i for i in all_issues
+ if REJECTIONS_LEDGER_LABEL not in _issue_label_names(i)]
+else:
+ ledger_issues = []
+ issues = list(all_issues)
+
prs_cache = {}
if UPSTREAM_REPO:
prs_path = f'{ROOT}/prs.json'
@@ -508,6 +531,79 @@ print(f"earliest createdAt: {earliest.isoformat()} ->
starts at {bucket_label(*s
print(f"now: {NOW.isoformat()} -> ends at {bucket_label(*end_key)}")
print(f"buckets in range ({BUCKETS_MODE}): {n_buckets}")
+
+# --- rejected-without-tracker ledger -------------------------------
+#
+# The skill records each report it rejects *without* creating a tracker
+# as a comment on a dedicated ledger issue (labelled
+# REJECTIONS_LEDGER_LABEL). Each rejection comment carries a
+# machine-parseable block:
+#
+# <!-- rejection v1 -->
+# date: YYYY-MM-DD
+# reporter: <email/name>
+# canned: <slug>
+# thread: <url-or-threadid>
+# summary: <one line>
+#
+# A one-time historical backfill is a single comment of the form:
+#
+# <!-- rejection-backfill v1 count: N -->
+#
+# Dated rejections are bucketed by their `date:` into the same monthly /
+# quarterly axis as the trackers. The backfill count is undated, so we
+# keep it as a separate "historical (pre-ledger)" headline number rather
+# than smearing it across buckets — that keeps the per-bucket series
+# faithful (only real dated rejections appear in it) while still
+# surfacing the historical lump in the summary / HTML header.
+REJECTION_MARKER = '<!-- rejection v1 -->'
+REJECTION_BACKFILL_RE = re.compile(
+ r'<!--\s*rejection-backfill\s+v1\s+count:\s*(\d+)\s*-->', re.I)
+REJECTION_DATE_RE = re.compile(r'^\s*date:\s*(\d{4})-(\d{2})-(\d{2})\s*$',
re.M)
+
+rejections_by_b = defaultdict(int) # bucket label -> dated rejection count
+rejections_dated_total = 0
+rejections_backfill_total = 0
+
+for li in ledger_issues:
+ for c in (li.get('comments') or []):
+ body = c.get('body') or ''
+ # Historical backfill marker (undated lump).
+ for m in REJECTION_BACKFILL_RE.finditer(body):
+ try:
+ rejections_backfill_total += int(m.group(1))
+ except ValueError:
+ continue
+ # Per-entry rejection markers. A single comment may in principle
+ # carry more than one block; count each marker that is followed
+ # by a parseable date line.
+ if REJECTION_MARKER not in body:
+ continue
+ # Split on the marker so a malformed block can't swallow the
+ # date of the next one. The text before the first marker is
+ # discarded (it belongs to no rejection block).
+ for chunk in body.split(REJECTION_MARKER)[1:]:
+ dm = REJECTION_DATE_RE.search(chunk)
+ if not dm:
+ # No / malformed date line — skip this entry defensively.
+ continue
+ try:
+ d = dt.datetime(int(dm.group(1)), int(dm.group(2)),
+ int(dm.group(3)), tzinfo=dt.timezone.utc)
+ except ValueError:
+ continue
+ cb = bucket_of(d)
+ if cb < buckets[0] or cb > buckets[-1]:
+ # Dated outside the chart range — still count in the
+ # headline total, just not in any visible bucket.
+ rejections_dated_total += 1
+ continue
+ rejections_by_b[bucket_label(*cb)] += 1
+ rejections_dated_total += 1
+
+rejected_series = [rejections_by_b.get(bucket_label(*b), 0) for b in buckets]
+rejections_total = rejections_dated_total + rejections_backfill_total
+
# Per-issue events
events_by_n = {}
for n in issues_by_n:
@@ -933,6 +1029,10 @@ print(f"open_untriaged: {latest('open_untriaged')},
open_triaged: {latest('open_
f"open_pr_merged: {latest('open_pr_merged')}, closed_other:
{latest('closed_other')}")
print(f"triage median {triage_median}h, mean {triage_mean}h, n={triage_n} "
f"(fallback={n_fallback_triage}, none={n_no_triage})")
+if REJECTIONS_LEDGER_LABEL:
+ print(f"rejected without tracker: {rejections_dated_total} dated + "
+ f"{rejections_backfill_total} historical = {rejections_total} "
+ f"(ledger issues: {len(ledger_issues)})")
if UPSTREAM_REPO:
print()
@@ -1043,6 +1143,36 @@ else:
pr_charts_js = ''
+# Build the optional "rejected (no tracker)" header banner, chart card,
+# and JS. Omitted entirely when the rejections-ledger stat is disabled
+# (null label) or there is no ledger issue and nothing was parsed.
+if REJECTIONS_LEDGER_LABEL and (rejections_total or ledger_issues):
+ rej_header_html = (
+ '<div class="banner">Reports rejected without a tracker: '
+ f'<strong>{rejections_total}</strong> '
+ f'({rejections_dated_total} dated + {rejections_backfill_total}
historical)</div>\n'
+ )
+ rej_cards_html = '<div class="card full"><div
id="c_rejected"></div></div>\n'
+ rej_chart_js = (
+ f"Plotly.newPlot('c_rejected', [\n"
+ f" {{x: buckets, y: {js_array(rejected_series)}, "
+ f"name: 'rejected (no tracker)', type: 'scatter', "
+ f"mode: 'lines+markers', connectgaps: true, line: {{color:
'#7f8c8d'}}, "
+ f"fill: 'tozeroy'}}\n"
+ f"], {{\n"
+ f" ...MILESTONES_LAYOUT,\n"
+ f" title: 'Reports rejected without a tracker (per {bucket_word},
dated; "
+ f"{rejections_backfill_total} historical pre-ledger not shown)',\n"
+ f" yaxis: {{title: 'count', rangemode: 'tozero'}},\n"
+ f" legend: {{orientation: 'h'}}\n"
+ f"}});"
+ )
+else:
+ rej_header_html = ''
+ rej_cards_html = ''
+ rej_chart_js = ''
+
+
HTML = f"""<!DOCTYPE html>
<html lang="en">
<head>
@@ -1054,16 +1184,18 @@ body {{ font-family: -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
.card {{ border: 1px solid #e0e0e0; border-radius: 8px; padding: 8px;
background: #fafafa; }}
.card.full {{ grid-column: 1 / -1; }}
+.banner {{ border: 1px solid #e0e0e0; border-radius: 8px; padding: 10px 14px;
background: #f4f6f6; margin-bottom: 16px; color: #444; }}
</style>
</head>
<body>
+{rej_header_html}
<div class="grid">
<div class="card full"><div id="c_states"></div></div>
<div class="card full"><div id="c_open_vs_untriaged"></div></div>
<div class="card full"><div id="c_cum"></div></div>
-<div class="card"><div id="c_triage"></div></div>
+{rej_cards_html}<div class="card"><div id="c_triage"></div></div>
<div class="card"><div id="c_resp"></div></div>
{pr_cards_html}
</div>
@@ -1118,6 +1250,8 @@ Plotly.newPlot('c_cum', [
legend: {{orientation: 'h'}}
}});
+{rej_chart_js}
+
function meanChart(divId, title, ys, ns, unit, color) {{
Plotly.newPlot(divId, [{{
x: buckets, y: ys,