use super::azure::AzurePipelineClient;
use super::bitbucket::BitbucketPipelineClient;
use super::gitea::GiteaPipelineClient;
use super::github::GitHubPipelineClient;
use super::gitlab::GitLabPipelineClient;
use super::radicle::RadiclePipelineClient;
use super::sourcehut::SourcehutPipelineClient;
use crate::error::{Result, ToriiError};
use chrono::{DateTime, Duration, Utc};
use reqwest::blocking::Client;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pipeline {
pub id: String,
pub status: String,
pub raw_status: String,
pub branch: String,
pub sha: String,
pub web_url: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Default)]
pub struct ListFilters {
pub status: Option<String>,
pub per_page: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Job {
pub id: String,
pub pipeline_id: String,
pub name: String,
pub status: String,
pub raw_status: String,
pub stage: String,
pub web_url: String,
pub created_at: String,
pub finished_at: Option<String>,
pub duration_seconds: Option<f64>,
}
#[allow(dead_code)]
pub trait PipelineClient: Send {
fn list(&self, owner: &str, repo: &str, filters: &ListFilters) -> Result<Vec<Pipeline>>;
fn cancel(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
fn retry(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()>;
fn list_jobs(
&self,
owner: &str,
repo: &str,
pipeline_id: &str,
status_filter: Option<&str>,
) -> Result<Vec<Job>>;
fn job_log(&self, owner: &str, repo: &str, job_id: &str) -> Result<String>;
fn job_retry(&self, owner: &str, repo: &str, job_id: &str) -> Result<()>;
fn job_cancel(&self, owner: &str, repo: &str, job_id: &str) -> Result<()>;
fn job_artifacts_download(
&self,
owner: &str,
repo: &str,
job_id: &str,
output_path: &std::path::Path,
) -> Result<()>;
fn job_erase(&self, owner: &str, repo: &str, job_id: &str) -> Result<()>;
}
pub(crate) fn post_no_body(client: &Client, url: &str, auth: &str, op: &str) -> Result<()> {
let req = client
.post(url)
.header("Authorization", auth)
.header("Accept", "application/vnd.github+json");
crate::http::send_empty(req, &format!("GitHub {}", op))
}
pub fn get_pipeline_client(platform: &str) -> Result<Box<dyn PipelineClient>> {
get_pipeline_client_with_base_url(platform, None)
}
pub fn get_pipeline_client_with_base_url(
platform: &str,
base_url: Option<&str>,
) -> Result<Box<dyn PipelineClient>> {
match platform.to_lowercase().as_str() {
"github" => Ok(Box::new(GitHubPipelineClient::new()?)),
"gitlab" => Ok(Box::new(GitLabPipelineClient::new_with_base_url(base_url)?)),
"gitea" => Ok(Box::new(GiteaPipelineClient::new()?)),
"sourcehut" => Ok(Box::new(SourcehutPipelineClient::new()?)),
"radicle" => Ok(Box::new(RadiclePipelineClient::new()?)),
"bitbucket" => Ok(Box::new(BitbucketPipelineClient::new()?)),
"azure" => Ok(Box::new(AzurePipelineClient::new()?)),
other => Err(ToriiError::Unsupported(format!("Unsupported platform: {}. Supported: github, gitlab, gitea, sourcehut, radicle, bitbucket, azure", other))),
}
}
pub fn filter_older_than(pipelines: Vec<Pipeline>, days: i64) -> Vec<Pipeline> {
let cutoff = Utc::now() - Duration::days(days);
pipelines
.into_iter()
.filter(|p| match DateTime::parse_from_rfc3339(&p.created_at) {
Ok(dt) => dt.with_timezone(&Utc) < cutoff,
Err(_) => true,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platforms::github::pipeline::parse_github_run;
use crate::platforms::gitlab::pipeline::parse_gitlab_pipeline;
fn mk(id: &str, status: &str, created_at: &str) -> Pipeline {
Pipeline {
id: id.into(),
status: status.into(),
raw_status: status.into(),
branch: "main".into(),
sha: "deadbeef".into(),
web_url: String::new(),
created_at: created_at.into(),
updated_at: created_at.into(),
}
}
#[test]
fn parse_github_completed_failure_normalizes_to_failed() {
let json = serde_json::json!({
"id": 12345u64,
"status": "completed",
"conclusion": "failure",
"head_branch": "main",
"head_sha": "abc",
"html_url": "https://x",
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:00Z",
});
let p = parse_github_run(&json).unwrap();
assert_eq!(p.id, "12345");
assert_eq!(p.status, "failed");
assert_eq!(p.raw_status, "failure");
}
#[test]
fn parse_github_in_progress_normalizes_to_running() {
let json = serde_json::json!({
"id": 1u64, "status": "in_progress", "conclusion": serde_json::Value::Null,
"head_branch": "main", "head_sha": "a", "html_url": "",
"created_at": "", "updated_at": "",
});
assert_eq!(parse_github_run(&json).unwrap().status, "running");
}
#[test]
fn parse_gitlab_canceled_normalizes() {
let json = serde_json::json!({
"id": 42u64, "status": "canceled", "ref": "main", "sha": "a",
"web_url": "https://x", "created_at": "", "updated_at": "",
});
let p = parse_gitlab_pipeline(&json).unwrap();
assert_eq!(p.status, "canceled");
assert_eq!(p.raw_status, "canceled");
}
#[test]
fn filter_older_than_keeps_recent_drops_old() {
let now = Utc::now();
let recent = (now - Duration::days(1)).to_rfc3339();
let ancient = (now - Duration::days(30)).to_rfc3339();
let list = vec![
mk("recent", "failed", &recent),
mk("ancient", "failed", &ancient),
];
let kept = filter_older_than(list, 7);
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].id, "ancient");
}
#[test]
fn filter_older_than_keeps_unparseable_timestamps() {
let kept = filter_older_than(vec![mk("x", "failed", "not a date")], 7);
assert_eq!(kept.len(), 1);
}
}