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
13pub fn resolve_github_repo(repo_path: Option<&str>) -> Option<String> {
14    let repo_path = repo_path?;
15    let output = Command::new("git")
16        .args(["config", "--get", "remote.origin.url"])
17        .current_dir(repo_path)
18        .output()
19        .ok()?;
20
21    if !output.status.success() {
22        return None;
23    }
24
25    let remote = String::from_utf8_lossy(&output.stdout).trim().to_string();
26    let parsed = crate::git::parse_github_url(&remote)?;
27    Some(format!("{}/{}", parsed.owner, parsed.repo))
28}
29
30fn github_token() -> Option<String> {
31    std::env::var("GITHUB_TOKEN")
32        .ok()
33        .filter(|value| !value.is_empty())
34        .or_else(|| {
35            std::env::var("GH_TOKEN")
36                .ok()
37                .filter(|value| !value.is_empty())
38        })
39}
40
41pub async fn create_github_issue(
42    repo: &str,
43    title: &str,
44    body: Option<&str>,
45    labels: &[String],
46    assignee: Option<&str>,
47) -> Result<GitHubIssueRef, String> {
48    let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
49    let client = reqwest::Client::new();
50    let mut payload = serde_json::json!({
51        "title": title,
52        "body": body,
53        "labels": labels,
54    });
55
56    if let Some(assignee) = assignee {
57        payload["assignees"] = serde_json::json!([assignee]);
58    }
59
60    let response = client
61        .post(format!("https://api.github.com/repos/{}/issues", repo))
62        .header(AUTHORIZATION, format!("token {}", token))
63        .header(ACCEPT, "application/vnd.github+json")
64        .header(CONTENT_TYPE, "application/json")
65        .header(USER_AGENT, "routa-rust-kanban")
66        .header("X-GitHub-Api-Version", "2022-11-28")
67        .json(&payload)
68        .send()
69        .await
70        .map_err(|error| format!("GitHub issue create failed: {}", error))?;
71
72    if !response.status().is_success() {
73        let status = response.status();
74        let text = response.text().await.unwrap_or_default();
75        return Err(format!("GitHub issue create failed: {} {}", status, text));
76    }
77
78    let data = response
79        .json::<serde_json::Value>()
80        .await
81        .map_err(|error| format!("GitHub issue create failed: {}", error))?;
82
83    Ok(GitHubIssueRef {
84        id: data
85            .get("id")
86            .and_then(|value| value.as_i64())
87            .unwrap_or_default()
88            .to_string(),
89        number: data
90            .get("number")
91            .and_then(|value| value.as_i64())
92            .unwrap_or_default(),
93        url: data
94            .get("html_url")
95            .and_then(|value| value.as_str())
96            .unwrap_or_default()
97            .to_string(),
98        state: data
99            .get("state")
100            .and_then(|value| value.as_str())
101            .unwrap_or("open")
102            .to_string(),
103        repo: repo.to_string(),
104    })
105}
106
107pub async fn update_github_issue(
108    repo: &str,
109    issue_number: i64,
110    title: &str,
111    body: Option<&str>,
112    labels: &[String],
113    state: &str,
114    assignee: Option<&str>,
115) -> Result<(), String> {
116    let token = github_token().ok_or_else(|| "GITHUB_TOKEN is not configured.".to_string())?;
117    let client = reqwest::Client::new();
118    let mut payload = serde_json::json!({
119        "title": title,
120        "body": body,
121        "labels": labels,
122        "state": state,
123    });
124
125    if let Some(assignee) = assignee {
126        payload["assignees"] = serde_json::json!([assignee]);
127    }
128
129    let response = client
130        .patch(format!(
131            "https://api.github.com/repos/{}/issues/{}",
132            repo, issue_number
133        ))
134        .header(AUTHORIZATION, format!("token {}", token))
135        .header(ACCEPT, "application/vnd.github+json")
136        .header(CONTENT_TYPE, "application/json")
137        .header(USER_AGENT, "routa-rust-kanban")
138        .header("X-GitHub-Api-Version", "2022-11-28")
139        .json(&payload)
140        .send()
141        .await
142        .map_err(|error| format!("GitHub issue update failed: {}", error))?;
143
144    if response.status().is_success() {
145        Ok(())
146    } else {
147        let status = response.status();
148        let text = response.text().await.unwrap_or_default();
149        Err(format!("GitHub issue update failed: {} {}", status, text))
150    }
151}
152
153pub fn build_task_issue_body(objective: &str, test_cases: Option<&Vec<String>>) -> String {
154    let normalized_test_cases: Vec<&str> = test_cases
155        .into_iter()
156        .flatten()
157        .map(|value| value.trim())
158        .filter(|value| !value.is_empty())
159        .collect();
160
161    if normalized_test_cases.is_empty() {
162        return objective.trim().to_string();
163    }
164
165    let mut sections = Vec::new();
166    if !objective.trim().is_empty() {
167        sections.push(objective.trim().to_string());
168    }
169    sections.push(format!(
170        "## Test Cases\n{}",
171        normalized_test_cases
172            .into_iter()
173            .map(|value| format!("- {}", value))
174            .collect::<Vec<_>>()
175            .join("\n")
176    ));
177    sections.join("\n\n")
178}