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}
87
88fn github_request(
89 request: reqwest::RequestBuilder,
90 token: Option<String>,
91) -> reqwest::RequestBuilder {
92 let builder = request
93 .header(ACCEPT, "application/vnd.github+json")
94 .header(CONTENT_TYPE, "application/json")
95 .header(USER_AGENT, "routa-rust-kanban")
96 .header("X-GitHub-Api-Version", "2022-11-28");
97
98 match token {
99 Some(token) => builder.header(AUTHORIZATION, format!("token {}", token)),
100 None => builder,
101 }
102}
103
104pub async fn list_github_issues(
105 repo: &str,
106 state: Option<&str>,
107 per_page: Option<usize>,
108) -> Result<Vec<GitHubIssueListItem>, String> {
109 let client = reqwest::Client::new();
110 let token = github_token();
111 let per_page = per_page.unwrap_or(50).clamp(1, 100);
112 let state = state.unwrap_or("open");
113 let url = format!(
114 "https://api.github.com/repos/{repo}/issues?state={state}&sort=updated&direction=desc&per_page={per_page}"
115 );
116
117 let response = github_request(client.get(url), token)
118 .send()
119 .await
120 .map_err(|error| format!("GitHub issue list failed: {}", error))?;
121
122 if !response.status().is_success() {
123 let status = response.status();
124 let text = response.text().await.unwrap_or_default();
125 return Err(format!("GitHub issue list failed: {} {}", status, text));
126 }
127
128 let data = response
129 .json::<Vec<serde_json::Value>>()
130 .await
131 .map_err(|error| format!("GitHub issue list failed: {}", error))?;
132
133 Ok(data
134 .into_iter()
135 .filter(|item| item.get("pull_request").is_none())
136 .map(|item| GitHubIssueListItem {
137 id: item
138 .get("id")
139 .and_then(|value| value.as_i64())
140 .unwrap_or_default()
141 .to_string(),
142 number: item
143 .get("number")
144 .and_then(|value| value.as_i64())
145 .unwrap_or_default(),
146 title: item
147 .get("title")
148 .and_then(|value| value.as_str())
149 .unwrap_or_default()
150 .to_string(),
151 body: item
152 .get("body")
153 .and_then(|value| value.as_str())
154 .map(str::to_string),
155 url: item
156 .get("html_url")
157 .and_then(|value| value.as_str())
158 .unwrap_or_default()
159 .to_string(),
160 state: item
161 .get("state")
162 .and_then(|value| value.as_str())
163 .unwrap_or("open")
164 .to_string(),
165 labels: item
166 .get("labels")
167 .and_then(|value| value.as_array())
168 .map(|labels| {
169 labels
170 .iter()
171 .filter_map(|label| {
172 label
173 .get("name")
174 .and_then(|value| value.as_str())
175 .map(str::trim)
176 .filter(|value| !value.is_empty())
177 .map(str::to_string)
178 })
179 .collect::<Vec<_>>()
180 })
181 .unwrap_or_default(),
182 assignees: item
183 .get("assignees")
184 .and_then(|value| value.as_array())
185 .map(|assignees| {
186 assignees
187 .iter()
188 .filter_map(|assignee| {
189 assignee
190 .get("login")
191 .and_then(|value| value.as_str())
192 .map(str::trim)
193 .filter(|value| !value.is_empty())
194 .map(str::to_string)
195 })
196 .collect::<Vec<_>>()
197 })
198 .unwrap_or_default(),
199 updated_at: item
200 .get("updated_at")
201 .and_then(|value| value.as_str())
202 .map(str::to_string),
203 })
204 .collect())
205}
206
207pub async fn list_github_pulls(
208 repo: &str,
209 state: Option<&str>,
210 per_page: Option<usize>,
211) -> Result<Vec<GitHubPullListItem>, String> {
212 let client = reqwest::Client::new();
213 let token = github_token();
214 let per_page = per_page.unwrap_or(50).clamp(1, 100);
215 let state = state.unwrap_or("open");
216 let url = format!(
217 "https://api.github.com/repos/{repo}/pulls?state={state}&sort=updated&direction=desc&per_page={per_page}"
218 );
219
220 let response = github_request(client.get(url), token)
221 .send()
222 .await
223 .map_err(|error| format!("GitHub pull request list failed: {}", error))?;
224
225 if !response.status().is_success() {
226 let status = response.status();
227 let text = response.text().await.unwrap_or_default();
228 return Err(format!(
229 "GitHub pull request list failed: {} {}",
230 status, text
231 ));
232 }
233
234 let data = response
235 .json::<Vec<serde_json::Value>>()
236 .await
237 .map_err(|error| format!("GitHub pull request list failed: {}", error))?;
238
239 Ok(data
240 .into_iter()
241 .map(|item| GitHubPullListItem {
242 id: item
243 .get("id")
244 .and_then(|value| value.as_i64())
245 .unwrap_or_default()
246 .to_string(),
247 number: item
248 .get("number")
249 .and_then(|value| value.as_i64())
250 .unwrap_or_default(),
251 title: item
252 .get("title")
253 .and_then(|value| value.as_str())
254 .unwrap_or_default()
255 .to_string(),
256 body: item
257 .get("body")
258 .and_then(|value| value.as_str())
259 .map(str::to_string),
260 url: item
261 .get("html_url")
262 .and_then(|value| value.as_str())
263 .unwrap_or_default()
264 .to_string(),
265 state: item
266 .get("state")
267 .and_then(|value| value.as_str())
268 .unwrap_or("open")
269 .to_string(),
270 labels: item
271 .get("labels")
272 .and_then(|value| value.as_array())
273 .map(|labels| {
274 labels
275 .iter()
276 .filter_map(|label| {
277 label
278 .get("name")
279 .and_then(|value| value.as_str())
280 .map(str::trim)
281 .filter(|value| !value.is_empty())
282 .map(str::to_string)
283 })
284 .collect::<Vec<_>>()
285 })
286 .unwrap_or_default(),
287 assignees: item
288 .get("assignees")
289 .and_then(|value| value.as_array())
290 .map(|assignees| {
291 assignees
292 .iter()
293 .filter_map(|assignee| {
294 assignee
295 .get("login")
296 .and_then(|value| value.as_str())
297 .map(str::trim)
298 .filter(|value| !value.is_empty())
299 .map(str::to_string)
300 })
301 .collect::<Vec<_>>()
302 })
303 .unwrap_or_default(),
304 updated_at: item
305 .get("updated_at")
306 .and_then(|value| value.as_str())
307 .map(str::to_string),
308 draft: item
309 .get("draft")
310 .and_then(|value| value.as_bool())
311 .unwrap_or(false),
312 merged_at: item
313 .get("merged_at")
314 .and_then(|value| value.as_str())
315 .map(str::to_string),
316 head_ref: item
317 .get("head")
318 .and_then(|value| value.get("ref"))
319 .and_then(|value| value.as_str())
320 .unwrap_or_default()
321 .to_string(),
322 base_ref: item
323 .get("base")
324 .and_then(|value| value.get("ref"))
325 .and_then(|value| value.as_str())
326 .unwrap_or_default()
327 .to_string(),
328 })
329 .collect())
330}
331
332pub async fn create_github_issue(
333 repo: &str,
334 title: &str,
335 body: Option<&str>,
336 labels: &[String],
337 assignee: Option<&str>,
338) -> Result<GitHubIssueRef, String> {
339 let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
340 let client = reqwest::Client::new();
341 let mut payload = serde_json::json!({
342 "title": title,
343 "body": body,
344 "labels": labels,
345 });
346
347 if let Some(assignee) = assignee {
348 payload["assignees"] = serde_json::json!([assignee]);
349 }
350
351 let response = github_request(
352 client.post(format!("https://api.github.com/repos/{}/issues", repo)),
353 Some(token),
354 )
355 .json(&payload)
356 .send()
357 .await
358 .map_err(|error| format!("GitHub issue create failed: {}", error))?;
359
360 if !response.status().is_success() {
361 let status = response.status();
362 let text = response.text().await.unwrap_or_default();
363 return Err(format!("GitHub issue create failed: {} {}", status, text));
364 }
365
366 let data = response
367 .json::<serde_json::Value>()
368 .await
369 .map_err(|error| format!("GitHub issue create failed: {}", error))?;
370
371 Ok(GitHubIssueRef {
372 id: data
373 .get("id")
374 .and_then(|value| value.as_i64())
375 .unwrap_or_default()
376 .to_string(),
377 number: data
378 .get("number")
379 .and_then(|value| value.as_i64())
380 .unwrap_or_default(),
381 url: data
382 .get("html_url")
383 .and_then(|value| value.as_str())
384 .unwrap_or_default()
385 .to_string(),
386 state: data
387 .get("state")
388 .and_then(|value| value.as_str())
389 .unwrap_or("open")
390 .to_string(),
391 repo: repo.to_string(),
392 })
393}
394
395pub async fn update_github_issue(
396 repo: &str,
397 issue_number: i64,
398 title: &str,
399 body: Option<&str>,
400 labels: &[String],
401 state: &str,
402 assignee: Option<&str>,
403) -> Result<(), String> {
404 let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
405 let client = reqwest::Client::new();
406 let mut payload = serde_json::json!({
407 "title": title,
408 "body": body,
409 "labels": labels,
410 "state": state,
411 });
412
413 if let Some(assignee) = assignee {
414 payload["assignees"] = serde_json::json!([assignee]);
415 }
416
417 let response = github_request(
418 client.patch(format!(
419 "https://api.github.com/repos/{}/issues/{}",
420 repo, issue_number
421 )),
422 Some(token),
423 )
424 .json(&payload)
425 .send()
426 .await
427 .map_err(|error| format!("GitHub issue update failed: {}", error))?;
428
429 if response.status().is_success() {
430 Ok(())
431 } else {
432 let status = response.status();
433 let text = response.text().await.unwrap_or_default();
434 Err(format!("GitHub issue update failed: {} {}", status, text))
435 }
436}
437
438pub fn build_task_issue_body(objective: &str, test_cases: Option<&Vec<String>>) -> String {
439 let normalized_test_cases: Vec<&str> = test_cases
440 .into_iter()
441 .flatten()
442 .map(|value| value.trim())
443 .filter(|value| !value.is_empty())
444 .collect();
445
446 if normalized_test_cases.is_empty() {
447 return objective.trim().to_string();
448 }
449
450 let mut sections = Vec::new();
451 if !objective.trim().is_empty() {
452 sections.push(objective.trim().to_string());
453 }
454 sections.push(format!(
455 "## Test Cases\n{}",
456 normalized_test_cases
457 .into_iter()
458 .map(|value| format!("- {}", value))
459 .collect::<Vec<_>>()
460 .join("\n")
461 ));
462 sections.join("\n\n")
463}