1use std::collections::HashMap;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use futures::future::join_all;
6use pipedash_plugin_api::*;
7
8use crate::{
9 client,
10 config,
11 mapper,
12 metadata,
13 types,
14};
15
16pub struct GitLabPlugin {
17 metadata: PluginMetadata,
18 client: Option<client::GitLabClient>,
19 provider_id: Option<i64>,
20 config: HashMap<String, String>,
21 status_source: Option<StatusSource>,
22}
23
24impl Default for GitLabPlugin {
25 fn default() -> Self {
26 Self::new()
27 }
28}
29
30impl GitLabPlugin {
31 pub fn new() -> Self {
32 Self {
33 metadata: metadata::create_metadata(),
34 client: None,
35 provider_id: None,
36 config: HashMap::new(),
37 status_source: None,
38 }
39 }
40
41 fn client(&self) -> PluginResult<&client::GitLabClient> {
42 self.client
43 .as_ref()
44 .ok_or_else(|| PluginError::Internal("Plugin not initialized".to_string()))
45 }
46
47 async fn fetch_all_projects(&self) -> PluginResult<Vec<types::Project>> {
48 let client = self.client()?;
49 let mut all_projects = Vec::new();
50 let mut page = 1;
51
52 loop {
53 let params = PaginationParams {
54 page,
55 page_size: 100,
56 };
57 let response = client.list_projects(¶ms).await?;
58 if response.items.is_empty() {
59 break;
60 }
61 all_projects.extend(response.items);
62
63 if !response.has_more || page > 100 {
64 break;
65 }
66 page += 1;
67 }
68
69 if let Some(selected_paths) = config::parse_selected_items(&self.config) {
70 Ok(all_projects
71 .into_iter()
72 .filter(|p| {
73 let normalized = p.name_with_namespace.replace(" ", "");
74 selected_paths.contains(&normalized)
75 })
76 .collect())
77 } else {
78 Ok(all_projects)
79 }
80 }
81
82 async fn fetch_projects_paginated(
83 &self, params: &PaginationParams,
84 ) -> PluginResult<PaginatedResponse<types::Project>> {
85 let client = self.client()?;
86 let response = client.list_projects(params).await?;
87
88 if let Some(selected_paths) = config::parse_selected_items(&self.config) {
89 let filtered_items: Vec<_> = response
90 .items
91 .into_iter()
92 .filter(|p| {
93 let normalized = p.name_with_namespace.replace(" ", "");
94 selected_paths.contains(&normalized)
95 })
96 .collect();
97 let filtered_count = filtered_items.len();
98 Ok(PaginatedResponse::new(
99 filtered_items,
100 params.page,
101 params.page_size,
102 filtered_count,
103 ))
104 } else {
105 Ok(response)
106 }
107 }
108}
109
110fn collect_partial<T>(results: Vec<PluginResult<T>>, item: &str) -> PluginResult<Vec<T>> {
111 let mut kept = Vec::with_capacity(results.len());
112 for result in results {
113 match result {
114 Ok(value) => kept.push(value),
115 Err(e) if e.is_transient() => return Err(e),
116 Err(e) => tracing::warn!(error = %e, item, "Skipping failed GitLab item"),
117 }
118 }
119 Ok(kept)
120}
121
122#[async_trait]
123impl Plugin for GitLabPlugin {
124 fn metadata(&self) -> &PluginMetadata {
125 &self.metadata
126 }
127
128 fn provider_type(&self) -> &str {
129 "gitlab"
130 }
131
132 fn status_source(&self) -> Option<&StatusSource> {
133 self.status_source.as_ref()
134 }
135
136 fn initialize(
137 &mut self, provider_id: i64, config: HashMap<String, String>,
138 http_client: Option<std::sync::Arc<reqwest::Client>>,
139 ) -> PluginResult<()> {
140 let token = config
141 .get("token")
142 .ok_or_else(|| PluginError::InvalidConfig("Missing GitLab access token".to_string()))?
143 .to_string();
144
145 let status_source = config::status_source(&config)?;
146 let base_url = config::get_base_url(&config);
147 let api_url = config::build_api_url(&base_url);
148
149 let client = http_client.unwrap_or_else(|| {
150 std::sync::Arc::new(
151 reqwest::Client::builder()
152 .use_rustls_tls()
153 .pool_max_idle_per_host(10)
154 .timeout(Duration::from_secs(30))
155 .connect_timeout(Duration::from_secs(10))
156 .tcp_keepalive(Duration::from_secs(60))
157 .build()
158 .expect("Failed to build HTTP client"),
159 )
160 });
161
162 self.status_source = status_source;
163 self.client = Some(client::GitLabClient::new(client, api_url, token));
164 self.provider_id = Some(provider_id);
165 self.config = config;
166
167 Ok(())
168 }
169
170 async fn validate_credentials(&self) -> PluginResult<bool> {
171 let client = self.client()?;
172 client.get_user().await?;
173 Ok(true)
174 }
175
176 async fn fetch_available_pipelines(
177 &self, params: Option<PaginationParams>,
178 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
179 let params = params.unwrap_or_default();
180 let response = self.fetch_projects_paginated(¶ms).await?;
181
182 let available_pipelines = response
183 .items
184 .iter()
185 .map(mapper::map_available_pipeline)
186 .collect();
187
188 Ok(PaginatedResponse::new(
189 available_pipelines,
190 response.page,
191 response.page_size,
192 response.total_count,
193 ))
194 }
195
196 async fn fetch_organizations(&self) -> PluginResult<Vec<pipedash_plugin_api::Organization>> {
197 let client = self.client()?;
198 client.list_groups().await
199 }
200
201 async fn fetch_available_pipelines_filtered(
202 &self, org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
203 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
204 let params = params.unwrap_or_default();
205 let client = self.client()?;
206 let response = client.list_projects_filtered(org, search, ¶ms).await?;
207
208 let available_pipelines = response
209 .items
210 .iter()
211 .map(mapper::map_available_pipeline)
212 .collect();
213
214 Ok(PaginatedResponse::new(
215 available_pipelines,
216 response.page,
217 response.page_size,
218 response.total_count,
219 ))
220 }
221
222 async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
223 let provider_id = self
224 .provider_id
225 .ok_or_else(|| PluginError::Internal("Provider ID not set".to_string()))?;
226
227 let client = self.client()?;
228 let projects = self.fetch_all_projects().await?;
229
230 let pipeline_futures = projects.iter().map(|project| async move {
231 let pipelines = client.get_project_pipelines(project.id, 1).await?;
232 let latest_pipeline = pipelines.first();
233 Ok(mapper::map_pipeline(project, latest_pipeline, provider_id))
234 });
235
236 collect_partial(join_all(pipeline_futures).await, "project")
237 }
238
239 async fn fetch_run_history(
240 &self, pipeline_id: &str, limit: usize,
241 ) -> PluginResult<Vec<PipelineRun>> {
242 let (provider_id, project_id) = config::parse_pipeline_id(pipeline_id)?;
243 let client = self.client()?;
244
245 let project = client.get_project(project_id).await?;
246
247 let pipeline_list = client.get_project_pipelines(project_id, limit).await?;
248
249 let parts: Vec<&str> = project.name_with_namespace.split('/').collect();
250 let namespace = if parts.len() >= 2 {
251 Some(parts[..parts.len() - 1].join("/"))
252 } else {
253 None
254 };
255
256 let detailed_pipeline_futures = pipeline_list
257 .iter()
258 .map(|p| async move { client.get_pipeline(project_id, p.id).await });
259
260 let detailed_pipelines =
261 collect_partial(join_all(detailed_pipeline_futures).await, "pipeline run")?;
262
263 Ok(detailed_pipelines
264 .into_iter()
265 .map(|p| mapper::map_pipeline_run(&p, project_id, provider_id, namespace.as_deref()))
266 .collect())
267 }
268
269 async fn fetch_run_details(
270 &self, pipeline_id: &str, run_number: i64,
271 ) -> PluginResult<PipelineRun> {
272 let (provider_id, project_id) = config::parse_pipeline_id(pipeline_id)?;
273 let client = self.client()?;
274
275 let project = client.get_project(project_id).await?;
276 let pipeline = client.get_pipeline(project_id, run_number).await?;
277
278 let parts: Vec<&str> = project.name_with_namespace.split('/').collect();
279 let namespace = if parts.len() >= 2 {
280 Some(parts[..parts.len() - 1].join("/"))
281 } else {
282 None
283 };
284
285 Ok(mapper::map_pipeline_run(
286 &pipeline,
287 project_id,
288 provider_id,
289 namespace.as_deref(),
290 ))
291 }
292
293 async fn fetch_workflow_parameters(
294 &self, _workflow_id: &str,
295 ) -> PluginResult<Vec<WorkflowParameter>> {
296 Ok(vec![WorkflowParameter {
297 name: "ref".to_string(),
298 label: Some("Ref".to_string()),
299 description: Some("Branch, tag, or commit SHA to run pipeline on".to_string()),
300 param_type: WorkflowParameterType::String {
301 default: Some("main".to_string()),
302 },
303 required: true,
304 }])
305 }
306
307 async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<String> {
308 let (_provider_id, project_id) = config::parse_pipeline_id(¶ms.workflow_id)?;
309
310 let client = self.client()?;
311
312 let ref_name = params
313 .inputs
314 .as_ref()
315 .and_then(|inputs| inputs.get("ref"))
316 .and_then(|v| v.as_str())
317 .unwrap_or("main")
318 .to_string();
319
320 let variables = params.inputs.as_ref().and_then(|inputs| {
321 inputs.get("variables").and_then(|vars| {
322 vars.as_object().map(|obj| {
323 obj.iter()
324 .map(|(key, value)| types::PipelineVariable {
325 key: key.clone(),
326 value: value.as_str().unwrap_or("").to_string(),
327 variable_type: Some("env_var".to_string()),
328 })
329 .collect()
330 })
331 })
332 });
333
334 let pipeline = client
335 .trigger_pipeline(project_id, ref_name, variables)
336 .await?;
337 Ok(pipeline.web_url)
338 }
339
340 async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
341 let (_, project_id) = config::parse_pipeline_id(pipeline_id)?;
342 let client = self.client()?;
343 client.cancel_pipeline(project_id, run_number).await?;
344 Ok(())
345 }
346
347 async fn fetch_agents(&self) -> PluginResult<Vec<BuildAgent>> {
348 Err(PluginError::NotSupported(
349 "GitLab runners monitoring not implemented".to_string(),
350 ))
351 }
352
353 async fn fetch_artifacts(&self, _run_id: &str) -> PluginResult<Vec<BuildArtifact>> {
354 Err(PluginError::NotSupported(
355 "Artifacts download not implemented".to_string(),
356 ))
357 }
358
359 async fn fetch_queues(&self) -> PluginResult<Vec<BuildQueue>> {
360 Err(PluginError::NotSupported(
361 "Build queues not supported by GitLab".to_string(),
362 ))
363 }
364
365 fn get_migrations(&self) -> Vec<String> {
366 vec![]
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use tokio::io::{
373 AsyncReadExt,
374 AsyncWriteExt,
375 };
376
377 use super::*;
378
379 async fn stub_gitlab() -> (GitLabPlugin, tokio::task::JoinHandle<()>) {
380 let _ = rustls::crypto::ring::default_provider().install_default();
381 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
382 let addr = listener.local_addr().unwrap();
383 let server = tokio::spawn(async move {
384 loop {
385 let (mut stream, _) = listener.accept().await.unwrap();
386 let mut request = Vec::new();
387 let mut buffer = [0; 4096];
388 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
389 let count = stream.read(&mut buffer).await.unwrap();
390 if count == 0 {
391 break;
392 }
393 request.extend_from_slice(&buffer[..count]);
394 }
395 let request = String::from_utf8(request).unwrap();
396 let target = request.split_whitespace().nth(1).unwrap();
397 let url = reqwest::Url::parse(&format!("http://{addr}{target}")).unwrap();
398 let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
399 let mut total = 0;
400 let mut next_page = String::new();
401 let body = match url.path() {
402 "/api/v4/user" => serde_json::json!({
403 "id": 7, "username": "alice", "name": "Alice"
404 }),
405 "/api/v4/projects/102/pipelines" => serde_json::json!([]),
406 "/api/v4/groups/42/projects"
407 | "/api/v4/projects"
408 | "/api/v4/users/7/projects" => {
409 let recursive = url.path() == "/api/v4/projects"
410 || query
411 .get("include_subgroups")
412 .is_some_and(|value| value == "true");
413 let projects: Vec<_> = [
414 (101, "team / root"),
415 (102, "team / backend / api"),
416 (103, "team / frontend / api"),
417 (104, "team / backend / nested / worker"),
418 ]
419 .into_iter()
420 .filter(|(_, path)| recursive || *path == "team / root")
421 .filter(|(_, path)| {
422 query
423 .get("search")
424 .is_none_or(|search| path.contains(search))
425 })
426 .map(|(id, path)| {
427 serde_json::json!({
428 "id": id,
429 "name": path.rsplit(" / ").next().unwrap(),
430 "name_with_namespace": path,
431 "web_url": format!("http://{addr}/{}", path.replace(" / ", "/"))
432 })
433 })
434 .collect();
435 total = projects.len();
436 let page = query.get("page").unwrap().parse::<usize>().unwrap();
437 let page_size = query.get("per_page").unwrap().parse::<usize>().unwrap();
438 serde_json::Value::Array(
439 projects
440 .into_iter()
441 .skip((page - 1) * page_size)
442 .take(page_size)
443 .collect(),
444 )
445 }
446 "/api/v4/groups" => {
447 let groups: Vec<_> = (1..=105)
448 .map(|id| {
449 let (name, full_path) = match id {
450 1 => ("backend".to_string(), "team-a/backend".to_string()),
451 2 => ("backend".to_string(), "team-b/backend".to_string()),
452 _ => (format!("group-{id}"), format!("group-{id}")),
453 };
454 serde_json::json!({"id": id, "name": name, "full_path": full_path})
455 })
456 .collect();
457 total = groups.len();
458 let page = query.get("page").map_or(1, |p| p.parse::<usize>().unwrap());
459 let page_size = query
460 .get("per_page")
461 .map_or(20, |p| p.parse::<usize>().unwrap())
462 .min(100);
463 if page < total.div_ceil(page_size) {
464 next_page = (page + 1).to_string();
465 }
466 serde_json::Value::Array(
467 groups
468 .into_iter()
469 .skip((page - 1) * page_size)
470 .take(page_size)
471 .collect(),
472 )
473 }
474 path => panic!("Unexpected stub path: {path}"),
475 }
476 .to_string();
477 let response = format!(
478 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-Total: {total}\r\nX-Next-Page: {next_page}\r\nConnection: close\r\n\r\n{body}",
479 body.len()
480 );
481 stream.write_all(response.as_bytes()).await.unwrap();
482 }
483 });
484 let mut plugin = GitLabPlugin::new();
485 plugin
486 .initialize(
487 1,
488 HashMap::from([
489 ("base_url".to_string(), format!("http://{addr}")),
490 ("token".to_string(), "stub-token".to_string()),
491 ]),
492 None,
493 )
494 .unwrap();
495 (plugin, server)
496 }
497
498 #[tokio::test]
499 async fn parent_group_discovers_nested_projects_with_distinct_paths() {
500 let (plugin, server) = stub_gitlab().await;
501 let response = plugin
502 .fetch_available_pipelines_filtered(Some("42".to_string()), None, None)
503 .await
504 .unwrap();
505
506 let paths: Vec<_> = response.items.iter().map(|item| item.id.as_str()).collect();
507 assert_eq!(
508 paths,
509 [
510 "team/root",
511 "team/backend/api",
512 "team/frontend/api",
513 "team/backend/nested/worker"
514 ]
515 );
516 let displayed_paths: Vec<_> = response
517 .items
518 .iter()
519 .map(|item| {
520 format!(
521 "{}/{}",
522 item.organization.as_deref().unwrap(),
523 item.repository.as_deref().unwrap()
524 )
525 .replace(' ', "")
526 })
527 .collect();
528 assert_eq!(displayed_paths, paths);
529 server.abort();
530 }
531
532 #[tokio::test]
533 async fn subgroup_search_keeps_pagination_and_total_count() {
534 let (plugin, server) = stub_gitlab().await;
535 for (page, expected, has_more) in [
536 (1, vec!["team/backend/api"], true),
537 (2, vec!["team/frontend/api"], true),
538 (3, vec![], false),
539 ] {
540 let response = plugin
541 .fetch_available_pipelines_filtered(
542 Some("42".to_string()),
543 Some("api".to_string()),
544 Some(PaginationParams { page, page_size: 1 }),
545 )
546 .await
547 .unwrap();
548
549 assert_eq!(
550 response
551 .items
552 .iter()
553 .map(|item| item.id.as_str())
554 .collect::<Vec<_>>(),
555 expected
556 );
557 assert_eq!(response.total_count, 2);
558 assert_eq!(response.page, page);
559 assert_eq!(response.has_more, has_more);
560 }
561 server.abort();
562 }
563
564 #[tokio::test]
565 async fn saved_subgroup_selection_still_matches_during_refresh() {
566 let (mut plugin, server) = stub_gitlab().await;
567 plugin
568 .config
569 .insert("selected_items".to_string(), "team/backend/api".to_string());
570 let pipelines = plugin.fetch_pipelines().await.unwrap();
571
572 assert_eq!(
573 pipelines
574 .iter()
575 .map(|pipeline| pipeline.id.as_str())
576 .collect::<Vec<_>>(),
577 ["gitlab__1__102"]
578 );
579 assert_eq!(pipelines[0].repository, "team/backend/api");
580 server.abort();
581 }
582
583 #[tokio::test]
584 async fn group_picker_lists_every_group_by_full_path() {
585 let (plugin, server) = stub_gitlab().await;
586 let orgs = plugin.fetch_organizations().await.unwrap();
587 server.abort();
588
589 assert_eq!(orgs.len(), 106);
590 assert_eq!(orgs[0].name, "alice");
591 assert_eq!(orgs[1].name, "team-a/backend");
592 assert_eq!(orgs[2].name, "team-b/backend");
593 assert!(orgs.iter().any(|org| org.id == "105"));
594 }
595}