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