1use super::azure::AzurePipelineClient;
2use super::bitbucket::BitbucketPipelineClient;
3use super::gitea::GiteaPipelineClient;
4use super::github::GitHubPipelineClient;
5use super::gitlab::GitLabPipelineClient;
6use super::radicle::RadiclePipelineClient;
7use super::sourcehut::SourcehutPipelineClient;
8use crate::error::{Result, ToriiError};
9use chrono::{DateTime, Duration, Utc};
10use reqwest::blocking::Client;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Pipeline {
19 pub id: String,
20 pub status: String,
22 pub raw_status: String,
26 pub branch: String,
27 pub sha: String,
28 pub web_url: String,
29 pub created_at: String,
30 pub updated_at: String,
31}
32
33#[derive(Debug, Clone, Default)]
34pub struct ListFilters {
35 pub status: Option<String>,
38 pub per_page: usize,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Job {
44 pub id: String,
45 pub pipeline_id: String,
47 pub name: String,
48 pub status: String,
50 pub raw_status: String,
51 pub stage: String,
52 pub web_url: String,
53 pub created_at: String,
54 pub finished_at: Option<String>,
55 pub duration_seconds: Option<f64>,
56}
57
58#[allow(dead_code)]
59pub trait PipelineClient: Send {
60 fn list(&self, owner: &str, repo: &str, filters: &ListFilters) -> Result<Vec<Pipeline>>;
62 fn cancel(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
63 fn retry(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
64 fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
65
66 fn list_jobs(
68 &self,
69 owner: &str,
70 repo: &str,
71 pipeline_id: &str,
72 status_filter: Option<&str>,
73 ) -> Result<Vec<Job>>;
74 fn job_log(&self, owner: &str, repo: &str, job_id: &str) -> Result<String>;
77 fn job_retry(&self, owner: &str, repo: &str, job_id: &str) -> Result<()>;
78 fn job_cancel(&self, owner: &str, repo: &str, job_id: &str) -> Result<()>;
79 fn job_artifacts_download(
84 &self,
85 owner: &str,
86 repo: &str,
87 job_id: &str,
88 output_path: &std::path::Path,
89 ) -> Result<()>;
90 fn job_erase(&self, owner: &str, repo: &str, job_id: &str) -> Result<()>;
93}
94
95pub(crate) fn post_no_body(client: &Client, url: &str, auth: &str, op: &str) -> Result<()> {
104 let req = client
105 .post(url)
106 .header("Authorization", auth)
107 .header("Accept", "application/vnd.github+json");
108 crate::http::send_empty(req, &format!("GitHub {}", op))
109}
110
111pub fn get_pipeline_client(platform: &str) -> Result<Box<dyn PipelineClient>> {
112 get_pipeline_client_with_base_url(platform, None)
113}
114
115pub fn get_pipeline_client_with_base_url(
121 platform: &str,
122 base_url: Option<&str>,
123) -> Result<Box<dyn PipelineClient>> {
124 match platform.to_lowercase().as_str() {
125 "github" => Ok(Box::new(GitHubPipelineClient::new()?)),
126 "gitlab" => Ok(Box::new(GitLabPipelineClient::new_with_base_url(base_url)?)),
127 "gitea" => Ok(Box::new(GiteaPipelineClient::new()?)),
128 "sourcehut" => Ok(Box::new(SourcehutPipelineClient::new()?)),
129 "radicle" => Ok(Box::new(RadiclePipelineClient::new()?)),
130 "bitbucket" => Ok(Box::new(BitbucketPipelineClient::new()?)),
131 "azure" => Ok(Box::new(AzurePipelineClient::new()?)),
132 other => Err(ToriiError::Unsupported(format!("Unsupported platform: {}. Supported: github, gitlab, gitea, sourcehut, radicle, bitbucket, azure", other))),
133 }
134}
135
136pub fn filter_older_than(pipelines: Vec<Pipeline>, days: i64) -> Vec<Pipeline> {
141 let cutoff = Utc::now() - Duration::days(days);
142 pipelines
143 .into_iter()
144 .filter(|p| match DateTime::parse_from_rfc3339(&p.created_at) {
145 Ok(dt) => dt.with_timezone(&Utc) < cutoff,
146 Err(_) => true,
147 })
148 .collect()
149}
150
151#[cfg(test)]
152mod tests {
153 use super::*;
154 use crate::platforms::github::pipeline::parse_github_run;
155 use crate::platforms::gitlab::pipeline::parse_gitlab_pipeline;
156
157 fn mk(id: &str, status: &str, created_at: &str) -> Pipeline {
158 Pipeline {
159 id: id.into(),
160 status: status.into(),
161 raw_status: status.into(),
162 branch: "main".into(),
163 sha: "deadbeef".into(),
164 web_url: String::new(),
165 created_at: created_at.into(),
166 updated_at: created_at.into(),
167 }
168 }
169
170 #[test]
171 fn parse_github_completed_failure_normalizes_to_failed() {
172 let json = serde_json::json!({
173 "id": 12345u64,
174 "status": "completed",
175 "conclusion": "failure",
176 "head_branch": "main",
177 "head_sha": "abc",
178 "html_url": "https://x",
179 "created_at": "2026-01-01T00:00:00Z",
180 "updated_at": "2026-01-01T00:00:00Z",
181 });
182 let p = parse_github_run(&json).unwrap();
183 assert_eq!(p.id, "12345");
184 assert_eq!(p.status, "failed");
185 assert_eq!(p.raw_status, "failure");
186 }
187
188 #[test]
189 fn parse_github_in_progress_normalizes_to_running() {
190 let json = serde_json::json!({
191 "id": 1u64, "status": "in_progress", "conclusion": serde_json::Value::Null,
192 "head_branch": "main", "head_sha": "a", "html_url": "",
193 "created_at": "", "updated_at": "",
194 });
195 assert_eq!(parse_github_run(&json).unwrap().status, "running");
196 }
197
198 #[test]
199 fn parse_gitlab_canceled_normalizes() {
200 let json = serde_json::json!({
201 "id": 42u64, "status": "canceled", "ref": "main", "sha": "a",
202 "web_url": "https://x", "created_at": "", "updated_at": "",
203 });
204 let p = parse_gitlab_pipeline(&json).unwrap();
205 assert_eq!(p.status, "canceled");
206 assert_eq!(p.raw_status, "canceled");
207 }
208
209 #[test]
210 fn filter_older_than_keeps_recent_drops_old() {
211 let now = Utc::now();
212 let recent = (now - Duration::days(1)).to_rfc3339();
213 let ancient = (now - Duration::days(30)).to_rfc3339();
214 let list = vec![
215 mk("recent", "failed", &recent),
216 mk("ancient", "failed", &ancient),
217 ];
218 let kept = filter_older_than(list, 7);
219 assert_eq!(kept.len(), 1);
220 assert_eq!(kept[0].id, "ancient");
221 }
222
223 #[test]
224 fn filter_older_than_keeps_unparseable_timestamps() {
225 let kept = filter_older_than(vec![mk("x", "failed", "not a date")], 7);
228 assert_eq!(kept.len(), 1);
229 }
230}