Skip to main content

lean_ctx_patterns/
gh.rs

1// SPDX-License-Identifier: Apache-2.0
2
3macro_rules! static_regex {
4    ($pattern:expr_2021) => {{
5        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
6        RE.get_or_init(|| {
7            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
8        })
9    }};
10}
11
12fn pr_line_re() -> &'static regex::Regex {
13    static_regex!(r"#(\d+)\s+(.+?)\s{2,}(\S+)\s+(\S+)")
14}
15fn issue_line_re() -> &'static regex::Regex {
16    static_regex!(r"#(\d+)\s+(.+?)\s{2,}")
17}
18fn pr_created_re() -> &'static regex::Regex {
19    static_regex!(r"https://github\.com/\S+/pull/(\d+)")
20}
21
22pub fn compress(command: &str, output: &str) -> Option<String> {
23    if command.contains(" api ") || command.starts_with("gh api") {
24        return None;
25    }
26    if command.contains("pr") {
27        if command.contains("diff") {
28            return None;
29        }
30        if command.contains("list") {
31            return Some(compress_pr_list(output));
32        }
33        if command.contains("view") {
34            return Some(compress_pr_view(output));
35        }
36        if command.contains("create") {
37            return Some(compress_pr_create(output));
38        }
39        if command.contains("merge") {
40            return Some(compress_simple_action(output, "merged"));
41        }
42        if command.contains("close") {
43            return Some(compress_simple_action(output, "closed"));
44        }
45        if command.contains("checkout") || command.contains("co") {
46            return Some(compress_simple_action(output, "checked out"));
47        }
48    }
49    if command.contains("issue") {
50        if command.contains("list") {
51            return Some(compress_issue_list(output));
52        }
53        if command.contains("view") {
54            return Some(compress_issue_view(output));
55        }
56        if command.contains("create") {
57            return Some(compress_simple_action(output, "created"));
58        }
59    }
60    if command.contains("run") {
61        if command.contains("list") {
62            return Some(compress_run_list(output));
63        }
64        if command.contains("view") {
65            return Some(compress_run_view(output));
66        }
67    }
68    if command.contains("repo") {
69        return Some(compress_repo(output));
70    }
71    if command.contains("release") {
72        return Some(compress_release(output));
73    }
74
75    None
76}
77
78fn compress_pr_list(output: &str) -> String {
79    let trimmed = output.trim();
80    if trimmed.is_empty() || trimmed.contains("no pull requests") {
81        return "no PRs".to_string();
82    }
83
84    let mut prs = Vec::new();
85    for line in trimmed.lines() {
86        if let Some(caps) = pr_line_re().captures(line) {
87            let num = &caps[1];
88            let title = caps[2].trim();
89            let branch = &caps[3];
90            prs.push(format!("#{num} {title} ({branch})"));
91        } else {
92            let l = line.trim();
93            if !l.is_empty() && l.starts_with('#') {
94                prs.push(l.to_string());
95            }
96        }
97    }
98
99    if prs.is_empty() {
100        return compact_output(trimmed, 10);
101    }
102    format!("{} PRs:\n{}", prs.len(), prs.join("\n"))
103}
104
105fn compress_pr_view(output: &str) -> String {
106    let lines: Vec<&str> = output.lines().collect();
107    if lines.len() <= 5 {
108        return output.to_string();
109    }
110
111    let mut title = String::new();
112    let mut state = String::new();
113    let mut labels = Vec::new();
114    let mut checks = Vec::new();
115
116    for line in &lines {
117        let l = line.trim();
118        if l.starts_with("title:") || (title.is_empty() && l.starts_with('#')) {
119            title = l.replace("title:", "").replace('#', "").trim().to_string();
120        }
121        if l.starts_with("state:") {
122            state = l.replace("state:", "").trim().to_string();
123        }
124        if l.starts_with("labels:") {
125            labels = l
126                .replace("labels:", "")
127                .split(',')
128                .map(|s| s.trim().to_string())
129                .collect();
130        }
131        if l.contains("✓") || l.contains("✗") || l.contains("pass") || l.contains("fail") {
132            checks.push(l.to_string());
133        }
134    }
135
136    let mut parts = Vec::new();
137    if !title.is_empty() {
138        parts.push(title);
139    }
140    if !state.is_empty() {
141        parts.push(format!("state: {state}"));
142    }
143    if !labels.is_empty() {
144        parts.push(format!("labels: {}", labels.join(", ")));
145    }
146    if !checks.is_empty() && checks.len() <= 5 {
147        parts.push(format!("checks: {}", checks.join("; ")));
148    }
149
150    if parts.is_empty() {
151        return compact_output(output, 10);
152    }
153    parts.join("\n")
154}
155
156fn compress_pr_create(output: &str) -> String {
157    if let Some(caps) = pr_created_re().captures(output) {
158        return format!("#{} created", &caps[1]);
159    }
160    let trimmed = output.trim();
161    if trimmed.contains("http") {
162        for line in trimmed.lines() {
163            if line.contains("http") {
164                return format!("created: {}", line.trim());
165            }
166        }
167    }
168    compact_output(trimmed, 3)
169}
170
171fn compress_issue_list(output: &str) -> String {
172    let trimmed = output.trim();
173    if trimmed.is_empty() || trimmed.contains("no issues") {
174        return "no issues".to_string();
175    }
176
177    let mut issues = Vec::new();
178    for line in trimmed.lines() {
179        if let Some(caps) = issue_line_re().captures(line) {
180            let num = &caps[1];
181            let title = caps[2].trim();
182            issues.push(format!("#{num} {title}"));
183        } else {
184            let l = line.trim();
185            if !l.is_empty() && l.starts_with('#') {
186                issues.push(l.to_string());
187            }
188        }
189    }
190
191    if issues.is_empty() {
192        return compact_output(trimmed, 10);
193    }
194    format!("{} issues:\n{}", issues.len(), issues.join("\n"))
195}
196
197fn compress_issue_view(output: &str) -> String {
198    // #884: an issue's body is the payload; the metadata fields above it are
199    // boilerplate. Head-only truncation (`compact_output`) dropped the body
200    // wholesale on any issue longer than the field block, forcing a second
201    // `--json body` call. Keep head AND tail so the body survives.
202    compact_head_tail(output, 40, 40)
203}
204
205fn compress_run_list(output: &str) -> String {
206    let trimmed = output.trim();
207    if trimmed.is_empty() {
208        return "no runs".to_string();
209    }
210
211    let mut runs = Vec::new();
212    for line in trimmed.lines() {
213        let l = line.trim();
214        if l.is_empty() || l.starts_with("STATUS") || l.starts_with("--") {
215            continue;
216        }
217        if l.contains("completed")
218            || l.contains("in_progress")
219            || l.contains("queued")
220            || l.contains("failure")
221            || l.contains("success")
222        {
223            runs.push(l.to_string());
224        }
225    }
226
227    if runs.is_empty() {
228        return compact_output(trimmed, 10);
229    }
230    format!("{} runs:\n{}", runs.len(), runs.join("\n"))
231}
232
233fn compress_run_view(output: &str) -> String {
234    // #884: a run's failure summary lives at the tail — head-only truncation hid
235    // exactly the part the agent came for. Keep head AND tail.
236    compact_head_tail(output, 40, 40)
237}
238
239fn compress_repo(output: &str) -> String {
240    compact_output(output, 10)
241}
242
243fn compress_release(output: &str) -> String {
244    compact_output(output, 10)
245}
246
247fn compress_simple_action(output: &str, action: &str) -> String {
248    let trimmed = output.trim();
249    if trimmed.is_empty() {
250        return format!("ok ({action})");
251    }
252    for line in trimmed.lines() {
253        if line.contains("http") || line.contains('#') {
254            return format!("{action}: {}", line.trim());
255        }
256    }
257    action.to_string()
258}
259
260fn compact_output(text: &str, max: usize) -> String {
261    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
262    if lines.len() <= max {
263        return lines.join("\n");
264    }
265    format!(
266        "{}\n{}",
267        lines[..max].join("\n"),
268        super::elision_marker(lines.len() - max)
269    )
270}
271
272/// Head+tail compaction (#884): keep the first `head` and last `tail` non-empty
273/// lines, eliding only the middle. Unlike [`compact_output`] (head-only), this
274/// never drops the tail — used for single-record `view` output whose payload (an
275/// issue/PR body, a run's failure summary) follows the metadata header.
276fn compact_head_tail(text: &str, head: usize, tail: usize) -> String {
277    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
278    if lines.len() <= head + tail {
279        return lines.join("\n");
280    }
281    let omitted = lines.len() - head - tail;
282    format!(
283        "{}\n{}\n{}",
284        lines[..head].join("\n"),
285        super::elision_marker(omitted),
286        lines[lines.len() - tail..].join("\n"),
287    )
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    #[test]
295    fn issue_view_keeps_body_not_just_metadata() {
296        // gh issue view: a metadata field block then the body. Head-only
297        // truncation (`compact_output(_, 15)`) dropped the body entirely (#884).
298        let mut out = String::new();
299        for f in [
300            "title:\tSomething broke",
301            "state:\tOPEN",
302            "author:\tandig",
303            "labels:",
304            "comments:\t0",
305            "assignees:",
306            "projects:",
307            "milestone:",
308            "number:\t875",
309            "--",
310        ] {
311            out.push_str(f);
312            out.push('\n');
313        }
314        for i in 0..40 {
315            out.push_str(&format!("body line {i} - the actual payload\n"));
316        }
317        let compressed = compress_issue_view(&out);
318        assert!(
319            compressed.contains("body line 39 - the actual payload"),
320            "the end of the body must survive compaction: {compressed}"
321        );
322        assert!(
323            compressed.contains("title:\tSomething broke"),
324            "metadata head must still be present: {compressed}"
325        );
326    }
327
328    #[test]
329    fn head_tail_returns_small_output_verbatim() {
330        assert_eq!(compact_head_tail("a\nb\nc\n", 40, 40), "a\nb\nc");
331    }
332
333    #[test]
334    fn head_tail_elides_only_the_middle() {
335        let mut lines = String::new();
336        for i in 0..200 {
337            use std::fmt::Write;
338            let _ = writeln!(lines, "L{i}");
339        }
340        let out = compact_head_tail(&lines, 5, 5);
341        assert!(out.contains("L0") && out.contains("L4"));
342        assert!(out.contains("L195") && out.contains("L199"));
343        // #1679: the marker is unmistakably lean-ctx's, not content-shaped,
344        // and names the way back — it is the only trace of the cut once the
345        // savings footer is hidden, which is the default on the MCP path.
346        assert!(out.contains("[lean-ctx: 190 lines elided"), "{out}");
347        assert!(out.contains("raw=true"), "{out}");
348        assert!(
349            !out.contains("... (190 more lines)"),
350            "the content-shaped marker must be gone: {out}"
351        );
352        assert!(!out.contains("L100"));
353    }
354}