Skip to main content

pipedash_plugin_gitlab/
plugin.rs

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(&params).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(&params).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, &params).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(&params.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}