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