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(¶ms).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(¶ms).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, ¶ms).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(¶ms.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}