torii_lib/platforms/github/
runner.rs1use 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 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 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#[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 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}