metrale_bench/gate/
telemetry.rs1use 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
28pub const MARKER_START: &str = "<!-- metrale-pr-telemetry:start -->";
32pub const MARKER_END: &str = "<!-- metrale-pr-telemetry:end -->";
33
34#[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 #[serde(default)]
48 pub merged: bool,
49 #[serde(default)]
52 pub changed_paths: Vec<String>,
53 #[serde(default)]
58 pub paths_unknown: bool,
59}
60
61#[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 pub whole_repo: bool,
72 pub promotion_debt: Vec<&'static str>,
76}
77
78pub 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 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 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
121pub 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
141pub 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 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 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 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 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 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;