1use crate::error::{MigrationError, Result};
4use reqwest::Client;
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6
7pub struct GutsClient {
9 client: Client,
10 base_url: String,
11 token: Option<String>,
12}
13
14#[derive(Debug, Serialize)]
15struct CreateRepoRequest {
16 name: String,
17 description: Option<String>,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 private: Option<bool>,
20}
21
22#[derive(Debug, Deserialize)]
23pub struct RepoResponse {
24 pub name: String,
25 pub owner: String,
26 pub clone_url: String,
27 pub description: Option<String>,
28}
29
30#[derive(Debug, Serialize)]
31pub struct CreateIssueRequest {
32 pub title: String,
33 pub body: Option<String>,
34 #[serde(skip_serializing_if = "Vec::is_empty")]
35 pub labels: Vec<String>,
36 #[serde(skip_serializing_if = "Vec::is_empty")]
37 pub assignees: Vec<String>,
38}
39
40#[derive(Debug, Deserialize)]
41pub struct IssueResponse {
42 pub number: u64,
43 pub title: String,
44 pub state: String,
45}
46
47#[derive(Debug, Serialize)]
48pub struct CreatePullRequestRequest {
49 pub title: String,
50 pub body: Option<String>,
51 pub source_branch: String,
52 pub target_branch: String,
53}
54
55#[derive(Debug, Deserialize)]
56pub struct PullRequestResponse {
57 pub number: u64,
58 pub title: String,
59 pub state: String,
60}
61
62#[derive(Debug, Serialize)]
63pub struct CreateCommentRequest {
64 pub body: String,
65}
66
67#[derive(Debug, Serialize)]
68pub struct CreateReleaseRequest {
69 pub tag_name: String,
70 pub name: String,
71 pub body: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub prerelease: Option<bool>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub draft: Option<bool>,
76}
77
78#[derive(Debug, Deserialize)]
79pub struct ReleaseResponse {
80 pub id: String,
81 pub tag_name: String,
82 pub name: String,
83}
84
85#[derive(Debug, Serialize)]
86pub struct CreateLabelRequest {
87 pub name: String,
88 pub color: String,
89 pub description: Option<String>,
90}
91
92impl GutsClient {
93 pub fn new(base_url: impl Into<String>, token: Option<String>) -> Result<Self> {
95 let client = Client::builder()
96 .user_agent("guts-migrate")
97 .timeout(std::time::Duration::from_secs(30))
98 .build()
99 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
100
101 Ok(Self {
102 client,
103 base_url: base_url.into(),
104 token,
105 })
106 }
107
108 fn auth_headers(&self) -> Option<String> {
110 self.token.as_ref().map(|t| format!("Bearer {t}"))
111 }
112
113 async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T> {
115 let url = format!("{}{path}", self.base_url);
116 let mut request = self.client.get(&url);
117
118 if let Some(auth) = self.auth_headers() {
119 request = request.header("Authorization", auth);
120 }
121
122 let response = request
123 .send()
124 .await
125 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
126
127 if !response.status().is_success() {
128 let status = response.status();
129 let body = response.text().await.unwrap_or_default();
130 return Err(MigrationError::ApiError(format!(
131 "Request failed with status {status}: {body}"
132 )));
133 }
134
135 response
136 .json()
137 .await
138 .map_err(|e| MigrationError::ApiError(e.to_string()))
139 }
140
141 async fn post<T: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<T> {
143 let url = format!("{}{path}", self.base_url);
144 let mut request = self.client.post(&url).json(body);
145
146 if let Some(auth) = self.auth_headers() {
147 request = request.header("Authorization", auth);
148 }
149
150 let response = request
151 .send()
152 .await
153 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
154
155 if !response.status().is_success() {
156 let status = response.status();
157 let body = response.text().await.unwrap_or_default();
158 return Err(MigrationError::ApiError(format!(
159 "Request failed with status {status}: {body}"
160 )));
161 }
162
163 response
164 .json()
165 .await
166 .map_err(|e| MigrationError::ApiError(e.to_string()))
167 }
168
169 async fn patch<T: DeserializeOwned, B: Serialize>(&self, path: &str, body: &B) -> Result<T> {
171 let url = format!("{}{path}", self.base_url);
172 let mut request = self.client.patch(&url).json(body);
173
174 if let Some(auth) = self.auth_headers() {
175 request = request.header("Authorization", auth);
176 }
177
178 let response = request
179 .send()
180 .await
181 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
182
183 if !response.status().is_success() {
184 let status = response.status();
185 let body = response.text().await.unwrap_or_default();
186 return Err(MigrationError::ApiError(format!(
187 "Request failed with status {status}: {body}"
188 )));
189 }
190
191 response
192 .json()
193 .await
194 .map_err(|e| MigrationError::ApiError(e.to_string()))
195 }
196
197 pub async fn create_repo(
199 &self,
200 name: &str,
201 description: Option<&str>,
202 private: bool,
203 ) -> Result<RepoResponse> {
204 self.post(
205 "/api/repos",
206 &CreateRepoRequest {
207 name: name.to_string(),
208 description: description.map(|s| s.to_string()),
209 private: Some(private),
210 },
211 )
212 .await
213 }
214
215 pub async fn get_repo(&self, owner: &str, name: &str) -> Result<RepoResponse> {
217 self.get(&format!("/api/repos/{owner}/{name}")).await
218 }
219
220 pub async fn create_issue(
222 &self,
223 owner: &str,
224 repo: &str,
225 request: &CreateIssueRequest,
226 ) -> Result<IssueResponse> {
227 self.post(&format!("/api/repos/{owner}/{repo}/issues"), request)
228 .await
229 }
230
231 pub async fn close_issue(&self, owner: &str, repo: &str, number: u64) -> Result<IssueResponse> {
233 #[derive(Serialize)]
234 struct CloseRequest {
235 state: String,
236 }
237
238 self.patch(
239 &format!("/api/repos/{owner}/{repo}/issues/{number}"),
240 &CloseRequest {
241 state: "closed".to_string(),
242 },
243 )
244 .await
245 }
246
247 pub async fn create_issue_comment(
249 &self,
250 owner: &str,
251 repo: &str,
252 number: u64,
253 body: &str,
254 ) -> Result<()> {
255 let _: serde_json::Value = self
256 .post(
257 &format!("/api/repos/{owner}/{repo}/issues/{number}/comments"),
258 &CreateCommentRequest {
259 body: body.to_string(),
260 },
261 )
262 .await?;
263 Ok(())
264 }
265
266 pub async fn create_pull_request(
268 &self,
269 owner: &str,
270 repo: &str,
271 request: &CreatePullRequestRequest,
272 ) -> Result<PullRequestResponse> {
273 self.post(&format!("/api/repos/{owner}/{repo}/pulls"), request)
274 .await
275 }
276
277 pub async fn create_pr_comment(
279 &self,
280 owner: &str,
281 repo: &str,
282 number: u64,
283 body: &str,
284 ) -> Result<()> {
285 let _: serde_json::Value = self
286 .post(
287 &format!("/api/repos/{owner}/{repo}/pulls/{number}/comments"),
288 &CreateCommentRequest {
289 body: body.to_string(),
290 },
291 )
292 .await?;
293 Ok(())
294 }
295
296 pub async fn create_release(
298 &self,
299 owner: &str,
300 repo: &str,
301 request: &CreateReleaseRequest,
302 ) -> Result<ReleaseResponse> {
303 self.post(&format!("/api/repos/{owner}/{repo}/releases"), request)
304 .await
305 }
306
307 pub async fn upload_release_asset(
309 &self,
310 owner: &str,
311 repo: &str,
312 release_id: &str,
313 name: &str,
314 content_type: &str,
315 data: Vec<u8>,
316 ) -> Result<()> {
317 let url = format!(
318 "{}/api/repos/{owner}/{repo}/releases/{release_id}/assets?name={name}",
319 self.base_url
320 );
321
322 let mut request = self
323 .client
324 .post(&url)
325 .header("Content-Type", content_type)
326 .body(data);
327
328 if let Some(auth) = self.auth_headers() {
329 request = request.header("Authorization", auth);
330 }
331
332 let response = request
333 .send()
334 .await
335 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
336
337 if !response.status().is_success() {
338 let status = response.status();
339 let body = response.text().await.unwrap_or_default();
340 return Err(MigrationError::ApiError(format!(
341 "Asset upload failed with status {status}: {body}"
342 )));
343 }
344
345 Ok(())
346 }
347
348 pub async fn create_label(
350 &self,
351 owner: &str,
352 repo: &str,
353 name: &str,
354 color: &str,
355 description: Option<&str>,
356 ) -> Result<()> {
357 let _: serde_json::Value = self
358 .post(
359 &format!("/api/repos/{owner}/{repo}/labels"),
360 &CreateLabelRequest {
361 name: name.to_string(),
362 color: color.to_string(),
363 description: description.map(|s| s.to_string()),
364 },
365 )
366 .await?;
367 Ok(())
368 }
369
370 pub async fn health_check(&self) -> Result<bool> {
372 let url = format!("{}/health/ready", self.base_url);
373 let response = self
374 .client
375 .get(&url)
376 .send()
377 .await
378 .map_err(|e| MigrationError::NetworkError(e.to_string()))?;
379
380 Ok(response.status().is_success())
381 }
382}
383
384#[cfg(test)]
385mod tests {
386 use super::*;
387
388 #[test]
389 fn test_client_creation() {
390 let client = GutsClient::new("http://localhost:8080", None);
391 assert!(client.is_ok());
392 }
393
394 #[test]
395 fn test_client_with_token() {
396 let client =
397 GutsClient::new("http://localhost:8080", Some("guts_test_token".to_string())).unwrap();
398 assert!(client.auth_headers().is_some());
399 }
400}