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::log_dialect::GitlabLogDialect;
9use crate::{
10    client,
11    config,
12    mapper,
13    metadata,
14    types,
15};
16
17#[derive(serde::Serialize, serde::Deserialize)]
18struct LogPosition {
19    job: String,
20    offset: u64,
21}
22
23pub struct GitLabPlugin {
24    metadata: PluginMetadata,
25    client: Option<client::GitLabClient>,
26    provider_id: Option<i64>,
27    config: HashMap<String, String>,
28    status_source: Option<StatusSource>,
29}
30
31impl Default for GitLabPlugin {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl GitLabPlugin {
38    pub fn new() -> Self {
39        Self {
40            metadata: metadata::create_metadata(),
41            client: None,
42            provider_id: None,
43            config: HashMap::new(),
44            status_source: None,
45        }
46    }
47
48    fn client(&self) -> PluginResult<&client::GitLabClient> {
49        self.client
50            .as_ref()
51            .ok_or_else(|| PluginError::Internal("Plugin not initialized".to_string()))
52    }
53
54    async fn fetch_all_projects(&self) -> PluginResult<Vec<types::Project>> {
55        let client = self.client()?;
56        let mut all_projects = Vec::new();
57        let mut page = 1;
58
59        loop {
60            let params = PaginationParams {
61                page,
62                page_size: 100,
63            };
64            let response = client.list_projects(&params).await?;
65            if response.items.is_empty() {
66                break;
67            }
68            all_projects.extend(response.items);
69
70            if !response.has_more || page > 100 {
71                break;
72            }
73            page += 1;
74        }
75
76        if let Some(selected_paths) = config::parse_selected_items(&self.config) {
77            Ok(all_projects
78                .into_iter()
79                .filter(|p| {
80                    let normalized = p.name_with_namespace.replace(" ", "");
81                    selected_paths.contains(&normalized)
82                })
83                .collect())
84        } else {
85            Ok(all_projects)
86        }
87    }
88
89    async fn fetch_projects_paginated(
90        &self, params: &PaginationParams,
91    ) -> PluginResult<PaginatedResponse<types::Project>> {
92        let client = self.client()?;
93        let response = client.list_projects(params).await?;
94
95        if let Some(selected_paths) = config::parse_selected_items(&self.config) {
96            let filtered_items: Vec<_> = response
97                .items
98                .into_iter()
99                .filter(|p| {
100                    let normalized = p.name_with_namespace.replace(" ", "");
101                    selected_paths.contains(&normalized)
102                })
103                .collect();
104            let filtered_count = filtered_items.len();
105            Ok(PaginatedResponse::new(
106                filtered_items,
107                params.page,
108                params.page_size,
109                filtered_count,
110            ))
111        } else {
112            Ok(response)
113        }
114    }
115}
116
117fn collect_partial<T>(results: Vec<PluginResult<T>>, item: &str) -> PluginResult<Vec<T>> {
118    let mut kept = Vec::with_capacity(results.len());
119    for result in results {
120        match result {
121            Ok(value) => kept.push(value),
122            Err(e) if e.is_transient() => return Err(e),
123            Err(e) => tracing::warn!(error = %e, item, "Skipping failed GitLab item"),
124        }
125    }
126    Ok(kept)
127}
128
129#[async_trait]
130impl Plugin for GitLabPlugin {
131    fn metadata(&self) -> &PluginMetadata {
132        &self.metadata
133    }
134
135    fn provider_type(&self) -> &str {
136        "gitlab"
137    }
138
139    fn status_source(&self) -> Option<&StatusSource> {
140        self.status_source.as_ref()
141    }
142
143    fn initialize(
144        &mut self, provider_id: i64, config: HashMap<String, String>,
145        http_client: Option<std::sync::Arc<reqwest::Client>>,
146    ) -> PluginResult<()> {
147        let token = config
148            .get("token")
149            .ok_or_else(|| PluginError::InvalidConfig("Missing GitLab access token".to_string()))?
150            .to_string();
151
152        let status_source = config::status_source(&config)?;
153        let base_url = config::get_base_url(&config);
154        let api_url = config::build_api_url(&base_url);
155
156        let client = http_client.unwrap_or_else(|| {
157            std::sync::Arc::new(
158                reqwest::Client::builder()
159                    .use_rustls_tls()
160                    .pool_max_idle_per_host(10)
161                    .timeout(Duration::from_secs(30))
162                    .connect_timeout(Duration::from_secs(10))
163                    .tcp_keepalive(Duration::from_secs(60))
164                    .build()
165                    .expect("Failed to build HTTP client"),
166            )
167        });
168
169        self.status_source = status_source;
170        self.client = Some(client::GitLabClient::new(client, api_url, token));
171        self.provider_id = Some(provider_id);
172        self.config = config;
173
174        Ok(())
175    }
176
177    async fn validate_credentials(&self) -> PluginResult<bool> {
178        let client = self.client()?;
179        client.get_user().await?;
180        Ok(true)
181    }
182
183    async fn fetch_available_pipelines(
184        &self, params: Option<PaginationParams>,
185    ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
186        let params = params.unwrap_or_default();
187        let response = self.fetch_projects_paginated(&params).await?;
188
189        let available_pipelines = response
190            .items
191            .iter()
192            .map(mapper::map_available_pipeline)
193            .collect();
194
195        Ok(PaginatedResponse::new(
196            available_pipelines,
197            response.page,
198            response.page_size,
199            response.total_count,
200        ))
201    }
202
203    async fn fetch_organizations(&self) -> PluginResult<Vec<pipedash_plugin_api::Organization>> {
204        let client = self.client()?;
205        client.list_groups().await
206    }
207
208    async fn ids_with_ci(&self, ids: Vec<String>) -> PluginResult<Vec<String>> {
209        let client = self.client()?;
210        let projects: HashMap<String, types::Project> = self
211            .fetch_all_projects()
212            .await?
213            .into_iter()
214            .map(|project| (project.name_with_namespace.replace(' ', ""), project))
215            .collect();
216        Ok(retain_with_ci(ids, |id| {
217            let project = projects.get(&id).cloned();
218            async move {
219                let Some(project) = project else {
220                    return Ok(true);
221                };
222                if !client
223                    .get_project_pipelines(project.id, 1)
224                    .await?
225                    .is_empty()
226                {
227                    return Ok(true);
228                }
229                client.has_ci_config(&project).await
230            }
231        })
232        .await)
233    }
234
235    async fn fetch_available_pipelines_filtered(
236        &self, org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
237    ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
238        let params = params.unwrap_or_default();
239        let client = self.client()?;
240        let response = client.list_projects_filtered(org, search, &params).await?;
241
242        let available_pipelines = response
243            .items
244            .iter()
245            .map(mapper::map_available_pipeline)
246            .collect();
247
248        Ok(PaginatedResponse::new(
249            available_pipelines,
250            response.page,
251            response.page_size,
252            response.total_count,
253        ))
254    }
255
256    async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
257        let provider_id = self
258            .provider_id
259            .ok_or_else(|| PluginError::Internal("Provider ID not set".to_string()))?;
260
261        let client = self.client()?;
262        let projects = self.fetch_all_projects().await?;
263
264        let pipeline_futures = projects.iter().map(|project| async move {
265            let pipelines = client.get_project_pipelines(project.id, 1).await?;
266            let latest_pipeline = pipelines.first();
267            if latest_pipeline.is_none() && !client.has_ci_config(project).await.unwrap_or(true) {
268                return Ok(None);
269            }
270            Ok(Some(mapper::map_pipeline(
271                project,
272                latest_pipeline,
273                provider_id,
274            )))
275        });
276
277        Ok(
278            collect_partial(join_all(pipeline_futures).await, "project")?
279                .into_iter()
280                .flatten()
281                .collect(),
282        )
283    }
284
285    async fn fetch_run_history(
286        &self, pipeline_id: &str, limit: usize,
287    ) -> PluginResult<Vec<PipelineRun>> {
288        let (provider_id, project_id) = config::parse_pipeline_id(pipeline_id)?;
289        let client = self.client()?;
290
291        let project = client.get_project(project_id).await?;
292
293        let pipeline_list = client.get_project_pipelines(project_id, limit).await?;
294
295        let parts: Vec<&str> = project.name_with_namespace.split('/').collect();
296        let namespace = if parts.len() >= 2 {
297            Some(parts[..parts.len() - 1].join("/"))
298        } else {
299            None
300        };
301
302        let detailed_pipeline_futures = pipeline_list
303            .iter()
304            .map(|p| async move { client.get_pipeline(project_id, p.id).await });
305
306        let detailed_pipelines =
307            collect_partial(join_all(detailed_pipeline_futures).await, "pipeline run")?;
308
309        Ok(detailed_pipelines
310            .into_iter()
311            .map(|p| mapper::map_pipeline_run(&p, project_id, provider_id, namespace.as_deref()))
312            .collect())
313    }
314
315    async fn fetch_run_details(
316        &self, pipeline_id: &str, run_number: i64,
317    ) -> PluginResult<PipelineRun> {
318        let (provider_id, project_id) = config::parse_pipeline_id(pipeline_id)?;
319        let client = self.client()?;
320
321        let project = client.get_project(project_id).await?;
322        let pipeline = client.get_pipeline(project_id, run_number).await?;
323
324        let parts: Vec<&str> = project.name_with_namespace.split('/').collect();
325        let namespace = if parts.len() >= 2 {
326            Some(parts[..parts.len() - 1].join("/"))
327        } else {
328            None
329        };
330
331        Ok(mapper::map_pipeline_run(
332            &pipeline,
333            project_id,
334            provider_id,
335            namespace.as_deref(),
336        ))
337    }
338
339    async fn fetch_log_nodes(&self, run: RunRef<'_>) -> PluginResult<Vec<LogNode>> {
340        let (_, project_id) = config::parse_pipeline_id(run.pipeline_id)?;
341        let jobs = self
342            .client()?
343            .get_pipeline_jobs(project_id, run.run_number)
344            .await?;
345
346        Ok(mapper::jobs_to_log_nodes(&jobs))
347    }
348
349    async fn read_log(
350        &self, run: RunRef<'_>, node_id: &str, from: Option<&LogCursor>, sink: &mut LogSink,
351    ) -> PluginResult<LogRead> {
352        if node_id.is_empty() || !node_id.bytes().all(|b| b.is_ascii_digit()) {
353            return Err(PluginError::InvalidConfig(format!(
354                "Invalid GitLab job ID: {node_id}"
355            )));
356        }
357        let (_, project_id) = config::parse_pipeline_id(run.pipeline_id)?;
358
359        let position = from
360            .and_then(|cursor| cursor.parse::<LogPosition>())
361            .filter(|position| position.job == node_id);
362        if from.is_some() && position.is_none() {
363            sink.restart()?;
364        }
365        let (mode, offset) = self
366            .client()?
367            .stream_job_log(project_id, node_id, position.map(|p| p.offset), sink)
368            .await?;
369        let job = node_id.to_string();
370        Ok(LogRead {
371            mode,
372            cursor: Some(LogCursor::new(&LogPosition { job, offset })?),
373        })
374    }
375
376    fn log_dialect(&self) -> &'static dyn LogDialect {
377        &GitlabLogDialect
378    }
379
380    async fn fetch_workflow_parameters(
381        &self, _workflow_id: &str,
382    ) -> PluginResult<Vec<WorkflowParameter>> {
383        Ok(vec![WorkflowParameter {
384            name: "ref".to_string(),
385            label: Some("Ref".to_string()),
386            description: Some("Branch, tag, or commit SHA to run pipeline on".to_string()),
387            param_type: WorkflowParameterType::String {
388                default: Some("main".to_string()),
389            },
390            required: true,
391        }])
392    }
393
394    async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<String> {
395        let (_provider_id, project_id) = config::parse_pipeline_id(&params.workflow_id)?;
396
397        let client = self.client()?;
398
399        let ref_name = params
400            .inputs
401            .as_ref()
402            .and_then(|inputs| inputs.get("ref"))
403            .and_then(|v| v.as_str())
404            .unwrap_or("main")
405            .to_string();
406
407        let variables = params.inputs.as_ref().and_then(|inputs| {
408            inputs.get("variables").and_then(|vars| {
409                vars.as_object().map(|obj| {
410                    obj.iter()
411                        .map(|(key, value)| types::PipelineVariable {
412                            key: key.clone(),
413                            value: value.as_str().unwrap_or("").to_string(),
414                            variable_type: Some("env_var".to_string()),
415                        })
416                        .collect()
417                })
418            })
419        });
420
421        let pipeline = client
422            .trigger_pipeline(project_id, ref_name, variables)
423            .await?;
424        Ok(pipeline.web_url)
425    }
426
427    async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
428        let (_, project_id) = config::parse_pipeline_id(pipeline_id)?;
429        let client = self.client()?;
430        client.cancel_pipeline(project_id, run_number).await?;
431        Ok(())
432    }
433
434    async fn fetch_agents(&self) -> PluginResult<Vec<BuildAgent>> {
435        Err(PluginError::NotSupported(
436            "GitLab runners monitoring not implemented".to_string(),
437        ))
438    }
439
440    async fn fetch_artifacts(&self, _run_id: &str) -> PluginResult<Vec<BuildArtifact>> {
441        Err(PluginError::NotSupported(
442            "Artifacts download not implemented".to_string(),
443        ))
444    }
445
446    async fn fetch_queues(&self) -> PluginResult<Vec<BuildQueue>> {
447        Err(PluginError::NotSupported(
448            "Build queues not supported by GitLab".to_string(),
449        ))
450    }
451
452    fn get_migrations(&self) -> Vec<String> {
453        vec![]
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use tokio::io::{
460        AsyncReadExt,
461        AsyncWriteExt,
462    };
463
464    use super::*;
465
466    fn stub_pipeline(id: i64) -> serde_json::Value {
467        serde_json::json!({
468            "id": id, "project_id": 200, "status": "success", "ref": "main", "sha": "abc",
469            "web_url": format!("https://gitlab.example/team/app/-/pipelines/{id}"),
470            "created_at": "2026-10-01T00:00:00Z", "updated_at": "2026-10-01T00:00:00Z",
471            "started_at": null, "finished_at": null, "duration": null, "user": null
472        })
473    }
474
475    async fn stub_gitlab() -> (GitLabPlugin, tokio::task::JoinHandle<()>) {
476        let _ = rustls::crypto::ring::default_provider().install_default();
477        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
478        let addr = listener.local_addr().unwrap();
479        let server = tokio::spawn(async move {
480            loop {
481                let (mut stream, _) = listener.accept().await.unwrap();
482                let mut request = Vec::new();
483                let mut buffer = [0; 4096];
484                while !request.windows(4).any(|window| window == b"\r\n\r\n") {
485                    let count = stream.read(&mut buffer).await.unwrap();
486                    if count == 0 {
487                        break;
488                    }
489                    request.extend_from_slice(&buffer[..count]);
490                }
491                let request = String::from_utf8(request).unwrap();
492                let target = request.split_whitespace().nth(1).unwrap();
493                let url = reqwest::Url::parse(&format!("http://{addr}{target}")).unwrap();
494                let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
495                let mut total = 0;
496                let mut next_page = String::new();
497                let mut status = "200 OK";
498                let body = match url.path() {
499                    "/api/v4/user" => serde_json::json!({
500                        "id": 7, "username": "alice", "name": "Alice"
501                    }),
502                    "/api/v4/projects/102/pipelines" => serde_json::json!([{
503                        "id": 9, "status": "success", "ref": "main", "sha": "abc",
504                        "web_url": format!("http://{addr}/team/backend/api/-/pipelines/9"),
505                        "created_at": "2026-10-26T12:00:00Z", "updated_at": "2026-10-26T12:05:00Z"
506                    }]),
507                    "/api/v4/projects/101/pipelines"
508                    | "/api/v4/projects/103/pipelines"
509                    | "/api/v4/projects/104/pipelines" => {
510                        serde_json::json!([])
511                    }
512                    "/api/v4/projects/104/repository/files/.gitlab-ci.yml" => {
513                        serde_json::json!({"file_name": ".gitlab-ci.yml"})
514                    }
515                    "/api/v4/projects/101/repository/files/.gitlab-ci.yml"
516                    | "/api/v4/projects/103/repository/files/.gitlab-ci.yml" => {
517                        status = "404 Not Found";
518                        serde_json::json!({"message": "404 File Not Found"})
519                    }
520                    "/api/v4/projects/200" => serde_json::json!({
521                        "id": 200, "name": "app", "name_with_namespace": "team / app",
522                        "description": null, "web_url": format!("http://{addr}/team/app")
523                    }),
524                    "/api/v4/projects/200/pipelines" => {
525                        total = 150_usize;
526                        let page = query.get("page").map_or(1, |p| p.parse::<usize>().unwrap());
527                        let page_size = query
528                            .get("per_page")
529                            .map_or(20, |p| p.parse::<usize>().unwrap())
530                            .min(100);
531                        if page < total.div_ceil(page_size) {
532                            next_page = (page + 1).to_string();
533                        }
534                        serde_json::Value::Array(
535                            (1..=150)
536                                .rev()
537                                .skip((page - 1) * page_size)
538                                .take(page_size)
539                                .map(stub_pipeline)
540                                .collect(),
541                        )
542                    }
543                    path if path.starts_with("/api/v4/projects/200/pipelines/") => {
544                        stub_pipeline(path.rsplit('/').next().unwrap().parse().unwrap())
545                    }
546                    "/api/v4/groups/42/projects"
547                    | "/api/v4/projects"
548                    | "/api/v4/users/7/projects" => {
549                        let recursive = url.path() == "/api/v4/projects"
550                            || query
551                                .get("include_subgroups")
552                                .is_some_and(|value| value == "true");
553                        let projects: Vec<_> = [
554                            (101, "team / root"),
555                            (102, "team / backend / api"),
556                            (103, "team / frontend / api"),
557                            (104, "team / backend / nested / worker"),
558                        ]
559                        .into_iter()
560                        .filter(|(_, path)| recursive || *path == "team / root")
561                        .filter(|(_, path)| {
562                            query
563                                .get("search")
564                                .is_none_or(|search| path.contains(search))
565                        })
566                        .map(|(id, path)| {
567                            serde_json::json!({
568                                "id": id,
569                                "name": path.rsplit(" / ").next().unwrap(),
570                                "name_with_namespace": path,
571                                "web_url": format!("http://{addr}/{}", path.replace(" / ", "/"))
572                            })
573                        })
574                        .collect();
575                        total = projects.len();
576                        let page = query.get("page").unwrap().parse::<usize>().unwrap();
577                        let page_size = query.get("per_page").unwrap().parse::<usize>().unwrap();
578                        serde_json::Value::Array(
579                            projects
580                                .into_iter()
581                                .skip((page - 1) * page_size)
582                                .take(page_size)
583                                .collect(),
584                        )
585                    }
586                    "/api/v4/groups" => {
587                        let groups: Vec<_> = (1..=105)
588                            .map(|id| {
589                                let (name, full_path) = match id {
590                                    1 => ("backend".to_string(), "team-a/backend".to_string()),
591                                    2 => ("backend".to_string(), "team-b/backend".to_string()),
592                                    _ => (format!("group-{id}"), format!("group-{id}")),
593                                };
594                                serde_json::json!({"id": id, "name": name, "full_path": full_path})
595                            })
596                            .collect();
597                        total = groups.len();
598                        let page = query.get("page").map_or(1, |p| p.parse::<usize>().unwrap());
599                        let page_size = query
600                            .get("per_page")
601                            .map_or(20, |p| p.parse::<usize>().unwrap())
602                            .min(100);
603                        if page < total.div_ceil(page_size) {
604                            next_page = (page + 1).to_string();
605                        }
606                        serde_json::Value::Array(
607                            groups
608                                .into_iter()
609                                .skip((page - 1) * page_size)
610                                .take(page_size)
611                                .collect(),
612                        )
613                    }
614                    path => panic!("Unexpected stub path: {path}"),
615                }
616                .to_string();
617                let response = format!(
618                    "HTTP/1.1 {status}\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}",
619                    body.len()
620                );
621                stream.write_all(response.as_bytes()).await.unwrap();
622            }
623        });
624        let mut plugin = GitLabPlugin::new();
625        plugin
626            .initialize(
627                1,
628                HashMap::from([
629                    ("base_url".to_string(), format!("http://{addr}")),
630                    ("token".to_string(), "stub-token".to_string()),
631                ]),
632                None,
633            )
634            .unwrap();
635        (plugin, server)
636    }
637
638    #[tokio::test]
639    async fn parent_group_discovers_nested_projects_with_distinct_paths() {
640        let (plugin, server) = stub_gitlab().await;
641        let response = plugin
642            .fetch_available_pipelines_filtered(Some("42".to_string()), None, None)
643            .await
644            .unwrap();
645
646        let paths: Vec<_> = response.items.iter().map(|item| item.id.as_str()).collect();
647        assert_eq!(
648            paths,
649            [
650                "team/root",
651                "team/backend/api",
652                "team/frontend/api",
653                "team/backend/nested/worker"
654            ]
655        );
656        let displayed_paths: Vec<_> = response
657            .items
658            .iter()
659            .map(|item| {
660                format!(
661                    "{}/{}",
662                    item.organization.as_deref().unwrap(),
663                    item.repository.as_deref().unwrap()
664                )
665                .replace(' ', "")
666            })
667            .collect();
668        assert_eq!(displayed_paths, paths);
669        server.abort();
670    }
671
672    #[tokio::test]
673    async fn subgroup_search_keeps_pagination_and_total_count() {
674        let (plugin, server) = stub_gitlab().await;
675        for (page, expected, has_more) in [
676            (1, vec!["team/backend/api"], true),
677            (2, vec!["team/frontend/api"], true),
678            (3, vec![], false),
679        ] {
680            let response = plugin
681                .fetch_available_pipelines_filtered(
682                    Some("42".to_string()),
683                    Some("api".to_string()),
684                    Some(PaginationParams { page, page_size: 1 }),
685                )
686                .await
687                .unwrap();
688
689            assert_eq!(
690                response
691                    .items
692                    .iter()
693                    .map(|item| item.id.as_str())
694                    .collect::<Vec<_>>(),
695                expected
696            );
697            assert_eq!(response.total_count, 2);
698            assert_eq!(response.page, page);
699            assert_eq!(response.has_more, has_more);
700        }
701        server.abort();
702    }
703
704    #[tokio::test]
705    async fn saved_subgroup_selection_still_matches_during_refresh() {
706        let (mut plugin, server) = stub_gitlab().await;
707        plugin
708            .config
709            .insert("selected_items".to_string(), "team/backend/api".to_string());
710        let pipelines = plugin.fetch_pipelines().await.unwrap();
711
712        assert_eq!(
713            pipelines
714                .iter()
715                .map(|pipeline| pipeline.id.as_str())
716                .collect::<Vec<_>>(),
717            ["gitlab__1__102"]
718        );
719        assert_eq!(pipelines[0].repository, "team/backend/api");
720        server.abort();
721    }
722
723    #[tokio::test]
724    async fn only_projects_without_runs_and_ci_file_are_dropped_from_a_selection() {
725        let (mut plugin, server) = stub_gitlab().await;
726        let selection = "team/backend/api,team/frontend/api,team/backend/nested/worker,gone/repo";
727        plugin
728            .config
729            .insert("selected_items".to_string(), selection.to_string());
730
731        let kept = plugin
732            .ids_with_ci(selection.split(',').map(String::from).collect())
733            .await
734            .unwrap();
735        server.abort();
736
737        assert_eq!(
738            kept,
739            [
740                "team/backend/api",
741                "team/backend/nested/worker",
742                "gone/repo"
743            ]
744        );
745    }
746
747    #[tokio::test]
748    async fn tracking_every_project_lists_those_with_runs_or_a_ci_file() {
749        let (plugin, server) = stub_gitlab().await;
750
751        let pipelines = plugin.fetch_pipelines().await.unwrap();
752        server.abort();
753
754        assert_eq!(
755            pipelines
756                .iter()
757                .map(|pipeline| (pipeline.id.as_str(), pipeline.status))
758                .collect::<Vec<_>>(),
759            [
760                ("gitlab__1__102", PipelineStatus::Success),
761                ("gitlab__1__104", PipelineStatus::Pending)
762            ]
763        );
764    }
765
766    #[tokio::test]
767    async fn group_picker_lists_every_group_by_full_path() {
768        let (plugin, server) = stub_gitlab().await;
769        let orgs = plugin.fetch_organizations().await.unwrap();
770        server.abort();
771
772        assert_eq!(orgs.len(), 106);
773        assert_eq!(orgs[0].name, "alice");
774        assert_eq!(orgs[1].name, "team-a/backend");
775        assert_eq!(orgs[2].name, "team-b/backend");
776        assert!(orgs.iter().any(|org| org.id == "105"));
777    }
778
779    #[tokio::test]
780    async fn run_history_reads_past_the_first_100_pipelines() {
781        let (plugin, server) = stub_gitlab().await;
782        let all = plugin
783            .fetch_run_history("gitlab__1__200", 200)
784            .await
785            .unwrap();
786        let some = plugin
787            .fetch_run_history("gitlab__1__200", 120)
788            .await
789            .unwrap();
790        let latest = plugin.fetch_run_history("gitlab__1__200", 1).await.unwrap();
791        server.abort();
792
793        let numbers = |runs: &[PipelineRun]| runs.iter().map(|r| r.run_number).collect::<Vec<_>>();
794        assert_eq!(numbers(&all), (1..=150).rev().collect::<Vec<_>>());
795        assert_eq!(numbers(&some), (31..=150).rev().collect::<Vec<_>>());
796        assert_eq!(numbers(&latest), vec![150]);
797    }
798
799    type Requests = std::sync::Arc<tokio::sync::Mutex<Vec<String>>>;
800
801    const PIPELINE_JOBS: &str = r#"[
802        {"id":41,"name":"compile","stage":"build","status":"success","started_at":"2026-10-27T10:05:00Z","finished_at":"2026-10-27T10:06:00Z","web_url":"https://gitlab.example/team/app/-/jobs/41"},
803        {"id":40,"name":"unit","stage":"test","status":"running","started_at":"2026-10-27T10:06:10Z","finished_at":null,"web_url":"https://gitlab.example/team/app/-/jobs/40"},
804        {"id":14,"name":"docs","stage":"deploy","status":"skipped","started_at":null,"finished_at":null,"web_url":"https://gitlab.example/team/app/-/jobs/14"},
805        {"id":13,"name":"ship","stage":"deploy","status":"manual","started_at":null,"finished_at":null,"web_url":"https://gitlab.example/team/app/-/jobs/13"},
806        {"id":12,"name":"lint","stage":"test","status":"success","started_at":"2026-10-27T10:01:00Z","finished_at":"2026-10-27T10:02:00Z","web_url":"https://gitlab.example/team/app/-/jobs/12"},
807        {"id":11,"name":"unit","stage":"test","status":"failed","started_at":"2026-10-27T10:01:00Z","finished_at":"2026-10-27T10:03:00Z","web_url":"https://gitlab.example/team/app/-/jobs/11"},
808        {"id":10,"name":"compile","stage":"build","status":"failed","started_at":"2026-10-27T10:00:00Z","finished_at":"2026-10-27T10:00:30Z","web_url":"https://gitlab.example/team/app/-/jobs/10"}
809    ]"#;
810
811    const JOB_TRACE: &[u8] = b"Running with gitlab-runner 17.0.0\n\
812        section_start:1700000000:step_script\r\x1b[0K\x1b[36;1mExecuting step_script\x1b[0;m\n\
813        $ make test\n\
814        section_end:1700000007:step_script\r\x1b[0K\n";
815
816    async fn stub_jobs_and_trace() -> (GitLabPlugin, Requests, tokio::task::JoinHandle<()>) {
817        let _ = rustls::crypto::ring::default_provider().install_default();
818        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
819        let addr = listener.local_addr().unwrap();
820        let requests = Requests::default();
821        let seen = requests.clone();
822        let server = tokio::spawn(async move {
823            loop {
824                let (mut stream, _) = listener.accept().await.unwrap();
825                let mut request = Vec::new();
826                let mut buffer = [0; 4096];
827                while !request.windows(4).any(|window| window == b"\r\n\r\n") {
828                    let count = stream.read(&mut buffer).await.unwrap();
829                    if count == 0 {
830                        break;
831                    }
832                    request.extend_from_slice(&buffer[..count]);
833                }
834                let request = String::from_utf8(request).unwrap();
835                seen.lock().await.push(request.clone());
836                let target = request.split_whitespace().nth(1).unwrap();
837                let url = reqwest::Url::parse(&format!("http://{addr}{target}")).unwrap();
838                let query: HashMap<_, _> = url.query_pairs().into_owned().collect();
839                let mut next_page = String::new();
840                let (status, content_type, body) = match url.path() {
841                    "/api/v4/projects/200/pipelines/77/jobs" => {
842                        let jobs: Vec<serde_json::Value> =
843                            serde_json::from_str(PIPELINE_JOBS).unwrap();
844                        let page = query["page"].parse::<usize>().unwrap();
845                        let page_size = query["per_page"].parse::<usize>().unwrap().min(2);
846                        if page * page_size < jobs.len() {
847                            next_page = (page + 1).to_string();
848                        }
849                        let items: Vec<_> = jobs
850                            .into_iter()
851                            .skip((page - 1) * page_size)
852                            .take(page_size)
853                            .collect();
854                        (
855                            "200 OK",
856                            "application/json",
857                            serde_json::Value::Array(items).to_string().into_bytes(),
858                        )
859                    }
860                    "/api/v4/projects/200/jobs/41/trace" => {
861                        ("200 OK", "text/plain", JOB_TRACE.to_vec())
862                    }
863                    _ => (
864                        "404 Not Found",
865                        "application/json",
866                        br#"{"message":"404 Not found"}"#.to_vec(),
867                    ),
868                };
869                let head = format!(
870                    "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nX-Next-Page: {next_page}\r\nConnection: close\r\n\r\n",
871                    body.len()
872                );
873                stream.write_all(head.as_bytes()).await.unwrap();
874                stream.write_all(&body).await.unwrap();
875            }
876        });
877        let mut plugin = GitLabPlugin::new();
878        plugin
879            .initialize(
880                1,
881                HashMap::from([
882                    ("base_url".to_string(), format!("http://{addr}")),
883                    ("token".to_string(), "stub-token".to_string()),
884                ]),
885                None,
886            )
887            .unwrap();
888        (plugin, requests, server)
889    }
890
891    fn run_77(metadata: &HashMap<String, serde_json::Value>) -> RunRef<'_> {
892        RunRef {
893            pipeline_id: "gitlab__1__200",
894            run_number: 77,
895            run_id: "gitlab__1__200_77",
896            metadata,
897        }
898    }
899
900    async fn read_job(plugin: &GitLabPlugin, node_id: &str) -> PluginResult<Vec<u8>> {
901        let metadata = HashMap::new();
902        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
903        let mut sink = LogSink::new(tx);
904        let result = plugin
905            .read_log(run_77(&metadata), node_id, None, &mut sink)
906            .await;
907        drop(sink);
908        let mut bytes = Vec::new();
909        while let Some(chunk) = rx.recv().await {
910            bytes.extend_from_slice(&chunk);
911        }
912        result.map(|_| bytes)
913    }
914
915    async fn targets(requests: &Requests) -> Vec<String> {
916        requests
917            .lock()
918            .await
919            .iter()
920            .map(|request| request.split_whitespace().nth(1).unwrap().to_string())
921            .collect()
922    }
923
924    #[tokio::test]
925    async fn pipeline_jobs_become_stages_holding_their_latest_jobs() {
926        let (plugin, requests, server) = stub_jobs_and_trace().await;
927        let metadata = HashMap::new();
928
929        let nodes = plugin.fetch_log_nodes(run_77(&metadata)).await.unwrap();
930        server.abort();
931
932        let summary: Vec<_> = nodes
933            .iter()
934            .map(|node| {
935                (
936                    node.id.as_str(),
937                    node.parent_id.as_deref(),
938                    node.kind,
939                    node.name.as_str(),
940                    node.status,
941                    node.log,
942                )
943            })
944            .collect();
945        assert_eq!(
946            summary,
947            [
948                (
949                    "stage:build",
950                    None,
951                    LogNodeKind::Stage,
952                    "build",
953                    PipelineStatus::Success,
954                    None
955                ),
956                (
957                    "41",
958                    Some("stage:build"),
959                    LogNodeKind::Job,
960                    "compile",
961                    PipelineStatus::Success,
962                    Some(LogAvailability::Ready)
963                ),
964                (
965                    "stage:test",
966                    None,
967                    LogNodeKind::Stage,
968                    "test",
969                    PipelineStatus::Running,
970                    None
971                ),
972                (
973                    "40",
974                    Some("stage:test"),
975                    LogNodeKind::Job,
976                    "unit",
977                    PipelineStatus::Running,
978                    Some(LogAvailability::Partial)
979                ),
980                (
981                    "12",
982                    Some("stage:test"),
983                    LogNodeKind::Job,
984                    "lint",
985                    PipelineStatus::Success,
986                    Some(LogAvailability::Ready)
987                ),
988                (
989                    "stage:deploy",
990                    None,
991                    LogNodeKind::Stage,
992                    "deploy",
993                    PipelineStatus::Pending,
994                    None
995                ),
996                (
997                    "13",
998                    Some("stage:deploy"),
999                    LogNodeKind::Job,
1000                    "ship",
1001                    PipelineStatus::Pending,
1002                    Some(LogAvailability::Pending)
1003                ),
1004                (
1005                    "14",
1006                    Some("stage:deploy"),
1007                    LogNodeKind::Job,
1008                    "docs",
1009                    PipelineStatus::Skipped,
1010                    None
1011                ),
1012            ]
1013        );
1014        assert_eq!(
1015            nodes[1].web_url.as_deref(),
1016            Some("https://gitlab.example/team/app/-/jobs/41")
1017        );
1018        assert_eq!(
1019            targets(&requests).await,
1020            (1..=4)
1021                .map(|page| format!(
1022                    "/api/v4/projects/200/pipelines/77/jobs?per_page=100&include_retried=true&page={page}"
1023                ))
1024                .collect::<Vec<_>>()
1025        );
1026    }
1027
1028    #[tokio::test]
1029    async fn job_trace_streams_the_raw_text() {
1030        let (plugin, requests, server) = stub_jobs_and_trace().await;
1031
1032        let trace = read_job(&plugin, "41").await.unwrap();
1033        server.abort();
1034
1035        assert_eq!(trace, JOB_TRACE);
1036        assert_eq!(
1037            targets(&requests).await,
1038            ["/api/v4/projects/200/jobs/41/trace"]
1039        );
1040        assert!(requests.lock().await[0]
1041            .to_lowercase()
1042            .contains("private-token: stub-token"));
1043    }
1044
1045    #[tokio::test]
1046    async fn missing_job_trace_is_not_found() {
1047        let (plugin, _, server) = stub_jobs_and_trace().await;
1048
1049        let error = read_job(&plugin, "999").await.unwrap_err();
1050        server.abort();
1051
1052        assert!(
1053            matches!(error, PluginError::PipelineNotFound(_)),
1054            "{error:?}"
1055        );
1056    }
1057
1058    #[tokio::test]
1059    async fn job_ids_that_are_not_numbers_are_rejected_without_a_request() {
1060        let (plugin, requests, server) = stub_jobs_and_trace().await;
1061
1062        for node_id in [
1063            "",
1064            "../../jobs/41",
1065            "41/trace",
1066            "41?x=1",
1067            "-41",
1068            "4 1",
1069            "stage:test",
1070            "41",
1071        ] {
1072            let error = read_job(&plugin, node_id).await.unwrap_err();
1073            assert!(
1074                matches!(error, PluginError::InvalidConfig(_)),
1075                "{node_id}: {error:?}"
1076            );
1077        }
1078        server.abort();
1079
1080        assert!(requests.lock().await.is_empty());
1081    }
1082
1083    struct Trace {
1084        body: Vec<u8>,
1085        honors_range: bool,
1086        ranges: Vec<Option<String>>,
1087    }
1088
1089    type SharedTrace = std::sync::Arc<std::sync::Mutex<Trace>>;
1090
1091    async fn stub_trace(
1092        body: &[u8], honors_range: bool,
1093    ) -> (GitLabPlugin, SharedTrace, tokio::task::JoinHandle<()>) {
1094        let _ = rustls::crypto::ring::default_provider().install_default();
1095        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1096        let addr = listener.local_addr().unwrap();
1097        let trace = SharedTrace::new(std::sync::Mutex::new(Trace {
1098            body: body.to_vec(),
1099            honors_range,
1100            ranges: Vec::new(),
1101        }));
1102        let state = trace.clone();
1103        let server = tokio::spawn(async move {
1104            loop {
1105                let (mut stream, _) = listener.accept().await.unwrap();
1106                let mut request = Vec::new();
1107                let mut buffer = [0; 4096];
1108                while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1109                    let count = stream.read(&mut buffer).await.unwrap();
1110                    if count == 0 {
1111                        break;
1112                    }
1113                    request.extend_from_slice(&buffer[..count]);
1114                }
1115                let request = String::from_utf8(request).unwrap();
1116                let range = request.lines().find_map(|line| {
1117                    let (name, value) = line.split_once(':')?;
1118                    name.eq_ignore_ascii_case("range")
1119                        .then(|| value.trim().to_string())
1120                });
1121                let (status, content_range, payload) = {
1122                    let mut trace = state.lock().unwrap();
1123                    trace.ranges.push(range.clone());
1124                    let total = trace.body.len();
1125                    let start = range
1126                        .as_deref()
1127                        .and_then(|value| {
1128                            value
1129                                .strip_prefix("bytes=")?
1130                                .strip_suffix('-')?
1131                                .parse()
1132                                .ok()
1133                        })
1134                        .filter(|_| trace.honors_range);
1135                    match start {
1136                        Some(start) if start >= total => (
1137                            "416 Range Not Satisfiable",
1138                            format!("Content-Range: bytes */{total}\r\n"),
1139                            Vec::new(),
1140                        ),
1141                        Some(start) => (
1142                            "206 Partial Content",
1143                            format!("Content-Range: bytes {start}-{}/{total}\r\n", total - 1),
1144                            trace.body[start..].to_vec(),
1145                        ),
1146                        None => ("200 OK", String::new(), trace.body.clone()),
1147                    }
1148                };
1149                let head = format!(
1150                    "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\n{content_range}Content-Length: {}\r\nConnection: close\r\n\r\n",
1151                    payload.len()
1152                );
1153                stream.write_all(head.as_bytes()).await.unwrap();
1154                stream.write_all(&payload).await.unwrap();
1155            }
1156        });
1157        let mut plugin = GitLabPlugin::new();
1158        plugin
1159            .initialize(
1160                1,
1161                HashMap::from([
1162                    ("base_url".to_string(), format!("http://{addr}")),
1163                    ("token".to_string(), "stub-token".to_string()),
1164                ]),
1165                None,
1166            )
1167            .unwrap();
1168        (plugin, trace, server)
1169    }
1170
1171    fn cursor(job: &str, offset: u64) -> LogCursor {
1172        LogCursor::new(&serde_json::json!({"job": job, "offset": offset})).unwrap()
1173    }
1174
1175    async fn read_trace(
1176        plugin: &GitLabPlugin, from: Option<&LogCursor>,
1177    ) -> (PluginResult<LogRead>, Vec<u8>, bool) {
1178        let metadata = HashMap::new();
1179        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1180        let mut sink = LogSink::new(tx);
1181        let result = plugin
1182            .read_log(run_77(&metadata), "40", from, &mut sink)
1183            .await;
1184        let restarted = sink.restarted();
1185        drop(sink);
1186        let mut bytes = Vec::new();
1187        while let Some(chunk) = rx.recv().await {
1188            bytes.extend_from_slice(&chunk);
1189        }
1190        (result, bytes, restarted)
1191    }
1192
1193    fn ranges(trace: &SharedTrace) -> Vec<Option<String>> {
1194        trace.lock().unwrap().ranges.clone()
1195    }
1196
1197    #[tokio::test]
1198    async fn resumed_trace_appends_only_the_new_bytes() {
1199        let (plugin, trace, server) = stub_trace(b"a\nb\n", true).await;
1200
1201        let (first, bytes, restarted) = read_trace(&plugin, None).await;
1202        assert_eq!(first.unwrap(), LogRead::replace(Some(cursor("40", 4))));
1203        assert_eq!(bytes, b"a\nb\n");
1204        assert!(!restarted);
1205
1206        trace.lock().unwrap().body.extend_from_slice(b"c\n");
1207        let (second, bytes, restarted) = read_trace(&plugin, Some(&cursor("40", 4))).await;
1208        assert_eq!(second.unwrap(), LogRead::append(cursor("40", 6)));
1209        assert_eq!(bytes, b"c\n");
1210        assert!(!restarted);
1211
1212        let (idle, bytes, restarted) = read_trace(&plugin, Some(&cursor("40", 6))).await;
1213        server.abort();
1214        assert_eq!(idle.unwrap(), LogRead::append(cursor("40", 6)));
1215        assert!(bytes.is_empty() && !restarted);
1216        assert_eq!(
1217            ranges(&trace),
1218            [None, Some("bytes=4-".into()), Some("bytes=6-".into())]
1219        );
1220    }
1221
1222    #[tokio::test]
1223    async fn server_ignoring_range_restarts_with_the_whole_trace() {
1224        let (plugin, trace, server) = stub_trace(b"a\nb\nc\n", false).await;
1225
1226        let (read, bytes, restarted) = read_trace(&plugin, Some(&cursor("40", 4))).await;
1227        server.abort();
1228
1229        assert_eq!(read.unwrap(), LogRead::replace(Some(cursor("40", 6))));
1230        assert_eq!(bytes, b"a\nb\nc\n");
1231        assert!(restarted);
1232        assert_eq!(ranges(&trace), [Some("bytes=4-".into())]);
1233    }
1234
1235    #[tokio::test]
1236    async fn shrunk_trace_restarts_with_the_whole_trace() {
1237        let (plugin, trace, server) = stub_trace(b"a\n", true).await;
1238
1239        let (read, bytes, restarted) = read_trace(&plugin, Some(&cursor("40", 6))).await;
1240        server.abort();
1241
1242        assert_eq!(read.unwrap(), LogRead::replace(Some(cursor("40", 2))));
1243        assert_eq!(bytes, b"a\n");
1244        assert!(restarted);
1245        assert_eq!(ranges(&trace), [Some("bytes=6-".into()), None]);
1246    }
1247
1248    #[tokio::test]
1249    async fn unusable_cursors_restart_without_a_range_request() {
1250        let (plugin, trace, server) = stub_trace(b"a\nb\n", true).await;
1251
1252        for stale in [LogCursor::new(&"garbage").unwrap(), cursor("39", 4)] {
1253            let (read, bytes, restarted) = read_trace(&plugin, Some(&stale)).await;
1254            assert_eq!(read.unwrap(), LogRead::replace(Some(cursor("40", 4))));
1255            assert_eq!(bytes, b"a\nb\n");
1256            assert!(restarted);
1257        }
1258        server.abort();
1259
1260        assert_eq!(ranges(&trace), [None, None]);
1261    }
1262}