1use reqwest::header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE, USER_AGENT};
2use std::process::Command;
3
4#[derive(Clone)]
5pub struct GitHubIssueRef {
6 pub id: String,
7 pub number: i64,
8 pub url: String,
9 pub state: String,
10 pub repo: String,
11}
12
13#[derive(Clone, serde::Serialize)]
14#[serde(rename_all = "camelCase")]
15pub struct GitHubIssueListItem {
16 pub id: String,
17 pub number: i64,
18 pub title: String,
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub body: Option<String>,
21 pub url: String,
22 pub state: String,
23 pub labels: Vec<String>,
24 pub assignees: Vec<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub updated_at: Option<String>,
27}
28
29#[derive(Clone, serde::Serialize)]
30#[serde(rename_all = "camelCase")]
31pub struct GitHubPullListItem {
32 pub id: String,
33 pub number: i64,
34 pub title: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub body: Option<String>,
37 pub url: String,
38 pub state: String,
39 pub labels: Vec<String>,
40 pub assignees: Vec<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub updated_at: Option<String>,
43 pub draft: bool,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub merged_at: Option<String>,
46 pub head_ref: String,
47 pub base_ref: String,
48}
49
50pub fn resolve_github_repo(repo_path: Option<&str>) -> Option<String> {
51 let repo_path = repo_path?;
52 let output = crate::git::git_command()
53 .args(["config", "--get", "remote.origin.url"])
54 .current_dir(repo_path)
55 .output()
56 .ok()?;
57
58 if !output.status.success() {
59 return None;
60 }
61
62 let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
63 let parsed = crate::git::parse_github_url(&remote)?;
64 Some(format!("{}/{}", parsed.owner, parsed.repo))
65}
66
67pub fn resolve_github_repo_for_codebase(
68 source_url: Option<&str>,
69 repo_path: Option<&str>,
70) -> Option<String> {
71 source_url
72 .and_then(crate::git::parse_github_url)
73 .map(|parsed| format!("{}/{}", parsed.owner, parsed.repo))
74 .or_else(|| resolve_github_repo(repo_path))
75}
76
77fn github_token() -> Option<String> {
78 std::env::var("GITHUB_TOKEN")
79 .ok()
80 .filter(|value| !value.is_empty())
81 .or_else(|| {
82 std::env::var("GH_TOKEN")
83 .ok()
84 .filter(|value| !value.is_empty())
85 })
86 .or_else(|| {
87 let output = Command::new("gh").args(["auth", "token"]).output().ok()?;
88 if !output.status.success() {
89 return None;
90 }
91
92 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
93 if token.is_empty() {
94 None
95 } else {
96 Some(token)
97 }
98 })
99}
100
101pub fn github_access_status() -> (&'static str, bool) {
102 if std::env::var("GITHUB_TOKEN")
103 .ok()
104 .filter(|value| !value.is_empty())
105 .is_some()
106 || std::env::var("GH_TOKEN")
107 .ok()
108 .filter(|value| !value.is_empty())
109 .is_some()
110 {
111 return ("env", true);
112 }
113
114 let output = match Command::new("gh").args(["auth", "token"]).output() {
115 Ok(output) => output,
116 Err(_) => return ("none", false),
117 };
118
119 if !output.status.success() {
120 return ("none", false);
121 }
122
123 let token = String::from_utf8_lossy(&output.stdout).trim().to_string();
124 if token.is_empty() {
125 ("none", false)
126 } else {
127 ("gh", true)
128 }
129}
130
131fn github_request(
132 request: reqwest::RequestBuilder,
133 token: Option<String>,
134) -> reqwest::RequestBuilder {
135 let builder = request
136 .header(ACCEPT, "application/vnd.github+json")
137 .header(CONTENT_TYPE, "application/json")
138 .header(USER_AGENT, "routa-rust-kanban")
139 .header("X-GitHub-Api-Version", "2022-11-28");
140
141 match token {
142 Some(token) => builder.header(AUTHORIZATION, format!("token {token}")),
143 None => builder,
144 }
145}
146
147pub async fn list_github_issues(
148 repo: &str,
149 state: Option<&str>,
150 per_page: Option<usize>,
151) -> Result<Vec<GitHubIssueListItem>, String> {
152 let client = reqwest::Client::new();
153 let token = github_token();
154 let per_page = per_page.unwrap_or(50).clamp(1, 100);
155 let state = state.unwrap_or("open");
156 let url = format!(
157 "https://api.github.com/repos/{repo}/issues?state={state}&sort=updated&direction=desc&per_page={per_page}"
158 );
159
160 let response = github_request(client.get(url), token)
161 .send()
162 .await
163 .map_err(|error| format!("GitHub issue list failed: {error}"))?;
164
165 if !response.status().is_success() {
166 let status = response.status();
167 let text = response.text().await.unwrap_or_default();
168 return Err(format!("GitHub issue list failed: {status} {text}"));
169 }
170
171 let data = response
172 .json::<Vec<serde_json::Value>>()
173 .await
174 .map_err(|error| format!("GitHub issue list failed: {error}"))?;
175
176 Ok(data
177 .into_iter()
178 .filter(|item| item.get("pull_request").is_none())
179 .map(|item| GitHubIssueListItem {
180 id: item
181 .get("id")
182 .and_then(|value| value.as_i64())
183 .unwrap_or_default()
184 .to_string(),
185 number: item
186 .get("number")
187 .and_then(|value| value.as_i64())
188 .unwrap_or_default(),
189 title: item
190 .get("title")
191 .and_then(|value| value.as_str())
192 .unwrap_or_default()
193 .to_string(),
194 body: item
195 .get("body")
196 .and_then(|value| value.as_str())
197 .map(str::to_string),
198 url: item
199 .get("html_url")
200 .and_then(|value| value.as_str())
201 .unwrap_or_default()
202 .to_string(),
203 state: item
204 .get("state")
205 .and_then(|value| value.as_str())
206 .unwrap_or("open")
207 .to_string(),
208 labels: item
209 .get("labels")
210 .and_then(|value| value.as_array())
211 .map(|labels| {
212 labels
213 .iter()
214 .filter_map(|label| {
215 label
216 .get("name")
217 .and_then(|value| value.as_str())
218 .map(str::trim)
219 .filter(|value| !value.is_empty())
220 .map(str::to_string)
221 })
222 .collect::<Vec<_>>()
223 })
224 .unwrap_or_default(),
225 assignees: item
226 .get("assignees")
227 .and_then(|value| value.as_array())
228 .map(|assignees| {
229 assignees
230 .iter()
231 .filter_map(|assignee| {
232 assignee
233 .get("login")
234 .and_then(|value| value.as_str())
235 .map(str::trim)
236 .filter(|value| !value.is_empty())
237 .map(str::to_string)
238 })
239 .collect::<Vec<_>>()
240 })
241 .unwrap_or_default(),
242 updated_at: item
243 .get("updated_at")
244 .and_then(|value| value.as_str())
245 .map(str::to_string),
246 })
247 .collect())
248}
249
250pub async fn list_github_pulls(
251 repo: &str,
252 state: Option<&str>,
253 per_page: Option<usize>,
254) -> Result<Vec<GitHubPullListItem>, String> {
255 let client = reqwest::Client::new();
256 let token = github_token();
257 let per_page = per_page.unwrap_or(50).clamp(1, 100);
258 let state = state.unwrap_or("open");
259 let url = format!(
260 "https://api.github.com/repos/{repo}/pulls?state={state}&sort=updated&direction=desc&per_page={per_page}"
261 );
262
263 let response = github_request(client.get(url), token)
264 .send()
265 .await
266 .map_err(|error| format!("GitHub pull request list failed: {error}"))?;
267
268 if !response.status().is_success() {
269 let status = response.status();
270 let text = response.text().await.unwrap_or_default();
271 return Err(format!("GitHub pull request list failed: {status} {text}"));
272 }
273
274 let data = response
275 .json::<Vec<serde_json::Value>>()
276 .await
277 .map_err(|error| format!("GitHub pull request list failed: {error}"))?;
278
279 Ok(data
280 .into_iter()
281 .map(|item| GitHubPullListItem {
282 id: item
283 .get("id")
284 .and_then(|value| value.as_i64())
285 .unwrap_or_default()
286 .to_string(),
287 number: item
288 .get("number")
289 .and_then(|value| value.as_i64())
290 .unwrap_or_default(),
291 title: item
292 .get("title")
293 .and_then(|value| value.as_str())
294 .unwrap_or_default()
295 .to_string(),
296 body: item
297 .get("body")
298 .and_then(|value| value.as_str())
299 .map(str::to_string),
300 url: item
301 .get("html_url")
302 .and_then(|value| value.as_str())
303 .unwrap_or_default()
304 .to_string(),
305 state: item
306 .get("state")
307 .and_then(|value| value.as_str())
308 .unwrap_or("open")
309 .to_string(),
310 labels: item
311 .get("labels")
312 .and_then(|value| value.as_array())
313 .map(|labels| {
314 labels
315 .iter()
316 .filter_map(|label| {
317 label
318 .get("name")
319 .and_then(|value| value.as_str())
320 .map(str::trim)
321 .filter(|value| !value.is_empty())
322 .map(str::to_string)
323 })
324 .collect::<Vec<_>>()
325 })
326 .unwrap_or_default(),
327 assignees: item
328 .get("assignees")
329 .and_then(|value| value.as_array())
330 .map(|assignees| {
331 assignees
332 .iter()
333 .filter_map(|assignee| {
334 assignee
335 .get("login")
336 .and_then(|value| value.as_str())
337 .map(str::trim)
338 .filter(|value| !value.is_empty())
339 .map(str::to_string)
340 })
341 .collect::<Vec<_>>()
342 })
343 .unwrap_or_default(),
344 updated_at: item
345 .get("updated_at")
346 .and_then(|value| value.as_str())
347 .map(str::to_string),
348 draft: item
349 .get("draft")
350 .and_then(|value| value.as_bool())
351 .unwrap_or(false),
352 merged_at: item
353 .get("merged_at")
354 .and_then(|value| value.as_str())
355 .map(str::to_string),
356 head_ref: item
357 .get("head")
358 .and_then(|value| value.get("ref"))
359 .and_then(|value| value.as_str())
360 .unwrap_or_default()
361 .to_string(),
362 base_ref: item
363 .get("base")
364 .and_then(|value| value.get("ref"))
365 .and_then(|value| value.as_str())
366 .unwrap_or_default()
367 .to_string(),
368 })
369 .collect())
370}
371
372pub async fn create_github_issue(
373 repo: &str,
374 title: &str,
375 body: Option<&str>,
376 labels: &[String],
377 assignee: Option<&str>,
378) -> Result<GitHubIssueRef, String> {
379 let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
380 let client = reqwest::Client::new();
381 let mut payload = serde_json::json!({
382 "title": title,
383 "body": body,
384 "labels": labels,
385 });
386
387 if let Some(assignee) = assignee {
388 payload["assignees"] = serde_json::json!([assignee]);
389 }
390
391 let response = github_request(
392 client.post(format!("https://api.github.com/repos/{repo}/issues")),
393 Some(token),
394 )
395 .json(&payload)
396 .send()
397 .await
398 .map_err(|error| format!("GitHub issue create failed: {error}"))?;
399
400 if !response.status().is_success() {
401 let status = response.status();
402 let text = response.text().await.unwrap_or_default();
403 return Err(format!("GitHub issue create failed: {status} {text}"));
404 }
405
406 let data = response
407 .json::<serde_json::Value>()
408 .await
409 .map_err(|error| format!("GitHub issue create failed: {error}"))?;
410
411 Ok(GitHubIssueRef {
412 id: data
413 .get("id")
414 .and_then(|value| value.as_i64())
415 .unwrap_or_default()
416 .to_string(),
417 number: data
418 .get("number")
419 .and_then(|value| value.as_i64())
420 .unwrap_or_default(),
421 url: data
422 .get("html_url")
423 .and_then(|value| value.as_str())
424 .unwrap_or_default()
425 .to_string(),
426 state: data
427 .get("state")
428 .and_then(|value| value.as_str())
429 .unwrap_or("open")
430 .to_string(),
431 repo: repo.to_string(),
432 })
433}
434
435pub async fn update_github_issue(
436 repo: &str,
437 issue_number: i64,
438 title: &str,
439 body: Option<&str>,
440 labels: &[String],
441 state: &str,
442 assignee: Option<&str>,
443) -> Result<(), String> {
444 let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
445 let client = reqwest::Client::new();
446 let mut payload = serde_json::json!({
447 "title": title,
448 "body": body,
449 "labels": labels,
450 "state": state,
451 });
452
453 if let Some(assignee) = assignee {
454 payload["assignees"] = serde_json::json!([assignee]);
455 }
456
457 let response = github_request(
458 client.patch(format!(
459 "https://api.github.com/repos/{repo}/issues/{issue_number}"
460 )),
461 Some(token),
462 )
463 .json(&payload)
464 .send()
465 .await
466 .map_err(|error| format!("GitHub issue update failed: {error}"))?;
467
468 if response.status().is_success() {
469 Ok(())
470 } else {
471 let status = response.status();
472 let text = response.text().await.unwrap_or_default();
473 Err(format!("GitHub issue update failed: {status} {text}"))
474 }
475}
476
477pub fn build_task_issue_body(objective: &str, test_cases: Option<&Vec<String>>) -> String {
478 let normalized_test_cases: Vec<&str> = test_cases
479 .into_iter()
480 .flatten()
481 .map(|value| value.trim())
482 .filter(|value| !value.is_empty())
483 .collect();
484
485 if normalized_test_cases.is_empty() {
486 return objective.trim().to_string();
487 }
488
489 let mut sections = Vec::new();
490 if !objective.trim().is_empty() {
491 sections.push(objective.trim().to_string());
492 }
493 sections.push(format!(
494 "## Test Cases\n{}",
495 normalized_test_cases
496 .into_iter()
497 .map(|value| format!("- {value}"))
498 .collect::<Vec<_>>()
499 .join("\n")
500 ));
501 sections.join("\n\n")
502}