metrale_bench/gate/
telemetry.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The open-PR telemetry comment: which kernel targets each PR
4//! re-opens, which targets more than one open PR re-opens, a recommended
5//! merge order, and promotion-candidate debt.
6//!
7//! [`render`] reads only its [`PrFacts`] and the tree (the kernel taxonomy and
8//! CODEOWNERS); the `pr-telemetry` workflow collects the facts from GitHub and
9//! posts the body. The comment is advisory: [`render`] returns only text.
10//!
11//! Owner: bench gate.
12//! Invariants:
13//! - A PR with `paths_unknown` is counted at the widest reach, every target
14//!   and every promotion candidate, and [`render`] lists such PRs (through
15//!   `order::cap_prs`) in a warning before the tables.
16//! - The body always starts with [`MARKER_START`] and ends with
17//!   [`MARKER_END`] and a newline.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::Path;
21
22use super::{codeowners, coverage, taxon};
23
24#[path = "telemetry_order.rs"]
25pub mod order;
26pub use order::{CHART_PR_BOUND, merge_order};
27
28/// 2026-09-26: The markers around the body. The workflow's publish job finds
29/// its previous comment by `MARKER_START` and replaces it
30/// (`.github/workflows/pr-telemetry.yml`, `marker:`).
31pub const MARKER_START: &str = "<!-- metrale-pr-telemetry:start -->";
32pub const MARKER_END: &str = "<!-- metrale-pr-telemetry:end -->";
33
34/// 2026-09-26: What the workflow collects about one open or merged PR.
35#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
36pub struct PrFacts {
37    pub number: u64,
38    pub title: String,
39    pub author: String,
40    #[serde(default)]
41    pub draft: bool,
42    /// 2026-09-26: True when GitHub reports the PR merged (`merged_at` set),
43    /// into whatever base it targeted. A merged PR stays in the debt table,
44    /// the next steps and the targets table, and leaves the PR table, the
45    /// collisions and the merge order. The workflow collects open and merged
46    /// PRs only, so a closed, unmerged PR never appears.
47    #[serde(default)]
48    pub merged: bool,
49    /// 2026-09-26: Repo-relative paths this PR changes. Meaningful only when
50    /// [`Self::paths_unknown`] is false.
51    #[serde(default)]
52    pub changed_paths: Vec<String>,
53    /// 2026-09-26: True when the collector could not read this PR's file list.
54    /// The PR is kept, and [`views`] counts it as whole-repo with every
55    /// promotion candidate as debt, so an unread diff never renders as an
56    /// empty one.
57    #[serde(default)]
58    pub paths_unknown: bool,
59}
60
61/// 2026-09-26: One PR's derived position in the kernel taxonomy.
62#[derive(Debug, Clone)]
63pub struct PrView {
64    pub facts: PrFacts,
65    pub hardware: BTreeSet<String>,
66    pub models: BTreeSet<(String, String)>,
67    pub targets: BTreeSet<taxon::Target>,
68    pub owners: Vec<String>,
69    /// 2026-09-26: True when the paths are unknown or any changed path is
70    /// outside `kernels/`; `targets` is then every target in the tree.
71    pub whole_repo: bool,
72    /// 2026-09-26: The [`coverage::PROMOTION_CANDIDATES`] this PR's paths
73    /// would invalidate (`coverage::promotion_debt`): gates that are not
74    /// required, so the PR can merge without them.
75    pub promotion_debt: Vec<&'static str>,
76}
77
78/// 2026-09-26: Derive every PR's view from its facts, the kernel taxonomy and
79/// CODEOWNERS.
80pub fn views(root: &Path, prs: &[PrFacts]) -> Vec<PrView> {
81    let rules = codeowners::load(root);
82    let all_targets: BTreeSet<taxon::Target> = taxon::walk(root).into_iter().collect();
83    prs.iter()
84        .map(|facts| {
85            let kernel_paths: Vec<String> = facts
86                .changed_paths
87                .iter()
88                .filter(|p| taxon::hardware_of(p).is_some())
89                .cloned()
90                .collect();
91            // 2026-09-26: Unknown paths take the whole-repo branch, which sets
92            // every target here and makes the PR contend with every gated PR
93            // in `order`.
94            let whole_repo = facts.paths_unknown || facts.changed_paths.len() > kernel_paths.len();
95            PrView {
96                hardware: taxon::hardware_span(&kernel_paths),
97                models: taxon::model_span(&kernel_paths),
98                targets: if whole_repo {
99                    all_targets.clone()
100                } else {
101                    taxon::affected(root, &kernel_paths)
102                },
103                owners: codeowners::owners_for_paths(&rules, &facts.changed_paths),
104                whole_repo,
105                // 2026-09-26: With unknown paths the debt is every candidate;
106                // `render` labels the row as assumed.
107                promotion_debt: if facts.paths_unknown {
108                    coverage::PROMOTION_CANDIDATES
109                        .iter()
110                        .map(|gate| gate.id)
111                        .collect()
112                } else {
113                    coverage::promotion_debt(facts.changed_paths.iter().map(String::as_str))
114                },
115                facts: facts.clone(),
116            }
117        })
118        .collect()
119}
120
121/// 2026-09-26: Targets that more than one unmerged PR re-opens, with those
122/// PRs' numbers.
123pub fn collisions(views: &[PrView]) -> BTreeMap<String, Vec<u64>> {
124    let mut by_target: BTreeMap<String, Vec<u64>> = BTreeMap::new();
125    for view in views.iter().filter(|view| !view.facts.merged) {
126        for target in &view.targets {
127            by_target
128                .entry(target.to_string())
129                .or_default()
130                .push(view.facts.number);
131        }
132    }
133    by_target.retain(|_, prs| prs.len() > 1);
134    by_target
135}
136
137fn escape(s: &str) -> String {
138    s.replace('|', "\\|").replace('\n', " ")
139}
140
141/// 2026-09-26: The comment body, markers included.
142pub fn render(root: &Path, prs: &[PrFacts]) -> String {
143    let views = views(root, prs);
144    let all_targets = taxon::walk(root);
145    let mut out = String::new();
146
147    out.push_str(MARKER_START);
148    out.push_str("\n## Open-PR telemetry\n\n");
149
150    if views.is_empty() {
151        out.push_str("_No open pull requests._\n");
152        out.push_str(MARKER_END);
153        out.push('\n');
154        return out;
155    }
156
157    out.push_str(&order::render_order_chart(&views));
158    out.push_str(
159        "\nAdvisory. Nothing here blocks a merge — the blocking checks live on each PR.\n",
160    );
161
162    // 2026-09-26: Name the PRs whose rows below are assumed rather than
163    // measured.
164    let blind: Vec<u64> = views
165        .iter()
166        .filter(|v| v.facts.paths_unknown)
167        .map(|v| v.facts.number)
168        .collect();
169    if !blind.is_empty() {
170        out.push_str(&format!(
171            "\n> ⚠ **Changed files unavailable for {}.** The API call for their diffs \
172             failed on this run, so nothing below measures them. They are counted at \
173             MAXIMUM blast radius — every target, every promotion candidate — because \
174             an unknown diff must never render as an empty one. Re-run this workflow \
175             before using any row they appear in.\n",
176            order::cap_prs(&blind)
177        ));
178    }
179
180    out.push_str(&order::render_next_steps(&views));
181
182    // 2026-09-26: Unmerged PRs, grouped by the hardware they touch.
183    let merged_count = views.iter().filter(|v| v.facts.merged).count();
184    out.push_str("\n### Pull requests\n\n");
185    if merged_count > 0 {
186        out.push_str(&format!(
187            "_{merged_count} merged PR(s) tracked for debt only — see the ledger below._\n\n"
188        ));
189    }
190    out.push_str("| PR | category | targets re-opened | codeowners |\n");
191    out.push_str("|---|---|---|---|\n");
192    let mut grouped: BTreeMap<String, Vec<&PrView>> = BTreeMap::new();
193    for view in views.iter().filter(|v| !v.facts.merged) {
194        // 2026-09-26: "host / non-kernel" means no changed path is under
195        // `kernels/`, which an unread diff cannot show.
196        let key = if view.facts.paths_unknown {
197            "unknown (changed files unreadable)".to_string()
198        } else if view.hardware.is_empty() {
199            "host / non-kernel".to_string()
200        } else {
201            view.hardware
202                .iter()
203                .cloned()
204                .collect::<Vec<_>>()
205                .join(" + ")
206        };
207        grouped.entry(key).or_default().push(view);
208    }
209    for (category, group) in &grouped {
210        for view in group {
211            let targets = if view.facts.paths_unknown {
212                "ALL — **assumed**, changed files unreadable".to_string()
213            } else if view.whole_repo {
214                "ALL (diff reaches outside kernels/)".to_string()
215            } else if view.targets.is_empty() {
216                "none".to_string()
217            } else {
218                format!("{}", view.targets.len())
219            };
220            // 2026-09-26: "—" means CODEOWNERS matched nobody; an unread diff
221            // is "unknown".
222            let owners = if view.facts.paths_unknown {
223                "unknown".to_string()
224            } else if view.owners.is_empty() {
225                "—".to_string()
226            } else {
227                view.owners.join(" ")
228            };
229            out.push_str(&format!(
230                "| #{} {}{} | {} | {} | {} |\n",
231                view.facts.number,
232                if view.facts.draft { "(draft) " } else { "" },
233                escape(&view.facts.title),
234                escape(category),
235                targets,
236                escape(&owners),
237            ));
238        }
239    }
240
241    // 2026-09-26: The debt section is rendered even when no PR owes
242    // anything: a placeholder row, or a sentence when no gate is a
243    // candidate, says so.
244    out.push_str("\n### Promotion-candidate debt\n\n");
245    if coverage::PROMOTION_CANDIDATES.is_empty() {
246        out.push_str(
247            "No gates are on a promotion path, so nothing can be owed. When one \
248             is registered (`coverage::PROMOTION_CANDIDATES`), every PR whose \
249             paths it covers appears here until a record discharges it.\n",
250        );
251    } else {
252        let owing: Vec<&PrView> = views
253            .iter()
254            .filter(|v| !v.promotion_debt.is_empty())
255            .collect();
256        out.push_str(
257            "These gates are NOT required, so these PRs can merge without them. \
258             Each row is coverage this repository chose not to buy — recorded so \
259             the choice stays visible rather than becoming an assumption.\n\n",
260        );
261        out.push_str("| PR | merged? | title | gates that wanted to run |\n|---|---|---|---|\n");
262        if owing.is_empty() {
263            out.push_str("| — | — | _no tracked PR touches a promotion candidate's paths_ | — |\n");
264        }
265        for v in owing {
266            out.push_str(&format!(
267                "| #{} | {} | {} | {} |\n",
268                v.facts.number,
269                if v.facts.merged { "**yes**" } else { "not yet" },
270                escape(&v.facts.title),
271                if v.facts.paths_unknown {
272                    format!(
273                        "{} — **assumed** (paths unreadable)",
274                        v.promotion_debt.join(", ")
275                    )
276                } else {
277                    v.promotion_debt.join(", ")
278                }
279            ));
280        }
281    }
282
283    let collisions = collisions(&views);
284    out.push_str("\n### Collisions\n\n");
285    if collisions.is_empty() {
286        out.push_str("None: no target is re-opened by more than one open PR.\n");
287    } else {
288        out.push_str(
289            "Each PR below is measured against a baseline another open PR will \
290             move. Whichever lands second needs re-gating.\n\n\
291             | target | PRs |\n|---|---|\n",
292        );
293        for (target, prs) in &collisions {
294            out.push_str(&format!("| `{target}` | {} |\n", order::cap_prs(prs)));
295        }
296    }
297
298    out.push_str(&format!(
299        "\n### Targets ({} total)\n\nEvery target is listed, including the ones no open PR \
300         touches. Showing only the affected ones would silently turn *ungated* into \
301         *unaffected*.\n\n| target | re-opened by |\n|---|---|\n",
302        all_targets.len()
303    ));
304    for target in &all_targets {
305        let key = target.to_string();
306        let touching: Vec<u64> = views
307            .iter()
308            .filter(|v| v.targets.contains(target))
309            .map(|v| v.facts.number)
310            .collect();
311        out.push_str(&format!(
312            "| `{key}` | {} |\n",
313            if touching.is_empty() {
314                "—".to_string()
315            } else {
316                order::cap_prs(&touching)
317            }
318        ));
319    }
320
321    out.push_str(MARKER_END);
322    out.push('\n');
323    out
324}
325
326#[cfg(test)]
327#[path = "telemetry_tests.rs"]
328mod telemetry_tests;
329
330#[cfg(test)]
331#[path = "telemetry_unknown_tests.rs"]
332mod telemetry_unknown_tests;