Skip to main content

pipedash_plugin_gitea/
plugin.rs

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