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 = Command::new("git")
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!(
272 "GitHub pull request list failed: {} {}",
273 status, text
274 ));
275 }
276
277 let data = response
278 .json::<Vec<serde_json::Value>>()
279 .await
280 .map_err(|error| format!("GitHub pull request list failed: {}", error))?;
281
282 Ok(data
283 .into_iter()
284 .map(|item| GitHubPullListItem {
285 id: item
286 .get("id")
287 .and_then(|value| value.as_i64())
288 .unwrap_or_default()
289 .to_string(),
290 number: item
291 .get("number")
292 .and_then(|value| value.as_i64())
293 .unwrap_or_default(),
294 title: item
295 .get("title")
296 .and_then(|value| value.as_str())
297 .unwrap_or_default()
298 .to_string(),
299 body: item
300 .get("body")
301 .and_then(|value| value.as_str())
302 .map(str::to_string),
303 url: item
304 .get("html_url")
305 .and_then(|value| value.as_str())
306 .unwrap_or_default()
307 .to_string(),
308 state: item
309 .get("state")
310 .and_then(|value| value.as_str())
311 .unwrap_or("open")
312 .to_string(),
313 labels: item
314 .get("labels")
315 .and_then(|value| value.as_array())
316 .map(|labels| {
317 labels
318 .iter()
319 .filter_map(|label| {
320 label
321 .get("name")
322 .and_then(|value| value.as_str())
323 .map(str::trim)
324 .filter(|value| !value.is_empty())
325 .map(str::to_string)
326 })
327 .collect::<Vec<_>>()
328 })
329 .unwrap_or_default(),
330 assignees: item
331 .get("assignees")
332 .and_then(|value| value.as_array())
333 .map(|assignees| {
334 assignees
335 .iter()
336 .filter_map(|assignee| {
337 assignee
338 .get("login")
339 .and_then(|value| value.as_str())
340 .map(str::trim)
341 .filter(|value| !value.is_empty())
342 .map(str::to_string)
343 })
344 .collect::<Vec<_>>()
345 })
346 .unwrap_or_default(),
347 updated_at: item
348 .get("updated_at")
349 .and_then(|value| value.as_str())
350 .map(str::to_string),
351 draft: item
352 .get("draft")
353 .and_then(|value| value.as_bool())
354 .unwrap_or(false),
355 merged_at: item
356 .get("merged_at")
357 .and_then(|value| value.as_str())
358 .map(str::to_string),
359 head_ref: item
360 .get("head")
361 .and_then(|value| value.get("ref"))
362 .and_then(|value| value.as_str())
363 .unwrap_or_default()
364 .to_string(),
365 base_ref: item
366 .get("base")
367 .and_then(|value| value.get("ref"))
368 .and_then(|value| value.as_str())
369 .unwrap_or_default()
370 .to_string(),
371 })
372 .collect())
373}
374
375pub async fn create_github_issue(
376 repo: &str,
377 title: &str,
378 body: Option<&str>,
379 labels: &[String],
380 assignee: Option<&str>,
381) -> Result<GitHubIssueRef, String> {
382 let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
383 let client = reqwest::Client::new();
384 let mut payload = serde_json::json!({
385 "title": title,
386 "body": body,
387 "labels": labels,
388 });
389
390 if let Some(assignee) = assignee {
391 payload["assignees"] = serde_json::json!([assignee]);
392 }
393
394 let response = github_request(
395 client.post(format!("https://api.github.com/repos/{}/issues", repo)),
396 Some(token),
397 )
398 .json(&payload)
399 .send()
400 .await
401 .map_err(|error| format!("GitHub issue create failed: {}", error))?;
402
403 if !response.status().is_success() {
404 let status = response.status();
405 let text = response.text().await.unwrap_or_default();
406 return Err(format!("GitHub issue create failed: {} {}", status, text));
407 }
408
409 let data = response
410 .json::<serde_json::Value>()
411 .await
412 .map_err(|error| format!("GitHub issue create failed: {}", error))?;
413
414 Ok(GitHubIssueRef {
415 id: data
416 .get("id")
417 .and_then(|value| value.as_i64())
418 .unwrap_or_default()
419 .to_string(),
420 number: data
421 .get("number")
422 .and_then(|value| value.as_i64())
423 .unwrap_or_default(),
424 url: data
425 .get("html_url")
426 .and_then(|value| value.as_str())
427 .unwrap_or_default()
428 .to_string(),
429 state: data
430 .get("state")
431 .and_then(|value| value.as_str())
432 .unwrap_or("open")
433 .to_string(),
434 repo: repo.to_string(),
435 })
436}
437
438pub async fn update_github_issue(
439 repo: &str,
440 issue_number: i64,
441 title: &str,
442 body: Option<&str>,
443 labels: &[String],
444 state: &str,
445 assignee: Option<&str>,
446) -> Result<(), String> {
447 let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
448 let client = reqwest::Client::new();
449 let mut payload = serde_json::json!({
450 "title": title,
451 "body": body,
452 "labels": labels,
453 "state": state,
454 });
455
456 if let Some(assignee) = assignee {
457 payload["assignees"] = serde_json::json!([assignee]);
458 }
459
460 let response = github_request(
461 client.patch(format!(
462 "https://api.github.com/repos/{}/issues/{}",
463 repo, issue_number
464 )),
465 Some(token),
466 )
467 .json(&payload)
468 .send()
469 .await
470 .map_err(|error| format!("GitHub issue update failed: {}", error))?;
471
472 if response.status().is_success() {
473 Ok(())
474 } else {
475 let status = response.status();
476 let text = response.text().await.unwrap_or_default();
477 Err(format!("GitHub issue update failed: {} {}", status, text))
478 }
479}
480
481pub fn build_task_issue_body(objective: &str, test_cases: Option<&Vec<String>>) -> String {
482 let normalized_test_cases: Vec<&str> = test_cases
483 .into_iter()
484 .flatten()
485 .map(|value| value.trim())
486 .filter(|value| !value.is_empty())
487 .collect();
488
489 if normalized_test_cases.is_empty() {
490 return objective.trim().to_string();
491 }
492
493 let mut sections = Vec::new();
494 if !objective.trim().is_empty() {
495 sections.push(objective.trim().to_string());
496 }
497 sections.push(format!(
498 "## Test Cases\n{}",
499 normalized_test_cases
500 .into_iter()
501 .map(|value| format!("- {}", value))
502 .collect::<Vec<_>>()
503 .join("\n")
504 ));
505 sections.join("\n\n")
506}