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