Skip to main content

torii_lib/platforms/github/
runner.rs

1//! GitHub — runner client.
2
3use crate::error::{Result, ToriiError};
4use crate::platforms::runner::*;
5use reqwest::blocking::Client;
6
7pub struct GitHubRunnerClient {
8    token: String,
9    base_url: String,
10}
11
12impl GitHubRunnerClient {
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 oauth github".to_string(),
19            })?;
20        Ok(Self {
21            token,
22            base_url: "https://api.github.com".to_string(),
23        })
24    }
25
26    fn client(&self) -> Client {
27        crate::http::make_client()
28    }
29    fn auth(&self) -> String {
30        format!("token {}", self.token)
31    }
32    fn accept(&self) -> &'static str {
33        "application/vnd.github+json"
34    }
35}
36
37impl RunnerClient for GitHubRunnerClient {
38    fn list(&self, owner: &str, repo: &str) -> Result<Vec<Runner>> {
39        let url = format!(
40            "{}/repos/{}/{}/actions/runners?per_page=100",
41            self.base_url, owner, repo
42        );
43        let req = self
44            .client()
45            .get(&url)
46            .header("Authorization", self.auth())
47            .header("Accept", self.accept());
48        let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
49        let arr = json["runners"]
50            .as_array()
51            .ok_or_else(|| ToriiError::MalformedResponse {
52                provider: "github".into(),
53                message: format!("GitHub returned no `runners` array: {}", json),
54            })?;
55        arr.iter().map(parse_github_runner).collect()
56    }
57
58    fn show(&self, owner: &str, repo: &str, id: &str) -> Result<Runner> {
59        let url = format!(
60            "{}/repos/{}/{}/actions/runners/{}",
61            self.base_url, owner, repo, id
62        );
63        let req = self
64            .client()
65            .get(&url)
66            .header("Authorization", self.auth())
67            .header("Accept", self.accept());
68        let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
69        parse_github_runner(&json)
70    }
71
72    fn remove(&self, owner: &str, repo: &str, id: &str) -> Result<()> {
73        let url = format!(
74            "{}/repos/{}/{}/actions/runners/{}",
75            self.base_url, owner, repo, id
76        );
77        let req = self
78            .client()
79            .delete(&url)
80            .header("Authorization", self.auth())
81            .header("Accept", self.accept());
82        crate::http::send_empty(req, "GitHub delete runner")
83    }
84
85    fn reset_token(&self, _owner: &str, _repo: &str, _id: &str) -> Result<String> {
86        Err(ToriiError::Unsupported(
87            "GitHub Actions doesn't expose a per-runner token reset. \
88             Re-register the runner: stop the agent, fetch a fresh \
89             registration token from `Settings → Actions → Runners`, \
90             and run `./config.sh remove` then `./config.sh` again."
91                .to_string(),
92        ))
93    }
94
95    fn pause(&self, _owner: &str, _repo: &str, _id: &str) -> Result<()> {
96        Err(ToriiError::Unsupported(
97            "GitHub Actions has no pause/resume on self-hosted runners. \
98             Use a workflow `runs-on:` label that the runner doesn't \
99             advertise, or stop the agent on the host."
100                .to_string(),
101        ))
102    }
103    fn resume(&self, _owner: &str, _repo: &str, _id: &str) -> Result<()> {
104        Err(ToriiError::Unsupported(
105            "GitHub Actions has no pause/resume on self-hosted runners.".to_string(),
106        ))
107    }
108
109    fn registration_token(&self, owner: &str, repo: &str) -> Result<RegistrationToken> {
110        // GitHub Actions: `POST /repos/:owner/:repo/actions/runners/registration-token`
111        // returns a token valid for ~1h. The token is single-use per
112        // registration but you can request new ones freely.
113        let url = format!(
114            "{}/repos/{}/{}/actions/runners/registration-token",
115            self.base_url, owner, repo
116        );
117        let req = self
118            .client()
119            .post(&url)
120            .header("Authorization", self.auth())
121            .header("Accept", self.accept());
122        let json = crate::http::send_json(req, &format!("GitHub (url: {})", url))?;
123        let token = json["token"]
124            .as_str()
125            .ok_or_else(|| ToriiError::Auth {
126                provider: "github".into(),
127                message: format!(
128                    "GitHub registration-token response missing `token`: {}",
129                    json
130                ),
131            })?
132            .to_string();
133        // `expires_at` is RFC3339; we don't parse it here, we just
134        // mark "an hour" because that's the documented default.
135        Ok(RegistrationToken {
136            token,
137            register_url: format!("https://github.com/{}/{}", owner, repo),
138            expires_in_seconds: Some(3600),
139        })
140    }
141}
142
143fn parse_github_runner(v: &serde_json::Value) -> Result<Runner> {
144    let id = v["id"]
145        .as_u64()
146        .ok_or_else(|| ToriiError::MalformedResponse {
147            provider: "github".into(),
148            message: format!("GitHub runner has no `id`: {}", v),
149        })?
150        .to_string();
151    let busy = v["busy"].as_bool().unwrap_or(false);
152    let raw_status = v["status"].as_str().unwrap_or("").to_string();
153    let status = if raw_status == "online" && busy {
154        "active".to_string()
155    } else {
156        raw_status
157    };
158
159    let tags = v["labels"]
160        .as_array()
161        .map(|a| {
162            a.iter()
163                .filter_map(|t| t["name"].as_str().map(String::from))
164                .collect()
165        })
166        .unwrap_or_default();
167
168    Ok(Runner {
169        id,
170        description: v["name"].as_str().unwrap_or("").to_string(),
171        status,
172        paused: false,
173        ip_address: String::new(),
174        os: v["os"].as_str().unwrap_or("").to_string(),
175        tags,
176        version: String::new(),
177        runner_type: "self-hosted".to_string(),
178        web_url: String::new(),
179    })
180}
181
182// ============================================================================
183// Factory
184// ============================================================================
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use httpmock::prelude::*;
190
191    fn client_for(server: &MockServer) -> GitHubRunnerClient {
192        GitHubRunnerClient {
193            token: "test-token".into(),
194            base_url: server.base_url(),
195        }
196    }
197
198    #[test]
199    fn parse_github_runner_maps_fields_and_busy_online_to_active() {
200        let json = serde_json::json!({
201            "id": 8u64,
202            "name": "runner-01",
203            "os": "linux",
204            "status": "online",
205            "busy": true,
206            "labels": [{ "name": "self-hosted" }, { "name": "x64" }],
207        });
208        let r = parse_github_runner(&json).unwrap();
209        assert_eq!(r.id, "8");
210        assert_eq!(r.description, "runner-01");
211        assert_eq!(r.os, "linux");
212        assert_eq!(r.status, "active");
213        assert_eq!(r.tags, vec!["self-hosted".to_string(), "x64".to_string()]);
214        assert_eq!(r.runner_type, "self-hosted");
215        assert!(!r.paused);
216    }
217
218    #[test]
219    fn parse_github_runner_idle_online_and_missing_optionals() {
220        let json = serde_json::json!({ "id": 9u64, "status": "online" });
221        let r = parse_github_runner(&json).unwrap();
222        // not busy → stays "online", no "active" promotion
223        assert_eq!(r.status, "online");
224        assert_eq!(r.description, "");
225        assert_eq!(r.os, "");
226        assert!(r.tags.is_empty());
227    }
228
229    #[test]
230    fn parse_github_runner_missing_id_is_malformed() {
231        let err = parse_github_runner(&serde_json::json!({ "name": "x" })).unwrap_err();
232        assert!(
233            matches!(err, ToriiError::MalformedResponse { .. }),
234            "expected MalformedResponse, got: {err:?}"
235        );
236    }
237
238    #[test]
239    fn list_parses_runners_array() {
240        let server = MockServer::start();
241        let m = server.mock(|when, then| {
242            when.method(GET)
243                .path("/repos/octo/demo/actions/runners")
244                .query_param("per_page", "100")
245                .header("Authorization", "token test-token");
246            then.status(200).json_body(serde_json::json!({
247                "runners": [
248                    { "id": 1u64, "name": "a", "os": "linux", "status": "online", "busy": false },
249                    { "id": 2u64, "name": "b", "os": "macos", "status": "offline", "busy": false },
250                ]
251            }));
252        });
253        let runners = client_for(&server).list("octo", "demo").unwrap();
254        m.assert();
255        assert_eq!(runners.len(), 2);
256        assert_eq!(runners[0].id, "1");
257        assert_eq!(runners[0].status, "online");
258        assert_eq!(runners[1].description, "b");
259        assert_eq!(runners[1].status, "offline");
260    }
261
262    #[test]
263    fn remove_deletes_runner_with_auth() {
264        let server = MockServer::start();
265        let m = server.mock(|when, then| {
266            when.method(DELETE)
267                .path("/repos/octo/demo/actions/runners/12")
268                .header("Authorization", "token test-token");
269            then.status(204);
270        });
271        client_for(&server).remove("octo", "demo", "12").unwrap();
272        m.assert();
273    }
274
275    #[test]
276    fn registration_token_posts_and_maps_response() {
277        let server = MockServer::start();
278        let m = server.mock(|when, then| {
279            when.method(POST)
280                .path("/repos/octo/demo/actions/runners/registration-token")
281                .header("Authorization", "token test-token");
282            then.status(201).json_body(serde_json::json!({
283                "token": "AAAREG123",
284                "expires_at": "2026-06-05T13:00:00Z",
285            }));
286        });
287        let reg = client_for(&server)
288            .registration_token("octo", "demo")
289            .unwrap();
290        m.assert();
291        assert_eq!(reg.token, "AAAREG123");
292        assert_eq!(reg.register_url, "https://github.com/octo/demo");
293        assert_eq!(reg.expires_in_seconds, Some(3600));
294    }
295
296    #[test]
297    fn show_maps_404_to_platform_api_error() {
298        let server = MockServer::start();
299        server.mock(|when, then| {
300            when.method(GET).path("/repos/octo/demo/actions/runners/77");
301            then.status(404)
302                .json_body(serde_json::json!({ "message": "Not Found" }));
303        });
304        let err = client_for(&server).show("octo", "demo", "77").unwrap_err();
305        assert!(
306            matches!(err, ToriiError::PlatformApi { status: 404, .. }),
307            "expected PlatformApi 404, got: {err:?}"
308        );
309    }
310}