Skip to main content

guts_migrate/
client.rs

1//! Guts API client for migration operations.
2
3use crate::error::{MigrationError, Result};
4use reqwest::Client;
5use serde::{de::DeserializeOwned, Deserialize, Serialize};
6
7/// Client for interacting with the Guts API during migration.
8pub 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    /// Create a new Guts client.
94    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    /// Get authorization headers.
109    fn auth_headers(&self) -> Option<String> {
110        self.token.as_ref().map(|t| format!("Bearer {t}"))
111    }
112
113    /// Make a GET request.
114    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    /// Make a POST request.
142    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    /// Make a PATCH request.
170    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    /// Create a repository on Guts.
198    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    /// Get repository information.
216    pub async fn get_repo(&self, owner: &str, name: &str) -> Result<RepoResponse> {
217        self.get(&format!("/api/repos/{owner}/{name}")).await
218    }
219
220    /// Create an issue.
221    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    /// Close an issue.
232    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    /// Create a comment on an issue.
248    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    /// Create a pull request.
267    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    /// Create a comment on a pull request.
278    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    /// Create a release.
297    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    /// Upload a release asset.
308    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    /// Create a label.
349    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    /// Check if the Guts node is healthy.
371    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}