metrale_bench/gate/
telemetry_order.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: The recommended merge order, the mermaid `gitGraph` that draws
4//! it (`main` as a line, each PR a branch merged back in order), and the
5//! "Recommended next steps" list.
6//!
7//! Owner: bench gate.
8//! Invariants:
9//! - The chart and every list of PR numbers built by [`cap_prs`] show at most
10//!   [`CHART_PR_BOUND`] PRs, and say how many more there are.
11//! - [`merge_order`] ranks only unmerged, non-draft PRs whose changed files
12//!   were read.
13
14use super::PrView;
15
16/// 2026-09-26: The most PRs the chart draws and [`cap_prs`] lists.
17pub const CHART_PR_BOUND: usize = 10;
18
19/// 2026-09-26: True when the two PRs re-open a common target, or one is
20/// whole-repo and the other is whole-repo or re-opens any target. Shared
21/// changed files are not compared.
22fn contend(a: &PrView, b: &PrView) -> bool {
23    let a_gated = a.whole_repo || !a.targets.is_empty();
24    let b_gated = b.whole_repo || !b.targets.is_empty();
25    if (a.whole_repo && b_gated) || (b.whole_repo && a_gated) {
26        return true;
27    }
28    !a.targets.is_disjoint(&b.targets)
29}
30
31/// 2026-09-26: The PRs a merge order can contain: unmerged, non-draft, and
32/// with a changed-file list this run could read. [`render_next_steps`] lists
33/// the unreadable ones.
34fn orderable(views: &[PrView]) -> Vec<&PrView> {
35    views
36        .iter()
37        .filter(|v| !v.facts.merged && !v.facts.draft && !v.facts.paths_unknown)
38        .collect()
39}
40
41/// 2026-09-26: The recommended merge order over the orderable PRs, sorted by,
42/// in turn:
43/// 1. fewest conflict partners (the private `contend` helper);
44/// 2. fewest targets re-opened, with a whole-repo PR last;
45/// 3. fewest changed paths;
46/// 4. lowest PR number, which makes the order total.
47///
48/// [`super::PrFacts`] carries no check, review, mergeability or dependency
49/// state, so the order ignores them.
50pub fn merge_order(views: &[PrView]) -> Vec<u64> {
51    let open = orderable(views);
52    let mut ranked: Vec<(usize, usize, usize, u64)> = open
53        .iter()
54        .map(|v| {
55            let partners = open
56                .iter()
57                .filter(|o| o.facts.number != v.facts.number && contend(v, o))
58                .count();
59            let breadth = if v.whole_repo {
60                usize::MAX
61            } else {
62                v.targets.len()
63            };
64            (
65                partners,
66                breadth,
67                v.facts.changed_paths.len(),
68                v.facts.number,
69            )
70        })
71        .collect();
72    ranked.sort_unstable();
73    ranked.into_iter().map(|(_, _, _, n)| n).collect()
74}
75
76/// 2026-09-26: `#1, #2 … +k more`: at most [`CHART_PR_BOUND`] numbers, then
77/// the count of the rest.
78pub fn cap_prs(numbers: &[u64]) -> String {
79    let shown = numbers.len().min(CHART_PR_BOUND);
80    let mut s = numbers[..shown]
81        .iter()
82        .map(|n| format!("#{n}"))
83        .collect::<Vec<_>>()
84        .join(", ");
85    if numbers.len() > shown {
86        s.push_str(&format!(" … +{} more", numbers.len() - shown));
87    }
88    s
89}
90
91/// 2026-09-26: The commit label for one PR inside the chart. The label is
92/// written inside double quotes, so a quote in the title becomes an
93/// apostrophe and a newline a space; the title is clipped to `TITLE_CLIP`
94/// characters with an ellipsis.
95fn commit_label(v: &PrView) -> String {
96    const TITLE_CLIP: usize = 24;
97    let clean = v.facts.title.replace('"', "'").replace('\n', " ");
98    let clipped: String = clean.chars().take(TITLE_CLIP).collect();
99    let ellipsis = if clean.chars().count() > TITLE_CLIP {
100        "…"
101    } else {
102        ""
103    };
104    format!("#{} {}{}", v.facts.number, clipped, ellipsis)
105}
106
107/// 2026-09-26: The chart: `main` as a line, each ordered PR as a branch off
108/// it, merged back in order, for the first [`CHART_PR_BOUND`] PRs. The
109/// caption says "Showing N of M" and lists the rest through [`cap_prs`]. With
110/// nothing to order, a sentence replaces the chart.
111pub fn render_order_chart(views: &[PrView]) -> String {
112    let order = merge_order(views);
113    if order.is_empty() {
114        if views
115            .iter()
116            .any(|v| !v.facts.merged && v.facts.paths_unknown)
117        {
118            return "_Nothing could be ordered: the changed files of the open PR(s) came \
119                    back unreadable on this run._\n"
120                .to_string();
121        }
122        return "_No open, non-draft PRs to order._\n".to_string();
123    }
124    let shown = order.len().min(CHART_PR_BOUND);
125    let mut out = String::from("```mermaid\ngitGraph\n  commit id: \"main\"\n");
126    for number in &order[..shown] {
127        let v = views
128            .iter()
129            .find(|v| v.facts.number == *number)
130            .expect("order is drawn from these views");
131        out.push_str(&format!(
132            "  branch pr-{number}\n  commit id: \"{}\"\n  checkout main\n  merge pr-{number}\n",
133            commit_label(v)
134        ));
135    }
136    out.push_str("```\n\n");
137    out.push_str(&format!(
138        "Showing {shown} of {} open PRs, left to right in recommended merge order.",
139        order.len()
140    ));
141    if order.len() > shown {
142        out.push_str(&format!(" Not charted: {}.", cap_prs(&order[shown..])));
143    }
144    out.push_str(
145        "\nOrder: fewest conflict partners first (a partner shares a gate target; \
146         a whole-repo diff contends with every gated PR), then fewest targets \
147         re-opened, then smallest diff, then lowest PR number. Drafts and merged \
148         PRs are not ranked.\n",
149    );
150    out
151}
152
153/// 2026-09-26: "Recommended next steps": the head of [`merge_order`], the
154/// unmerged PRs whose changed files were unreadable, unmerged PRs that
155/// contend with a merged one, and merged PRs with promotion debt. Its inputs
156/// are the changed paths and the draft and merged flags; the closing line
157/// names what they cannot show.
158pub fn render_next_steps(views: &[PrView]) -> String {
159    let mut out = String::from("\n### Recommended next steps\n\n");
160    let order = merge_order(views);
161    match order.first() {
162        None if views
163            .iter()
164            .any(|v| !v.facts.merged && v.facts.paths_unknown) =>
165        {
166            out.push_str(
167                "- **No recommendation.** Every rankable PR's changed files came \
168                       back unreadable on this run.\n",
169            )
170        }
171        None => out.push_str("- Nothing to merge: no open, non-draft PRs.\n"),
172        Some(head) => {
173            let v = views.iter().find(|v| v.facts.number == *head).unwrap();
174            out.push_str(&format!(
175                "- **Merge next: #{head}** ({}) — least disruptive open PR under the \
176                 order rule above.\n",
177                super::escape(&v.facts.title)
178            ));
179        }
180    }
181
182    let blind: Vec<u64> = views
183        .iter()
184        .filter(|v| !v.facts.merged && v.facts.paths_unknown)
185        .map(|v| v.facts.number)
186        .collect();
187    if !blind.is_empty() {
188        out.push_str(&format!(
189            "- **Cannot rank {}:** their changed files came back unreadable, so this \
190             run has no input to order them by. They are counted as touching \
191             everything elsewhere in this comment; re-run before trusting the order.\n",
192            cap_prs(&blind)
193        ));
194    }
195
196    let merged: Vec<&PrView> = views.iter().filter(|v| v.facts.merged).collect();
197    let mut regate: Vec<u64> = Vec::new();
198    for v in views.iter().filter(|v| !v.facts.merged) {
199        if merged.iter().any(|m| contend(v, m)) {
200            regate.push(v.facts.number);
201        }
202    }
203    if !regate.is_empty() {
204        out.push_str(&format!(
205            "- **Re-gate before merging:** {} — a merged PR in this window moved a \
206             baseline they were measured against.\n",
207            cap_prs(&regate)
208        ));
209    }
210
211    let mut owing: Vec<u64> = merged
212        .iter()
213        .filter(|v| !v.promotion_debt.is_empty())
214        .map(|v| v.facts.number)
215        .collect();
216    owing.sort_unstable();
217    if !owing.is_empty() {
218        out.push_str(&format!(
219            "- **Discharge promotion debt:** merged {} shipped without a \
220             promotion-candidate gate (see the debt table below).\n",
221            cap_prs(&owing)
222        ));
223    }
224
225    out.push_str(
226        "\n_Derived only from changed paths and merge state. This section cannot \
227         see check or required-context status, review state, true git \
228         mergeability, or whether one PR unblocks another — those live on each \
229         PR._\n",
230    );
231    out
232}
233
234#[cfg(test)]
235#[path = "telemetry_order_tests.rs"]
236mod telemetry_order_tests;