1use std::collections::HashMap;
2use std::sync::OnceLock;
3
4use async_trait::async_trait;
5use futures::future::{
6 join_all,
7 try_join_all,
8};
9use pipedash_plugin_api::*;
10
11use crate::{
12 client,
13 config,
14 mapper,
15 metadata,
16 types,
17};
18
19pub struct TektonPlugin {
20 metadata: PluginMetadata,
21 client: OnceLock<client::TektonClient>,
22 provider_id: Option<i64>,
23 config: HashMap<String, String>,
24}
25
26impl Default for TektonPlugin {
27 fn default() -> Self {
28 Self::new()
29 }
30}
31
32impl TektonPlugin {
33 pub fn new() -> Self {
34 Self {
35 metadata: metadata::create_metadata(),
36 client: OnceLock::new(),
37 provider_id: None,
38 config: HashMap::new(),
39 }
40 }
41
42 async fn client(&self) -> PluginResult<&client::TektonClient> {
43 if let Some(client) = self.client.get() {
44 return Ok(client);
45 }
46
47 let kubeconfig_path = config::get_kubeconfig_path(&self.config);
48 let context = config::get_context(&self.config);
49
50 let new_client =
51 client::TektonClient::from_kubeconfig(kubeconfig_path.as_deref(), context.as_deref())
52 .await?;
53
54 Ok(self.client.get_or_init(|| new_client))
55 }
56
57 async fn fetch_all_pipelines_in_namespaces(&self) -> PluginResult<Vec<types::TektonPipeline>> {
58 let client = self.client().await?;
59
60 let selected_ids = config::get_selected_pipelines(&self.config);
61
62 let namespaces = if selected_ids.is_empty() {
63 let namespace_mode = config::get_namespace_mode(&self.config);
64
65 match namespace_mode {
66 config::NamespaceMode::Custom => config::get_namespaces(&self.config),
67 config::NamespaceMode::All => client.list_namespaces_with_pipelines().await?,
68 }
69 } else {
70 let unique_namespaces: std::collections::HashSet<String> = selected_ids
71 .iter()
72 .filter_map(|id| {
73 id.split_once("__")
74 .map(|(namespace, _)| namespace.to_string())
75 })
76 .collect();
77 unique_namespaces.into_iter().collect()
78 };
79
80 let all_pipelines: Vec<types::TektonPipeline> = try_join_all(
81 namespaces
82 .iter()
83 .map(|namespace| client.list_pipelines(namespace)),
84 )
85 .await?
86 .into_iter()
87 .flatten()
88 .collect();
89
90 if selected_ids.is_empty() {
91 Ok(all_pipelines)
92 } else {
93 Ok(all_pipelines
94 .into_iter()
95 .filter(|p| {
96 let id = format!("{}__{}", p.metadata.namespace, p.metadata.name);
97 selected_ids.contains(&id)
98 })
99 .collect())
100 }
101 }
102
103 async fn fetch_latest_run_for_pipeline(
104 &self, namespace: &str, pipeline_name: &str,
105 ) -> Option<types::TektonPipelineRun> {
106 let client = self.client().await.ok()?;
107 let mut runs = client
108 .list_pipelineruns(namespace, Some(pipeline_name))
109 .await
110 .ok()?;
111
112 runs.sort_by(|a, b| {
113 let a_time = types::parse_timestamp(&a.metadata.creation_timestamp);
114 let b_time = types::parse_timestamp(&b.metadata.creation_timestamp);
115 b_time.cmp(&a_time)
116 });
117
118 runs.into_iter().next()
119 }
120
121 fn get_available_contexts(&self, kubeconfig_path: Option<&str>) -> PluginResult<Vec<String>> {
122 use std::collections::HashSet;
123 use std::path::PathBuf;
124
125 let paths = if let Some(path_str) = kubeconfig_path {
126 config::split_kubeconfig_paths(path_str)
127 } else {
128 let default_path = config::get_default_kubeconfig_path();
129 config::split_kubeconfig_paths(&default_path)
130 };
131
132 let mut all_contexts = HashSet::new();
133
134 for path_str in paths {
135 let path = PathBuf::from(&path_str);
136 if !path.exists() {
137 continue;
138 }
139
140 match kube::config::Kubeconfig::read_from(&path) {
141 Ok(kubeconfig) => {
142 for context in kubeconfig.contexts {
143 all_contexts.insert(context.name);
144 }
145 }
146 Err(_) => continue,
147 }
148 }
149
150 if all_contexts.is_empty() {
151 return Err(PluginError::InvalidConfig(
152 "No valid kubeconfig files found or no contexts available".to_string(),
153 ));
154 }
155
156 let mut contexts: Vec<String> = all_contexts.into_iter().collect();
157 contexts.sort();
158 Ok(contexts)
159 }
160}
161
162fn find_run(
163 runs: Vec<types::TektonPipelineRun>, run_number: i64,
164) -> PluginResult<types::TektonPipelineRun> {
165 let mut matching: Vec<_> = runs
166 .into_iter()
167 .filter(|r| mapper::run_number(&r.metadata) == run_number)
168 .collect();
169
170 match matching.len() {
171 0 => Err(PluginError::PipelineNotFound(format!(
172 "PipelineRun #{run_number} not found"
173 ))),
174 1 => Ok(matching.remove(0)),
175 _ => {
176 let names: Vec<_> = matching.iter().map(|r| r.metadata.name.as_str()).collect();
177 Err(PluginError::ApiError(format!(
178 "PipelineRuns {} share run number {run_number}. Use kubectl to pick one",
179 names.join(", ")
180 )))
181 }
182 }
183}
184
185#[async_trait]
186impl Plugin for TektonPlugin {
187 fn metadata(&self) -> &PluginMetadata {
188 &self.metadata
189 }
190
191 fn provider_type(&self) -> &str {
192 "tekton"
193 }
194
195 fn initialize(
196 &mut self, provider_id: i64, config: HashMap<String, String>,
197 _http_client: Option<std::sync::Arc<reqwest::Client>>,
198 ) -> PluginResult<()> {
199 self.provider_id = Some(provider_id);
200 self.config = config;
201 Ok(())
202 }
203
204 async fn validate_credentials(&self) -> PluginResult<bool> {
205 let client = self.client().await?;
206 let namespace_mode = config::get_namespace_mode(&self.config);
207
208 let namespaces = match namespace_mode {
209 config::NamespaceMode::Custom => {
210 let manual_namespaces = config::get_namespaces(&self.config);
211
212 if manual_namespaces.is_empty() {
213 return Err(PluginError::InvalidConfig(
214 "Namespace mode is set to 'custom' but no namespaces are specified. Please provide at least one namespace in the 'namespaces' field (e.g., 'default,tekton-pipelines').".to_string(),
215 ));
216 }
217
218 client
219 .validate_namespaces_have_pipelines(&manual_namespaces)
220 .await?
221 }
222 config::NamespaceMode::All => match client.try_list_namespaces_cluster_wide().await {
223 Ok(all_namespaces) => {
224 if all_namespaces.is_empty() {
225 return Err(PluginError::InvalidConfig(
226 "No namespaces found in the cluster. Please verify your cluster connection and permissions.".to_string(),
227 ));
228 }
229 client.list_namespaces_with_pipelines().await?
230 }
231 Err(e) => return Err(e),
232 },
233 };
234
235 if namespaces.is_empty() {
236 let hint = match namespace_mode {
237 config::NamespaceMode::Custom => "Verify that the specified namespaces exist and contain Tekton pipelines, and that you have permissions to access them.",
238 config::NamespaceMode::All => "Try switching to 'custom' namespace mode and manually specify the namespaces containing your Tekton pipelines.",
239 };
240
241 return Err(PluginError::InvalidConfig(format!(
242 "No Tekton pipelines found in any accessible namespace. {}",
243 hint
244 )));
245 }
246
247 Ok(true)
248 }
249
250 async fn fetch_available_pipelines(
251 &self, params: Option<PaginationParams>,
252 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
253 let params = params.unwrap_or_default();
254 let pipelines = self.fetch_all_pipelines_in_namespaces().await?;
255 let all_pipelines: Vec<_> = pipelines
256 .iter()
257 .map(mapper::map_available_pipeline)
258 .collect();
259
260 let total_count = all_pipelines.len();
261 let start = ((params.page - 1) * params.page_size).min(total_count);
262 let end = (start + params.page_size).min(total_count);
263 let items = all_pipelines[start..end].to_vec();
264
265 Ok(PaginatedResponse::new(
266 items,
267 params.page,
268 params.page_size,
269 total_count,
270 ))
271 }
272
273 async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
274 let provider_id = self
275 .provider_id
276 .ok_or_else(|| PluginError::Internal("Provider ID not set".to_string()))?;
277
278 let pipelines = self.fetch_all_pipelines_in_namespaces().await?;
279
280 let pipeline_futures = pipelines.iter().map(|pipeline| async move {
281 let latest_run = self
282 .fetch_latest_run_for_pipeline(
283 &pipeline.metadata.namespace,
284 &pipeline.metadata.name,
285 )
286 .await;
287 mapper::map_pipeline(pipeline, latest_run.as_ref(), provider_id)
288 });
289
290 let results = join_all(pipeline_futures).await;
291 Ok(results)
292 }
293
294 async fn fetch_run_history(
295 &self, pipeline_id: &str, limit: usize,
296 ) -> PluginResult<Vec<PipelineRun>> {
297 let (provider_id, namespace, pipeline_name) = config::parse_pipeline_id(pipeline_id)?;
298 let client = self.client().await?;
299
300 let mut runs = client
301 .list_pipelineruns(&namespace, Some(&pipeline_name))
302 .await?;
303
304 runs.sort_by(|a, b| {
305 let a_time = types::parse_timestamp(&a.metadata.creation_timestamp);
306 let b_time = types::parse_timestamp(&b.metadata.creation_timestamp);
307 b_time.cmp(&a_time)
308 });
309
310 let limited_runs: Vec<types::TektonPipelineRun> = runs.into_iter().take(limit).collect();
311
312 Ok(limited_runs
313 .iter()
314 .map(|run| mapper::map_pipeline_run(run, provider_id))
315 .collect())
316 }
317
318 async fn fetch_run_details(
319 &self, pipeline_id: &str, run_number: i64,
320 ) -> PluginResult<PipelineRun> {
321 let (provider_id, namespace, pipeline_name) = config::parse_pipeline_id(pipeline_id)?;
322 let client = self.client().await?;
323
324 let runs = client
325 .list_pipelineruns(&namespace, Some(&pipeline_name))
326 .await?;
327
328 let run = find_run(runs, run_number)?;
329
330 Ok(mapper::map_pipeline_run(&run, provider_id))
331 }
332
333 async fn fetch_workflow_parameters(
334 &self, workflow_id: &str,
335 ) -> PluginResult<Vec<WorkflowParameter>> {
336 let (_provider_id, namespace, pipeline_name) = config::parse_pipeline_id(workflow_id)?;
337 let client = self.client().await?;
338
339 let pipeline = client.get_pipeline(&namespace, &pipeline_name).await?;
340
341 Ok(mapper::map_workflow_parameters(&pipeline))
342 }
343
344 async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<String> {
345 let (_provider_id, namespace, pipeline_name) =
346 config::parse_pipeline_id(¶ms.workflow_id)?;
347
348 let client = self.client().await?;
349
350 let pipeline = client.get_pipeline(&namespace, &pipeline_name).await?;
351
352 let param_values: Vec<types::ParamValue> = if let Some(inputs) = ¶ms.inputs {
353 inputs
354 .as_object()
355 .map(|obj| {
356 obj.iter()
357 .map(|(key, value)| types::ParamValue {
358 name: key.clone(),
359 value: value.clone(),
360 })
361 .collect()
362 })
363 .unwrap_or_default()
364 } else {
365 vec![]
366 };
367
368 let workspaces: Vec<types::WorkspaceBinding> = pipeline
369 .spec
370 .workspaces
371 .iter()
372 .filter_map(|ws| {
373 if ws.optional.unwrap_or(false) {
374 None
375 } else {
376 Some(types::WorkspaceBinding {
377 name: ws.name.clone(),
378 empty_dir: Some(serde_json::json!({})),
379 persistent_volume_claim: None,
380 config_map: None,
381 secret: None,
382 })
383 }
384 })
385 .collect();
386
387 let run_name = format!("{}-{}", pipeline_name, chrono::Utc::now().timestamp());
388
389 let mut annotations = HashMap::new();
390 annotations.insert("tekton.dev/triggeredBy".to_string(), "pipedash".to_string());
391
392 let pipelinerun = types::TektonPipelineRun {
393 api_version: "tekton.dev/v1".to_string(),
394 kind: "PipelineRun".to_string(),
395 metadata: types::ObjectMeta {
396 name: run_name.clone(),
397 namespace: namespace.clone(),
398 uid: None,
399 creation_timestamp: None,
400 labels: HashMap::new(),
401 annotations,
402 },
403 spec: types::PipelineRunSpec {
404 pipeline_ref: Some(types::PipelineRef {
405 name: pipeline_name.clone(),
406 }),
407 params: param_values,
408 workspaces,
409 timeout: None,
410 task_run_template: None,
411 },
412 status: types::PipelineRunStatus {
413 conditions: vec![],
414 start_time: None,
415 completion_time: None,
416 task_runs: HashMap::new(),
417 child_references: vec![],
418 },
419 };
420
421 let created_run = client.create_pipelinerun(&namespace, &pipelinerun).await?;
422
423 Ok(format!(
424 "PipelineRun created: {}/{}",
425 namespace, created_run.metadata.name
426 ))
427 }
428
429 async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
430 let (_provider_id, namespace, pipeline_name) = config::parse_pipeline_id(pipeline_id)?;
431 let client = self.client().await?;
432
433 let runs = client
434 .list_pipelineruns(&namespace, Some(&pipeline_name))
435 .await?;
436
437 let run = find_run(runs, run_number)?;
438
439 client
440 .delete_pipelinerun(&namespace, &run.metadata.name)
441 .await?;
442
443 Ok(())
444 }
445
446 async fn fetch_organizations(&self) -> PluginResult<Vec<Organization>> {
447 Ok(vec![Organization {
448 id: "default".to_string(),
449 name: "All Namespaces".to_string(),
450 description: Some("All accessible Kubernetes namespaces".to_string()),
451 }])
452 }
453
454 async fn fetch_available_pipelines_filtered(
455 &self, _org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
456 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
457 let params = params.unwrap_or_default();
458 let pipelines = self.fetch_all_pipelines_in_namespaces().await?;
459 let mut all_pipelines: Vec<_> = pipelines
460 .iter()
461 .map(mapper::map_available_pipeline)
462 .collect();
463
464 if let Some(search_term) = search {
465 let search_lower = search_term.to_lowercase();
466 all_pipelines.retain(|p| {
467 p.name.to_lowercase().contains(&search_lower)
468 || p.id.to_lowercase().contains(&search_lower)
469 || p.description
470 .as_ref()
471 .is_some_and(|d| d.to_lowercase().contains(&search_lower))
472 });
473 }
474
475 let total_count = all_pipelines.len();
476 let start = ((params.page - 1) * params.page_size).min(total_count);
477 let end = (start + params.page_size).min(total_count);
478 let items = all_pipelines[start..end].to_vec();
479
480 Ok(PaginatedResponse::new(
481 items,
482 params.page,
483 params.page_size,
484 total_count,
485 ))
486 }
487
488 async fn fetch_agents(&self) -> PluginResult<Vec<BuildAgent>> {
489 Err(PluginError::NotSupported(
490 "Build agents not supported by Tekton plugin".to_string(),
491 ))
492 }
493
494 async fn fetch_artifacts(&self, _run_id: &str) -> PluginResult<Vec<BuildArtifact>> {
495 Err(PluginError::NotSupported(
496 "Artifacts not implemented for Tekton plugin".to_string(),
497 ))
498 }
499
500 async fn fetch_queues(&self) -> PluginResult<Vec<BuildQueue>> {
501 Err(PluginError::NotSupported(
502 "Build queues not supported by Tekton plugin".to_string(),
503 ))
504 }
505
506 fn get_migrations(&self) -> Vec<String> {
507 vec![]
508 }
509
510 async fn get_field_options(
511 &self, field_key: &str, config: &HashMap<String, String>,
512 ) -> PluginResult<Vec<String>> {
513 if field_key == "context" {
514 let kubeconfig_path = config::get_kubeconfig_path(config);
515 let contexts = self.get_available_contexts(kubeconfig_path.as_deref())?;
516 Ok(contexts)
517 } else if field_key == "namespaces" {
518 let kubeconfig_path = config::get_kubeconfig_path(config);
519 let context = config::get_context(config);
520
521 match client::TektonClient::from_kubeconfig(
522 kubeconfig_path.as_deref(),
523 context.as_deref(),
524 )
525 .await
526 {
527 Ok(temp_client) => match temp_client.try_list_namespaces_cluster_wide().await {
528 Ok(namespaces) => Ok(namespaces),
529 Err(e) => {
530 tracing::warn!(error = %e, "Failed to fetch namespaces for Tekton autocomplete");
531 Ok(Vec::new())
532 }
533 },
534 Err(e) => {
535 tracing::warn!(error = %e, "Failed to create Tekton client for namespace autocomplete");
536 Ok(Vec::new())
537 }
538 }
539 } else {
540 Ok(Vec::new())
541 }
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use std::io::{
548 Read,
549 Write,
550 };
551 use std::net::TcpListener;
552 use std::sync::mpsc;
553
554 use super::*;
555
556 const CREATED: &str = "2026-10-20T10:00:00Z";
557
558 fn run_json(name: &str, pipeline: &str, uid: &str) -> String {
559 format!(
560 r#"{{"apiVersion":"tekton.dev/v1","kind":"PipelineRun","metadata":{{"name":"{name}","namespace":"ci","uid":"{uid}","creationTimestamp":"{CREATED}","labels":{{"tekton.dev/pipeline":"{pipeline}"}}}},"spec":{{"pipelineRef":{{"name":"{pipeline}"}}}},"status":{{"startTime":"{CREATED}","conditions":[{{"type":"Running","status":"Unknown","reason":"Running"}}]}}}}"#
561 )
562 }
563
564 fn stub_apiserver(
565 runs: &'static [(&'static str, &'static str, &'static str)],
566 ) -> (String, mpsc::Receiver<String>) {
567 let (log, requests) = mpsc::channel();
568 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
569 let addr = listener.local_addr().unwrap();
570 std::thread::spawn(move || {
571 for mut stream in listener.incoming().flatten() {
572 let mut head = Vec::new();
573 let mut byte = [0u8; 1];
574 while !head.ends_with(b"\r\n\r\n") {
575 match stream.read(&mut byte) {
576 Ok(0) | Err(_) => break,
577 Ok(_) => head.push(byte[0]),
578 }
579 }
580 let head = String::from_utf8_lossy(&head).to_string();
581 let mut parts = head.split_whitespace();
582 let method = parts.next().unwrap_or_default().to_string();
583 let target = parts.next().unwrap_or_default().to_string();
584 let _ = log.send(format!("{method} {target}"));
585
586 let body = if method == "GET" && target.contains("/pipelines") {
587 let mut names: Vec<&str> =
588 runs.iter().map(|(_, pipeline, _)| *pipeline).collect();
589 names.dedup();
590 let items: Vec<String> = names
591 .iter()
592 .map(|name| {
593 format!(
594 r#"{{"apiVersion":"tekton.dev/v1","kind":"Pipeline","metadata":{{"name":"{name}","namespace":"ci"}},"spec":{{}}}}"#
595 )
596 })
597 .collect();
598 format!(r#"{{"items":[{}]}}"#, items.join(","))
599 } else if method == "GET" {
600 let selector = target.split("labelSelector=tekton.dev/pipeline=").nth(1);
601 let items: Vec<String> = runs
602 .iter()
603 .filter(|(_, pipeline, _)| selector.is_none_or(|s| s == *pipeline))
604 .map(|(name, pipeline, uid)| run_json(name, pipeline, uid))
605 .collect();
606 format!(r#"{{"items":[{}]}}"#, items.join(","))
607 } else {
608 "{}".to_string()
609 };
610 let status = if target.contains("/namespaces/denied/") {
611 "403 Forbidden"
612 } else {
613 "200 OK"
614 };
615 let response = format!(
616 "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
617 body.len()
618 );
619 let _ = stream.write_all(response.as_bytes());
620 }
621 });
622 (format!("http://{addr}"), requests)
623 }
624
625 fn plugin(server: &str, dir: &tempfile::TempDir) -> TektonPlugin {
626 let kubeconfig = dir.path().join("kubeconfig");
627 std::fs::write(
628 &kubeconfig,
629 format!(
630 "apiVersion: v1\nkind: Config\nclusters:\n- name: c\n cluster:\n server: {server}\ncontexts:\n- name: ctx\n context:\n cluster: c\n user: u\ncurrent-context: ctx\nusers:\n- name: u\n user:\n token: t\n"
631 ),
632 )
633 .unwrap();
634 let mut config = HashMap::new();
635 config.insert(
636 "kubeconfig_path".to_string(),
637 kubeconfig.to_string_lossy().to_string(),
638 );
639 config.insert("namespace_mode".to_string(), "custom".to_string());
640 config.insert("namespaces".to_string(), "ci".to_string());
641 let mut plugin = TektonPlugin::new();
642 plugin.initialize(1, config, None).unwrap();
643 plugin
644 }
645
646 fn deletes(requests: &mpsc::Receiver<String>) -> Vec<String> {
647 requests
648 .try_iter()
649 .filter(|line| line.starts_with("DELETE"))
650 .collect()
651 }
652
653 const TWO_PIPELINES: &[(&str, &str, &str)] = &[
654 (
655 "build-run-1",
656 "build",
657 "0000000a-0000-4000-8000-000000000000",
658 ),
659 ("lint-run-1", "lint", "0000000b-0000-4000-8000-000000000000"),
660 ];
661
662 #[tokio::test]
663 async fn fetch_pipelines_loads_only_selected_pipelines() {
664 let dir = tempfile::tempdir().unwrap();
665 let (server, _requests) = stub_apiserver(TWO_PIPELINES);
666 let mut plugin = plugin(&server, &dir);
667 let available = plugin.fetch_available_pipelines(None).await.unwrap();
668 let build = available.items.iter().find(|p| p.name == "build").unwrap();
669
670 let mut config = plugin.config.clone();
671 config.insert("selected_items".to_string(), build.id.clone());
672 plugin.initialize(1, config, None).unwrap();
673 let ids: Vec<String> = plugin
674 .fetch_pipelines()
675 .await
676 .unwrap()
677 .into_iter()
678 .map(|p| p.id)
679 .collect();
680
681 assert_eq!(ids, vec!["tekton__1__ci__build"]);
682 }
683
684 #[tokio::test]
685 async fn fetch_pipelines_fails_when_any_namespace_is_forbidden() {
686 let dir = tempfile::tempdir().unwrap();
687 let (server, _requests) = stub_apiserver(TWO_PIPELINES);
688 let mut plugin = plugin(&server, &dir);
689 let mut config = plugin.config.clone();
690 config.insert("namespaces".to_string(), "ci,denied".to_string());
691 plugin.initialize(1, config, None).unwrap();
692
693 assert!(plugin.fetch_pipelines().await.is_err());
694 }
695
696 #[tokio::test]
697 async fn run_details_returns_run_of_requested_pipeline() {
698 let dir = tempfile::tempdir().unwrap();
699 let (server, _requests) = stub_apiserver(TWO_PIPELINES);
700 let plugin = plugin(&server, &dir);
701
702 let history = plugin
703 .fetch_run_history("tekton__1__ci__lint", 10)
704 .await
705 .unwrap();
706 let run = plugin
707 .fetch_run_details("tekton__1__ci__lint", history[0].run_number)
708 .await
709 .unwrap();
710
711 assert_eq!(run.pipeline_id, "tekton__1__ci__lint");
712 assert_eq!(run.id, "tekton__1__ci__lint-run-1");
713 }
714
715 #[tokio::test]
716 async fn cancel_run_deletes_run_of_requested_pipeline() {
717 let dir = tempfile::tempdir().unwrap();
718 let (server, requests) = stub_apiserver(TWO_PIPELINES);
719 let plugin = plugin(&server, &dir);
720
721 let history = plugin
722 .fetch_run_history("tekton__1__ci__lint", 10)
723 .await
724 .unwrap();
725 plugin
726 .cancel_run("tekton__1__ci__lint", history[0].run_number)
727 .await
728 .unwrap();
729
730 assert_eq!(
731 deletes(&requests),
732 vec!["DELETE /apis/tekton.dev/v1/namespaces/ci/pipelineruns/lint-run-1"]
733 );
734 }
735
736 #[tokio::test]
737 async fn cancel_run_deletes_only_the_requested_run_when_runs_share_the_second() {
738 let dir = tempfile::tempdir().unwrap();
739 let (server, requests) = stub_apiserver(&[
740 ("lint-run-1", "lint", "0000000b-0000-4000-8000-000000000000"),
741 ("lint-run-2", "lint", "0000000c-0000-4000-8000-000000000000"),
742 ]);
743 let plugin = plugin(&server, &dir);
744
745 let history = plugin
746 .fetch_run_history("tekton__1__ci__lint", 10)
747 .await
748 .unwrap();
749 assert_ne!(history[0].run_number, history[1].run_number);
750 let target = history
751 .iter()
752 .find(|run| run.id == "tekton__1__ci__lint-run-2")
753 .unwrap();
754 plugin
755 .cancel_run("tekton__1__ci__lint", target.run_number)
756 .await
757 .unwrap();
758
759 assert_eq!(
760 deletes(&requests),
761 vec!["DELETE /apis/tekton.dev/v1/namespaces/ci/pipelineruns/lint-run-2"]
762 );
763 }
764
765 #[tokio::test]
766 async fn cancel_run_refuses_when_run_numbers_collide() {
767 let dir = tempfile::tempdir().unwrap();
768 let (server, requests) = stub_apiserver(&[
769 ("lint-run-1", "lint", "00000001-0000-4000-8000-000000000001"),
770 ("lint-run-2", "lint", "00000001-0000-4000-8000-000000000002"),
771 ]);
772 let plugin = plugin(&server, &dir);
773
774 let history = plugin
775 .fetch_run_history("tekton__1__ci__lint", 10)
776 .await
777 .unwrap();
778 assert_eq!(history[0].run_number, history[1].run_number);
779 let result = plugin
780 .cancel_run("tekton__1__ci__lint", history[0].run_number)
781 .await;
782
783 assert!(result.is_err(), "{result:?}");
784 assert_eq!(deletes(&requests), Vec::<String>::new());
785 assert!(plugin
786 .fetch_run_details("tekton__1__ci__lint", history[0].run_number)
787 .await
788 .is_err());
789 }
790}