Skip to main content

lean_ctx/core/patterns/
gh.rs

1macro_rules! static_regex {
2    ($pattern:expr) => {{
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("pr") {
22        if command.contains("diff") {
23            return Some(output.to_string()); // safety: verbatim (issue #149)
24        }
25        if command.contains("list") {
26            return Some(compress_pr_list(output));
27        }
28        if command.contains("view") {
29            return Some(compress_pr_view(output));
30        }
31        if command.contains("create") {
32            return Some(compress_pr_create(output));
33        }
34        if command.contains("merge") {
35            return Some(compress_simple_action(output, "merged"));
36        }
37        if command.contains("close") {
38            return Some(compress_simple_action(output, "closed"));
39        }
40        if command.contains("checkout") || command.contains("co") {
41            return Some(compress_simple_action(output, "checked out"));
42        }
43    }
44    if command.contains("issue") {
45        if command.contains("list") {
46            return Some(compress_issue_list(output));
47        }
48        if command.contains("view") {
49            return Some(compress_issue_view(output));
50        }
51        if command.contains("create") {
52            return Some(compress_simple_action(output, "created"));
53        }
54    }
55    if command.contains("run") {
56        if command.contains("list") {
57            return Some(compress_run_list(output));
58        }
59        if command.contains("view") {
60            return Some(compress_run_view(output));
61        }
62    }
63    if command.contains("repo") {
64        return Some(compress_repo(output));
65    }
66    if command.contains("release") {
67        return Some(compress_release(output));
68    }
69
70    // Safety: unknown gh subcommands (api, search, diff, workflow, auth, secret, etc.)
71    // pass through verbatim to prevent fallback compressors from stripping content.
72    Some(output.to_string())
73}
74
75fn compress_pr_list(output: &str) -> String {
76    let trimmed = output.trim();
77    if trimmed.is_empty() || trimmed.contains("no pull requests") {
78        return "no PRs".to_string();
79    }
80
81    let mut prs = Vec::new();
82    for line in trimmed.lines() {
83        if let Some(caps) = pr_line_re().captures(line) {
84            let num = &caps[1];
85            let title = caps[2].trim();
86            let branch = &caps[3];
87            prs.push(format!("#{num} {title} ({branch})"));
88        } else {
89            let l = line.trim();
90            if !l.is_empty() && l.starts_with('#') {
91                prs.push(l.to_string());
92            }
93        }
94    }
95
96    if prs.is_empty() {
97        return compact_output(trimmed, 10);
98    }
99    format!("{} PRs:\n{}", prs.len(), prs.join("\n"))
100}
101
102fn compress_pr_view(output: &str) -> String {
103    let lines: Vec<&str> = output.lines().collect();
104    if lines.len() <= 5 {
105        return output.to_string();
106    }
107
108    let mut title = String::new();
109    let mut state = String::new();
110    let mut labels = Vec::new();
111    let mut checks = Vec::new();
112
113    for line in &lines {
114        let l = line.trim();
115        if l.starts_with("title:") || (title.is_empty() && l.starts_with('#')) {
116            title = l.replace("title:", "").replace('#', "").trim().to_string();
117        }
118        if l.starts_with("state:") {
119            state = l.replace("state:", "").trim().to_string();
120        }
121        if l.starts_with("labels:") {
122            labels = l
123                .replace("labels:", "")
124                .split(',')
125                .map(|s| s.trim().to_string())
126                .collect();
127        }
128        if l.contains("✓") || l.contains("✗") || l.contains("pass") || l.contains("fail") {
129            checks.push(l.to_string());
130        }
131    }
132
133    let mut parts = Vec::new();
134    if !title.is_empty() {
135        parts.push(title);
136    }
137    if !state.is_empty() {
138        parts.push(format!("state: {state}"));
139    }
140    if !labels.is_empty() {
141        parts.push(format!("labels: {}", labels.join(", ")));
142    }
143    if !checks.is_empty() && checks.len() <= 5 {
144        parts.push(format!("checks: {}", checks.join("; ")));
145    }
146
147    if parts.is_empty() {
148        return compact_output(output, 10);
149    }
150    parts.join("\n")
151}
152
153fn compress_pr_create(output: &str) -> String {
154    if let Some(caps) = pr_created_re().captures(output) {
155        return format!("#{} created", &caps[1]);
156    }
157    let trimmed = output.trim();
158    if trimmed.contains("http") {
159        for line in trimmed.lines() {
160            if line.contains("http") {
161                return format!("created: {}", line.trim());
162            }
163        }
164    }
165    compact_output(trimmed, 3)
166}
167
168fn compress_issue_list(output: &str) -> String {
169    let trimmed = output.trim();
170    if trimmed.is_empty() || trimmed.contains("no issues") {
171        return "no issues".to_string();
172    }
173
174    let mut issues = Vec::new();
175    for line in trimmed.lines() {
176        if let Some(caps) = issue_line_re().captures(line) {
177            let num = &caps[1];
178            let title = caps[2].trim();
179            issues.push(format!("#{num} {title}"));
180        } else {
181            let l = line.trim();
182            if !l.is_empty() && l.starts_with('#') {
183                issues.push(l.to_string());
184            }
185        }
186    }
187
188    if issues.is_empty() {
189        return compact_output(trimmed, 10);
190    }
191    format!("{} issues:\n{}", issues.len(), issues.join("\n"))
192}
193
194fn compress_issue_view(output: &str) -> String {
195    compact_output(output, 15)
196}
197
198fn compress_run_list(output: &str) -> String {
199    let trimmed = output.trim();
200    if trimmed.is_empty() {
201        return "no runs".to_string();
202    }
203
204    let mut runs = Vec::new();
205    for line in trimmed.lines() {
206        let l = line.trim();
207        if l.is_empty() || l.starts_with("STATUS") || l.starts_with("--") {
208            continue;
209        }
210        if l.contains("completed")
211            || l.contains("in_progress")
212            || l.contains("queued")
213            || l.contains("failure")
214            || l.contains("success")
215        {
216            runs.push(l.to_string());
217        }
218    }
219
220    if runs.is_empty() {
221        return compact_output(trimmed, 10);
222    }
223    format!("{} runs:\n{}", runs.len(), runs.join("\n"))
224}
225
226fn compress_run_view(output: &str) -> String {
227    compact_output(output, 15)
228}
229
230fn compress_repo(output: &str) -> String {
231    compact_output(output, 10)
232}
233
234fn compress_release(output: &str) -> String {
235    compact_output(output, 10)
236}
237
238fn compress_simple_action(output: &str, action: &str) -> String {
239    let trimmed = output.trim();
240    if trimmed.is_empty() {
241        return format!("ok ({action})");
242    }
243    for line in trimmed.lines() {
244        if line.contains("http") || line.contains('#') {
245            return format!("{action}: {}", line.trim());
246        }
247    }
248    action.to_string()
249}
250
251fn compact_output(text: &str, max: usize) -> String {
252    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
253    if lines.len() <= max {
254        return lines.join("\n");
255    }
256    format!(
257        "{}\n... ({} more lines)",
258        lines[..max].join("\n"),
259        lines.len() - max
260    )
261}