Skip to main content

torii_lib/platforms/
pipeline.rs

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// ============================================================================
14// Shared types
15// ============================================================================
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Pipeline {
19    pub id: String,
20    /// Normalized status: success | failed | running | canceled | pending | other
21    pub status: String,
22    /// Platform-native status string for display (GitLab uses one set,
23    /// GitHub Actions splits status/conclusion — we squash that into a
24    /// single label here).
25    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    /// Normalized status filter. Translated to platform-specific
36    /// query parameter inside each client.
37    pub status: Option<String>,
38    /// Page size, clamped to [1, 100] per platform API limits.
39    pub per_page: usize,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct Job {
44    pub id: String,
45    /// Pipeline / workflow-run this job belongs to.
46    pub pipeline_id: String,
47    pub name: String,
48    /// Normalized status: success | failed | running | canceled | pending | other
49    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    // --- pipeline ops ---
61    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    // --- job ops (0.7.10) ---
67    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    /// Fetch the raw log/trace for a single job. Returned as a String;
75    /// caller decides whether to print it all or `--tail N`.
76    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    /// Download the job's artifacts archive to `output_path`. GitLab
80    /// supports this per-job; GitHub Actions only offers artifacts at
81    /// the workflow-run level, so the GitHub impl returns an error
82    /// pointing the user at the per-run download flow.
83    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    /// Erase a job's log + artifacts but keep its metadata visible in
91    /// the UI (GitLab-specific operation; GitHub returns unsupported).
92    fn job_erase(&self, owner: &str, repo: &str, job_id: &str) -> Result<()>;
93}
94
95// ============================================================================
96// GitHub Actions (workflow runs)
97// ============================================================================
98
99/// Helper used by GitHub Actions and Gitea Actions clients for cancel /
100/// retry / job_retry — POSTs to an action URL with no body and translates
101/// the response to a clear error. Used by GitHub clients that need to
102/// send the `Accept: vnd.github+json` header.
103pub(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
115/// 0.8.0 — same as `get_pipeline_client` but lets the caller override
116/// the API base URL from a `platforms.toml` entry. Today only GitLab
117/// honours the override end-to-end; the rest of the kinds still
118/// build against their builtin defaults. v0.8.1 will extend the
119/// override to Gitea / GitHub Enterprise / Bitbucket Data Center.
120pub 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
136/// Keep only pipelines created more than `days` ago. Pipelines whose
137/// `created_at` is empty or unparseable are kept (we don't act on
138/// state we can't reason about — the user can still inspect via
139/// `pipeline list`).
140pub 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        // Conservative: if we can't tell when it was created, we
226        // don't delete it. Keep it so the user can see it.
227        let kept = filter_older_than(vec![mk("x", "failed", "not a date")], 7);
228        assert_eq!(kept.len(), 1);
229    }
230}