Skip to main content

pipedash_plugin_bitbucket/
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::BitbucketLogDialect;
9use crate::{
10    client,
11    config,
12    mapper,
13    metadata,
14    types,
15};
16
17pub struct BitbucketPlugin {
18    metadata: PluginMetadata,
19    client: Option<client::BitbucketClient>,
20    provider_id: Option<i64>,
21    config: HashMap<String, String>,
22    status_source: Option<StatusSource>,
23}
24
25impl Default for BitbucketPlugin {
26    fn default() -> Self {
27        Self::new()
28    }
29}
30
31impl BitbucketPlugin {
32    pub fn new() -> Self {
33        Self {
34            metadata: metadata::create_metadata(),
35            client: None,
36            provider_id: None,
37            config: HashMap::new(),
38            status_source: None,
39        }
40    }
41
42    fn client(&self) -> PluginResult<&client::BitbucketClient> {
43        self.client
44            .as_ref()
45            .ok_or_else(|| PluginError::Internal("Plugin not initialized".to_string()))
46    }
47
48    async fn fetch_all_repositories(&self) -> PluginResult<Vec<types::Repository>> {
49        let client = self.client()?;
50        let mut all_repos = Vec::new();
51        let mut page = 1;
52
53        const MAX_PAGES: usize = 50;
54
55        loop {
56            let params = PaginationParams {
57                page,
58                page_size: 100,
59            };
60            let response = client.list_all_repositories(&params).await?;
61
62            if response.items.is_empty() {
63                break;
64            }
65
66            all_repos.extend(response.items);
67
68            if !response.has_more || page >= MAX_PAGES {
69                break;
70            }
71            page += 1;
72        }
73
74        if let Some(selected) = config::parse_selected_items(&self.config) {
75            Ok(all_repos
76                .into_iter()
77                .filter(|r| selected.contains(&r.full_name))
78                .collect())
79        } else {
80            Ok(all_repos)
81        }
82    }
83}
84
85fn collect_partial<T>(results: Vec<PluginResult<T>>, item: &str) -> PluginResult<Vec<T>> {
86    let mut kept = Vec::with_capacity(results.len());
87    for result in results {
88        match result {
89            Ok(value) => kept.push(value),
90            Err(e) if e.is_transient() => return Err(e),
91            Err(e) => tracing::warn!(error = %e, item, "Skipping failed Bitbucket item"),
92        }
93    }
94    Ok(kept)
95}
96
97#[async_trait]
98impl Plugin for BitbucketPlugin {
99    fn metadata(&self) -> &PluginMetadata {
100        &self.metadata
101    }
102
103    fn provider_type(&self) -> &str {
104        "bitbucket"
105    }
106
107    fn status_source(&self) -> Option<&StatusSource> {
108        self.status_source.as_ref()
109    }
110
111    fn initialize(
112        &mut self, provider_id: i64, config: HashMap<String, String>,
113        http_client: Option<std::sync::Arc<reqwest::Client>>,
114    ) -> PluginResult<()> {
115        let (email, api_token) = config::get_auth(&config)?;
116        let api_url = config::get_api_url();
117
118        let credentials = format!("{}:{}", email, api_token);
119        let encoded = base64::Engine::encode(
120            &base64::engine::general_purpose::STANDARD,
121            credentials.as_bytes(),
122        );
123        let auth_value = format!("Basic {}", encoded);
124
125        let client = http_client.unwrap_or_else(|| {
126            std::sync::Arc::new(
127                reqwest::Client::builder()
128                    .use_rustls_tls()
129                    .pool_max_idle_per_host(10)
130                    .timeout(Duration::from_secs(30))
131                    .connect_timeout(Duration::from_secs(10))
132                    .tcp_keepalive(Duration::from_secs(60))
133                    .build()
134                    .expect("Failed to build HTTP client"),
135            )
136        });
137
138        self.status_source = Some(config::status_source()?);
139        self.client = Some(client::BitbucketClient::new(client, api_url, auth_value));
140        self.provider_id = Some(provider_id);
141        self.config = config;
142
143        Ok(())
144    }
145
146    async fn validate_credentials(&self) -> PluginResult<bool> {
147        let client = self.client()?;
148        client.get_user().await?;
149        Ok(true)
150    }
151
152    async fn fetch_organizations(&self) -> PluginResult<Vec<Organization>> {
153        let client = self.client()?;
154        let workspaces = client.list_workspaces().await?;
155
156        Ok(workspaces
157            .into_iter()
158            .map(|w| Organization {
159                id: w.uuid,
160                name: w.name,
161                description: Some(w.slug),
162            })
163            .collect())
164    }
165
166    async fn ids_with_ci(&self, ids: Vec<String>) -> PluginResult<Vec<String>> {
167        let client = self.client()?;
168        let repos: HashMap<String, (String, String)> = self
169            .fetch_all_repositories()
170            .await?
171            .into_iter()
172            .map(|repo| (repo.full_name, (repo.workspace.slug, repo.slug)))
173            .collect();
174        Ok(retain_with_ci(ids, |id| {
175            let repo = repos.get(&id).cloned();
176            async move {
177                let Some((workspace, slug)) = repo else {
178                    return Ok(true);
179                };
180                if !client
181                    .list_pipelines(&workspace, &slug, 1)
182                    .await?
183                    .is_empty()
184                {
185                    return Ok(true);
186                }
187                client.has_pipelines_file(&workspace, &slug).await
188            }
189        })
190        .await)
191    }
192
193    async fn fetch_available_pipelines(
194        &self, params: Option<PaginationParams>,
195    ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
196        let client = self.client()?;
197        let params = params.unwrap_or_default();
198
199        let response = client.list_all_repositories(&params).await?;
200
201        let available = response
202            .items
203            .iter()
204            .map(mapper::map_available_pipeline)
205            .collect();
206
207        Ok(PaginatedResponse::new(
208            available,
209            response.page,
210            response.page_size,
211            response.total_count,
212        ))
213    }
214
215    async fn fetch_available_pipelines_filtered(
216        &self, org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
217    ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
218        let client = self.client()?;
219        let params = params.unwrap_or_default();
220
221        let response = if let Some(workspace) = &org {
222            client.list_repositories(workspace, &params).await?
223        } else {
224            client.list_all_repositories(&params).await?
225        };
226
227        let mut available: Vec<AvailablePipeline> = response
228            .items
229            .iter()
230            .map(mapper::map_available_pipeline)
231            .collect();
232
233        if let Some(search_term) = search {
234            let search_lower = search_term.to_lowercase();
235            available.retain(|p| {
236                p.name.to_lowercase().contains(&search_lower)
237                    || p.id.to_lowercase().contains(&search_lower)
238            });
239        }
240
241        Ok(PaginatedResponse::new(
242            available,
243            params.page,
244            params.page_size,
245            response.total_count,
246        ))
247    }
248
249    async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
250        let provider_id = self
251            .provider_id
252            .ok_or_else(|| PluginError::Internal("Provider ID not set".to_string()))?;
253
254        let client = self.client()?;
255        let repos = self.fetch_all_repositories().await?;
256
257        let pipeline_futures = repos.iter().map(|repo| async move {
258            let pipelines = client
259                .list_pipelines(&repo.workspace.slug, &repo.slug, 1)
260                .await?;
261            let latest = pipelines.first();
262            if latest.is_none()
263                && !client
264                    .has_pipelines_file(&repo.workspace.slug, &repo.slug)
265                    .await
266                    .unwrap_or(true)
267            {
268                return Ok(None);
269            }
270            Ok(Some(mapper::map_pipeline(repo, latest, provider_id)))
271        });
272
273        Ok(
274            collect_partial(join_all(pipeline_futures).await, "repository")?
275                .into_iter()
276                .flatten()
277                .collect(),
278        )
279    }
280
281    async fn fetch_run_history(
282        &self, pipeline_id: &str, limit: usize,
283    ) -> PluginResult<Vec<PipelineRun>> {
284        let (provider_id, workspace, repo_slug) = config::parse_pipeline_id(pipeline_id)?;
285        let client = self.client()?;
286
287        let pipelines = client.list_pipelines(&workspace, &repo_slug, limit).await?;
288
289        Ok(pipelines
290            .iter()
291            .map(|p| mapper::map_pipeline_run(p, &workspace, &repo_slug, provider_id))
292            .collect())
293    }
294
295    async fn fetch_run_details(
296        &self, pipeline_id: &str, run_number: i64,
297    ) -> PluginResult<PipelineRun> {
298        let (provider_id, workspace, repo_slug) = config::parse_pipeline_id(pipeline_id)?;
299
300        let pipeline = self
301            .client()?
302            .find_pipeline_by_build_number(&workspace, &repo_slug, run_number)
303            .await?;
304
305        Ok(mapper::map_pipeline_run(
306            &pipeline,
307            &workspace,
308            &repo_slug,
309            provider_id,
310        ))
311    }
312
313    async fn fetch_log_nodes(&self, run: RunRef<'_>) -> PluginResult<Vec<LogNode>> {
314        let (_, workspace, repo_slug) = config::parse_pipeline_id(run.pipeline_id)?;
315        let client = self.client()?;
316
317        let pipeline = client
318            .find_pipeline_by_build_number(&workspace, &repo_slug, run.run_number)
319            .await?;
320        let steps = client
321            .list_steps(&workspace, &repo_slug, &pipeline.uuid)
322            .await?;
323
324        Ok(mapper::steps_to_log_nodes(&steps))
325    }
326
327    async fn read_log(
328        &self, run: RunRef<'_>, node_id: &str, sink: &mut LogSink,
329    ) -> PluginResult<()> {
330        let (_, workspace, repo_slug) = config::parse_pipeline_id(run.pipeline_id)?;
331        config::check_step_uuid(node_id)?;
332        let client = self.client()?;
333
334        let pipeline = client
335            .find_pipeline_by_build_number(&workspace, &repo_slug, run.run_number)
336            .await?;
337
338        client
339            .stream_step_log(&workspace, &repo_slug, &pipeline.uuid, node_id, sink)
340            .await
341    }
342
343    fn log_dialect(&self) -> &'static dyn LogDialect {
344        &BitbucketLogDialect
345    }
346
347    async fn fetch_workflow_parameters(
348        &self, _workflow_id: &str,
349    ) -> PluginResult<Vec<WorkflowParameter>> {
350        Ok(vec![
351            WorkflowParameter {
352                name: "ref".to_string(),
353                label: Some("Branch".to_string()),
354                description: Some("Branch name to run pipeline on".to_string()),
355                param_type: WorkflowParameterType::String {
356                    default: Some("main".to_string()),
357                },
358                required: true,
359            },
360            WorkflowParameter {
361                name: "custom_pipeline".to_string(),
362                label: Some("Custom Pipeline".to_string()),
363                description: Some("Name of custom pipeline to run (optional)".to_string()),
364                param_type: WorkflowParameterType::String { default: None },
365                required: false,
366            },
367        ])
368    }
369
370    async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<String> {
371        let (_, workspace, repo_slug) = config::parse_pipeline_id(&params.workflow_id)?;
372        let client = self.client()?;
373
374        let ref_name = params
375            .inputs
376            .as_ref()
377            .and_then(|i| i.get("ref"))
378            .and_then(|v| v.as_str())
379            .unwrap_or("main")
380            .to_string();
381
382        let custom_pipeline = params
383            .inputs
384            .as_ref()
385            .and_then(|i| i.get("custom_pipeline"))
386            .and_then(|v| v.as_str())
387            .filter(|s| !s.is_empty());
388
389        let request = types::TriggerPipelineRequest {
390            target: types::TriggerTarget {
391                target_type: "pipeline_ref_target".to_string(),
392                ref_name,
393                ref_type: "branch".to_string(),
394                selector: custom_pipeline.map(|name| types::TriggerSelector {
395                    selector_type: "custom".to_string(),
396                    pattern: Some(name.to_string()),
397                }),
398            },
399        };
400
401        let pipeline = client
402            .trigger_pipeline(&workspace, &repo_slug, request)
403            .await?;
404
405        let url = pipeline.links.html.map(|l| l.href).unwrap_or_else(|| {
406            format!(
407                "https://bitbucket.org/{}/{}/pipelines/results/{}",
408                workspace, repo_slug, pipeline.build_number
409            )
410        });
411
412        Ok(url)
413    }
414
415    async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
416        let (_, workspace, repo_slug) = config::parse_pipeline_id(pipeline_id)?;
417        let client = self.client()?;
418
419        let pipeline = client
420            .find_pipeline_by_build_number(&workspace, &repo_slug, run_number)
421            .await?;
422
423        let state = pipeline.state.name.as_str();
424        if state == "PAUSED" || state == "HALTED" {
425            return Err(PluginError::ApiError(format!(
426                "Cannot cancel pipeline in '{}' state. Pipelines waiting for manual approval \
427                 must be cancelled directly in Bitbucket UI.",
428                state
429            )));
430        }
431
432        if state == "COMPLETED" {
433            return Err(PluginError::ApiError(
434                "Cannot cancel a completed pipeline.".to_string(),
435            ));
436        }
437
438        if state == "IN_PROGRESS" {
439            let pipeline_is_paused = pipeline
440                .state
441                .stage
442                .as_ref()
443                .map(|s| s.name == "PAUSED")
444                .unwrap_or(false);
445
446            if pipeline_is_paused {
447                return Err(PluginError::ApiError(
448                    "Cannot cancel pipeline waiting for manual approval. \
449                     Please cancel directly in Bitbucket UI."
450                        .to_string(),
451                ));
452            }
453
454            let steps = client
455                .list_steps(&workspace, &repo_slug, &pipeline.uuid)
456                .await?;
457
458            let has_paused_step = steps.iter().any(|step| {
459                step.state.name == "PENDING"
460                    && step
461                        .state
462                        .stage
463                        .as_ref()
464                        .map(|s| s.name == "PAUSED")
465                        .unwrap_or(false)
466            });
467
468            if has_paused_step {
469                return Err(PluginError::ApiError(
470                    "Cannot cancel pipeline with steps waiting for manual approval. \
471                     Please cancel directly in Bitbucket UI."
472                        .to_string(),
473                ));
474            }
475        }
476
477        client
478            .stop_pipeline(&workspace, &repo_slug, &pipeline.uuid)
479            .await
480    }
481
482    async fn fetch_agents(&self) -> PluginResult<Vec<BuildAgent>> {
483        Err(PluginError::NotSupported(
484            "Bitbucket Pipelines uses Atlassian infrastructure - no agent monitoring".to_string(),
485        ))
486    }
487
488    async fn fetch_artifacts(&self, _run_id: &str) -> PluginResult<Vec<BuildArtifact>> {
489        Err(PluginError::NotSupported(
490            "Artifact fetching not yet implemented for Bitbucket".to_string(),
491        ))
492    }
493
494    async fn fetch_queues(&self) -> PluginResult<Vec<BuildQueue>> {
495        Err(PluginError::NotSupported(
496            "Build queues not supported by Bitbucket Pipelines".to_string(),
497        ))
498    }
499
500    fn get_migrations(&self) -> Vec<String> {
501        vec![]
502    }
503}
504
505#[cfg(test)]
506mod tests {
507    use std::net::SocketAddr;
508    use std::sync::{
509        mpsc,
510        Arc,
511    };
512
513    use tokio::io::{
514        AsyncReadExt,
515        AsyncWriteExt,
516    };
517
518    use super::*;
519
520    async fn serve_runs(total: i64) -> (BitbucketPlugin, mpsc::Receiver<String>) {
521        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
522        let address = listener.local_addr().unwrap();
523        let (recorded, requests) = mpsc::channel();
524
525        tokio::spawn(async move {
526            while let Ok((mut stream, _)) = listener.accept().await {
527                let mut buffer = [0; 4096];
528                let count = stream.read(&mut buffer).await.unwrap();
529                let request = String::from_utf8_lossy(&buffer[..count]);
530                let line = request.lines().next().unwrap().to_string();
531                let target = line.split_whitespace().nth(1).unwrap();
532                let url = reqwest::Url::parse(&format!("http://{address}{target}")).unwrap();
533                let (status, body) = match (line.starts_with("GET "), url.path()) {
534                    (true, "/repositories/ws/repo/pipelines") => {
535                        let page = url
536                            .query_pairs()
537                            .find(|(key, _)| key == "page")
538                            .map(|(_, value)| value.parse::<i64>().unwrap())
539                            .unwrap_or(1);
540                        let high = total - (page - 1) * 100;
541                        let low = (high - 99).max(1);
542                        let values: Vec<_> = (low..=high)
543                            .rev()
544                            .map(|number| {
545                                serde_json::json!({
546                                    "uuid": format!("{{{number}}}"),
547                                    "build_number": number,
548                                    "state": if number == 1 {
549                                        serde_json::json!({"name": "IN_PROGRESS"})
550                                    } else {
551                                        serde_json::json!({
552                                            "name": "COMPLETED",
553                                            "result": {"name": "SUCCESSFUL"}
554                                        })
555                                    },
556                                    "target": {"type": "pipeline_ref_target", "ref_name": "main"},
557                                    "created_on": "2026-10-01T00:00:00Z",
558                                    "links": {}
559                                })
560                            })
561                            .collect();
562                        let next = (low > 1).then(|| format!(
563                            "http://{address}/repositories/ws/repo/pipelines?pagelen=100&sort=-created_on&page={}",
564                            page + 1
565                        ));
566                        (
567                            "200 OK",
568                            serde_json::json!({
569                                "values": values, "next": next, "pagelen": 100
570                            })
571                            .to_string(),
572                        )
573                    }
574                    (true, "/repositories") => {
575                        let repo = |slug: &str| {
576                            serde_json::json!({
577                                "uuid": format!("{{{slug}}}"), "name": slug,
578                                "full_name": format!("ws/{slug}"), "slug": slug,
579                                "workspace": {"uuid": "{ws}", "slug": "ws", "name": "ws"},
580                                "links": {"html": {"href": format!("http://{address}/ws/{slug}")}}
581                            })
582                        };
583                        (
584                            "200 OK",
585                            serde_json::json!({
586                                "values": [repo("repo"), repo("empty"), repo("configured")],
587                                "size": 3, "pagelen": 100
588                            })
589                            .to_string(),
590                        )
591                    }
592                    (true, "/repositories/ws/empty/pipelines")
593                    | (true, "/repositories/ws/configured/pipelines") => (
594                        "200 OK",
595                        serde_json::json!({"values": [], "pagelen": 100}).to_string(),
596                    ),
597                    (true, "/repositories/ws/configured/src/HEAD/bitbucket-pipelines.yml") => {
598                        ("200 OK", "pipelines: {}".to_string())
599                    }
600                    (true, "/repositories/ws/repo/pipelines/%7B1%7D/steps") => (
601                        "200 OK",
602                        serde_json::json!({"values": [], "pagelen": 100}).to_string(),
603                    ),
604                    (false, "/repositories/ws/repo/pipelines/%7B1%7D/stopPipeline") => {
605                        ("204 No Content", String::new())
606                    }
607                    _ => ("404 Not Found", "{}".to_string()),
608                };
609                recorded.send(line).unwrap();
610                let response = format!(
611                    "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
612                    body.len()
613                );
614                stream.write_all(response.as_bytes()).await.unwrap();
615            }
616        });
617
618        let _ = rustls::crypto::ring::default_provider().install_default();
619        let mut plugin = BitbucketPlugin::new();
620        plugin.client = Some(client::BitbucketClient::new(
621            Arc::new(reqwest::Client::builder().no_proxy().build().unwrap()),
622            format!("http://{address}"),
623            "Basic stub".to_string(),
624        ));
625        plugin.provider_id = Some(1);
626        (plugin, requests)
627    }
628
629    #[tokio::test]
630    async fn run_details_finds_old_builds_without_a_page_cap() {
631        let (plugin, requests) = serve_runs(1150).await;
632
633        let run = plugin
634            .fetch_run_details("bitbucket__1__ws__repo", 1)
635            .await
636            .unwrap();
637        assert_eq!(run.run_number, 1);
638        assert_eq!(run.status, PipelineStatus::Running);
639        assert_eq!(requests.try_iter().count(), 12);
640
641        let latest = plugin
642            .fetch_run_details("bitbucket__1__ws__repo", 1150)
643            .await
644            .unwrap();
645        assert_eq!(latest.run_number, 1150);
646        assert_eq!(latest.status, PipelineStatus::Success);
647        assert_eq!(requests.try_iter().count(), 1);
648    }
649
650    #[tokio::test]
651    async fn only_repositories_without_runs_and_pipelines_file_are_dropped_from_a_selection() {
652        let (plugin, requests) = serve_runs(3).await;
653
654        let kept = plugin
655            .ids_with_ci(
656                ["ws/repo", "ws/empty", "ws/configured"]
657                    .map(String::from)
658                    .to_vec(),
659            )
660            .await
661            .unwrap();
662
663        assert_eq!(kept, ["ws/repo", "ws/configured"]);
664        assert!(requests
665            .try_iter()
666            .any(|line| line.contains("/ws/empty/src/HEAD/bitbucket-pipelines.yml")));
667    }
668
669    #[tokio::test]
670    async fn tracking_every_repository_lists_those_with_runs_or_a_pipelines_file() {
671        let (plugin, _requests) = serve_runs(3).await;
672
673        let pipelines = plugin.fetch_pipelines().await.unwrap();
674
675        assert_eq!(
676            pipelines
677                .iter()
678                .map(|pipeline| (pipeline.id.as_str(), pipeline.status))
679                .collect::<Vec<_>>(),
680            [
681                ("bitbucket__1__ws__repo", PipelineStatus::Success),
682                ("bitbucket__1__ws__configured", PipelineStatus::Pending)
683            ]
684        );
685    }
686
687    #[tokio::test]
688    async fn run_details_rejects_missing_builds_after_the_last_page() {
689        for total in [0, 150] {
690            let (plugin, requests) = serve_runs(total).await;
691
692            let error = plugin
693                .fetch_run_details("bitbucket__1__ws__repo", 9999)
694                .await
695                .unwrap_err();
696            assert!(matches!(error, PluginError::PipelineNotFound(_)));
697            assert_eq!(requests.try_iter().count(), if total == 0 { 1 } else { 2 });
698        }
699    }
700
701    #[tokio::test]
702    async fn cancel_run_stops_an_old_running_build_by_its_uuid() {
703        let (plugin, requests) = serve_runs(150).await;
704
705        plugin
706            .cancel_run("bitbucket__1__ws__repo", 1)
707            .await
708            .unwrap();
709
710        let requests: Vec<_> = requests.try_iter().collect();
711        assert_eq!(requests.len(), 4);
712        assert_eq!(
713            requests.last().unwrap(),
714            "POST /repositories/ws/repo/pipelines/%7B1%7D/stopPipeline HTTP/1.1"
715        );
716    }
717
718    const PIPELINE_UUID: &str = "{3f9d6e5c-2b1a-4c7d-8e9f-0a1b2c3d4e5f}";
719    const STEP_BUILD: &str = "{1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61}";
720    const STEP_TEST: &str = "{2c1f6d8b-1e22-4a7f-8b68-4d2e3f5a6b72}";
721    const STEP_DEPLOY: &str = "{3d207e9c-2f33-4b80-9c79-5e3f4a6b7c83}";
722    const STEP_RELEASE: &str = "{4e318fad-3044-4c91-8d8a-6f405b7c8d94}";
723    const STEP_SMOKE: &str = "{5f4290be-4155-4da2-9e9b-70516c8d9ea5}";
724    const STEP_PRUNED: &str = "{60503acf-5266-4eb3-8fac-81627d9eafb6}";
725    const STEP_EXPIRED: &str = "{71614bd0-6377-4fc4-90bd-92738eafb0c7}";
726    const BUILD_LOG: &str = "+ npm ci\nadded 214 packages\n+ npm run build\nbuilt in 9s\n";
727    const TEST_LOG: &str =
728        "+ make test\nrunning 3 tests\ntest result: FAILED. 2 passed; 1 failed\n";
729
730    struct Reply {
731        status: &'static str,
732        headers: Vec<(&'static str, String)>,
733        body: String,
734    }
735
736    fn reply(status: &'static str, body: &str) -> Reply {
737        Reply {
738            status,
739            headers: Vec::new(),
740            body: body.to_string(),
741        }
742    }
743
744    type Recorded = Arc<tokio::sync::Mutex<Vec<(String, Option<String>)>>>;
745
746    async fn stub(
747        handler: impl Fn(&str, &str) -> Reply + Send + 'static,
748    ) -> (SocketAddr, Recorded) {
749        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
750        let address = listener.local_addr().unwrap();
751        let recorded = Recorded::default();
752        let journal = recorded.clone();
753        tokio::spawn(async move {
754            while let Ok((mut stream, _)) = listener.accept().await {
755                let mut buffer = [0; 8192];
756                let count = stream.read(&mut buffer).await.unwrap();
757                let request = String::from_utf8_lossy(&buffer[..count]);
758                let mut lines = request.lines();
759                let request_line = lines.next().unwrap().to_string();
760                let header = |wanted: &str| {
761                    request
762                        .lines()
763                        .filter_map(|line| line.split_once(':'))
764                        .find(|(name, _)| name.eq_ignore_ascii_case(wanted))
765                        .map(|(_, value)| value.trim().to_string())
766                };
767                let target = request_line.split_whitespace().nth(1).unwrap();
768                let reply = handler(target, &header("host").unwrap());
769                journal
770                    .lock()
771                    .await
772                    .push((request_line.clone(), header("authorization")));
773                let headers: String = reply
774                    .headers
775                    .iter()
776                    .map(|(name, value)| format!("{name}: {value}\r\n"))
777                    .collect();
778                let response = format!(
779                    "HTTP/1.1 {}\r\n{headers}content-length: {}\r\nconnection: close\r\n\r\n{}",
780                    reply.status,
781                    reply.body.len(),
782                    reply.body
783                );
784                stream.write_all(response.as_bytes()).await.unwrap();
785            }
786        });
787        (address, recorded)
788    }
789
790    fn steps_path() -> String {
791        format!(
792            "/repositories/ws/repo/pipelines/{}/steps",
793            urlencoding::encode(PIPELINE_UUID)
794        )
795    }
796
797    fn log_path(step_uuid: &str) -> String {
798        format!("{}/{}/log", steps_path(), urlencoding::encode(step_uuid))
799    }
800
801    fn step(uuid: &str, name: &str, state: serde_json::Value, started: bool) -> serde_json::Value {
802        let mut step = serde_json::json!({"uuid": uuid, "name": name, "state": state});
803        if started {
804            step["started_on"] = "2026-10-28T10:00:00.000Z".into();
805        }
806        if state["name"] == "COMPLETED" && started {
807            step["completed_on"] = "2026-10-28T10:01:15.000Z".into();
808        }
809        step
810    }
811
812    fn completed(result: &str) -> serde_json::Value {
813        serde_json::json!({"name": "COMPLETED", "result": {"name": result}})
814    }
815
816    fn steps_page(host: &str, second_page: bool) -> String {
817        let (values, next) = if second_page {
818            (
819                vec![
820                    step(
821                        STEP_DEPLOY,
822                        "Deploy",
823                        serde_json::json!({"name": "IN_PROGRESS"}),
824                        true,
825                    ),
826                    step(
827                        STEP_RELEASE,
828                        "Release",
829                        serde_json::json!({"name": "PENDING"}),
830                        false,
831                    ),
832                    step(STEP_SMOKE, "Smoke", completed("NOT_RUN"), false),
833                ],
834                None,
835            )
836        } else {
837            (
838                vec![
839                    step(STEP_BUILD, "Build", completed("SUCCESSFUL"), true),
840                    step(STEP_TEST, "Test", completed("FAILED"), true),
841                ],
842                Some(format!("http://{host}{}?pagelen=100&page=2", steps_path())),
843            )
844        };
845        serde_json::json!({"values": values, "next": next, "pagelen": 100}).to_string()
846    }
847
848    fn pipelines_page() -> String {
849        serde_json::json!({
850            "values": [{
851                "uuid": PIPELINE_UUID,
852                "build_number": 7,
853                "state": {"name": "IN_PROGRESS"},
854                "target": {"type": "pipeline_ref_target", "ref_name": "main"},
855                "created_on": "2026-10-28T10:00:00Z",
856                "links": {}
857            }],
858            "pagelen": 100
859        })
860        .to_string()
861    }
862
863    async fn serve_pipeline_logs() -> (BitbucketPlugin, Recorded, Recorded) {
864        let (storage, stored) = stub(|target, _| match target {
865            "/storage/test.log" => reply("200 OK", TEST_LOG),
866            _ => reply("404 Not Found", "{}"),
867        })
868        .await;
869        let (api, requests) = stub(move |target, host| {
870            let url = reqwest::Url::parse(&format!("http://{host}{target}")).unwrap();
871            let path = url.path();
872            if path == "/repositories/ws/repo/pipelines" {
873                reply("200 OK", &pipelines_page())
874            } else if path == steps_path() {
875                let second_page = url.query_pairs().any(|(key, _)| key == "page");
876                reply("200 OK", &steps_page(host, second_page))
877            } else if path == log_path(STEP_BUILD) {
878                reply("200 OK", BUILD_LOG)
879            } else if path == log_path(STEP_TEST) {
880                Reply {
881                    status: "307 Temporary Redirect",
882                    headers: vec![("location", format!("http://{storage}/storage/test.log"))],
883                    body: String::new(),
884                }
885            } else if path == log_path(STEP_EXPIRED) {
886                reply("410 Gone", "{}")
887            } else {
888                reply("404 Not Found", "{}")
889            }
890        })
891        .await;
892        let _ = rustls::crypto::ring::default_provider().install_default();
893        let mut plugin = BitbucketPlugin::new();
894        plugin.client = Some(client::BitbucketClient::new(
895            Arc::new(reqwest::Client::builder().no_proxy().build().unwrap()),
896            format!("http://{api}"),
897            "Basic stub".to_string(),
898        ));
899        plugin.provider_id = Some(1);
900        (plugin, requests, stored)
901    }
902
903    fn run_7(metadata: &HashMap<String, serde_json::Value>) -> RunRef<'_> {
904        RunRef {
905            pipeline_id: "bitbucket__1__ws__repo",
906            run_number: 7,
907            run_id: "bitbucket__1__ws__repo__7",
908            metadata,
909        }
910    }
911
912    async fn read_step(plugin: &BitbucketPlugin, node_id: &str) -> PluginResult<Vec<u8>> {
913        let metadata = HashMap::new();
914        let (tx, mut rx) = tokio::sync::mpsc::channel(16);
915        let mut sink = LogSink::new(tx);
916        let result = plugin.read_log(run_7(&metadata), node_id, &mut sink).await;
917        drop(sink);
918        let mut bytes = Vec::new();
919        while let Some(chunk) = rx.recv().await {
920            bytes.extend_from_slice(&chunk);
921        }
922        result.map(|()| bytes)
923    }
924
925    #[tokio::test]
926    async fn steps_on_two_pages_become_job_nodes() {
927        let (plugin, requests, _) = serve_pipeline_logs().await;
928        let metadata = HashMap::new();
929
930        let nodes = plugin.fetch_log_nodes(run_7(&metadata)).await.unwrap();
931
932        let summary: Vec<_> = nodes
933            .iter()
934            .map(|node| (node.id.as_str(), node.name.as_str(), node.status, node.log))
935            .collect();
936        assert_eq!(
937            summary,
938            vec![
939                (
940                    STEP_BUILD,
941                    "Build",
942                    PipelineStatus::Success,
943                    Some(LogAvailability::Ready)
944                ),
945                (
946                    STEP_TEST,
947                    "Test",
948                    PipelineStatus::Failed,
949                    Some(LogAvailability::Ready)
950                ),
951                (
952                    STEP_DEPLOY,
953                    "Deploy",
954                    PipelineStatus::Running,
955                    Some(LogAvailability::Partial)
956                ),
957                (
958                    STEP_RELEASE,
959                    "Release",
960                    PipelineStatus::Pending,
961                    Some(LogAvailability::Pending)
962                ),
963                (STEP_SMOKE, "Smoke", PipelineStatus::Skipped, None),
964            ]
965        );
966        let lines: Vec<String> = requests
967            .lock()
968            .await
969            .iter()
970            .map(|(line, _)| line.clone())
971            .collect();
972        let steps = steps_path();
973        assert_eq!(
974            &lines[1..],
975            [
976                format!("GET {steps}?pagelen=100 HTTP/1.1"),
977                format!("GET {steps}?pagelen=100&page=2 HTTP/1.1"),
978            ]
979        );
980    }
981
982    #[tokio::test]
983    async fn step_log_streams_into_the_sink() {
984        let (plugin, requests, _) = serve_pipeline_logs().await;
985
986        let log = read_step(&plugin, STEP_BUILD).await.unwrap();
987
988        assert_eq!(log, BUILD_LOG.as_bytes());
989        let requests = requests.lock().await;
990        assert_eq!(requests.len(), 2);
991        assert_eq!(
992            requests[1],
993            (
994                format!("GET {} HTTP/1.1", log_path(STEP_BUILD)),
995                Some("Basic stub".to_string())
996            )
997        );
998    }
999
1000    #[tokio::test]
1001    async fn step_log_redirect_is_followed_without_the_api_credentials() {
1002        let (plugin, requests, stored) = serve_pipeline_logs().await;
1003
1004        let log = read_step(&plugin, STEP_TEST).await.unwrap();
1005
1006        assert_eq!(log, TEST_LOG.as_bytes());
1007        let requests = requests.lock().await;
1008        assert_eq!(requests.len(), 2);
1009        assert_eq!(requests[1].1.as_deref(), Some("Basic stub"));
1010        assert_eq!(
1011            *stored.lock().await,
1012            [("GET /storage/test.log HTTP/1.1".to_string(), None)]
1013        );
1014    }
1015
1016    #[tokio::test]
1017    async fn step_log_that_is_gone_is_not_found() {
1018        let (plugin, _, _) = serve_pipeline_logs().await;
1019
1020        for step in [STEP_PRUNED, STEP_EXPIRED] {
1021            let error = read_step(&plugin, step).await.unwrap_err();
1022            assert!(
1023                matches!(&error, PluginError::PipelineNotFound(message) if message.contains("no longer available")),
1024                "{step}: {error:?}"
1025            );
1026        }
1027    }
1028
1029    #[tokio::test]
1030    async fn step_ids_outside_the_braced_uuid_shape_are_rejected_without_a_request() {
1031        let (plugin, requests, stored) = serve_pipeline_logs().await;
1032
1033        let rejected = [
1034            "",
1035            "{}",
1036            "1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61",
1037            "%7B1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61%7D",
1038            "{1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a6}",
1039            "{1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a611}",
1040            "{1b0e5c7a0d11-4f6e-9a57-3c1d2e4f5a61--}",
1041            "{1g0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61}",
1042            "{../../../pipelines/%7Bx%7D/stopPipeline}",
1043            "{1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61}/log",
1044            "{1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61}?x=1",
1045            "{1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61}\n",
1046            " {1b0e5c7a-0d11-4f6e-9a57-3c1d2e4f5a61}",
1047        ];
1048        for node_id in rejected {
1049            let error = read_step(&plugin, node_id).await.unwrap_err();
1050            assert!(
1051                matches!(error, PluginError::InvalidConfig(_)),
1052                "{node_id:?}: {error:?}"
1053            );
1054        }
1055
1056        assert!(requests.lock().await.is_empty());
1057        assert!(stored.lock().await.is_empty());
1058    }
1059}