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,

Reply via email to