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 "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 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 "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_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_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
235pub 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 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 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
298pub 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}