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(¶ms).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(¶ms).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, ¶ms).await?
223 } else {
224 client.list_all_repositories(¶ms).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(¶ms.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}