1use 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 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 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 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 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 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 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 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 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(), 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#[cfg(test)]
295mod tests {
296 use super::*;
297 use httpmock::prelude::*;
298
299 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 .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}