routa_server/api/
tasks_github.rs1use 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}