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