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