ironflow_ops_gitlab/
client.rs1use gitlab::api::{Endpoint, Pageable, Pagination};
4use gitlab::{AsyncGitlab, GitlabBuilder};
5use ironflow_core::error::OperationError;
6use ironflow_core::operation::OperationContext;
7
8use crate::operation::GitLabOp;
9use crate::paged_operation::GitLabPagedOp;
10
11pub struct GitLab {
35 inner: AsyncGitlab,
36}
37
38impl GitLab {
39 pub async fn from_context(ctx: &OperationContext) -> Result<Self, OperationError> {
48 Self::from_context_with_host(ctx, "gitlab.com").await
49 }
50
51 pub async fn from_context_with_host(
58 ctx: &OperationContext,
59 host: &str,
60 ) -> Result<Self, OperationError> {
61 let secret =
62 ctx.secrets()
63 .get("gitlab_token")
64 .await?
65 .ok_or_else(|| OperationError::Secret {
66 message: "gitlab_token secret not found".to_string(),
67 })?;
68 Self::new(&secret.value, host).await
69 }
70
71 pub async fn new(token: &str, host: &str) -> Result<Self, OperationError> {
88 let inner = GitlabBuilder::new(host, token)
89 .build_async()
90 .await
91 .map_err(|e| OperationError::Http {
92 status: None,
93 message: e.to_string(),
94 })?;
95 Ok(Self { inner })
96 }
97
98 pub fn client(&self) -> &AsyncGitlab {
103 &self.inner
104 }
105
106 pub fn op<E>(&self, endpoint: E) -> GitLabOp<E> {
125 GitLabOp::new(self.inner.clone(), endpoint)
126 }
127
128 pub fn paged_op<E>(&self, endpoint: E, pagination: Pagination) -> GitLabPagedOp<E>
152 where
153 E: Pageable + Endpoint + Send + Sync,
154 {
155 GitLabPagedOp::new(self.inner.clone(), endpoint, pagination)
156 }
157}
158
159impl From<AsyncGitlab> for GitLab {
183 fn from(inner: AsyncGitlab) -> Self {
184 Self { inner }
185 }
186}
187
188impl std::fmt::Debug for GitLab {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 f.debug_struct("GitLab")
191 .field("client", &"[AsyncGitlab]")
192 .finish()
193 }
194}
195
196#[cfg(test)]
197mod tests {
198 use std::sync::Arc;
199
200 use gitlab::GitlabBuilder;
201 use gitlab::api::projects;
202 use gitlab::api::projects::merge_requests::MergeRequests;
203 use ironflow_core::operation::{NoopSecretResolver, Operation, OperationContext};
204 use serde_json::{Value, json};
205 use wiremock::matchers::{method, path, query_param};
206 use wiremock::{Mock, MockServer, ResponseTemplate};
207
208 use super::*;
209
210 fn ctx() -> OperationContext {
211 OperationContext::new(Arc::new(NoopSecretResolver))
212 }
213
214 async fn insecure_client(server: &MockServer) -> AsyncGitlab {
215 Mock::given(method("GET"))
216 .and(path("/api/v4/user"))
217 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": 1})))
218 .mount(server)
219 .await;
220
221 GitlabBuilder::new(server.address().to_string(), "token")
222 .insecure()
223 .build_async()
224 .await
225 .unwrap()
226 }
227
228 #[tokio::test]
229 #[ignore]
230 async fn new_builds_with_valid_host() {
231 let gitlab = GitLab::new("glpat-xxxx", "gitlab.com").await;
232 assert!(gitlab.is_ok());
233 }
234
235 #[tokio::test]
236 #[ignore]
237 async fn debug_does_not_leak_token() {
238 let gitlab = GitLab::new("super-secret", "gitlab.com").await.unwrap();
239 let debug = format!("{gitlab:?}");
240 assert!(!debug.contains("super-secret"));
241 }
242
243 #[tokio::test]
244 async fn from_context_fails_when_token_missing() {
245 let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
246 let err = GitLab::from_context(&ctx).await.unwrap_err();
247 assert!(err.to_string().contains("gitlab_token"));
248 }
249
250 #[tokio::test]
251 async fn from_async_gitlab_hits_mock_server() {
252 let server = MockServer::start().await;
253 Mock::given(method("GET"))
254 .and(path("/api/v4/user"))
255 .respond_with(
256 ResponseTemplate::new(200).set_body_string(r#"{"id":1,"username":"demo"}"#),
257 )
258 .mount(&server)
259 .await;
260 Mock::given(method("GET"))
261 .and(path("/api/v4/projects/42"))
262 .respond_with(ResponseTemplate::new(200).set_body_string(r#"{"id":42,"name":"demo"}"#))
263 .mount(&server)
264 .await;
265
266 let client = GitlabBuilder::new(server.address().to_string(), "test-token")
267 .insecure()
268 .build_async()
269 .await
270 .unwrap();
271 let gitlab: GitLab = client.into();
272
273 let endpoint = projects::Project::builder().project(42).build().unwrap();
274 let op = gitlab.op(endpoint);
275 let ctx = OperationContext::new(Arc::new(NoopSecretResolver));
276 let result = op.execute(&ctx).await.unwrap();
277
278 assert_eq!(result["id"], 42);
279 assert_eq!(result["name"], "demo");
280
281 let requests = server.received_requests().await.unwrap();
282 assert_eq!(requests.len(), 2);
283 let paths: Vec<&str> = requests.iter().map(|r| r.url.path()).collect();
284 assert!(paths.contains(&"/api/v4/user"));
285 assert!(paths.contains(&"/api/v4/projects/42"));
286 }
287
288 #[tokio::test]
289 async fn paged_op_all_pagination_concatenates_every_page() {
290 let server = MockServer::start().await;
291 let page1: Vec<Value> = (0..100).map(|i| json!({"iid": i})).collect();
292 let page2: Vec<Value> = vec![
293 json!({"iid": 100}),
294 json!({"iid": 101}),
295 json!({"iid": 102}),
296 ];
297
298 Mock::given(method("GET"))
299 .and(path("/api/v4/projects/42/merge_requests"))
300 .and(query_param("page", "1"))
301 .and(query_param("per_page", "100"))
302 .respond_with(ResponseTemplate::new(200).set_body_json(&page1))
303 .mount(&server)
304 .await;
305 Mock::given(method("GET"))
306 .and(path("/api/v4/projects/42/merge_requests"))
307 .and(query_param("page", "2"))
308 .and(query_param("per_page", "100"))
309 .respond_with(ResponseTemplate::new(200).set_body_json(&page2))
310 .mount(&server)
311 .await;
312
313 let client = insecure_client(&server).await;
314 let gitlab = GitLab { inner: client };
315 let endpoint = MergeRequests::builder().project(42).build().unwrap();
316 let op = gitlab.paged_op(endpoint, Pagination::All);
317
318 let result = op.execute(&ctx()).await.unwrap();
319 let items = result.as_array().unwrap();
320 assert_eq!(items.len(), 103);
321 assert_eq!(items[0]["iid"], 0);
322 assert_eq!(items[100]["iid"], 100);
323 assert_eq!(items[102]["iid"], 102);
324 }
325
326 #[tokio::test]
327 async fn paged_op_limit_truncates_output() {
328 let server = MockServer::start().await;
329 let page: Vec<Value> = vec![json!({"iid": 0})];
330
331 Mock::given(method("GET"))
332 .and(path("/api/v4/projects/42/merge_requests"))
333 .and(query_param("page", "1"))
334 .and(query_param("per_page", "1"))
335 .respond_with(ResponseTemplate::new(200).set_body_json(&page))
336 .mount(&server)
337 .await;
338
339 let client = insecure_client(&server).await;
340 let gitlab = GitLab { inner: client };
341 let endpoint = MergeRequests::builder().project(42).build().unwrap();
342 let op = gitlab.paged_op(endpoint, Pagination::Limit(1));
343
344 let result = op.execute(&ctx()).await.unwrap();
345 let items = result.as_array().unwrap();
346 assert_eq!(items.len(), 1);
347 }
348
349 #[tokio::test]
350 async fn paged_op_kind_and_input() {
351 let server = MockServer::start().await;
352 let client = insecure_client(&server).await;
353 let gitlab = GitLab { inner: client };
354 let endpoint = MergeRequests::builder().project(42).build().unwrap();
355 let op = gitlab.paged_op(endpoint, Pagination::Limit(5));
356
357 assert_eq!(op.kind(), "gitlab");
358 let input = op.input().unwrap();
359 assert_eq!(input["endpoint"], "projects/42/merge_requests");
360 assert_eq!(input["pagination"], "limit(5)");
361 }
362}