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 types::JobAttempt,
20};
21
22#[derive(serde::Serialize, serde::Deserialize)]
23struct LogPosition {
24 job: String,
25 attempt: JobAttempt,
26 offset: u64,
27}
28
29pub struct GiteaPlugin {
30 metadata: PluginMetadata,
31 client: Option<client::GiteaClient>,
32 provider_id: Option<i64>,
33 config: HashMap<String, String>,
34 status_source: Option<StatusSource>,
35}
36
37impl Default for GiteaPlugin {
38 fn default() -> Self {
39 Self::new()
40 }
41}
42
43impl GiteaPlugin {
44 pub fn new() -> Self {
45 Self {
46 metadata: metadata::create_metadata(),
47 client: None,
48 provider_id: None,
49 config: HashMap::new(),
50 status_source: None,
51 }
52 }
53
54 fn client(&self) -> PluginResult<&client::GiteaClient> {
55 self.client
56 .as_ref()
57 .ok_or_else(|| PluginError::Internal("Plugin not initialized".to_string()))
58 }
59
60 fn provider_id(&self) -> PluginResult<i64> {
61 self.provider_id
62 .ok_or_else(|| PluginError::Internal("Provider ID not set".to_string()))
63 }
64
65 async fn fetch_repo_pipelines(
66 &self, provider_id: i64, repo_full_name: &str,
67 ) -> PluginResult<Vec<Pipeline>> {
68 let (owner, repo) = config::parse_repo(repo_full_name)?;
69 let client = self.client()?;
70 let workflows = client.list_workflows(owner, repo).await?;
71 let workflow_ids: Vec<String> = workflows.iter().map(|w| w.id.clone()).collect();
72 let latest_runs = client.latest_runs(owner, repo, &workflow_ids).await?;
73
74 Ok(workflows
75 .iter()
76 .map(|workflow| {
77 let latest_run = latest_runs.get(&workflow.id);
78 mapper::map_pipeline(provider_id, owner, repo, workflow, latest_run)
79 })
80 .collect())
81 }
82}
83
84#[async_trait]
85impl Plugin for GiteaPlugin {
86 fn metadata(&self) -> &PluginMetadata {
87 &self.metadata
88 }
89
90 fn status_source(&self) -> Option<&StatusSource> {
91 self.status_source.as_ref()
92 }
93
94 fn initialize(
95 &mut self, provider_id: i64, config: HashMap<String, String>,
96 http_client: Option<Arc<reqwest::Client>>,
97 ) -> PluginResult<()> {
98 let base_url = config::get_base_url(&config)?;
99 let token = config
100 .get("token")
101 .map(|token| token.trim())
102 .filter(|token| !token.is_empty())
103 .ok_or_else(|| PluginError::InvalidConfig("Missing Gitea access token".to_string()))?
104 .to_string();
105 let status_source = custom_status_source(&config)?;
106
107 let http_client = http_client.unwrap_or_else(|| {
108 Arc::new(
109 reqwest::Client::builder()
110 .use_rustls_tls()
111 .https_only(cfg!(target_os = "ios"))
112 .pool_max_idle_per_host(10)
113 .timeout(Duration::from_secs(30))
114 .connect_timeout(Duration::from_secs(10))
115 .tcp_keepalive(Duration::from_secs(60))
116 .build()
117 .expect("Failed to build HTTP client"),
118 )
119 });
120
121 self.status_source = status_source;
122 self.client = Some(client::GiteaClient::new(http_client, base_url, token));
123 self.provider_id = Some(provider_id);
124 self.config = config;
125 Ok(())
126 }
127
128 async fn validate_credentials(&self) -> PluginResult<bool> {
129 self.client()?.get_user().await?;
130 Ok(true)
131 }
132
133 async fn fetch_available_pipelines(
134 &self, params: Option<PaginationParams>,
135 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
136 self.fetch_available_pipelines_filtered(None, None, params)
137 .await
138 }
139
140 async fn fetch_organizations(&self) -> PluginResult<Vec<Organization>> {
141 self.client()?.list_organizations().await
142 }
143
144 async fn ids_with_ci(&self, ids: Vec<String>) -> PluginResult<Vec<String>> {
145 let client = self.client()?;
146 Ok(retain_with_ci(ids, |id| async move {
147 let (owner, repo) = config::parse_repo(&id)?;
148 Ok(!client.list_workflows(owner, repo).await?.is_empty())
149 })
150 .await)
151 }
152
153 async fn fetch_available_pipelines_filtered(
154 &self, org: Option<String>, search: Option<String>, params: Option<PaginationParams>,
155 ) -> PluginResult<PaginatedResponse<AvailablePipeline>> {
156 let client = self.client()?;
157 let params = params.unwrap_or_default();
158 let (uid, exclusive) = match org {
159 Some(org) => (
160 org.parse().map_err(|_| {
161 PluginError::InvalidConfig(format!("Invalid organization ID: {org}"))
162 })?,
163 true,
164 ),
165 None => (client.get_user().await?.id, false),
166 };
167
168 let response = client
169 .search_repositories(uid, exclusive, search.as_deref(), ¶ms)
170 .await?;
171
172 Ok(PaginatedResponse {
173 items: response
174 .items
175 .iter()
176 .map(mapper::map_available_pipeline)
177 .collect(),
178 page: response.page,
179 page_size: response.page_size,
180 total_count: response.total_count,
181 total_pages: response.total_pages,
182 has_more: response.has_more,
183 })
184 }
185
186 async fn fetch_pipelines(&self) -> PluginResult<Vec<Pipeline>> {
187 let provider_id = self.provider_id()?;
188 let repositories = config::get_repositories(&self.config);
189 if repositories.is_empty() {
190 return Err(PluginError::InvalidConfig(
191 "No repositories configured".to_string(),
192 ));
193 }
194
195 let results = try_join_all(
196 repositories
197 .iter()
198 .map(|repo| self.fetch_repo_pipelines(provider_id, repo)),
199 )
200 .await?;
201
202 Ok(results.into_iter().flatten().collect())
203 }
204
205 async fn fetch_run_history(
206 &self, pipeline_id: &str, limit: usize,
207 ) -> PluginResult<Vec<PipelineRun>> {
208 let id = config::parse_pipeline_id(pipeline_id)?;
209 let runs = self
210 .client()?
211 .workflow_runs(id.owner, id.repo, id.workflow, limit)
212 .await?;
213 Ok(runs
214 .iter()
215 .map(|run| mapper::map_run(run, pipeline_id, id.owner))
216 .collect())
217 }
218
219 async fn fetch_run_details(
220 &self, pipeline_id: &str, run_number: i64,
221 ) -> PluginResult<PipelineRun> {
222 let id = config::parse_pipeline_id(pipeline_id)?;
223 let run = self
224 .client()?
225 .find_run(id.owner, id.repo, id.workflow, run_number)
226 .await?;
227 Ok(mapper::map_run(&run, pipeline_id, id.owner))
228 }
229
230 async fn fetch_log_nodes(&self, run: RunRef<'_>) -> PluginResult<Vec<LogNode>> {
231 let id = config::parse_pipeline_id(run.pipeline_id)?;
232 let run_id = run
233 .metadata
234 .get("run_id")
235 .and_then(serde_json::Value::as_i64)
236 .ok_or_else(|| {
237 PluginError::InvalidConfig(format!("Run #{} has no Gitea run ID", run.run_number))
238 })?;
239
240 let jobs = self
241 .client()?
242 .list_run_jobs(id.owner, id.repo, run_id)
243 .await?;
244
245 Ok(mapper::jobs_to_log_nodes(&jobs))
246 }
247
248 async fn read_log(
249 &self, run: RunRef<'_>, node_id: &str, from: Option<&LogCursor>, sink: &mut LogSink,
250 ) -> PluginResult<LogRead> {
251 let id = config::parse_pipeline_id(run.pipeline_id)?;
252 if node_id.is_empty() || !node_id.bytes().all(|b| b.is_ascii_digit()) {
253 return Err(PluginError::InvalidConfig(format!(
254 "Invalid Gitea job ID: {node_id}"
255 )));
256 }
257
258 let client = self.client()?;
259 let attempt = client.job_attempt(id.owner, id.repo, node_id).await?;
260 let position = from
261 .and_then(|cursor| cursor.parse::<LogPosition>())
262 .filter(|position| position.job == node_id && position.attempt == attempt);
263 if from.is_some() && position.is_none() {
264 sink.restart()?;
265 }
266 let (mode, offset) = client
267 .stream_job_log(id.owner, id.repo, node_id, position.map(|p| p.offset), sink)
268 .await?;
269 let job = node_id.to_string();
270 Ok(LogRead {
271 mode,
272 cursor: Some(LogCursor::new(&LogPosition {
273 job,
274 attempt,
275 offset,
276 })?),
277 })
278 }
279
280 fn log_dialect(&self) -> &'static dyn LogDialect {
281 &GiteaLogDialect
282 }
283
284 async fn fetch_workflow_parameters(
285 &self, workflow_id: &str,
286 ) -> PluginResult<Vec<WorkflowParameter>> {
287 let id = config::parse_pipeline_id(workflow_id)?;
288 let client = self.client()?;
289 let workflows = client.list_workflows(id.owner, id.repo).await?;
290 let path = workflows
291 .iter()
292 .find(|workflow| workflow.id == id.workflow)
293 .map(|workflow| workflow.path.as_str())
294 .ok_or_else(|| {
295 PluginError::PipelineNotFound(format!("Workflow {} not found", id.workflow))
296 })?;
297 let source = client.raw_file(id.owner, id.repo, path).await?;
298
299 let mut parameters = vec![WorkflowParameter {
300 name: "ref".to_string(),
301 label: Some("Ref".to_string()),
302 description: Some("Branch, tag, or commit SHA to run workflow on".to_string()),
303 param_type: WorkflowParameterType::String {
304 default: Some("main".to_string()),
305 },
306 required: true,
307 }];
308 parameters.extend(dispatch_inputs(&source)?);
309 Ok(parameters)
310 }
311
312 async fn trigger_pipeline(&self, params: TriggerParams) -> PluginResult<String> {
313 let id = config::parse_pipeline_id(¶ms.workflow_id)?;
314 let client = self.client()?;
315
316 let body = dispatch_body(params.inputs);
317
318 client
319 .dispatch_workflow(id.owner, id.repo, id.workflow, &body)
320 .await?;
321 Ok(client.actions_url(id.owner, id.repo))
322 }
323
324 async fn cancel_run(&self, pipeline_id: &str, run_number: i64) -> PluginResult<()> {
325 let id = config::parse_pipeline_id(pipeline_id)?;
326 let client = self.client()?;
327 let run = client
328 .find_run(id.owner, id.repo, id.workflow, run_number)
329 .await?;
330 client.cancel_run(id.owner, id.repo, run.id).await
331 }
332}
333
334#[cfg(all(test, not(target_os = "ios")))]
335mod tests {
336 use std::sync::atomic::{
337 AtomicUsize,
338 Ordering,
339 };
340
341 use tokio::io::{
342 AsyncReadExt,
343 AsyncWriteExt,
344 };
345
346 use super::*;
347
348 const STUB_REPOS: usize = 150;
349 const STUB_ORGS: usize = 70;
350 const MAX_RESPONSE_ITEMS: usize = 50;
351 const DEFAULT_PAGING_NUM: usize = 30;
352
353 fn stub_page(url: &reqwest::Url, total: usize) -> std::ops::Range<usize> {
354 let query = |key: &str| {
355 url.query_pairs()
356 .find(|(k, _)| k == key)
357 .map(|(_, v)| v.parse::<usize>().unwrap())
358 };
359 let limit = query("limit")
360 .unwrap_or(DEFAULT_PAGING_NUM)
361 .min(MAX_RESPONSE_ITEMS);
362 let page = query("page").unwrap_or(1);
363 ((page - 1) * limit).min(total)..(page * limit).min(total)
364 }
365
366 const JOB_LOG: &str = "2026-10-28T10:00:01.1000000Z ::group::Set up job\n\
367 2026-10-28T10:00:02.2000000Z cloning into /workspace\n\
368 2026-10-28T10:00:03.3000000Z ::endgroup::\n";
369
370 fn stub_jobs(page: usize) -> serde_json::Value {
371 let step = |number: i64, name: &str, status: &str, conclusion: &str| {
372 let completed_at = if status == "completed" {
373 format!("2026-10-28T10:00:0{}Z", number + 2)
374 } else {
375 "1970-01-01T00:00:00Z".to_string()
376 };
377 serde_json::json!({
378 "name": name, "number": number, "status": status, "conclusion": conclusion,
379 "started_at": format!("2026-10-28T10:00:0{}Z", number + 1),
380 "completed_at": completed_at
381 })
382 };
383 let job = |id: i64, name: &str, status: &str, conclusion: &str, started: &str| {
384 serde_json::json!({
385 "id": id, "name": name, "status": status, "conclusion": conclusion,
386 "html_url": format!("http://g/claude/smoke__ci/actions/runs/7/jobs/{}", id - 101),
387 "started_at": started, "completed_at": "1970-01-01T00:00:00Z",
388 "steps": null
389 })
390 };
391 let unset = "1970-01-01T00:00:00Z";
392 let mut build = job(101, "build", "completed", "success", "2026-10-28T10:00:00Z");
393 build["steps"] = serde_json::json!([
394 step(0, "Set up job", "completed", "success"),
395 step(1, "Run tests", "completed", "success")
396 ]);
397 let mut lint = job(102, "lint", "in_progress", "", "2026-10-28T10:00:00Z");
398 lint["steps"] = serde_json::json!([step(0, "Set up job", "in_progress", "")]);
399 let jobs = match page {
400 1 => vec![build, lint],
401 2 => vec![
402 job(103, "deploy", "queued", "", unset),
403 job(104, "publish", "completed", "skipped", unset),
404 ],
405 _ => Vec::new(),
406 };
407 serde_json::json!({"jobs": jobs, "total_count": 4})
408 }
409
410 async fn stub_gitea() -> (GiteaPlugin, tokio::task::JoinHandle<()>) {
411 let (plugin, server, _) = stub_gitea_counting().await;
412 (plugin, server)
413 }
414
415 async fn stub_gitea_counting() -> (GiteaPlugin, tokio::task::JoinHandle<()>, Arc<AtomicUsize>) {
416 let _ = rustls::crypto::ring::default_provider().install_default();
417 let requests = Arc::new(AtomicUsize::new(0));
418 let counter = requests.clone();
419 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
420 let addr = listener.local_addr().unwrap();
421 let server = tokio::spawn(async move {
422 loop {
423 let (mut stream, _) = listener.accept().await.unwrap();
424 let mut request = Vec::new();
425 let mut buffer = [0; 4096];
426 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
427 let count = stream.read(&mut buffer).await.unwrap();
428 if count == 0 {
429 break;
430 }
431 request.extend_from_slice(&buffer[..count]);
432 }
433 let request = String::from_utf8(request).unwrap();
434 counter.fetch_add(1, Ordering::SeqCst);
435 let mut head = request.split_whitespace();
436 let method = head.next().unwrap();
437 let url =
438 reqwest::Url::parse(&format!("http://{addr}{}", head.next().unwrap())).unwrap();
439 let mut total_count = None;
440 let (status, body) = match (method, url.path()) {
441 ("GET", "/api/v1/repos/search") => {
442 total_count = Some(STUB_REPOS);
443 let data: Vec<_> = stub_page(&url, STUB_REPOS)
444 .map(|i| {
445 serde_json::json!({
446 "name": format!("r{i}"),
447 "full_name": format!("org/r{i}"),
448 "owner": {"login": "org"}
449 })
450 })
451 .collect();
452 (200, serde_json::json!({"data": data}))
453 }
454 ("GET", "/api/v1/user/orgs") => {
455 total_count = Some(STUB_ORGS);
456 let orgs: Vec<_> = stub_page(&url, STUB_ORGS)
457 .map(|i| serde_json::json!({"id": 100 + i, "username": format!("o{i}")}))
458 .collect();
459 (200, serde_json::json!(orgs))
460 }
461 ("GET", "/api/v1/user") => {
462 (200, serde_json::json!({"id": 2, "login": "claude"}))
463 }
464 ("GET", "/api/v1/repos/claude/smoke__ci/actions/workflows") => (
465 200,
466 serde_json::json!({"workflows": [
467 {"id": "build__test.yaml", "name": "Build", "path": ".gitea/workflows/build__test.yaml"},
468 {"id": "lint.yaml", "name": "Lint", "path": ".gitea/workflows/lint.yaml"}
469 ]}),
470 ),
471 ("GET", "/api/v1/repos/claude/smoke__ci/actions/runs") => {
472 let page: usize = url
473 .query_pairs()
474 .find(|(key, _)| key == "page")
475 .map(|(_, value)| value.parse().unwrap())
476 .unwrap();
477 let runs: Vec<_> = if page == 1 {
478 (0..50)
479 .map(|i| {
480 serde_json::json!({
481 "id": 100 - i, "run_number": 100 - i, "status": "queued",
482 "path": "build__test.yaml@refs/heads/main", "head_branch": "main",
483 "html_url": format!("http://g/claude/smoke__ci/actions/runs/{}", 100 - i)
484 })
485 })
486 .collect()
487 } else {
488 vec![
489 serde_json::json!({"id": 3, "run_number": 3, "status": "completed", "conclusion": "failure",
490 "path": "lint.yaml@refs/heads/main", "head_branch": "main",
491 "html_url": "http://g/claude/smoke__ci/actions/runs/3"}),
492 serde_json::json!({"id": 2, "run_number": 2, "status": "queued",
493 "path": "build__test.yaml@refs/heads/main", "head_branch": "main",
494 "html_url": "http://g/claude/smoke__ci/actions/runs/2"}),
495 serde_json::json!({"id": 1, "run_number": 1, "status": "completed", "conclusion": "success",
496 "path": "build__test.yaml@refs/heads/main", "head_branch": "main",
497 "html_url": "http://g/claude/smoke__ci/actions/runs/1"}),
498 ]
499 };
500 (200, serde_json::json!({"workflow_runs": runs}))
501 }
502 ("POST", "/api/v1/repos/claude/smoke__ci/actions/runs/2/cancel") => {
503 (404, serde_json::json!({"message": "not found"}))
504 }
505 ("GET", "/api/v1/repos/claude/missing/actions/workflows") => {
506 (404, serde_json::json!({"message": "not found"}))
507 }
508 ("GET", "/api/v1/repos/claude/empty/actions/workflows") => {
509 (200, serde_json::json!({"workflows": []}))
510 }
511 ("GET", "/api/v1/repos/claude/smoke__ci/raw/.gitea/workflows/build__test.yaml") => {
512 (200, serde_json::json!("on:\n workflow_dispatch:\n inputs:\n greeting:\n description: Greeting\n default: hi\n"))
513 }
514 ("GET", "/api/v1/repos/claude/smoke__ci/actions/runs/7/jobs") => {
515 let page = url
516 .query_pairs()
517 .find(|(key, _)| key == "page")
518 .map(|(_, value)| value.parse().unwrap())
519 .unwrap();
520 (200, stub_jobs(page))
521 }
522 ("GET", "/api/v1/repos/claude/smoke__ci/actions/jobs/101") => (
523 200,
524 serde_json::json!({"id": 101, "name": "build", "status": "completed", "run_attempt": 1, "started_at": "2026-10-28T10:00:00Z"}),
525 ),
526 ("GET", "/api/v1/repos/claude/smoke__ci/actions/jobs/104") => (
527 200,
528 serde_json::json!({"id": 104, "name": "deploy", "status": "waiting"}),
529 ),
530 ("GET", "/api/v1/repos/claude/smoke__ci/actions/jobs/101/logs") => {
531 (200, serde_json::json!(JOB_LOG))
532 }
533 ("GET", "/api/v1/repos/claude/smoke__ci/actions/jobs/104/logs") => {
534 (404, serde_json::json!({"message": "job not started"}))
535 }
536 ("GET", "/api/v1/repos/claude/old/actions/runs/7/jobs") => {
537 (404, serde_json::json!({"message": "not found"}))
538 }
539 (method, path) => panic!("Unexpected stub request: {method} {path}"),
540 };
541 let body = match body {
542 serde_json::Value::String(raw) => raw,
543 json => json.to_string(),
544 };
545 let total_header = total_count
546 .map(|total| format!("X-Total-Count: {total}\r\n"))
547 .unwrap_or_default();
548 let response = format!(
549 "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\n{total_header}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
550 body.len()
551 );
552 stream.write_all(response.as_bytes()).await.unwrap();
553 }
554 });
555 let mut plugin = GiteaPlugin::new();
556 plugin
557 .initialize(
558 1,
559 HashMap::from([
560 ("base_url".to_string(), format!("http://{addr}/")),
561 ("token".to_string(), "stub-token".to_string()),
562 ("selected_items".to_string(), "claude/smoke__ci".to_string()),
563 ]),
564 None,
565 )
566 .unwrap();
567 (plugin, server, requests)
568 }
569
570 #[tokio::test]
571 async fn repositories_without_workflows_are_dropped_from_a_selection() {
572 let (plugin, server) = stub_gitea().await;
573
574 let kept = plugin
575 .ids_with_ci(
576 ["claude/smoke__ci", "claude/empty", "claude/missing"]
577 .map(String::from)
578 .to_vec(),
579 )
580 .await
581 .unwrap();
582 server.abort();
583
584 assert_eq!(kept, ["claude/smoke__ci", "claude/missing"]);
585 }
586
587 #[tokio::test]
588 async fn pipelines_find_latest_run_of_each_workflow_beyond_first_page() {
589 let (plugin, server) = stub_gitea().await;
590 let pipelines = plugin.fetch_pipelines().await.unwrap();
591
592 let summary: Vec<_> = pipelines
593 .iter()
594 .map(|p| (p.id.as_str(), p.status))
595 .collect();
596 assert_eq!(
597 summary,
598 [
599 (
600 "gitea__1__claude/smoke__ci/build__test.yaml",
601 PipelineStatus::Running
602 ),
603 (
604 "gitea__1__claude/smoke__ci/lint.yaml",
605 PipelineStatus::Failed
606 ),
607 ]
608 );
609 server.abort();
610 }
611
612 #[tokio::test]
613 async fn one_failing_repository_fails_the_whole_fetch() {
614 let (mut plugin, server) = stub_gitea().await;
615 plugin.config.insert(
616 "selected_items".to_string(),
617 "claude/smoke__ci,claude/missing".to_string(),
618 );
619 let error = plugin.fetch_pipelines().await.unwrap_err();
620 assert!(matches!(error, PluginError::PipelineNotFound(_)), "{error}");
621 server.abort();
622 }
623
624 #[tokio::test]
625 async fn workflow_parameters_include_dispatch_inputs_after_ref() {
626 let (plugin, server) = stub_gitea().await;
627 let params = plugin
628 .fetch_workflow_parameters("gitea__1__claude/smoke__ci/build__test.yaml")
629 .await
630 .unwrap();
631 assert_eq!(
632 params.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
633 ["ref", "inputs.greeting"]
634 );
635 assert_eq!(params[1].label.as_deref(), Some("greeting"));
636 server.abort();
637 }
638
639 #[tokio::test]
640 async fn run_history_filters_by_workflow_and_respects_limit() {
641 let (plugin, server) = stub_gitea().await;
642 let runs = plugin
643 .fetch_run_history("gitea__1__claude/smoke__ci/build__test.yaml", 1)
644 .await
645 .unwrap();
646
647 assert_eq!(runs.iter().map(|r| r.run_number).collect::<Vec<_>>(), [100]);
648 let details = plugin
649 .fetch_run_details("gitea__1__claude/smoke__ci/build__test.yaml", 1)
650 .await
651 .unwrap();
652 assert_eq!(details.status, PipelineStatus::Success);
653 assert!(plugin
654 .fetch_run_details("gitea__1__claude/smoke__ci/build__test.yaml", 3)
655 .await
656 .is_err());
657 server.abort();
658 }
659
660 #[tokio::test]
661 async fn cancel_reports_not_supported_when_endpoint_is_missing() {
662 let (plugin, server) = stub_gitea().await;
663 let error = plugin
664 .cancel_run("gitea__1__claude/smoke__ci/build__test.yaml", 2)
665 .await
666 .unwrap_err();
667 assert!(matches!(error, PluginError::NotSupported(_)), "{error}");
668 server.abort();
669 }
670
671 #[tokio::test]
672 async fn dispatch_is_not_resent_after_a_server_error() {
673 let _ = rustls::crypto::ring::default_provider().install_default();
674 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
675 let addr = listener.local_addr().unwrap();
676 let requests = Arc::new(std::sync::atomic::AtomicUsize::new(0));
677 let counter = requests.clone();
678 let server = tokio::spawn(async move {
679 loop {
680 let (mut stream, _) = listener.accept().await.unwrap();
681 let mut buffer = [0; 4096];
682 let _ = stream.read(&mut buffer).await.unwrap();
683 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
684 stream
685 .write_all(b"HTTP/1.1 503 X\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
686 .await
687 .unwrap();
688 }
689 });
690 let mut plugin = GiteaPlugin::new();
691 plugin
692 .initialize(
693 1,
694 HashMap::from([
695 ("base_url".to_string(), format!("http://{addr}/")),
696 ("token".to_string(), "stub-token".to_string()),
697 ]),
698 None,
699 )
700 .unwrap();
701
702 let error = plugin
703 .client()
704 .unwrap()
705 .dispatch_workflow("o", "r", "w.yaml", &serde_json::json!({}))
706 .await
707 .unwrap_err();
708 server.abort();
709
710 assert!(
711 matches!(error, PluginError::ServiceUnavailable { .. }),
712 "{error}"
713 );
714 assert_eq!(requests.load(std::sync::atomic::Ordering::SeqCst), 1);
715 }
716
717 #[tokio::test]
718 async fn repo_picker_pages_reach_every_repo_when_gitea_caps_the_limit() {
719 let (plugin, server) = stub_gitea().await;
720 let mut names = Vec::new();
721 let mut has_more = true;
722 let mut page = 1;
723 while has_more {
724 let response = plugin
725 .fetch_available_pipelines_filtered(
726 Some("7".into()),
727 None,
728 Some(PaginationParams {
729 page,
730 page_size: 100,
731 }),
732 )
733 .await
734 .unwrap();
735 names.extend(response.items.into_iter().map(|item| item.name));
736 has_more = response.has_more;
737 page += 1;
738 }
739 server.abort();
740
741 let expected: Vec<_> = (0..STUB_REPOS).map(|i| format!("org/r{i}")).collect();
742 assert_eq!(names, expected);
743 }
744
745 #[tokio::test]
746 async fn organizations_include_every_page() {
747 let (plugin, server) = stub_gitea().await;
748 let orgs = plugin.fetch_organizations().await.unwrap();
749 server.abort();
750
751 assert_eq!(orgs.len(), STUB_ORGS + 1);
752 assert_eq!(orgs.last().unwrap().name, format!("o{}", STUB_ORGS - 1));
753 }
754
755 fn run_metadata() -> HashMap<String, serde_json::Value> {
756 HashMap::from([("run_id".to_string(), serde_json::json!(7))])
757 }
758
759 fn run_7<'a>(
760 pipeline_id: &'a str, metadata: &'a HashMap<String, serde_json::Value>,
761 ) -> RunRef<'a> {
762 RunRef {
763 pipeline_id,
764 run_number: 3,
765 run_id: "gitea-run-7",
766 metadata,
767 }
768 }
769
770 const SMOKE_PIPELINE: &str = "gitea__1__claude/smoke__ci/build__test.yaml";
771
772 async fn read(plugin: &GiteaPlugin, node_id: &str) -> PluginResult<Vec<u8>> {
773 let metadata = run_metadata();
774 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
775 let mut sink = LogSink::new(tx);
776 let result = plugin
777 .read_log(run_7(SMOKE_PIPELINE, &metadata), node_id, None, &mut sink)
778 .await;
779 drop(sink);
780 let mut bytes = Vec::new();
781 while let Some(chunk) = rx.recv().await {
782 bytes.extend_from_slice(&chunk);
783 }
784 result.map(|_| bytes)
785 }
786
787 #[tokio::test]
788 async fn run_jobs_and_steps_become_log_nodes_across_pages() {
789 let (plugin, server, requests) = stub_gitea_counting().await;
790 let metadata = run_metadata();
791
792 let nodes = plugin
793 .fetch_log_nodes(run_7(SMOKE_PIPELINE, &metadata))
794 .await
795 .unwrap();
796 server.abort();
797
798 let shape: Vec<_> = nodes
799 .iter()
800 .map(|node| {
801 (
802 node.id.as_str(),
803 node.parent_id.as_deref(),
804 node.kind,
805 node.status,
806 node.log,
807 )
808 })
809 .collect();
810 assert_eq!(
811 shape,
812 [
813 (
814 "101",
815 None,
816 LogNodeKind::Job,
817 PipelineStatus::Success,
818 Some(LogAvailability::Ready)
819 ),
820 (
821 "101:0",
822 Some("101"),
823 LogNodeKind::Step,
824 PipelineStatus::Success,
825 None
826 ),
827 (
828 "101:1",
829 Some("101"),
830 LogNodeKind::Step,
831 PipelineStatus::Success,
832 None
833 ),
834 (
835 "102",
836 None,
837 LogNodeKind::Job,
838 PipelineStatus::Running,
839 Some(LogAvailability::Partial)
840 ),
841 (
842 "102:0",
843 Some("102"),
844 LogNodeKind::Step,
845 PipelineStatus::Running,
846 None
847 ),
848 (
849 "103",
850 None,
851 LogNodeKind::Job,
852 PipelineStatus::Running,
853 Some(LogAvailability::Pending)
854 ),
855 ("104", None, LogNodeKind::Job, PipelineStatus::Skipped, None),
856 ]
857 );
858 assert_eq!(nodes[0].name, "build");
859 assert_eq!(nodes[2].name, "Run tests");
860 assert_eq!(
861 nodes[0].web_url.as_deref(),
862 Some("http://g/claude/smoke__ci/actions/runs/7/jobs/0")
863 );
864 assert_eq!(requests.load(Ordering::SeqCst), 2);
865 }
866
867 #[tokio::test]
868 async fn job_log_streams_the_text_gitea_stores() {
869 let (plugin, server) = stub_gitea().await;
870
871 let bytes = read(&plugin, "101").await.unwrap();
872 server.abort();
873
874 assert_eq!(bytes, JOB_LOG.as_bytes());
875 }
876
877 #[tokio::test]
878 async fn stored_log_lines_reach_the_dialect_as_sections() {
879 let (plugin, server) = stub_gitea().await;
880
881 let bytes = read(&plugin, "101").await.unwrap();
882 server.abort();
883
884 let markers: Vec<_> = bytes
885 .split(|b| *b == b'\n')
886 .filter(|line| !line.is_empty())
887 .map(|line| plugin.log_dialect().parse(line).marker)
888 .collect();
889 assert_eq!(
890 markers,
891 [
892 LineMarker::GroupStart,
893 LineMarker::Plain,
894 LineMarker::GroupEnd
895 ]
896 );
897 }
898
899 #[tokio::test]
900 async fn job_that_never_started_has_no_log() {
901 let (plugin, server) = stub_gitea().await;
902
903 let error = read(&plugin, "104").await.unwrap_err();
904 server.abort();
905
906 assert!(
907 matches!(error, PluginError::PipelineNotFound(_)),
908 "{error:?}"
909 );
910 }
911
912 #[tokio::test]
913 async fn node_ids_that_are_not_job_numbers_are_rejected_without_a_request() {
914 let (plugin, server, requests) = stub_gitea_counting().await;
915
916 for node_id in [
917 "",
918 "101:1",
919 "../runs/7",
920 "101/logs",
921 "x?y=1",
922 "+101",
923 "-1",
924 "1 2",
925 ] {
926 let error = read(&plugin, node_id).await.unwrap_err();
927 assert!(
928 matches!(error, PluginError::InvalidConfig(_)),
929 "{node_id}: {error:?}"
930 );
931 }
932 server.abort();
933
934 assert_eq!(requests.load(Ordering::SeqCst), 0);
935 }
936
937 #[tokio::test]
938 async fn run_without_a_gitea_run_id_is_rejected_without_a_request() {
939 let (plugin, server, requests) = stub_gitea_counting().await;
940 let metadata = HashMap::new();
941
942 let error = plugin
943 .fetch_log_nodes(run_7(SMOKE_PIPELINE, &metadata))
944 .await
945 .unwrap_err();
946 server.abort();
947
948 assert!(matches!(error, PluginError::InvalidConfig(_)), "{error:?}");
949 assert_eq!(requests.load(Ordering::SeqCst), 0);
950 }
951
952 #[tokio::test]
953 async fn gitea_without_the_jobs_route_reports_the_minimum_version() {
954 let (plugin, server) = stub_gitea().await;
955 let metadata = run_metadata();
956
957 let error = plugin
958 .fetch_log_nodes(run_7("gitea__1__claude/old/ci.yaml", &metadata))
959 .await
960 .unwrap_err();
961 server.abort();
962
963 let PluginError::NotSupported(message) = error else {
964 panic!("expected NotSupported, got {error:?}");
965 };
966 assert!(message.contains("Gitea 1.25 or newer"), "{message}");
967 }
968
969 #[derive(Clone, Copy)]
970 enum RangeSupport {
971 Honored,
972 Ignored,
973 Misaligned,
974 }
975
976 const STARTED_AT: &str = "2026-10-28T10:00:00Z";
977
978 struct Log {
979 body: Vec<u8>,
980 range_support: RangeSupport,
981 ranges: Vec<Option<String>>,
982 run_attempt: i64,
983 started_at: &'static str,
984 }
985
986 type SharedLog = Arc<std::sync::Mutex<Log>>;
987
988 async fn stub_log(
989 body: &[u8], range_support: RangeSupport,
990 ) -> (GiteaPlugin, SharedLog, tokio::task::JoinHandle<()>) {
991 let _ = rustls::crypto::ring::default_provider().install_default();
992 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
993 let addr = listener.local_addr().unwrap();
994 let log = SharedLog::new(std::sync::Mutex::new(Log {
995 body: body.to_vec(),
996 range_support,
997 ranges: Vec::new(),
998 run_attempt: 1,
999 started_at: STARTED_AT,
1000 }));
1001 let state = log.clone();
1002 let server = tokio::spawn(async move {
1003 loop {
1004 let (mut stream, _) = listener.accept().await.unwrap();
1005 let mut request = Vec::new();
1006 let mut buffer = [0; 4096];
1007 while !request.windows(4).any(|window| window == b"\r\n\r\n") {
1008 let count = stream.read(&mut buffer).await.unwrap();
1009 if count == 0 {
1010 break;
1011 }
1012 request.extend_from_slice(&buffer[..count]);
1013 }
1014 let request = String::from_utf8(request).unwrap();
1015 let range = request.lines().find_map(|line| {
1016 let (name, value) = line.split_once(':')?;
1017 name.eq_ignore_ascii_case("range")
1018 .then(|| value.trim().to_string())
1019 });
1020 let target = request.split_whitespace().nth(1).unwrap().to_string();
1021 let (status, content_range, payload) = {
1022 let mut log = state.lock().unwrap();
1023 if target.ends_with("/logs") {
1024 log.ranges.push(range.clone());
1025 let total = log.body.len();
1026 let start = range.as_deref().and_then(|value| {
1027 value
1028 .strip_prefix("bytes=")?
1029 .strip_suffix('-')?
1030 .parse()
1031 .ok()
1032 });
1033 match (start, log.range_support) {
1034 (Some(start), RangeSupport::Honored) if start >= total => (
1035 "416 Range Not Satisfiable",
1036 format!("Content-Range: bytes */{total}\r\n"),
1037 Vec::new(),
1038 ),
1039 (Some(start), RangeSupport::Honored) => (
1040 "206 Partial Content",
1041 format!("Content-Range: bytes {start}-{}/{total}\r\n", total - 1),
1042 log.body[start..].to_vec(),
1043 ),
1044 (Some(_), RangeSupport::Misaligned) => (
1045 "206 Partial Content",
1046 format!("Content-Range: bytes 0-{}/{total}\r\n", total - 1),
1047 log.body.clone(),
1048 ),
1049 _ => ("200 OK", String::new(), log.body.clone()),
1050 }
1051 } else {
1052 let job = serde_json::json!({
1053 "id": 101,
1054 "name": "build",
1055 "status": "in_progress",
1056 "run_attempt": log.run_attempt,
1057 "started_at": log.started_at
1058 });
1059 ("200 OK", String::new(), job.to_string().into_bytes())
1060 }
1061 };
1062 let head = format!(
1063 "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\n{content_range}Content-Length: {}\r\nConnection: close\r\n\r\n",
1064 payload.len()
1065 );
1066 stream.write_all(head.as_bytes()).await.unwrap();
1067 stream.write_all(&payload).await.unwrap();
1068 }
1069 });
1070 let mut plugin = GiteaPlugin::new();
1071 plugin
1072 .initialize(
1073 1,
1074 HashMap::from([
1075 ("base_url".to_string(), format!("http://{addr}/")),
1076 ("token".to_string(), "stub-token".to_string()),
1077 ]),
1078 None,
1079 )
1080 .unwrap();
1081 (plugin, log, server)
1082 }
1083
1084 fn cursor(job: &str, offset: u64) -> LogCursor {
1085 cursor_of(job, 1, STARTED_AT, offset)
1086 }
1087
1088 fn cursor_of(job: &str, run: i64, started_at: &str, offset: u64) -> LogCursor {
1089 LogCursor::new(&serde_json::json!({
1090 "job": job,
1091 "attempt": {"run": run, "started_at": started_at},
1092 "offset": offset
1093 }))
1094 .unwrap()
1095 }
1096
1097 async fn read_log(
1098 plugin: &GiteaPlugin, from: Option<&LogCursor>,
1099 ) -> (PluginResult<LogRead>, Vec<u8>, bool) {
1100 let metadata = run_metadata();
1101 let (tx, mut rx) = tokio::sync::mpsc::channel(16);
1102 let mut sink = LogSink::new(tx);
1103 let result = plugin
1104 .read_log(run_7(SMOKE_PIPELINE, &metadata), "101", from, &mut sink)
1105 .await;
1106 let restarted = sink.restarted();
1107 drop(sink);
1108 let mut bytes = Vec::new();
1109 while let Some(chunk) = rx.recv().await {
1110 bytes.extend_from_slice(&chunk);
1111 }
1112 (result, bytes, restarted)
1113 }
1114
1115 fn ranges(log: &SharedLog) -> Vec<Option<String>> {
1116 log.lock().unwrap().ranges.clone()
1117 }
1118
1119 #[tokio::test]
1120 async fn resumed_log_appends_only_the_new_bytes() {
1121 let (plugin, log, server) = stub_log(b"a\nb\n", RangeSupport::Honored).await;
1122
1123 let (first, bytes, restarted) = read_log(&plugin, None).await;
1124 assert_eq!(first.unwrap(), LogRead::replace(Some(cursor("101", 4))));
1125 assert_eq!(bytes, b"a\nb\n");
1126 assert!(!restarted);
1127
1128 log.lock().unwrap().body.extend_from_slice(b"c\n");
1129 let (second, bytes, restarted) = read_log(&plugin, Some(&cursor("101", 4))).await;
1130 assert_eq!(second.unwrap(), LogRead::append(cursor("101", 6)));
1131 assert_eq!(bytes, b"c\n");
1132 assert!(!restarted);
1133
1134 let (idle, bytes, restarted) = read_log(&plugin, Some(&cursor("101", 6))).await;
1135 server.abort();
1136 assert_eq!(idle.unwrap(), LogRead::append(cursor("101", 6)));
1137 assert!(bytes.is_empty() && !restarted);
1138 assert_eq!(
1139 ranges(&log),
1140 [None, Some("bytes=4-".into()), Some("bytes=6-".into())]
1141 );
1142 }
1143
1144 #[tokio::test]
1145 async fn server_ignoring_range_restarts_with_the_whole_log() {
1146 let (plugin, log, server) = stub_log(b"a\nb\nc\n", RangeSupport::Ignored).await;
1147
1148 let (read, bytes, restarted) = read_log(&plugin, Some(&cursor("101", 4))).await;
1149 server.abort();
1150
1151 assert_eq!(read.unwrap(), LogRead::replace(Some(cursor("101", 6))));
1152 assert_eq!(bytes, b"a\nb\nc\n");
1153 assert!(restarted);
1154 assert_eq!(ranges(&log), [Some("bytes=4-".into())]);
1155 }
1156
1157 #[tokio::test]
1158 async fn partial_reply_starting_elsewhere_restarts_with_the_whole_log() {
1159 let (plugin, log, server) = stub_log(b"a\nb\nc\n", RangeSupport::Misaligned).await;
1160
1161 let (read, bytes, restarted) = read_log(&plugin, Some(&cursor("101", 4))).await;
1162 server.abort();
1163
1164 assert_eq!(read.unwrap(), LogRead::replace(Some(cursor("101", 6))));
1165 assert_eq!(bytes, b"a\nb\nc\n");
1166 assert!(restarted);
1167 assert_eq!(ranges(&log), [Some("bytes=4-".into()), None]);
1168 }
1169
1170 #[tokio::test]
1171 async fn shrunk_log_restarts_with_the_whole_log() {
1172 let (plugin, log, server) = stub_log(b"a\n", RangeSupport::Honored).await;
1173
1174 let (read, bytes, restarted) = read_log(&plugin, Some(&cursor("101", 6))).await;
1175 server.abort();
1176
1177 assert_eq!(read.unwrap(), LogRead::replace(Some(cursor("101", 2))));
1178 assert_eq!(bytes, b"a\n");
1179 assert!(restarted);
1180 assert_eq!(ranges(&log), [Some("bytes=6-".into()), None]);
1181 }
1182
1183 #[tokio::test]
1184 async fn unusable_cursors_restart_without_a_range_request() {
1185 let (plugin, log, server) = stub_log(b"a\nb\n", RangeSupport::Honored).await;
1186
1187 for stale in [LogCursor::new(&"garbage").unwrap(), cursor("102", 4)] {
1188 let (read, bytes, restarted) = read_log(&plugin, Some(&stale)).await;
1189 assert_eq!(read.unwrap(), LogRead::replace(Some(cursor("101", 4))));
1190 assert_eq!(bytes, b"a\nb\n");
1191 assert!(restarted);
1192 }
1193 server.abort();
1194
1195 assert_eq!(ranges(&log), [None, None]);
1196 }
1197
1198 #[tokio::test]
1199 async fn rerun_keeping_the_job_id_restarts_even_when_its_log_is_longer_than_the_cursor() {
1200 let (plugin, log, server) = stub_log(b"a\nb\n", RangeSupport::Honored).await;
1201
1202 let (first, _, _) = read_log(&plugin, None).await;
1203 let first = first.unwrap();
1204 assert_eq!(first, LogRead::replace(Some(cursor("101", 4))));
1205
1206 for (run, started_at) in [(2, STARTED_AT), (1, "2026-10-28T11:00:00Z")] {
1207 {
1208 let mut log = log.lock().unwrap();
1209 log.run_attempt = run;
1210 log.started_at = started_at;
1211 log.body = b"x\ny\nz\nw\n".to_vec();
1212 }
1213 let (read, bytes, restarted) = read_log(&plugin, first.cursor.as_ref()).await;
1214 assert_eq!(
1215 read.unwrap(),
1216 LogRead::replace(Some(cursor_of("101", run, started_at, 8)))
1217 );
1218 assert_eq!(bytes, b"x\ny\nz\nw\n");
1219 assert!(restarted);
1220 }
1221 server.abort();
1222
1223 assert_eq!(ranges(&log), [None, None, None]);
1224 }
1225}