Skip to main content

pipedash_plugin_tekton/
plugin.rs

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(&params.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) = &params.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}