Skip to main content

routa_server/api/
tasks_github.rs

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}