Skip to main content

torii_lib/platforms/github/
pipeline.rs

1//! GitHub — pipeline client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::pipeline::*;
5use reqwest::blocking::Client;
6
7pub struct GitHubPipelineClient {
8    token: String,
9    base_url: String,
10}
11
12impl GitHubPipelineClient {
13    pub fn new() -> Result<Self> {
14        let token = crate::auth::resolve_token("github", ".")
15            .value
16            .ok_or_else(|| ToriiError::Auth {
17                provider: "github".into(),
18                message: "GitHub token not found. Run: torii auth set github YOUR_TOKEN"
19                    .to_string(),
20            })?;
21        Ok(Self {
22            token,
23            base_url: "https://api.github.com".to_string(),
24        })
25    }
26
27    fn client(&self) -> Client {
28        crate::http::make_client()
29    }
30
31    fn auth_header(&self) -> String {
32        format!("token {}", self.token)
33    }
34}
35
36impl PipelineClient for GitHubPipelineClient {
37    fn list(&self, owner: &str, repo: &str, filters: &ListFilters) -> Result<Vec<Pipeline>> {
38        // GitHub splits run state across two parameters:
39        //   status=queued|in_progress|completed
40        //   ...and once completed, conclusion=success|failure|cancelled|...
41        // The API also accepts conclusion-style values directly on the
42        // `status` parameter as of 2022 (success, failure, etc.) — they
43        // map onto status=completed&conclusion=<value> internally. We
44        // exploit that to keep the request to a single param.
45        let mut url = format!(
46            "{}/repos/{}/{}/actions/runs?per_page={}",
47            self.base_url,
48            owner,
49            repo,
50            filters.per_page.clamp(1, 100)
51        );
52        if let Some(ref s) = filters.status {
53            let gh = match s.as_str() {
54                "success" => "success",
55                "failed" => "failure",
56                "running" => "in_progress",
57                "canceled" => "cancelled",
58                "pending" => "queued",
59                other => other,
60            };
61            url.push_str(&format!("&status={}", gh));
62        }
63        let req = self
64            .client()
65            .get(&url)
66            .header("Authorization", self.auth_header())
67            .header("Accept", "application/vnd.github+json");
68        let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
69        let arr =
70            json["workflow_runs"]
71                .as_array()
72                .ok_or_else(|| ToriiError::MalformedResponse {
73                    provider: "github".into(),
74                    message: format!("GitHub returned no workflow_runs array. Body: {}", json),
75                })?;
76        arr.iter().map(parse_github_run).collect()
77    }
78
79    fn cancel(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
80        let url = format!(
81            "{}/repos/{}/{}/actions/runs/{}/cancel",
82            self.base_url, owner, repo, id
83        );
84        post_no_body(&self.client(), &url, &self.auth_header(), "cancel")
85    }
86
87    fn retry(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
88        let url = format!(
89            "{}/repos/{}/{}/actions/runs/{}/rerun",
90            self.base_url, owner, repo, id
91        );
92        post_no_body(&self.client(), &url, &self.auth_header(), "retry")
93    }
94
95    fn delete(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
96        let url = format!(
97            "{}/repos/{}/{}/actions/runs/{}",
98            self.base_url, owner, repo, id
99        );
100        let req = self
101            .client()
102            .delete(&url)
103            .header("Authorization", self.auth_header())
104            .header("Accept", "application/vnd.github+json");
105        crate::http::send_empty(req, "GitHub delete run")
106    }
107
108    // ---- job ops on GitHub Actions ----
109
110    fn list_jobs(
111        &self,
112        owner: &str,
113        repo: &str,
114        pipeline_id: &str,
115        status_filter: Option<&str>,
116    ) -> Result<Vec<Job>> {
117        // GitHub Actions: "jobs in a workflow run". The `filter` query
118        // param accepts `latest` | `all`; per-status filtering happens
119        // client-side after the fetch.
120        let url = format!(
121            "{}/repos/{}/{}/actions/runs/{}/jobs?per_page=100",
122            self.base_url, owner, repo, pipeline_id
123        );
124        let req = self
125            .client()
126            .get(&url)
127            .header("Authorization", self.auth_header())
128            .header("Accept", "application/vnd.github+json");
129        let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
130        let arr = json["jobs"]
131            .as_array()
132            .ok_or_else(|| ToriiError::MalformedResponse {
133                provider: "github".into(),
134                message: format!("GitHub returned no `jobs` array. Body: {}", json),
135            })?;
136        let jobs: Vec<Job> = arr
137            .iter()
138            .filter_map(|v| parse_github_job(v, pipeline_id).ok())
139            .collect();
140        // Apply status filter client-side
141        if let Some(s) = status_filter {
142            Ok(jobs.into_iter().filter(|j| j.status == s).collect())
143        } else {
144            Ok(jobs)
145        }
146    }
147
148    fn job_log(&self, owner: &str, repo: &str, job_id: &str) -> Result<String> {
149        // GitHub returns a 302 redirect to a signed log URL. reqwest
150        // follows redirects by default. We can't use send_json here —
151        // the body is plain text, not JSON.
152        let url = format!(
153            "{}/repos/{}/{}/actions/jobs/{}/logs",
154            self.base_url, owner, repo, job_id
155        );
156        let req = self
157            .client()
158            .get(&url)
159            .header("Authorization", self.auth_header())
160            .header("Accept", "application/vnd.github+json");
161        crate::http::send_text(req, "GitHub job log")
162    }
163
164    fn job_retry(&self, _owner: &str, _repo: &str, _job_id: &str) -> Result<()> {
165        // GitHub Actions has no per-job retry — only `/runs/:run_id/rerun`
166        // and `/runs/:run_id/rerun-failed-jobs`. Both operate at the run
167        // level. Point the user at `torii pipeline retry <run-id>` so
168        // the CLI surface stays honest.
169        Err(ToriiError::Unsupported("GitHub Actions doesn't support per-job retry. Use `torii pipeline retry <run-id>` to re-run failed jobs in a workflow run.".to_string()))
170    }
171
172    fn job_cancel(&self, _owner: &str, _repo: &str, _job_id: &str) -> Result<()> {
173        Err(ToriiError::Unsupported("GitHub Actions doesn't support per-job cancel. Use `torii pipeline cancel <run-id>` to stop a workflow run.".to_string()))
174    }
175
176    fn job_artifacts_download(
177        &self,
178        _owner: &str,
179        _repo: &str,
180        _job_id: &str,
181        _output_path: &std::path::Path,
182    ) -> Result<()> {
183        Err(ToriiError::Unsupported("GitHub Actions artifacts are scoped to the workflow run, not the job. List artifacts with `torii pipeline list` then use the GitHub UI / API directly until torii adds per-run artifact download.".to_string()))
184    }
185
186    fn job_erase(&self, _owner: &str, _repo: &str, _job_id: &str) -> Result<()> {
187        // GitLab-only operation; GitHub Actions doesn't expose log-erase.
188        Err(ToriiError::Unsupported("GitHub Actions doesn't support per-job log erase. Logs are retained for the run lifetime; use `torii pipeline delete <run-id>` to discard the run entirely.".to_string()))
189    }
190}
191
192fn parse_github_job(v: &serde_json::Value, pipeline_id: &str) -> Result<Job> {
193    let id = v["id"]
194        .as_u64()
195        .map(|n| n.to_string())
196        .or_else(|| v["id"].as_str().map(String::from))
197        .ok_or_else(|| ToriiError::MalformedResponse {
198            provider: "github".into(),
199            message: "GitHub job missing id".into(),
200        })?;
201    let raw_status = v["status"].as_str().unwrap_or("").to_string();
202    let conclusion = v["conclusion"].as_str().unwrap_or("");
203    let label = if raw_status == "completed" && !conclusion.is_empty() {
204        conclusion.to_string()
205    } else {
206        raw_status.clone()
207    };
208    let status = match raw_status.as_str() {
209        "queued" => "pending".to_string(),
210        "in_progress" => "running".to_string(),
211        "completed" => match conclusion {
212            "success" => "success",
213            "failure" | "timed_out" => "failed",
214            "cancelled" => "canceled",
215            _ => "other",
216        }
217        .to_string(),
218        _ => "other".to_string(),
219    };
220    // GitHub job duration = finished_at - started_at if both set.
221    let started_at = v["started_at"].as_str();
222    let finished_at = v["completed_at"].as_str();
223    let duration = match (started_at, finished_at) {
224        (Some(s), Some(f)) => {
225            use chrono::DateTime;
226            match (
227                DateTime::parse_from_rfc3339(s),
228                DateTime::parse_from_rfc3339(f),
229            ) {
230                (Ok(s), Ok(f)) => Some((f - s).num_milliseconds() as f64 / 1000.0),
231                _ => None,
232            }
233        }
234        _ => None,
235    };
236    Ok(Job {
237        id,
238        pipeline_id: pipeline_id.to_string(),
239        name: v["name"].as_str().unwrap_or("").to_string(),
240        status,
241        raw_status: label,
242        stage: String::new(), // GitHub Actions has no "stage" concept
243        web_url: v["html_url"].as_str().unwrap_or("").to_string(),
244        created_at: v["created_at"].as_str().unwrap_or("").to_string(),
245        finished_at: finished_at.map(String::from),
246        duration_seconds: duration,
247    })
248}
249
250pub(crate) fn parse_github_run(v: &serde_json::Value) -> Result<Pipeline> {
251    let id = v["id"]
252        .as_u64()
253        .map(|n| n.to_string())
254        .or_else(|| v["id"].as_str().map(String::from))
255        .ok_or_else(|| ToriiError::MalformedResponse {
256            provider: "github".into(),
257            message: "GitHub run missing id".into(),
258        })?;
259    let raw_status = v["status"].as_str().unwrap_or("").to_string();
260    let conclusion = v["conclusion"].as_str().unwrap_or("");
261    let label = if raw_status == "completed" && !conclusion.is_empty() {
262        conclusion.to_string()
263    } else {
264        raw_status.clone()
265    };
266    let status = match raw_status.as_str() {
267        "queued" => "pending".to_string(),
268        "in_progress" => "running".to_string(),
269        "completed" => match conclusion {
270            "success" => "success",
271            "failure" | "timed_out" => "failed",
272            "cancelled" => "canceled",
273            _ => "other",
274        }
275        .to_string(),
276        _ => "other".to_string(),
277    };
278    Ok(Pipeline {
279        id,
280        status,
281        raw_status: label,
282        branch: v["head_branch"].as_str().unwrap_or("").to_string(),
283        sha: v["head_sha"].as_str().unwrap_or("").to_string(),
284        web_url: v["html_url"].as_str().unwrap_or("").to_string(),
285        created_at: v["created_at"].as_str().unwrap_or("").to_string(),
286        updated_at: v["updated_at"].as_str().unwrap_or("").to_string(),
287    })
288}
289
290// ============================================================================
291// GitLab Pipelines
292// ============================================================================
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use httpmock::prelude::*;
298
299    // Note: parse_github_run's completed/failure and in_progress mappings
300    // are already covered in src/platforms/pipeline.rs — the tests here
301    // cover only the cases those don't (missing id, queued, jobs).
302
303    fn client_for(server: &MockServer) -> GitHubPipelineClient {
304        GitHubPipelineClient {
305            token: "test-token".into(),
306            base_url: server.base_url(),
307        }
308    }
309
310    #[test]
311    fn parse_github_run_missing_id_is_malformed() {
312        let err = parse_github_run(&serde_json::json!({ "status": "queued" })).unwrap_err();
313        assert!(
314            matches!(err, ToriiError::MalformedResponse { .. }),
315            "expected MalformedResponse, got: {err:?}"
316        );
317    }
318
319    #[test]
320    fn parse_github_run_queued_normalizes_to_pending() {
321        let json = serde_json::json!({ "id": 9u64, "status": "queued" });
322        let p = parse_github_run(&json).unwrap();
323        assert_eq!(p.id, "9");
324        assert_eq!(p.status, "pending");
325        assert_eq!(p.raw_status, "queued");
326        assert_eq!(p.branch, "");
327    }
328
329    #[test]
330    fn parse_github_job_completed_success_with_duration() {
331        let json = serde_json::json!({
332            "id": 111u64,
333            "name": "build",
334            "status": "completed",
335            "conclusion": "success",
336            "html_url": "https://x/job/111",
337            "created_at": "2026-01-01T00:00:00Z",
338            "started_at": "2026-01-01T00:00:10Z",
339            "completed_at": "2026-01-01T00:01:40Z",
340        });
341        let job = parse_github_job(&json, "555").unwrap();
342        assert_eq!(job.id, "111");
343        assert_eq!(job.pipeline_id, "555");
344        assert_eq!(job.name, "build");
345        assert_eq!(job.status, "success");
346        assert_eq!(job.raw_status, "success");
347        assert_eq!(job.stage, "");
348        assert_eq!(job.web_url, "https://x/job/111");
349        assert_eq!(job.finished_at.as_deref(), Some("2026-01-01T00:01:40Z"));
350        assert_eq!(job.duration_seconds, Some(90.0));
351    }
352
353    #[test]
354    fn parse_github_job_in_progress_has_no_duration() {
355        let json = serde_json::json!({
356            "id": 1u64, "name": "test", "status": "in_progress",
357            "started_at": "2026-01-01T00:00:00Z",
358        });
359        let job = parse_github_job(&json, "p").unwrap();
360        assert_eq!(job.status, "running");
361        assert_eq!(job.raw_status, "in_progress");
362        assert_eq!(job.finished_at, None);
363        assert_eq!(job.duration_seconds, None);
364    }
365
366    #[test]
367    fn parse_github_job_missing_id_is_malformed() {
368        let err = parse_github_job(&serde_json::json!({ "status": "queued" }), "p").unwrap_err();
369        assert!(
370            matches!(err, ToriiError::MalformedResponse { .. }),
371            "expected MalformedResponse, got: {err:?}"
372        );
373    }
374
375    #[test]
376    fn list_translates_status_filter_and_parses_runs() {
377        let server = MockServer::start();
378        let m = server.mock(|when, then| {
379            when.method(GET)
380                .path("/repos/octo/demo/actions/runs")
381                .query_param("per_page", "30")
382                // torii's normalized "failed" → GitHub's "failure"
383                .query_param("status", "failure")
384                .header("Authorization", "token test-token");
385            then.status(200).json_body(serde_json::json!({
386                "workflow_runs": [{
387                    "id": 1001u64,
388                    "status": "completed",
389                    "conclusion": "failure",
390                    "head_branch": "main",
391                    "head_sha": "abc123",
392                    "html_url": "https://x/runs/1001",
393                    "created_at": "2026-01-01T00:00:00Z",
394                    "updated_at": "2026-01-01T00:05:00Z",
395                }]
396            }));
397        });
398        let filters = ListFilters {
399            status: Some("failed".into()),
400            per_page: 30,
401        };
402        let runs = client_for(&server).list("octo", "demo", &filters).unwrap();
403        m.assert();
404        assert_eq!(runs.len(), 1);
405        assert_eq!(runs[0].id, "1001");
406        assert_eq!(runs[0].status, "failed");
407        assert_eq!(runs[0].branch, "main");
408        assert_eq!(runs[0].sha, "abc123");
409    }
410
411    #[test]
412    fn list_without_workflow_runs_array_is_malformed() {
413        let server = MockServer::start();
414        server.mock(|when, then| {
415            when.method(GET).path("/repos/octo/demo/actions/runs");
416            then.status(200)
417                .json_body(serde_json::json!({ "total_count": 0 }));
418        });
419        let filters = ListFilters {
420            status: None,
421            per_page: 10,
422        };
423        let err = client_for(&server)
424            .list("octo", "demo", &filters)
425            .unwrap_err();
426        assert!(
427            matches!(err, ToriiError::MalformedResponse { .. }),
428            "expected MalformedResponse, got: {err:?}"
429        );
430    }
431
432    #[test]
433    fn cancel_posts_to_cancel_endpoint_with_auth() {
434        let server = MockServer::start();
435        let m = server.mock(|when, then| {
436            when.method(POST)
437                .path("/repos/octo/demo/actions/runs/99/cancel")
438                .header("Authorization", "token test-token");
439            then.status(202);
440        });
441        client_for(&server).cancel("octo", "demo", "99").unwrap();
442        m.assert();
443    }
444
445    #[test]
446    fn cancel_maps_500_to_platform_api_error() {
447        let server = MockServer::start();
448        server.mock(|when, then| {
449            when.method(POST)
450                .path("/repos/octo/demo/actions/runs/99/cancel");
451            then.status(500).body("boom");
452        });
453        let err = client_for(&server)
454            .cancel("octo", "demo", "99")
455            .unwrap_err();
456        assert!(
457            matches!(err, ToriiError::PlatformApi { status: 500, .. }),
458            "expected PlatformApi 500, got: {err:?}"
459        );
460    }
461
462    #[test]
463    fn list_jobs_filters_client_side_by_normalized_status() {
464        let server = MockServer::start();
465        let m = server.mock(|when, then| {
466            when.method(GET)
467                .path("/repos/octo/demo/actions/runs/7/jobs")
468                .header("Authorization", "token test-token");
469            then.status(200).json_body(serde_json::json!({
470                "jobs": [
471                    { "id": 1u64, "name": "ok",   "status": "completed", "conclusion": "success" },
472                    { "id": 2u64, "name": "boom", "status": "completed", "conclusion": "failure" },
473                ]
474            }));
475        });
476        let jobs = client_for(&server)
477            .list_jobs("octo", "demo", "7", Some("failed"))
478            .unwrap();
479        m.assert();
480        assert_eq!(jobs.len(), 1);
481        assert_eq!(jobs[0].id, "2");
482        assert_eq!(jobs[0].status, "failed");
483        assert_eq!(jobs[0].pipeline_id, "7");
484    }
485}