1use std::collections::HashMap;
2use std::sync::Arc;
3use std::time::Duration;
4
5use async_trait::async_trait;
6use futures::future::try_join_all;
7use pipedash_plugin_actions::{
8 dispatch_body,
9 dispatch_inputs,
10};
11use pipedash_plugin_api::*;
12
13use crate::log_dialect::GiteaLogDialect;
14use crate::{
15 client,
16 config,
17 mapper,
18 metadata,
19};
20
21pub struct GiteaPlugin {
22 metadata: PluginMetadata,
23 client: Option<client::GiteaClient>,
24 provider_id: Option<i64>,
25 config: HashMap<String, String>,
26 status_source: Option<StatusSource>,
27}
28
29impl Default for GiteaPlugin {
30 fn default() -> Self {
31 Self::new()
32 }
33}
34
35impl GiteaPlugin {
36 pub fn new() -> Self {
37 Self {
38 metadata: metadata::create_metadata(),
39 client: None,
40 provider_id: None,
41 config: HashMap::new(),
42 status_source: None,
43 }
44 }
45
46 fn client(&self) -> PluginResult<&client::GiteaClient> {
47 self.client
48 .as_ref()
49 .ok_or_else(|| PluginError::Internal("Plugin not initialized".to_string()))
50 }
51
52 fn provider_id(&self) -> PluginResult<i64> {
53 self.provider_id
54 .ok_or_else(|| PluginError::Internal("Provider ID not set".to_string()))
55 }
56
57 async fn fetch_repo_pipelines(
58 &self, provider_id: i64, repo_full_name: &str,
59 ) -> PluginResult<Vec<Pipeline>> {
60 let (owner, repo) = config::parse_repo(repo_full_name)?;
61 let client = self.client()?;
62 let workflows = client.list_workflows(owner, repo).await?;
63 let workflow_ids: Vec<String> = workflows.iter().map(|w| w.id.clone()).collect();
64 let latest_runs = client.latest_runs(owner, repo, &workflow_ids).await?;
65
66 Ok(workflows
67 .iter()
68 .map(|workflow| {
69 let latest_run = latest_runs.get(&workflow.id);
70 mapper::map_pipeline(provider_id, owner, repo, workflow, latest_run)
71 })
72 .collect())
73 }
74}
75
76#[async_trait]
77impl Plugin for GiteaPlugin {
78 fn metadata(&self) -> &PluginMetadata {
79 &self.metadata
80 }
81
82 fn status_source(&self) -> Option<&StatusSource> {
83 self.status_source.as_ref()
84 }
85
86 fn initialize(
87 &mut self, provider_id: i64, config: HashMap<String, String>,
88 http_client: Option<Arc<reqwest::Client>>,
89 ) -> PluginResult<()> {
90 let base_url = config::get_base_url(&config)?;
91 let token = config
92 .get("token")
93 .map(|token| token.trim())
94 .filter(|token| !token.is_empty())
95 .ok_or_else(|| PluginError::InvalidConfig("Missing Gitea access token".to_string()))?
96 .to_string();
97 let status_source = custom_status_source(&config)?;
98
99 let http_client = http_client.unwrap_or_else(|| {
100 Arc::new(
101 reqwest::Client::builder()
102 .use_rustls_tls()
103 .https_only(cfg!(target_os = "ios"))
104 .pool_max_idle_per_host(10)
105 .timeout(Duration::from_secs(30))
106 .connect_timeout(Duration::from_secs(10))
107 .tcp_keepalive(Duration::from_secs(60))
108 .build()
109 .expect("Failed to build HTTP client"),
110 )
111 });
112
113 self.status_source = status_source;
114 self.client = Some(client::GiteaClient::new(http_client, base_url, token));
115 self.provider_id = Some(provider_id);
116 self.config = config;
117 Ok(())
118 }
119
120 async fn validate_credentials(&self) -> PluginResult<bool> {
121 self.client()?.get_user().await?;
122 Ok(true)
123 }
124
125 async fn fetch_available_pipelines(
126 &self, params: Option<PaginationParams>,
127 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
128 self.fetch_available_pipelines_filtered(None, None, params)
129 .await
130 }
131
132 async fn fetch_organizations(&self) -> PluginResult<Vec<Organization>> {
133 self.client()?.list_organizations().await
134 }
135
136 async fn ids_with_ci(&self, ids: Vec<String>) -> PluginResult<Vec<String>> {
137 let client = self.client()?;
138 Ok(retain_with_ci(ids, |id| async move {
139 let (owner, repo) = config::parse_repo(&id)?;
140 Ok(!client.list_workflows(owner, repo).await?.is_empty())
141 })
142 .await)
143 }
144
145 async fn fetch_available_pipelines_filtered(
146 &self, org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
147 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
148 let client = self.client()?;
149 let params = params.unwrap_or_default();
150 let (uid, exclusive) = match org {
151 Some(org) => (
152 org.parse().map_err(|_| {
153 PluginError::InvalidConfig(format!("Invalid organization ID: {org}"))
154 })?,
155 true,
156 ),
157 None => (client.get_user().await?.id, false),
158 };
159
160 let response = client
161 .search_repositories(uid, exclusive, search.as_deref(), ¶ms)
162 .await?;
163
164 Ok(PaginatedResponse {
165 items: response
166 .items
167 .iter()
168 .map(mapper::map_available_pipeline)
169 .collect(),
170 page: response.page,
171 page_size: response.page_size,
172 total_count: response.total_count,
173 total_pages: response.total_pages,
174 has_more: response.has_more,
175 })
176 }
177
178 async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
179 let provider_id = self.provider_id()?;
180 let repositories = config::get_repositories(&self.config);
181 if repositories.is_empty() {
182 return Err(PluginError::InvalidConfig(
183 "No repositories configured".to_string(),
184 ));
185 }
186
187 let results = try_join_all(
188 repositories
189 .iter()
190 .map(|repo| self.fetch_repo_pipelines(provider_id, repo)),
191 )
192 .await?;
193
194 Ok(results.into_iter().flatten().collect())
195 }
196
197 async fn fetch_run_history(
198 &self, pipeline_id: &str, limit: usize,
199 ) -> PluginResult<Vec<PipelineRun>> {
200 let id = config::parse_pipeline_id(pipeline_id)?;
201 let runs = self
202 .client()?
203 .workflow_runs(id.owner, id.repo, id.workflow, limit)
204 .await?;
205 Ok(runs
206 .iter()
207 .map(|run| mapper::map_run(run, pipeline_id, id.owner))
208 .collect())
209 }
210
211 async fn fetch_run_details(
212 &self, pipeline_id: &str, run_number: i64,
213 ) -> PluginResult<PipelineRun> {
214 let id = config::parse_pipeline_id(pipeline_id)?;
215 let run = self
216 .client()?
217 .find_run(id.owner, id.repo, id.workflow, run_number)
218 .await?;
219 Ok(mapper::map_run(&run, pipeline_id, id.owner))
220 }
221
222 async fn fetch_log_nodes(&self, run: RunRef<'_>) -> PluginResult<Vec<LogNode>> {
223 let id = config::parse_pipeline_id(run.pipeline_id)?;
224 let run_id = run
225 .metadata
226 .get("run_id")
227 .and_then(serde_json::Value::as_i64)
228 .ok_or_else(|| {
229 PluginError::InvalidConfig(format!("Run #{} has no Gitea run ID", run.run_number))
230 })?;
231
232 let jobs = self
233 .client()?
234 .list_run_jobs(id.owner, id.repo, run_id)
235 .await?;
236
237 Ok(mapper::jobs_to_log_nodes(&jobs))
238 }
239
240 async fn read_log(
241 &self, run: RunRef<'_>, node_id: &str, sink: &mut LogSink,
242 ) -> PluginResult<()> {
243 let id = config::parse_pipeline_id(run.pipeline_id)?;
244 if node_id.is_empty() || !node_id.bytes().all(|b| b.is_ascii_digit()) {
245 return Err(PluginError::InvalidConfig(format!(
246 "Invalid Gitea job ID: {node_id}"
247 )));
248 }
249
250 self.client()?
251 .stream_job_log(id.owner, id.repo, node_id, sink)
252 .await
253 }
254
255 fn log_dialect(&self) -> &'static dyn LogDialect {
256 &GiteaLogDialect
257 }
258
259 async fn fetch_workflow_parameters(
260 &self, workflow_id: &str,
261 ) -> PluginResult<Vec<WorkflowParameter>> {
262 let id = config::parse_pipeline_id(workflow_id)?;
263 let client = self.client()?;
264 let workflows = client.list_workflows(id.owner, id.repo).await?;
265 let path = workflows
266 .iter()
267 .find(|workflow| workflow.id == id.workflow)
268 .map(|workflow| workflow.path.as_str())
269 .ok_or_else(|| {
270 PluginError::PipelineNotFound(format!("Workflow {} not found", id.workflow))
271 })?;
272 let source = client.raw_file(id.owner, id.repo, path).await?;
273
274 let mut parameters = vec![WorkflowParameter {
275 name: "ref".to_string(),
276 label: Some("Ref".to_string()),
277 description: Some("Branch, tag, or commit SHA to run workflow on".to_string()),
278 param_type: WorkflowParameterType::String {
279 default: Some("main".to_string()),
280 },
281 required: true,
282 }];
283 parameters.extend(dispatch_inputs(&source)?);
284 Ok(parameters)
285 }
286
287 async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<String> {
288 let id = config::parse_pipeline_id(¶ms.workflow_id)?;
289 let client = self.client()?;
290
291 let body = dispatch_body(params.inputs);
292
293 client
294 .dispatch_workflow(id.owner, id.repo, id.workflow, &body)
295 .await?;
296 Ok(client.actions_url(id.owner, id.repo))
297 }
298
299 async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
300 let id = config::parse_pipeline_id(pipeline_id)?;
301 let client = self.client()?;
302 let run = client
303 .find_run(id.owner, id.repo, id.workflow, run_number)
304 .await?;
305 client.cancel_run(id.owner, id.repo, run.id).await
306 }
307}
308
309#[cfg(all(test, not(target_os = "ios")))]
310mod tests {
311 use std::sync::atomic::{
312 AtomicUsize,
313 Ordering,
314 };
315
316 use tokio::io::{
317 AsyncReadExt,
318 AsyncWriteExt,
319 };
320
321 use super::*;
322
323 const STUB_REPOS: usize = 150;
324 const STUB_ORGS: usize = 70;
325 const MAX_RESPONSE_ITEMS: usize = 50;
326 const DEFAULT_PAGING_NUM: usize = 30;
327
328 fn stub_page(url: &reqwest::Url, total: usize) -> std::ops::Range<usize> {
329 let query = |key: &str| {
330 url.query_pairs()
331 .find(|(k, _)| k == key)
332 .map(|(_, v)| v.parse::<usize>().unwrap())
333 };
334 let limit = query("limit")
335 .unwrap_or(DEFAULT_PAGING_NUM)
336 .min(MAX_RESPONSE_ITEMS);
337 let page = query("page").unwrap_or(1);
338 ((page - 1) * limit).min(total)..(page * limit).min(total)
339 }
340
341 const JOB_LOG: &str = "2026-10-28T10:00:01.1000000Z ::group::Set up job\n\
342 2026-10-28T10:00:02.2000000Z cloning into /workspace\n\
343 2026-10-28T10:00:03.3000000Z ::endgroup::\n";
344
345 fn stub_jobs(page: usize) -> serde_json::Value {
346 let step = |number: i64, name: &str, status: &str, conclusion: &str| {
347 let completed_at = if status == "completed" {
348 format!("2026-10-28T10:00:0{}Z", number + 2)
349 } else {
350 "1970-01-01T00:00:00Z".to_string()
351 };
352 serde_json::json!({
353 "name": name, "number": number, "status": status, "conclusion": conclusion,
354 "started_at": format!("2026-10-28T10:00:0{}Z", number + 1),
355 "completed_at": completed_at
356 })
357 };
358 let job = |id: i64, name: &str, status: &str, conclusion: &str, started: &str| {
359 serde_json::json!({
360 "id": id, "name": name, "status": status, "conclusion": conclusion,
361 "html_url": format!("http://g/claude/smoke__ci/actions/runs/7/jobs/{}", id - 101),
362 "started_at": started, "completed_at": "1970-01-01T00:00:00Z",
363 "steps": null
364 })
365 };
366 let unset = "1970-01-01T00:00:00Z";
367 let mut build = job(101, "build", "completed", "success", "2026-10-28T10:00:00Z");
368 build["steps"] = serde_json::json!([
369 step(0, "Set up job", "completed", "success"),
370 step(1, "Run tests", "completed", "success")
371 ]);
372 let mut lint = job(102, "lint", "in_progress", "", "2026-10-28T10:00:00Z");
373 lint["steps"] = serde_json::json!([step(0, "Set up job", "in_progress", "")]);
374 let jobs = match page {
375 1 => vec![build, lint],
376 2 => vec![
377 job(103, "deploy", "queued", "", unset),
378 job(104, "publish", "completed", "skipped", unset),
379 ],
380 _ => Vec::new(),
381 };
382 serde_json::json!({"jobs": jobs, "total_count": 4})
383 }
384
385 async fn stub_gitea() -> (GiteaPlugin, tokio::task::JoinHandle<()>) {
386 let (plugin, server, _) = stub_gitea_counting().await;
387 (plugin, server)
388 }
389
390 async fn stub_gitea_counting() -> (GiteaPlugin, tokio::task::JoinHandle<()>, Arc<AtomicUsize>) {
391 let _ = rustls::crypto::ring::default_provider().install_default();
392 let requests = Arc::new(AtomicUsize::new(0));
393 let counter = requests.clone();
394 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
395 let addr = listener.local_addr().unwrap();
396 let server = tokio::spawn(async move {
397 loop {
398 let (mut stream, _) = listener.accept().await.unwrap();
399 let mut request = Vec::new();
400 let mut buffer = [0; 4096];
401 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
402 let count = stream.read(&mut buffer).await.unwrap();
403 if count == 0 {
404 break;
405 }
406 request.extend_from_slice(&buffer[..count]);
407 }
408 let request = String::from_utf8(request).unwrap();
409 counter.fetch_add(1, Ordering::SeqCst);
410 let mut head = request.split_whitespace();
411 let method = head.next().unwrap();
412 let url =
413 reqwest::Url::parse(&format!("http://{addr}{}", head.next().unwrap())).unwrap();
414 let mut total_count = None;
415 let (status, body) = match (method, url.path()) {
416 ("GET", "/api/v1/repos/search") => {
417 total_count = Some(STUB_REPOS);
418 let data: Vec<_> = stub_page(&url, STUB_REPOS)
419 .map(|i| {
420 serde_json::json!({
421 "name": format!("r{i}"),
422 "full_name": format!("org/r{i}"),
423 "owner": {"login": "org"}
424 })
425 })
426 .collect();
427 (200, serde_json::json!({"data": data}))
428 }
429 ("GET", "/api/v1/user/orgs") => {
430 total_count = Some(STUB_ORGS);
431 let orgs: Vec<_> = stub_page(&url, STUB_ORGS)
432 .map(|i| serde_json::json!({"id": 100 + i, "username": format!("o{i}")}))
433 .collect();
434 (200, serde_json::json!(orgs))
435 }
436 ("GET", "/api/v1/user") => {
437 (200, serde_json::json!({"id": 2, "login": "claude"}))
438 }
439 ("GET", "/api/v1/repos/claude/smoke__ci/actions/workflows") => (
440 200,
441 serde_json::json!({"workflows": [
442 {"id": "build__test.yaml", "name": "Build", "path": ".gitea/workflows/build__test.yaml"},
443 {"id": "lint.yaml", "name": "Lint", "path": ".gitea/workflows/lint.yaml"}
444 ]}),
445 ),
446 ("GET", "/api/v1/repos/claude/smoke__ci/actions/runs") => {
447 let page: usize = url
448 .query_pairs()
449 .find(|(key, _)| key == "page")
450 .map(|(_, value)| value.parse().unwrap())
451 .unwrap();
452 let runs: Vec<_> = if page == 1 {
453 (0..50)
454 .map(|i| {
455 serde_json::json!({
456 "id": 100 - i, "run_number": 100 - i, "status": "queued",
457 "path": "build__test.yaml@refs/heads/main", "head_branch": "main",
458 "html_url": format!("http://g/claude/smoke__ci/actions/runs/{}", 100 - i)
459 })
460 })
461 .collect()
462 } else {
463 vec![
464 serde_json::json!({"id": 3, "run_number": 3, "status": "completed", "conclusion": "failure",
465 "path": "lint.yaml@refs/heads/main", "head_branch": "main",
466 "html_url": "http://g/claude/smoke__ci/actions/runs/3"}),
467 serde_json::json!({"id": 2, "run_number": 2, "status": "queued",
468 "path": "build__test.yaml@refs/heads/main", "head_branch": "main",
469 "html_url": "http://g/claude/smoke__ci/actions/runs/2"}),
470 serde_json::json!({"id": 1, "run_number": 1, "status": "completed", "conclusion": "success",
471 "path": "build__test.yaml@refs/heads/main", "head_branch": "main",
472 "html_url": "http://g/claude/smoke__ci/actions/runs/1"}),
473 ]
474 };
475 (200, serde_json::json!({"workflow_runs": runs}))
476 }
477 ("POST", "/api/v1/repos/claude/smoke__ci/actions/runs/2/cancel") => {
478 (404, serde_json::json!({"message": "not found"}))
479 }
480 ("GET", "/api/v1/repos/claude/missing/actions/workflows") => {
481 (404, serde_json::json!({"message": "not found"}))
482 }
483 ("GET", "/api/v1/repos/claude/empty/actions/workflows") => {
484 (200, serde_json::json!({"workflows": []}))
485 }
486 ("GET", "/api/v1/repos/claude/smoke__ci/raw/.gitea/workflows/build__test.yaml") => {
487 (200, serde_json::json!("on:\n workflow_dispatch:\n inputs:\n greeting:\n description: Greeting\n default: hi\n"))
488 }
489 ("GET", "/api/v1/repos/claude/smoke__ci/actions/runs/7/jobs") => {
490 let page = url
491 .query_pairs()
492 .find(|(key, _)| key == "page")
493 .map(|(_, value)| value.parse().unwrap())
494 .unwrap();
495 (200, stub_jobs(page))
496 }
497 ("GET", "/api/v1/repos/claude/smoke__ci/actions/jobs/101/logs") => {
498 (200, serde_json::json!(JOB_LOG))
499 }
500 ("GET", "/api/v1/repos/claude/smoke__ci/actions/jobs/104/logs") => {
501 (404, serde_json::json!({"message": "job not started"}))
502 }
503 ("GET", "/api/v1/repos/claude/old/actions/runs/7/jobs") => {
504 (404, serde_json::json!({"message": "not found"}))
505 }
506 (method, path) => panic!("Unexpected stub request: {method} {path}"),
507 };
508 let body = match body {
509 serde_json::Value::String(raw) => raw,
510 json => json.to_string(),
511 };
512 let total_header = total_count
513 .map(|total| format!("X-Total-Count: {total}\r\n"))
514 .unwrap_or_default();
515 let response = format!(
516 "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{total_header}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
517 body.len()
518 );
519 stream.write_all(response.as_bytes()).await.unwrap();
520 }
521 });
522 let mut plugin = GiteaPlugin::new();
523 plugin
524 .initialize(
525 1,
526 HashMap::from([
527 ("base_url".to_string(), format!("http://{addr}/")),
528 ("token".to_string(), "stub-token".to_string()),
529 ("selected_items".to_string(), "claude/smoke__ci".to_string()),
530 ]),
531 None,
532 )
533 .unwrap();
534 (plugin, server, requests)
535 }
536
537 #[tokio::test]
538 async fn repositories_without_workflows_are_dropped_from_a_selection() {
539 let (plugin, server) = stub_gitea().await;
540
541 let kept = plugin
542 .ids_with_ci(
543 ["claude/smoke__ci", "claude/empty", "claude/missing"]
544 .map(String::from)
545 .to_vec(),
546 )
547 .await
548 .unwrap();
549 server.abort();
550
551 assert_eq!(kept, ["claude/smoke__ci", "claude/missing"]);
552 }
553
554 #[tokio::test]
555 async fn pipelines_find_latest_run_of_each_workflow_beyond_first_page() {
556 let (plugin, server) = stub_gitea().await;
557 let pipelines = plugin.fetch_pipelines().await.unwrap();
558
559 let summary: Vec<_> = pipelines
560 .iter()
561 .map(|p| (p.id.as_str(), p.status))
562 .collect();
563 assert_eq!(
564 summary,
565 [
566 (
567 "gitea__1__claude/smoke__ci/build__test.yaml",
568 PipelineStatus::Running
569 ),
570 (
571 "gitea__1__claude/smoke__ci/lint.yaml",
572 PipelineStatus::Failed
573 ),
574 ]
575 );
576 server.abort();
577 }
578
579 #[tokio::test]
580 async fn one_failing_repository_fails_the_whole_fetch() {
581 let (mut plugin, server) = stub_gitea().await;
582 plugin.config.insert(
583 "selected_items".to_string(),
584 "claude/smoke__ci,claude/missing".to_string(),
585 );
586 let error = plugin.fetch_pipelines().await.unwrap_err();
587 assert!(matches!(error, PluginError::PipelineNotFound(_)), "{error}");
588 server.abort();
589 }
590
591 #[tokio::test]
592 async fn workflow_parameters_include_dispatch_inputs_after_ref() {
593 let (plugin, server) = stub_gitea().await;
594 let params = plugin
595 .fetch_workflow_parameters("gitea__1__claude/smoke__ci/build__test.yaml")
596 .await
597 .unwrap();
598 assert_eq!(
599 params.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
600 ["ref", "inputs.greeting"]
601 );
602 assert_eq!(params[1].label.as_deref(), Some("greeting"));
603 server.abort();
604 }
605
606 #[tokio::test]
607 async fn run_history_filters_by_workflow_and_respects_limit() {
608 let (plugin, server) = stub_gitea().await;
609 let runs = plugin
610 .fetch_run_history("gitea__1__claude/smoke__ci/build__test.yaml", 1)
611 .await
612 .unwrap();
613
614 assert_eq!(runs.iter().map(|r| r.run_number).collect::<Vec<_>>(), [100]);
615 let details = plugin
616 .fetch_run_details("gitea__1__claude/smoke__ci/build__test.yaml", 1)
617 .await
618 .unwrap();
619 assert_eq!(details.status, PipelineStatus::Success);
620 assert!(plugin
621 .fetch_run_details("gitea__1__claude/smoke__ci/build__test.yaml", 3)
622 .await
623 .is_err());
624 server.abort();
625 }
626
627 #[tokio::test]
628 async fn cancel_reports_not_supported_when_endpoint_is_missing() {
629 let (plugin, server) = stub_gitea().await;
630 let error = plugin
631 .cancel_run("gitea__1__claude/smoke__ci/build__test.yaml", 2)
632 .await
633 .unwrap_err();
634 assert!(matches!(error, PluginError::NotSupported(_)), "{error}");
635 server.abort();
636 }
637
638 #[tokio::test]
639 async fn dispatch_is_not_resent_after_a_server_error() {
640 let _ = rustls::crypto::ring::default_provider().install_default();
641 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
642 let addr = listener.local_addr().unwrap();
643 let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0));
644 let counter = requests.clone();
645 let server = tokio::spawn(async move {
646 loop {
647 let (mut stream, _) = listener.accept().await.unwrap();
648 let mut buffer = [0; 4096];
649 let _ = stream.read(&mut buffer).await.unwrap();
650 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
651 stream
652 .write_all(b"HTTP/1.1 503 X\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
653 .await
654 .unwrap();
655 }
656 });
657 let mut plugin = GiteaPlugin::new();
658 plugin
659 .initialize(
660 1,
661 HashMap::from([
662 ("base_url".to_string(), format!("http://{addr}/")),
663 ("token".to_string(), "stub-token".to_string()),
664 ]),
665 None,
666 )
667 .unwrap();
668
669 let error = plugin
670 .client()
671 .unwrap()
672 .dispatch_workflow("o", "r", "w.yaml", &serde_json::json!({}))
673 .await
674 .unwrap_err();
675 server.abort();
676
677 assert!(
678 matches!(error, PluginError::ServiceUnavailable { .. }),
679 "{error}"
680 );
681 assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1);
682 }
683
684 #[tokio::test]
685 async fn repo_picker_pages_reach_every_repo_when_gitea_caps_the_limit() {
686 let (plugin, server) = stub_gitea().await;
687 let mut names = Vec::new();
688 let mut has_more = true;
689 let mut page = 1;
690 while has_more {
691 let response = plugin
692 .fetch_available_pipelines_filtered(
693 Some("7".into()),
694 None,
695 Some(PaginationParams {
696 page,
697 page_size: 100,
698 }),
699 )
700 .await
701 .unwrap();
702 names.extend(response.items.into_iter().map(|item| item.name));
703 has_more = response.has_more;
704 page += 1;
705 }
706 server.abort();
707
708 let expected: Vec<_> = (0..STUB_REPOS).map(|i| format!("org/r{i}")).collect();
709 assert_eq!(names, expected);
710 }
711
712 #[tokio::test]
713 async fn organizations_include_every_page() {
714 let (plugin, server) = stub_gitea().await;
715 let orgs = plugin.fetch_organizations().await.unwrap();
716 server.abort();
717
718 assert_eq!(orgs.len(), STUB_ORGS + 1);
719 assert_eq!(orgs.last().unwrap().name, format!("o{}", STUB_ORGS - 1));
720 }
721
722 fn run_metadata() -> HashMap<String, serde_json::Value> {
723 HashMap::from([("run_id".to_string(), serde_json::json!(7))])
724 }
725
726 fn run_7<'a>(
727 pipeline_id: &'a str, metadata: &'a HashMap<String, serde_json::Value>,
728 ) -> RunRef<'a> {
729 RunRef {
730 pipeline_id,
731 run_number: 3,
732 run_id: "gitea-run-7",
733 metadata,
734 }
735 }
736
737 const SMOKE_PIPELINE: &str = "gitea__1__claude/smoke__ci/build__test.yaml";
738
739 async fn read(plugin: &GiteaPlugin, node_id: &str) -> PluginResult<Vec<u8>> {
740 let metadata = run_metadata();
741 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
742 let mut sink = LogSink::new(tx);
743 let result = plugin
744 .read_log(run_7(SMOKE_PIPELINE, &metadata), node_id, &mut sink)
745 .await;
746 drop(sink);
747 let mut bytes = Vec::new();
748 while let Some(chunk) = rx.recv().await {
749 bytes.extend_from_slice(&chunk);
750 }
751 result.map(|()| bytes)
752 }
753
754 #[tokio::test]
755 async fn run_jobs_and_steps_become_log_nodes_across_pages() {
756 let (plugin, server, requests) = stub_gitea_counting().await;
757 let metadata = run_metadata();
758
759 let nodes = plugin
760 .fetch_log_nodes(run_7(SMOKE_PIPELINE, &metadata))
761 .await
762 .unwrap();
763 server.abort();
764
765 let shape: Vec<_> = nodes
766 .iter()
767 .map(|node| {
768 (
769 node.id.as_str(),
770 node.parent_id.as_deref(),
771 node.kind,
772 node.status,
773 node.log,
774 )
775 })
776 .collect();
777 assert_eq!(
778 shape,
779 [
780 (
781 "101",
782 None,
783 LogNodeKind::Job,
784 PipelineStatus::Success,
785 Some(LogAvailability::Ready)
786 ),
787 (
788 "101:0",
789 Some("101"),
790 LogNodeKind::Step,
791 PipelineStatus::Success,
792 None
793 ),
794 (
795 "101:1",
796 Some("101"),
797 LogNodeKind::Step,
798 PipelineStatus::Success,
799 None
800 ),
801 (
802 "102",
803 None,
804 LogNodeKind::Job,
805 PipelineStatus::Running,
806 Some(LogAvailability::Partial)
807 ),
808 (
809 "102:0",
810 Some("102"),
811 LogNodeKind::Step,
812 PipelineStatus::Running,
813 None
814 ),
815 (
816 "103",
817 None,
818 LogNodeKind::Job,
819 PipelineStatus::Running,
820 Some(LogAvailability::Pending)
821 ),
822 ("104", None, LogNodeKind::Job, PipelineStatus::Skipped, None),
823 ]
824 );
825 assert_eq!(nodes[0].name, "build");
826 assert_eq!(nodes[2].name, "Run tests");
827 assert_eq!(
828 nodes[0].web_url.as_deref(),
829 Some("http://g/claude/smoke__ci/actions/runs/7/jobs/0")
830 );
831 assert_eq!(requests.load(Ordering::SeqCst), 2);
832 }
833
834 #[tokio::test]
835 async fn job_log_streams_the_text_gitea_stores() {
836 let (plugin, server) = stub_gitea().await;
837
838 let bytes = read(&plugin, "101").await.unwrap();
839 server.abort();
840
841 assert_eq!(bytes, JOB_LOG.as_bytes());
842 }
843
844 #[tokio::test]
845 async fn stored_log_lines_reach_the_dialect_as_sections() {
846 let (plugin, server) = stub_gitea().await;
847
848 let bytes = read(&plugin, "101").await.unwrap();
849 server.abort();
850
851 let markers: Vec<_> = bytes
852 .split(|b| *b == b'\n')
853 .filter(|line| !line.is_empty())
854 .map(|line| plugin.log_dialect().parse(line).marker)
855 .collect();
856 assert_eq!(
857 markers,
858 [
859 LineMarker::GroupStart,
860 LineMarker::Plain,
861 LineMarker::GroupEnd
862 ]
863 );
864 }
865
866 #[tokio::test]
867 async fn job_that_never_started_has_no_log() {
868 let (plugin, server) = stub_gitea().await;
869
870 let error = read(&plugin, "104").await.unwrap_err();
871 server.abort();
872
873 assert!(
874 matches!(error, PluginError::PipelineNotFound(_)),
875 "{error:?}"
876 );
877 }
878
879 #[tokio::test]
880 async fn node_ids_that_are_not_job_numbers_are_rejected_without_a_request() {
881 let (plugin, server, requests) = stub_gitea_counting().await;
882
883 for node_id in [
884 "",
885 "101:1",
886 "../runs/7",
887 "101/logs",
888 "x?y=1",
889 "+101",
890 "-1",
891 "1 2",
892 ] {
893 let error = read(&plugin, node_id).await.unwrap_err();
894 assert!(
895 matches!(error, PluginError::InvalidConfig(_)),
896 "{node_id}: {error:?}"
897 );
898 }
899 server.abort();
900
901 assert_eq!(requests.load(Ordering::SeqCst), 0);
902 }
903
904 #[tokio::test]
905 async fn run_without_a_gitea_run_id_is_rejected_without_a_request() {
906 let (plugin, server, requests) = stub_gitea_counting().await;
907 let metadata = HashMap::new();
908
909 let error = plugin
910 .fetch_log_nodes(run_7(SMOKE_PIPELINE, &metadata))
911 .await
912 .unwrap_err();
913 server.abort();
914
915 assert!(matches!(error, PluginError::InvalidConfig(_)), "{error:?}");
916 assert_eq!(requests.load(Ordering::SeqCst), 0);
917 }
918
919 #[tokio::test]
920 async fn gitea_without_the_jobs_route_reports_the_minimum_version() {
921 let (plugin, server) = stub_gitea().await;
922 let metadata = run_metadata();
923
924 let error = plugin
925 .fetch_log_nodes(run_7("gitea__1__claude/old/ci.yaml", &metadata))
926 .await
927 .unwrap_err();
928 server.abort();
929
930 let PluginError::NotSupported(message) = error else {
931 panic!("expected NotSupported, got {error:?}");
932 };
933 assert!(message.contains("Gitea 1.25 or newer"), "{message}");
934 }
935}