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(¶ms.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) = ¶ms.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}