Skip to main content

hematite/tools/
github.rs

1use serde_json::Value;
2use std::process::Command;
3
4fn gh_available() -> bool {
5    Command::new("gh")
6        .arg("--version")
7        .output()
8        .map(|o| o.status.success())
9        .unwrap_or(false)
10}
11
12fn run_gh(args: &[&str]) -> Result<String, String> {
13    if !gh_available() {
14        return Err("GitHub CLI (`gh`) is not installed or not on PATH. \
15             Install it from https://cli.github.com/ and run `gh auth login`."
16            .to_string());
17    }
18    let out = Command::new("gh")
19        .args(args)
20        .output()
21        .map_err(|e| format!("gh exec failed: {e}"))?;
22    let stdout = String::from_utf8_lossy(&out.stdout).to_string();
23    let stderr = String::from_utf8_lossy(&out.stderr).to_string();
24    if out.status.success() {
25        Ok(stdout)
26    } else if !stderr.is_empty() {
27        Err(stderr)
28    } else {
29        Err(format!("gh exited with status {}", out.status))
30    }
31}
32
33fn current_branch() -> String {
34    Command::new("git")
35        .args(["branch", "--show-current"])
36        .output()
37        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
38        .unwrap_or_else(|_| "HEAD".to_string())
39}
40
41pub async fn execute(args: &Value) -> Result<String, String> {
42    let action = args
43        .get("action")
44        .and_then(|v| v.as_str())
45        .ok_or("Missing required argument: 'action'")?;
46
47    match action {
48        // ── Pull Requests ───────────────────────────────────────────────────
49        "pr_list" => {
50            let limit = args
51                .get("limit")
52                .and_then(|v| v.as_u64())
53                .unwrap_or(10)
54                .to_string();
55            run_gh(&[
56                "pr",
57                "list",
58                "--limit",
59                &limit,
60                "--json",
61                "number,title,state,author,headRefName,createdAt",
62                "--template",
63                "{{range .}}#{{.number}}\t{{.state}}\t{{.headRefName}}\t{{.title}}\n{{end}}",
64            ])
65        }
66
67        "pr_view" => {
68            let pr = args.get("pr").and_then(|v| v.as_str()).unwrap_or("");
69            if pr.is_empty() {
70                // default: current branch's PR
71                run_gh(&[
72                    "pr",
73                    "view",
74                    "--json",
75                    "number,title,state,body,reviews,url",
76                ])
77            } else {
78                run_gh(&[
79                    "pr",
80                    "view",
81                    pr,
82                    "--json",
83                    "number,title,state,body,reviews,url",
84                ])
85            }
86        }
87
88        "pr_create" => {
89            let title = args
90                .get("title")
91                .and_then(|v| v.as_str())
92                .ok_or("Missing 'title' for pr_create")?;
93            let body = args.get("body").and_then(|v| v.as_str()).unwrap_or("");
94            let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("main");
95            let draft = args.get("draft").and_then(|v| v.as_bool()).unwrap_or(false);
96            let mut gh_args = vec![
97                "pr", "create", "--title", title, "--body", body, "--base", base,
98            ];
99            if draft {
100                gh_args.push("--draft");
101            }
102            run_gh(&gh_args)
103        }
104
105        "pr_status" => run_gh(&["pr", "status"]),
106
107        "pr_checks" => {
108            let pr = args.get("pr").and_then(|v| v.as_str()).unwrap_or("");
109            if pr.is_empty() {
110                run_gh(&["pr", "checks"])
111            } else {
112                run_gh(&["pr", "checks", pr])
113            }
114        }
115
116        "pr_merge" => {
117            let pr = args.get("pr").and_then(|v| v.as_str()).unwrap_or("");
118            let strategy = args
119                .get("strategy")
120                .and_then(|v| v.as_str())
121                .unwrap_or("merge");
122            let flag = match strategy {
123                "squash" => "--squash",
124                "rebase" => "--rebase",
125                _ => "--merge",
126            };
127            if pr.is_empty() {
128                run_gh(&["pr", "merge", flag])
129            } else {
130                run_gh(&["pr", "merge", pr, flag])
131            }
132        }
133
134        // ── Issues ──────────────────────────────────────────────────────────
135        "issue_list" => {
136            let limit = args
137                .get("limit")
138                .and_then(|v| v.as_u64())
139                .unwrap_or(10)
140                .to_string();
141            let state = args.get("state").and_then(|v| v.as_str()).unwrap_or("open");
142            run_gh(&[
143                "issue",
144                "list",
145                "--limit",
146                &limit,
147                "--state",
148                state,
149                "--json",
150                "number,title,state,labels,createdAt",
151                "--template",
152                "{{range .}}#{{.number}}\t{{.state}}\t{{.title}}\n{{end}}",
153            ])
154        }
155
156        "issue_view" => {
157            let number = args
158                .get("number")
159                .and_then(|v| v.as_u64())
160                .map(|n| n.to_string())
161                .or_else(|| {
162                    args.get("number")
163                        .and_then(|v| v.as_str())
164                        .map(str::to_string)
165                })
166                .ok_or("Missing 'number' for issue_view")?;
167            run_gh(&["issue", "view", &number])
168        }
169
170        "issue_create" => {
171            let title = args
172                .get("title")
173                .and_then(|v| v.as_str())
174                .ok_or("Missing 'title' for issue_create")?;
175            let body = args.get("body").and_then(|v| v.as_str()).unwrap_or("");
176            run_gh(&["issue", "create", "--title", title, "--body", body])
177        }
178
179        // ── CI / Actions ────────────────────────────────────────────────────
180        "ci_status" => {
181            let branch = args
182                .get("branch")
183                .and_then(|v| v.as_str())
184                .map(str::to_string)
185                .unwrap_or_else(current_branch);
186            let limit = args
187                .get("limit")
188                .and_then(|v| v.as_u64())
189                .unwrap_or(5)
190                .to_string();
191            run_gh(&[
192                "run",
193                "list",
194                "--branch",
195                &branch,
196                "--limit",
197                &limit,
198                "--json",
199                "status,conclusion,name,headBranch,createdAt,url",
200                "--template",
201                "{{range .}}{{.name}}\t{{.status}}\t{{.conclusion}}\t{{.headBranch}}\n{{end}}",
202            ])
203        }
204
205        "run_view" => {
206            let run_id = args
207                .get("run_id")
208                .and_then(|v| v.as_str())
209                .ok_or("Missing 'run_id' for run_view")?;
210            run_gh(&["run", "view", run_id])
211        }
212
213        // ── Repo ─────────────────────────────────────────────────────────────
214        "repo_view" => run_gh(&["repo", "view"]),
215
216        "release_list" => {
217            let limit = args
218                .get("limit")
219                .and_then(|v| v.as_u64())
220                .unwrap_or(5)
221                .to_string();
222            run_gh(&["release", "list", "--limit", &limit])
223        }
224
225        other => Err(format!(
226            "Unknown github_ops action: '{}'. Valid actions: \
227             pr_list, pr_view, pr_create, pr_status, pr_checks, pr_merge, \
228             issue_list, issue_view, issue_create, \
229             ci_status, run_view, repo_view, release_list",
230            other
231        )),
232    }
233}
234
235/// Harness-side PR creation: gathers context, calls gh, returns formatted result.
236/// Used by the `/pr` slash command without model involvement.
237pub fn create_pr_from_context(title: Option<&str>, draft: bool) -> Result<String, String> {
238    if !gh_available() {
239        return Err(
240            "`gh` not installed. Install from https://cli.github.com/ and run `gh auth login`."
241                .to_string(),
242        );
243    }
244
245    let branch = current_branch();
246    if branch.is_empty() || branch == "HEAD" {
247        return Err("Not on a named branch. Check out a branch first.".to_string());
248    }
249
250    // Build title from last commit if not supplied
251    let auto_title = if title.is_none() {
252        Command::new("git")
253            .args(["log", "-1", "--format=%s"])
254            .output()
255            .ok()
256            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
257            .filter(|s| !s.is_empty())
258    } else {
259        None
260    };
261    let pr_title = title
262        .map(str::to_string)
263        .or(auto_title)
264        .unwrap_or_else(|| branch.replace('-', " ").replace('_', " "));
265
266    // Gather commit log for body
267    let commits = Command::new("git")
268        .args(["log", "main..HEAD", "--oneline"])
269        .output()
270        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
271        .unwrap_or_default();
272    let body = if commits.is_empty() {
273        String::new()
274    } else {
275        format!("## Commits\n\n```\n{}\n```", commits)
276    };
277
278    let mut gh_args = vec![
279        "pr", "create", "--title", &pr_title, "--body", &body, "--base", "main",
280    ];
281    if draft {
282        gh_args.push("--draft");
283    }
284
285    let out = Command::new("gh")
286        .args(&gh_args)
287        .output()
288        .map_err(|e| format!("gh exec failed: {e}"))?;
289    let stdout = String::from_utf8_lossy(&out.stdout).trim().to_string();
290    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
291    if out.status.success() {
292        Ok(format!("PR created: {}", stdout))
293    } else {
294        Err(if !stderr.is_empty() { stderr } else { stdout })
295    }
296}
297
298/// Quick CI status for the current branch — used by `/ci` slash command.
299pub fn ci_status_current() -> Result<String, String> {
300    let branch = current_branch();
301    run_gh(&[
302        "run",
303        "list",
304        "--branch",
305        &branch,
306        "--limit",
307        "5",
308        "--json",
309        "status,conclusion,name,headBranch,createdAt",
310        "--template",
311        "{{range .}}{{.name}}\t{{.status}}\t{{.conclusion}}\n{{end}}",
312    ])
313}