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        .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}