1use std::path::Path;
12
13fn b64url(bytes: &[u8]) -> String {
15 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
16 let mut out = String::new();
17 for chunk in bytes.chunks(3) {
18 let b = [
19 chunk[0],
20 *chunk.get(1).unwrap_or(&0),
21 *chunk.get(2).unwrap_or(&0),
22 ];
23 let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
24 out.push(ALPHABET[(n >> 18) as usize & 63] as char);
25 out.push(ALPHABET[(n >> 12) as usize & 63] as char);
26 if chunk.len() > 1 {
27 out.push(ALPHABET[(n >> 6) as usize & 63] as char);
28 }
29 if chunk.len() > 2 {
30 out.push(ALPHABET[n as usize & 63] as char);
31 }
32 }
33 out
34}
35
36pub fn app_jwt(app_id: &str, pem: &Path) -> Result<String, String> {
43 let now = std::time::SystemTime::now()
44 .duration_since(std::time::UNIX_EPOCH)
45 .map_err(|e| e.to_string())?
46 .as_secs();
47 let header = b64url(br#"{"alg":"RS256","typ":"JWT"}"#);
48 let payload = b64url(
49 format!(
51 r#"{{"iat":{},"exp":{},"iss":"{app_id}"}}"#,
52 now - 60,
53 now + 540
54 )
55 .as_bytes(),
56 );
57 let signing_input = format!("{header}.{payload}");
58
59 let dir = std::env::temp_dir().join(format!("choir-jwt-{}", std::process::id()));
60 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
61 let input = dir.join("input");
62 let sig = dir.join("sig");
63 std::fs::write(&input, &signing_input).map_err(|e| e.to_string())?;
64 let out = std::process::Command::new("openssl")
65 .arg("dgst")
66 .arg("-sha256")
67 .arg("-sign")
68 .arg(pem)
69 .arg("-out")
70 .arg(&sig)
71 .arg(&input)
72 .output()
73 .map_err(|e| format!("spawn openssl: {e}"))?;
74 if !out.status.success() {
75 std::fs::remove_dir_all(&dir).ok();
76 return Err(format!(
77 "openssl sign failed: {}",
78 String::from_utf8_lossy(&out.stderr)
79 ));
80 }
81 let signature = std::fs::read(&sig).map_err(|e| e.to_string())?;
82 std::fs::remove_dir_all(&dir).ok();
83 Ok(format!("{signing_input}.{}", b64url(&signature)))
84}
85
86fn gh(method: &str, url: &str, auth: &str, body: Option<&str>) -> Result<(u16, String), String> {
88 let mut args: Vec<String> = vec![
89 "-s".into(),
90 "-w".into(),
91 "\n%{http_code}".into(),
92 "-X".into(),
93 method.into(),
94 "-H".into(),
95 format!("Authorization: Bearer {auth}"),
96 "-H".into(),
97 "Accept: application/vnd.github+json".into(),
98 "-H".into(),
99 "User-Agent: choir-bridge".into(),
100 ];
101 if let Some(b) = body {
102 args.push("-d".into());
103 args.push(b.into());
104 }
105 args.push(url.into());
106 let out = std::process::Command::new("curl")
107 .args(&args)
108 .output()
109 .map_err(|e| format!("spawn curl: {e}"))?;
110 let text = String::from_utf8_lossy(&out.stdout).into_owned();
111 match text.rsplit_once('\n') {
112 Some((b, code)) => Ok((code.trim().parse().unwrap_or(0), b.to_string())),
113 None => Ok((0, text)),
114 }
115}
116
117pub fn installation_token(jwt: &str) -> Result<String, String> {
124 let (status, body) = gh("GET", "https://api.github.com/app/installations", jwt, None)?;
125 if status != 200 {
126 return Err(format!("list installations: {status}: {body}"));
127 }
128 let installs: serde_json::Value =
129 serde_json::from_str(&body).map_err(|e| format!("installations json: {e}"))?;
130 let id = installs
131 .as_array()
132 .and_then(|a| a.first())
133 .and_then(|i| i["id"].as_u64())
134 .ok_or("no installations found — is the App installed on a repo?")?;
135 let (status, body) = gh(
136 "POST",
137 &format!("https://api.github.com/app/installations/{id}/access_tokens"),
138 jwt,
139 None,
140 )?;
141 if status != 201 {
142 return Err(format!("mint token: {status}: {body}"));
143 }
144 let tok: serde_json::Value =
145 serde_json::from_str(&body).map_err(|e| format!("token json: {e}"))?;
146 tok["token"]
147 .as_str()
148 .map(String::from)
149 .ok_or_else(|| "token field missing".to_string())
150}
151
152pub fn installations_debug(jwt: &str) -> Result<String, String> {
159 let (status, body) = gh("GET", "https://api.github.com/app/installations", jwt, None)?;
160 if status != 200 {
161 return Err(format!("list installations: {status}: {body}"));
162 }
163 Ok(body)
164}
165
166pub fn head_sha(token: &str, repo: &str) -> Result<String, String> {
173 let (status, body) = gh(
174 "GET",
175 &format!("https://api.github.com/repos/{repo}/commits/HEAD"),
176 token,
177 None,
178 )?;
179 if status != 200 {
180 return Err(format!("resolve HEAD: {status}: {body}"));
181 }
182 let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
183 v["sha"]
184 .as_str()
185 .map(String::from)
186 .ok_or_else(|| "sha field missing".to_string())
187}
188
189pub fn post_status(
196 token: &str,
197 repo: &str,
198 sha: &str,
199 context: &str,
200 state: &str,
201 description: &str,
202) -> Result<(), String> {
203 let body = serde_json::json!({
204 "state": state,
205 "context": context,
206 "description": description,
207 })
208 .to_string();
209 let (status, resp) = gh(
210 "POST",
211 &format!("https://api.github.com/repos/{repo}/statuses/{sha}"),
212 token,
213 Some(&body),
214 )?;
215 if status == 201 {
216 Ok(())
217 } else {
218 Err(format!("post status: {status}: {resp}"))
219 }
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
240pub struct Pr {
241 pub number: u64,
243 pub head_sha: String,
245}
246
247#[must_use]
250pub fn parse_prs(body: &str) -> Vec<Pr> {
251 let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
252 let mut prs: Vec<Pr> = v
253 .as_array()
254 .into_iter()
255 .flatten()
256 .filter_map(|p| {
257 Some(Pr {
258 number: p["number"].as_u64()?,
259 head_sha: p["head"]["sha"].as_str()?.to_string(),
260 })
261 })
262 .collect();
263 prs.sort_by_key(|p| p.number);
264 prs
265}
266
267pub fn list_open_prs(token: &str, repo: &str) -> Result<Vec<Pr>, String> {
273 let (status, body) = gh(
274 "GET",
275 &format!("https://api.github.com/repos/{repo}/pulls?state=open&per_page=100"),
276 token,
277 None,
278 )?;
279 if status != 200 {
280 return Err(format!("list prs: {status}: {body}"));
281 }
282 Ok(parse_prs(&body))
283}
284
285#[derive(Debug, Clone, Copy, PartialEq, Eq)]
287pub enum Verdict {
288 NoRuns,
290 Pending,
292 Success,
294 Failure,
296}
297
298#[must_use]
309pub fn parse_check_verdict(body: &str) -> Verdict {
310 let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
311 let runs: Vec<&serde_json::Value> = v["check_runs"].as_array().into_iter().flatten().collect();
312 if runs.is_empty() {
313 return Verdict::NoRuns;
314 }
315 let mut verdict = Verdict::Success;
316 for run in runs {
317 if run["status"].as_str() != Some("completed") {
318 return Verdict::Pending;
319 }
320 match run["conclusion"].as_str() {
321 Some("success" | "neutral" | "skipped") => {}
322 _ => verdict = Verdict::Failure,
323 }
324 }
325 verdict
326}
327
328pub fn check_verdict(token: &str, repo: &str, sha: &str) -> Result<Verdict, String> {
335 let (status, body) = gh(
336 "GET",
337 &format!("https://api.github.com/repos/{repo}/commits/{sha}/check-runs?per_page=100"),
338 token,
339 None,
340 )?;
341 if status != 200 {
342 return Err(format!("check runs: {status}: {body}"));
343 }
344 Ok(parse_check_verdict(&body))
345}
346
347pub fn default_branch(token: &str, repo: &str) -> Result<String, String> {
353 let (status, body) = gh(
354 "GET",
355 &format!("https://api.github.com/repos/{repo}"),
356 token,
357 None,
358 )?;
359 if status != 200 {
360 return Err(format!("repo info: {status}: {body}"));
361 }
362 let v: serde_json::Value = serde_json::from_str(&body).map_err(|e| e.to_string())?;
363 v["default_branch"]
364 .as_str()
365 .map(String::from)
366 .ok_or_else(|| "default_branch missing".to_string())
367}