//! The GitHub REST calls `cairns export` makes. The token comes from //! `gh auth token`, so it lives in the GitHub CLI's keyring and never in a //! file, a log or a process argument. use base64::Engine as _; use base64::engine::general_purpose::STANDARD; use reqwest::Method; use serde_json::{Value, json}; use crate::Error; const API: &str = "https://api.github.com"; pub struct GitHub { client: reqwest::Client, token: String, } /// `(owner, repo)` from a GitHub URL: `https://github.com/o/r(.git)`, /// `git@github.com:o/r(.git)` or `github.com/o/r`. pub fn parse_repo(url: &str) -> Result<(String, String), Error> { let rest = url .strip_prefix("https://github.com/") .or_else(|| url.strip_prefix("http://github.com/")) .or_else(|| url.strip_prefix("git@github.com:")) .or_else(|| url.strip_prefix("github.com/")) .ok_or_else(|| Error::new(format!("{url} isn't a GitHub repo URL")))?; let rest = rest.trim_end_matches('/').trim_end_matches(".git"); match rest.split('/').collect::>()[..] { [owner, repo] if !owner.is_empty() && !repo.is_empty() => { Ok((owner.to_owned(), repo.to_owned())) } _ => Err(Error::new(format!("{url} isn't a GitHub repo URL"))), } } /// `path` with every byte outside unreserved characters and `/` /// percent-encoded, for use in a URL path. fn encode_path(path: &str) -> String { path.bytes() .map(|b| match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' | b'/' => { (b as char).to_string() } _ => format!("%{b:02X}"), }) .collect() } impl GitHub { pub fn from_gh() -> Result { let out = std::process::Command::new("gh") .args(["auth", "token"]) .output() .map_err(|e| Error::new(format!("running gh: {e}; install the GitHub CLI")))?; let token = String::from_utf8_lossy(&out.stdout).trim().to_owned(); if !out.status.success() || token.is_empty() { return Err(Error::new("no GitHub token; run `gh auth login`")); } Ok(GitHub { client: reqwest::Client::new(), token, }) } /// Calls the API. A 404 is `Ok(None)`; any other failure is an error that /// names the status and GitHub's message. pub async fn call( &self, method: Method, path: &str, body: Option<&Value>, ) -> Result, Error> { let url = if path.starts_with("https://") { path.to_owned() } else { format!("{API}{path}") }; let mut request = self .client .request(method.clone(), &url) .bearer_auth(&self.token) .header("Accept", "application/vnd.github+json") .header("X-GitHub-Api-Version", "2022-11-28") .header("User-Agent", "cairns-cli"); if let Some(body) = body { request = request.json(body); } let response = request .send() .await .map_err(|e| Error::new(format!("{method} {path}: {e}")))?; let status = response.status(); if status == reqwest::StatusCode::NOT_FOUND { return Ok(None); } let value: Value = response.json().await.unwrap_or(Value::Null); if !status.is_success() { return Err(Error::new(format!( "{method} {path}: {status}: {}", value["message"].as_str().unwrap_or_default() ))); } Ok(Some(value)) } pub async fn get(&self, path: &str) -> Result, Error> { self.call(Method::GET, path, None).await } pub async fn post(&self, path: &str, body: &Value) -> Result { self.call(Method::POST, path, Some(body)) .await? .ok_or_else(|| Error::new(format!("POST {path}: 404"))) } pub async fn login(&self) -> Result { let user = self.get("/user").await?.unwrap_or_default(); user["login"] .as_str() .map(str::to_owned) .ok_or_else(|| Error::new("GitHub didn't say who the token belongs to")) } /// The bytes of `path` at `branch` in `repo` (`owner/name`). pub async fn raw( &self, repo: &str, path: &str, branch: &str, ) -> Result>, Error> { let Some(file) = self .get(&format!( "/repos/{repo}/contents/{}?ref={}", encode_path(path), encode_path(branch) )) .await? else { return Ok(None); }; // Files over 1 MB come back without content; fetch them by blob. let encoded = match file["content"].as_str().filter(|c| !c.is_empty()) { Some(content) => content.to_owned(), None => { let sha = file["sha"].as_str().unwrap_or_default(); let blob = self .get(&format!("/repos/{repo}/git/blobs/{sha}")) .await? .unwrap_or_default(); blob["content"].as_str().unwrap_or_default().to_owned() } }; let joined: String = encoded.split_whitespace().collect(); STANDARD .decode(joined) .map(Some) .map_err(|e| Error::new(format!("{repo}/{path}: {e}"))) } /// Uploads `bytes` as asset `name` of a prerelease tagged `tag` on /// `branch`, creating the release if it is missing, and returns the /// asset's download URL. pub async fn release_asset( &self, repo: &str, tag: &str, branch: &str, name: &str, bytes: Vec, ) -> Result { let release = match self .get(&format!("/repos/{repo}/releases/tags/{}", encode_path(tag))) .await? { Some(release) => release, None => { self.post( &format!("/repos/{repo}/releases"), &json!({ "tag_name": tag, "target_commitish": branch, "name": tag, "prerelease": true }), ) .await? } }; if let Some(asset) = release["assets"] .as_array() .into_iter() .flatten() .find(|a| a["name"] == name) { return Ok(asset["browser_download_url"] .as_str() .unwrap_or_default() .to_owned()); } let upload = release["upload_url"] .as_str() .unwrap_or_default() .split('{') .next() .unwrap_or_default() .to_owned(); let response = self .client .post(format!("{upload}?name={}", encode_path(name))) .bearer_auth(&self.token) .header("Accept", "application/vnd.github+json") .header("User-Agent", "cairns-cli") .header("Content-Type", "application/vnd.ipld.car") .body(bytes) .send() .await .map_err(|e| Error::new(format!("uploading {name}: {e}")))?; let status = response.status(); let asset: Value = response.json().await.unwrap_or(Value::Null); if !status.is_success() { return Err(Error::new(format!( "uploading {name}: {status}: {}", asset["message"] ))); } Ok(asset["browser_download_url"] .as_str() .unwrap_or_default() .to_owned()) } /// Commits `files` on top of `base` (a commit SHA) and points `branch` at /// the new commit, creating or moving it. Returns the commit SHA. pub async fn commit( &self, repo: &str, base: &str, branch: &str, message: &str, files: &[(String, Vec)], ) -> Result { let base_commit = self .get(&format!("/repos/{repo}/git/commits/{base}")) .await? .ok_or_else(|| Error::new(format!("{repo} has no commit {base}")))?; let mut entries = Vec::new(); for (path, bytes) in files { let blob = self .post( &format!("/repos/{repo}/git/blobs"), &json!({ "content": STANDARD.encode(bytes), "encoding": "base64" }), ) .await?; entries.push( json!({ "path": path, "mode": "100644", "type": "blob", "sha": blob["sha"] }), ); } let tree = self .post( &format!("/repos/{repo}/git/trees"), &json!({ "base_tree": base_commit["tree"]["sha"], "tree": entries }), ) .await?; let commit = self .post( &format!("/repos/{repo}/git/commits"), &json!({ "message": message, "tree": tree["sha"], "parents": [base] }), ) .await?; let sha = commit["sha"].as_str().unwrap_or_default().to_owned(); let existing = self .get(&format!("/repos/{repo}/git/ref/heads/{branch}")) .await?; if existing.is_some() { self.call( Method::PATCH, &format!("/repos/{repo}/git/refs/heads/{branch}"), Some(&json!({ "sha": sha, "force": true })), ) .await?; } else { self.post( &format!("/repos/{repo}/git/refs"), &json!({ "ref": format!("refs/heads/{branch}"), "sha": sha }), ) .await?; } Ok(sha) } } #[cfg(test)] mod tests { use super::*; #[test] fn paths_are_percent_encoded_except_slashes() { assert_eq!(encode_path("2026/a b#c.csv"), "2026/a%20b%23c.csv"); assert_eq!(encode_path("cairns/export/3mz5"), "cairns/export/3mz5"); } #[test] fn repo_urls_parse_to_owner_and_name() { for url in [ "https://github.com/openelections/openelections-data-mi", "https://github.com/openelections/openelections-data-mi.git", "https://github.com/openelections/openelections-data-mi/", "git@github.com:openelections/openelections-data-mi.git", "github.com/openelections/openelections-data-mi", ] { assert_eq!( parse_repo(url).unwrap(), ("openelections".into(), "openelections-data-mi".into()), "{url}" ); } for bad in [ "https://gitlab.com/o/r", "https://github.com/o", "https://github.com/o/r/tree/main", "https://tangled.org/alpacasoft.dev/cairn", ] { assert!(parse_repo(bad).is_err(), "{bad}"); } } }