Skip to main content

lean_ctx/core/patterns/
gh.rs

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