GitHub API
Work with GitHub from a program: issues, comments, pull requests and check runs, every list followed through its Link header, primary and secondary rate limits waited out or named, webhook signatures checked, and a GitHub App's RS256 JWT and installation tokens made and kept.
Version 1.0.0 · 21 functions · 7 crates
Functions
What use github_api.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use github_request, github_all from github_api (Quillo 0.6).
github_request(method: str, path: str, body: any, options: any) → anyor an errorCalls any endpoint: method, path like /repos/o/r/issues (with its query), body or null. Options: token (else GITHUB_TOKEN), base_url (else GITHUB_API_URL, for GitHub Enterprise), max_wait_s (60, the longest a rate limit is waited out). Gives { status, body, next, rate: { limit, remaining, reset, resource } }.
github_all(path: str, options: any) → listor an errorEvery item of a list, following the Link header at 100 a page. Options add max_items.
github_rate_limit(options: any) → anyor an errorWhat is left of the token's rate limits: { core, search, graphql, ... }, each { limit, remaining, reset, used }.
github_issue_create(repo: str, params: any, options: any) → anyor an errorOpens an issue: params holds title (needed), body, labels, assignees, milestone. Gives the issue.
github_issue(repo: str, number: int, options: any) → anyor an errorOne issue by number.
github_issue_update(repo: str, number: int, params: any, options: any) → anyor an errorChanges an issue: title, body, state ("open", "closed"), state_reason, labels, assignees.
github_issues(repo: str, options: any) → listor an errorA repository's issues, all pages, without the pull requests that endpoint also gives. Options: state, labels, assignee, since, sort, direction, max_items.
github_comment(repo: str, number: int, body: str, options: any) → anyor an errorComments on an issue or pull request. Gives the comment.
github_comments(repo: str, number: int, options: any) → listor an errorEvery comment on an issue or pull request.
github_pr_create(repo: str, params: any, options: any) → anyor an errorOpens a pull request: params holds title, head (the branch, or owner:branch from a fork), base, body, draft.
github_pr(repo: str, number: int, options: any) → anyor an errorOne pull request by number. mergeable may be null while GitHub works it out; ask again in a moment.
github_prs(repo: str, options: any) → listor an errorA repository's pull requests, all pages. Options: state, head, base, sort, direction, max_items.
github_pr_files(repo: str, number: int, options: any) → listor an errorThe files a pull request changes: { filename, status, additions, deletions, patch }.
github_pr_merge(repo: str, number: int, params: any, options: any) → anyor an errorMerges a pull request: params holds merge_method (merge, squash, rebase), commit_title, commit_message, sha (refuse unless the head is this). GitHub answers 405 when it cannot be merged, 409 when the sha moved.
github_check_create(repo: str, params: any, options: any) → anyor an errorCreates a check run on a commit (a GitHub App's token is needed): params holds name, head_sha, status (queued, in_progress, completed), conclusion (when completed: success, failure, neutral...), output ({ title, summary, text, annotations }), details_url, external_id.
github_check_update(repo: str, id: int, params: any, options: any) → anyor an errorUpdates a check run by id, usually to finish it. At most 50 annotations go in one request; this sends the rest in further requests.
github_check_runs(repo: str, git_ref: str, options: any) → listor an errorThe check runs on a commit (a sha, branch or tag), all pages.
github_signature_ok(body: str, header: str, secret: str) → boolWhether a webhook is really GitHub's: the raw body, its X-Hub-Signature-256 header (sha256=...) and the hook's secret, compared in constant time.
github_webhook(headers: map, body: str, secret: str) → anyor an errorA webhook checked and read: headers are the request's. Gives { event, delivery, action, repository, sender, payload }, or an error when the signature is wrong or missing.
github_app_jwt(app_id: str, pem: str) → stror an errorA GitHub App's JSON Web Token, signed RS256 with its private key (a PEM, PKCS#1 or PKCS#8; "" for GITHUB_APP_PRIVATE_KEY), app_id "" for GITHUB_APP_ID. Good for nine minutes.
github_installation_token(installation_id: int, options: any) → stror an errorAn installation's token, for acting as the App in an organisation: made with github_app_jwt and kept until five minutes before its hour is up. Pass it as the option token. Options: app_id, private_key, repositories, permissions (narrower than the App's), base_url.
Get it
$ quillo install github_api # github_api.qwer into this folder$ quillo install github_api --example # and its example, github_api.qweOr with curl:
$ curl -O https://quillolang.org/plugins/files/github_api.qwer$ curl -O https://quillolang.org/plugins/files/github_api.qweThen, in your program, all of it or only what you call:
use github_api.qweruse github_request, github_all from github_api // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
ureq = "2"hmac = "0.12"sha2 = { version = "0.10", features = ["oid"] }hex = "0.4"base64 = "0.22"rsa = "0.9"serde_json = { version = "1", features = ["preserve_order"] }Example
// A bot on GitHub: issues opened and commented on, pull requests read and merged, a check run on a// commit, every list followed to its end, a webhook believed only when signed, and acting as a// GitHub App through an installation token.//// GITHUB_TOKEN=... GITHUB_REPO=owner/name GITHUB_WEBHOOK_SECRET=... quillo run github_api.qwe// then open http://localhost:8080/issues, /pull, /checks, /app, /hook-check and /failures//// As an App: GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY (the .pem's text) and GITHUB_INSTALLATION_ID.// GITHUB_API_URL is the address of GitHub Enterprise, or a test server.use "github_api.qwer"let REPO = env("GITHUB_REPO", "octo/shop")let SECRET = env("GITHUB_WEBHOOK_SECRET", "")route GET /issues { let issue = github_issue_create(REPO, { title: "Checkout is slow", body: "Since Tuesday.", labels: ["bug"] }, {})? github_comment(REPO, issue.number, "Looking into it.", {})? github_comment(REPO, issue.number, "Fixed in 2.4.", {})? let closed = github_issue_update(REPO, issue.number, { state: "closed", state_reason: "completed" }, {})? let open = github_issues(REPO, { state: "open" })? { number: issue.number, state: closed.state, comments: map(github_comments(REPO, issue.number, {})?, (c) => c.body), open_issues: len(open), first_three: len(github_issues(REPO, { state: "open", max_items: 3 })?), any_pull: len(filter(open, (i) => i.pull_request != null)), }}route GET /pull { let pr = github_pr_create(REPO, { title: "Faster checkout", head: "fast", base: "main", body: "Closes #1" }, {})? let files = github_pr_files(REPO, pr.number, {})? let merged = github_pr_merge(REPO, pr.number, { merge_method: "squash", sha: pr.head.sha }, {})? { number: pr.number, files: map(files, (f) => f.filename), merged: merged.merged, open: len(github_prs(REPO, { state: "open" })?), stale: github_pr_merge(REPO, pr.number, { sha: "0000" }, {}).error, }}route GET /checks { let token = github_installation_token(int(env("GITHUB_INSTALLATION_ID", "0")), {})? let o = { token: token } let sha = "9fceb02d0ae598e95dc970b74767f19372d61af8" let run = github_check_create(REPO, { name: "lint", head_sha: sha, status: "in_progress" }, o)? let notes = map(range(0, 120), (i) => { path: "a.qwe", start_line: i + 1, end_line: i + 1, annotation_level: "warning", message: "long line" }) let done = github_check_update(REPO, run.id, { status: "completed", conclusion: "success", output: { title: "Lint", summary: "120 notes", annotations: notes } }, o)? { status: run.status, conclusion: done.conclusion, notes: done.output.annotations_count, runs: len(github_check_runs(REPO, sha, o)?), no_conclusion: github_check_create(REPO, { name: "x", head_sha: sha, status: "completed" }, o).error, }}// The token is kept: the second call must not make another.route GET /app { let id = int(env("GITHUB_INSTALLATION_ID", "0")) let jwt = github_app_jwt("", "")? { jwt_parts: len(split(jwt, ".")), same: github_installation_token(id, {})? == github_installation_token(id, {})?, bad_key: github_app_jwt("1", "not a key").error, }}route GET /rate { let r = github_request("GET", `/repos/${REPO}`, null, {})? let all = github_rate_limit({})? { remaining: r.rate.remaining, resource: r.rate.resource, core: all.core.limit }}route POST /github/hook { let hook = github_webhook(request.headers, request.body, SECRET) if is_err(hook) { reply 401 hook.error } { event: hook.event, action: hook.action, repo: hook.repository }}// GitHub's own example from "Validating webhook deliveries": this body and secret give this value.route GET /hook-check { let body = "Hello, World!" let key = "It's a Secret to Everybody" let sig = "sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17" let headers = { "X-Hub-Signature-256": sig, "X-GitHub-Event": "ping", "X-GitHub-Delivery": "72d3162e" } { good: github_signature_ok(body, sig, key), tampered: github_signature_ok(body + "!", sig, key), no_secret: github_signature_ok(body, sig, ""), not_json: github_webhook(headers, body, key).error, wrong_secret: github_webhook(headers, body, "other").error, unsigned: github_webhook({}, body, key).error, }}route GET /failures = { validation: github_issue_create(REPO, { body: "no title" }, {}).error, missing: github_issue(REPO, 9999, {}).error, bad_token: github_issue(REPO, 1, { token: "wrong" }).error, bad_repo: github_issue("just-a-name", 1, {}).error, secondary: github_comment(REPO, 1, "secondary limit", {})?.body, primary: github_comment(REPO, 1, "spent", { max_wait_s: 0 }).error, no_branch: github_pr_create(REPO, { title: "x", head: "", base: "main" }, {}).error, elsewhere: github_request("GET", "https://evil.example/x", null, {}).error,}$ quillo run github_api.qwe$ curl 'localhost:8080/issues'$ curl 'localhost:8080/pull'$ curl 'localhost:8080/checks'$ curl 'localhost:8080/app'$ curl 'localhost:8080/rate'$ curl 'localhost:8080/hook-check'$ curl 'localhost:8080/failures'It talks to the Redis that REDIS_URL names, else Redis on this machine. The site's test runs it against a stand-in.
Source
//! GitHub's REST API: issues, comments, pull requests and check runs, every list followed by its Link header, rate limits waited out or named, webhook signatures checked, and a GitHub App's JWT and installation tokens made here.//! version: 1.0.0//!//! [dependencies]//! ureq = "2"//! hmac = "0.12"//! sha2 = { version = "0.10", features = ["oid"] }//! hex = "0.4"//! base64 = "0.22"//! rsa = "0.9"//! serde_json = { version = "1", features = ["preserve_order"] }use base64::Engine;use hmac::{Hmac, Mac};use rsa::pkcs1::DecodeRsaPrivateKey;use rsa::pkcs1v15::SigningKey;use rsa::pkcs8::DecodePrivateKey;use rsa::signature::{SignatureEncoding, Signer};use rsa::RsaPrivateKey;use serde_json::{json, Value};use sha2::Sha256;use std::collections::HashMap;use std::sync::{Mutex, OnceLock};use std::time::{Duration, SystemTime, UNIX_EPOCH};struct Conf { base: String, token: Option<String>, max_wait: u64,}fn opt(o: &Value, k: &str, var: &str) -> Option<String> { o.get(k).and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(str::to_string).or_else(|| std::env::var(var).ok().filter(|v| !v.is_empty()))}fn conf(o: &Value) -> Conf { Conf { base: opt(o, "base_url", "GITHUB_API_URL").unwrap_or_else(|| "https://api.github.com".into()).trim_end_matches('/').to_string(), token: opt(o, "token", "GITHUB_TOKEN"), max_wait: o.get("max_wait_s").and_then(|v| v.as_u64()).unwrap_or(60), }}fn now() -> u64 { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)}fn enc(s: &str) -> String { s.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{:02X}", b) }).collect()}fn repo_ok(repo: &str) -> Result<&str, String> { let ok = repo.split_once('/').map(|(a, b)| !a.is_empty() && !b.is_empty() && !b.contains('/')).unwrap_or(false); if ok { Ok(repo) } else { Err(format!("{:?} is not owner/name", repo)) }}// `Link: <url>; rel="next", <url>; rel="last"`fn link(header: &str, rel: &str) -> Option<String> { header.split(',').find_map(|part| { let (url, params) = part.split_once(';')?; if params.split(';').any(|p| p.trim() == format!("rel=\"{}\"", rel)) { Some(url.trim().trim_matches(['<', '>']).to_string()) } else { None } })}fn error_text(code: u16, body: &str) -> String { let v: Value = serde_json::from_str(body).unwrap_or(Value::Null); let msg = v["message"].as_str().map(str::to_string).unwrap_or_else(|| body.trim().chars().take(300).collect()); // A 422 lists what was wrong, as { resource, field, code } or a text. let details: Vec<String> = v["errors"].as_array().map(|l| l.iter().map(|e| match e { Value::String(s) => s.clone(), o => match (o["resource"].as_str(), o["field"].as_str(), o["code"].as_str(), o["message"].as_str()) { (_, _, _, Some(m)) => m.to_string(), (r, f, c, _) => format!("{} {} {}", r.unwrap_or(""), f.unwrap_or(""), c.unwrap_or("")).trim().to_string(), }, }).collect()).unwrap_or_default(); let hint = match code { 401 => " (check GITHUB_TOKEN)", 404 => " (or this token may not see it: private things answer 404)", _ => "", }; let more = if details.is_empty() { String::new() } else { format!(": {}", details.join("; ")) }; format!("GitHub {}: {}{}{}", code, msg, more, hint)}struct Answer { status: u16, body: Value, next: Option<String>, rate: Value,}fn call(c: &Conf, method: &str, target: &str, body: Option<&Value>) -> Result<Answer, String> { let url = if target.starts_with("http") { if !target.starts_with(&format!("{}/", c.base)) { return Err(format!("{} is not on {}", target, c.base)); } target.to_string() } else { format!("{}{}", c.base, target) }; let text = body.map(|b| b.to_string()); let agent = ureq::AgentBuilder::new().timeout_connect(Duration::from_secs(10)).timeout_read(Duration::from_secs(60)).build(); let mut tries = 0; loop { let mut r = agent.request(method, &url).set("Accept", "application/vnd.github+json").set("X-GitHub-Api-Version", "2022-11-28").set("User-Agent", "quillo-github/1.0"); if let Some(t) = &c.token { r = r.set("Authorization", &format!("Bearer {}", t)); } let res = match &text { Some(t) => r.set("Content-Type", "application/json").send_string(t), None => r.call(), }; match res { Ok(resp) => { let status = resp.status(); let next = resp.header("link").and_then(|h| link(h, "next")); let rate = json!({ "limit": resp.header("x-ratelimit-limit").and_then(|h| h.parse::<i64>().ok()), "remaining": resp.header("x-ratelimit-remaining").and_then(|h| h.parse::<i64>().ok()), "reset": resp.header("x-ratelimit-reset").and_then(|h| h.parse::<i64>().ok()), "resource": resp.header("x-ratelimit-resource"), }); let t = resp.into_string().map_err(|e| e.to_string())?; let body = if t.trim().is_empty() { Value::Null } else { serde_json::from_str(&t).unwrap_or(json!(t)) }; return Ok(Answer { status, body, next, rate }); } Err(ureq::Error::Status(code, resp)) => { let after = resp.header("retry-after").and_then(|h| h.trim().parse::<u64>().ok()); let spent = resp.header("x-ratelimit-remaining") == Some("0"); let reset = resp.header("x-ratelimit-reset").and_then(|h| h.parse::<u64>().ok()); let t = resp.into_string().unwrap_or_default(); // A secondary limit gives retry-after; a spent primary one, the time it comes back. let wait = match (code, after, spent, reset) { (403 | 429, Some(a), _, _) => Some(a), (403 | 429, None, true, Some(r)) => Some(r.saturating_sub(now())), (500 | 502 | 503 | 504, _, _, _) if method == "GET" => Some(1), _ => None, }; match wait { Some(w) if w <= c.max_wait && tries < 2 => { std::thread::sleep(Duration::from_secs(w)); tries += 1; } Some(w) if code == 403 || code == 429 => return Err(format!("{} (rate limited: comes back in {} s)", error_text(code, &t), w)), _ => return Err(error_text(code, &t)), } } Err(e) => return Err(format!("cannot reach {}: {}", c.base, e)), } }}/// Calls any endpoint: `method`, `path` like /repos/o/r/issues (with its query), `body` or null. Options: token (else GITHUB_TOKEN), base_url (else GITHUB_API_URL, for GitHub Enterprise), max_wait_s (60, the longest a rate limit is waited out). Gives { status, body, next, rate: { limit, remaining, reset, resource } }.pub fn github_request(method: &str, path: &str, body: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { let b = if body.is_null() { None } else { Some(&body) }; let a = call(&conf(&options), &method.to_uppercase(), path, b)?; Ok(json!({ "status": a.status, "body": a.body, "next": a.next, "rate": a.rate }))}/// Every item of a list, following the Link header at 100 a page. Options add max_items.pub fn github_all(path: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { let c = conf(&options); let max = options.get("max_items").and_then(|v| v.as_u64()).unwrap_or(u64::MAX) as usize; let mut target = format!("{}{}per_page=100", path, if path.contains('?') { "&" } else { "?" }); let mut out = vec![]; loop { let a = call(&c, "GET", &target, None)?; // Some lists are wrapped: { total_count, check_runs: [...] }. let items = match a.body { Value::Array(l) => l, Value::Object(m) => m.into_iter().find_map(|(_, v)| if let Value::Array(l) = v { Some(l) } else { None }).unwrap_or_default(), _ => vec![], }; out.extend(items); match a.next { Some(n) if out.len() < max => target = n, _ => break, } } out.truncate(max); Ok(out)}/// What is left of the token's rate limits: { core, search, graphql, ... }, each { limit, remaining, reset, used }.pub fn github_rate_limit(options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(call(&conf(&options), "GET", "/rate_limit", None)?.body["resources"].clone())}fn query(o: &Value, keys: &[&str]) -> String { let mut q = vec![]; for k in keys { match o.get(*k) { Some(Value::String(s)) if !s.is_empty() => q.push(format!("{}={}", k, enc(s))), Some(Value::Number(n)) => q.push(format!("{}={}", k, n)), _ => {} } } if q.is_empty() { String::new() } else { format!("?{}", q.join("&")) }}/// Opens an issue: `params` holds title (needed), body, labels, assignees, milestone. Gives the issue.pub fn github_issue_create(repo: &str, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(call(&conf(&options), "POST", &format!("/repos/{}/issues", repo_ok(repo)?), Some(¶ms))?.body)}/// One issue by number.pub fn github_issue(repo: &str, number: i64, options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(call(&conf(&options), "GET", &format!("/repos/{}/issues/{}", repo_ok(repo)?, number), None)?.body)}/// Changes an issue: title, body, state ("open", "closed"), state_reason, labels, assignees.pub fn github_issue_update(repo: &str, number: i64, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(call(&conf(&options), "PATCH", &format!("/repos/{}/issues/{}", repo_ok(repo)?, number), Some(¶ms))?.body)}/// A repository's issues, all pages, without the pull requests that endpoint also gives. Options: state, labels, assignee, since, sort, direction, max_items.pub fn github_issues(repo: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { let path = format!("/repos/{}/issues{}", repo_ok(repo)?, query(&options, &["state", "labels", "assignee", "since", "sort", "direction"])); Ok(github_all(&path, options)?.into_iter().filter(|i| i.get("pull_request").is_none()).collect())}/// Comments on an issue or pull request. Gives the comment.pub fn github_comment(repo: &str, number: i64, body: &str, options: serde_json::Value) -> Result<serde_json::Value, String> { if body.trim().is_empty() { return Err("a comment needs a body".into()); } Ok(call(&conf(&options), "POST", &format!("/repos/{}/issues/{}/comments", repo_ok(repo)?, number), Some(&json!({ "body": body })))?.body)}/// Every comment on an issue or pull request.pub fn github_comments(repo: &str, number: i64, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { github_all(&format!("/repos/{}/issues/{}/comments", repo_ok(repo)?, number), options)}/// Opens a pull request: `params` holds title, head (the branch, or owner:branch from a fork), base, body, draft.pub fn github_pr_create(repo: &str, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { for k in ["title", "head", "base"] { if params.get(k).and_then(|v| v.as_str()).map(|s| s.is_empty()).unwrap_or(true) { return Err(format!("a pull request needs `{}`", k)); } } Ok(call(&conf(&options), "POST", &format!("/repos/{}/pulls", repo_ok(repo)?), Some(¶ms))?.body)}/// One pull request by number. `mergeable` may be null while GitHub works it out; ask again in a moment.pub fn github_pr(repo: &str, number: i64, options: serde_json::Value) -> Result<serde_json::Value, String> { Ok(call(&conf(&options), "GET", &format!("/repos/{}/pulls/{}", repo_ok(repo)?, number), None)?.body)}/// A repository's pull requests, all pages. Options: state, head, base, sort, direction, max_items.pub fn github_prs(repo: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { let path = format!("/repos/{}/pulls{}", repo_ok(repo)?, query(&options, &["state", "head", "base", "sort", "direction"])); github_all(&path, options)}/// The files a pull request changes: { filename, status, additions, deletions, patch }.pub fn github_pr_files(repo: &str, number: i64, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { github_all(&format!("/repos/{}/pulls/{}/files", repo_ok(repo)?, number), options)}/// Merges a pull request: `params` holds merge_method (merge, squash, rebase), commit_title, commit_message, sha (refuse unless the head is this). GitHub answers 405 when it cannot be merged, 409 when the sha moved.pub fn github_pr_merge(repo: &str, number: i64, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { let p = if params.is_null() { json!({}) } else { params }; Ok(call(&conf(&options), "PUT", &format!("/repos/{}/pulls/{}/merge", repo_ok(repo)?, number), Some(&p))?.body)}/// Creates a check run on a commit (a GitHub App's token is needed): `params` holds name, head_sha, status (queued, in_progress, completed), conclusion (when completed: success, failure, neutral...), output ({ title, summary, text, annotations }), details_url, external_id.pub fn github_check_create(repo: &str, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { for k in ["name", "head_sha"] { if params.get(k).and_then(|v| v.as_str()).is_none() { return Err(format!("a check run needs `{}`", k)); } } if params["status"] == "completed" && params.get("conclusion").is_none() { return Err("a completed check run needs a `conclusion`".into()); } Ok(call(&conf(&options), "POST", &format!("/repos/{}/check-runs", repo_ok(repo)?), Some(¶ms))?.body)}/// Updates a check run by id, usually to finish it. At most 50 annotations go in one request; this sends the rest in further requests.pub fn github_check_update(repo: &str, id: i64, params: serde_json::Value, options: serde_json::Value) -> Result<serde_json::Value, String> { let c = conf(&options); let path = format!("/repos/{}/check-runs/{}", repo_ok(repo)?, id); let notes = params.pointer("/output/annotations").and_then(|a| a.as_array()).cloned().unwrap_or_default(); if notes.len() <= 50 { return Ok(call(&c, "PATCH", &path, Some(¶ms))?.body); } let mut last = Value::Null; for chunk in notes.chunks(50) { let mut p = params.clone(); p["output"]["annotations"] = json!(chunk); last = call(&c, "PATCH", &path, Some(&p))?.body; } Ok(last)}/// The check runs on a commit (a sha, branch or tag), all pages.pub fn github_check_runs(repo: &str, git_ref: &str, options: serde_json::Value) -> Result<Vec<serde_json::Value>, String> { github_all(&format!("/repos/{}/commits/{}/check-runs", repo_ok(repo)?, enc(git_ref)), options)}fn hmac_hex(secret: &str, body: &str) -> String { let mut m = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).expect("HMAC takes any key"); m.update(body.as_bytes()); hex::encode(m.finalize().into_bytes())}/// Whether a webhook is really GitHub's: the raw `body`, its X-Hub-Signature-256 header (`sha256=...`) and the hook's `secret`, compared in constant time.pub fn github_signature_ok(body: &str, header: &str, secret: &str) -> bool { let want = format!("sha256={}", hmac_hex(secret, body)); !secret.is_empty() && want.len() == header.len() && want.bytes().zip(header.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0}/// A webhook checked and read: `headers` are the request's. Gives { event, delivery, action, repository, sender, payload }, or an error when the signature is wrong or missing.pub fn github_webhook(headers: std::collections::HashMap<String, serde_json::Value>, body: &str, secret: &str) -> Result<serde_json::Value, String> { let h = |k: &str| headers.iter().find(|(n, _)| n.eq_ignore_ascii_case(k)).and_then(|(_, v)| v.as_str()).map(str::to_string); let sig = h("x-hub-signature-256").ok_or("no X-Hub-Signature-256 header: give the hook a secret")?; if !github_signature_ok(body, &sig, secret) { return Err("the webhook's signature does not match: a wrong secret, or the body was changed (use the raw body)".into()); } let p: Value = serde_json::from_str(body).map_err(|e| format!("the webhook's body is not JSON: {}", e))?; Ok(json!({ "event": h("x-github-event"), "delivery": h("x-github-delivery"), "action": p.get("action").cloned().unwrap_or(Value::Null), "repository": p.pointer("/repository/full_name").cloned().unwrap_or(Value::Null), "sender": p.pointer("/sender/login").cloned().unwrap_or(Value::Null), "payload": p }))}fn private_key(pem: &str) -> Result<RsaPrivateKey, String> { // A key kept in an env var often has its line breaks written as \n. let pem = pem.replace("\\n", "\n"); let t = pem.trim(); if t.contains("BEGIN RSA PRIVATE KEY") { RsaPrivateKey::from_pkcs1_pem(t).map_err(|e| format!("the private key does not parse: {}", e)) } else if t.contains("BEGIN PRIVATE KEY") { RsaPrivateKey::from_pkcs8_pem(t).map_err(|e| format!("the private key does not parse: {}", e)) } else { Err("the private key is not a PEM: it is the .pem GitHub gave for the App".into()) }}fn app_jwt(app_id: &str, key: &RsaPrivateKey, at: u64) -> String { let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; // Issued a minute ago against clock drift; GitHub takes ten minutes at most. let claims = json!({ "iat": at - 60, "exp": at + 540, "iss": app_id }); let unsigned = format!("{}.{}", b64.encode(json!({ "alg": "RS256", "typ": "JWT" }).to_string()), b64.encode(claims.to_string())); let sig = SigningKey::<Sha256>::new(key.clone()).sign(unsigned.as_bytes()); format!("{}.{}", unsigned, b64.encode(sig.to_bytes()))}/// A GitHub App's JSON Web Token, signed RS256 with its private key (a PEM, PKCS#1 or PKCS#8; "" for GITHUB_APP_PRIVATE_KEY), `app_id` "" for GITHUB_APP_ID. Good for nine minutes.pub fn github_app_jwt(app_id: &str, pem: &str) -> Result<String, String> { let id = if app_id.is_empty() { std::env::var("GITHUB_APP_ID").map_err(|_| "no app id: set GITHUB_APP_ID".to_string())? } else { app_id.to_string() }; let p = if pem.is_empty() { std::env::var("GITHUB_APP_PRIVATE_KEY").map_err(|_| "no private key: set GITHUB_APP_PRIVATE_KEY".to_string())? } else { pem.to_string() }; Ok(app_jwt(&id, &private_key(&p)?, now()))}fn cache() -> &'static Mutex<HashMap<i64, (String, u64)>> { static C: OnceLock<Mutex<HashMap<i64, (String, u64)>>> = OnceLock::new(); C.get_or_init(|| Mutex::new(HashMap::new()))}// `2026-11-01T11:00:00Z` as seconds since 1970.fn parse_time(t: &str) -> Option<u64> { let n = |r: std::ops::Range<usize>| t.get(r)?.parse::<i64>().ok(); let (y, m, d, h, min, s) = (n(0..4)?, n(5..7)?, n(8..10)?, n(11..13)?, n(14..16)?, n(17..19)?); let (y2, m2) = if m <= 2 { (y - 1, m + 12) } else { (y, m) }; let days = 365 * y2 + y2 / 4 - y2 / 100 + y2 / 400 + (153 * (m2 - 3) + 2) / 5 + d - 719469; Some((days * 86400 + h * 3600 + min * 60 + s) as u64)}/// An installation's token, for acting as the App in an organisation: made with github_app_jwt and kept until five minutes before its hour is up. Pass it as the option `token`. Options: app_id, private_key, repositories, permissions (narrower than the App's), base_url.pub fn github_installation_token(installation_id: i64, options: serde_json::Value) -> Result<String, String> { if let Some((t, until)) = cache().lock().map_err(|e| e.to_string())?.get(&installation_id) { if now() + 300 < *until { return Ok(t.clone()); } } let jwt = github_app_jwt(options.get("app_id").and_then(|v| v.as_str()).unwrap_or(""), options.get("private_key").and_then(|v| v.as_str()).unwrap_or(""))?; let mut c = conf(&options); c.token = Some(jwt); let mut body = json!({}); for k in ["repositories", "permissions"] { if let Some(v) = options.get(k) { body[k] = v.clone(); } } let r = call(&c, "POST", &format!("/app/installations/{}/access_tokens", installation_id), Some(&body))?.body; let t = r["token"].as_str().ok_or(format!("no token in the answer: {}", r))?.to_string(); let until = r["expires_at"].as_str().and_then(parse_time).unwrap_or(now() + 3000); cache().lock().map_err(|e| e.to_string())?.insert(installation_id, (t.clone(), until)); Ok(t)}Related
gitlab_api.qwer · 1.0.0Work with GitLab from a program: issues, merge requests, notes and pipelines, projects named by their path, lists followed by X-Next-Page or keyset links, errors read whether they are texts or maps of fields, 429s waited out, and the webhook's token compared in constant time.4 views · 6 downloadsAsana asana.qwer · 1.0.0Asana's API without an SDK: tasks, projects and comments in Asana's data envelope, offset pages walked with an expired one named, opt_fields, Retry-After waited out, and webhooks from their handshake on: X-Hook-Secret answered and kept, refused once one is held, and each delivery's X-Hook-Signature checked.3 views · 6 downloadsCalendly calendly.qwer · 1.0.0Calendly's API v2 without an SDK: event types, scheduled events and their invitees through page tokens, cancels, webhook subscriptions with a signing key insisted on, error details read, resource addresses shortened to uuids, and the Calendly-Webhook-Signature checked with its timestamp tolerance.3 views · 9 downloadsZendesk zendesk.qwer · 1.0.0Run a help desk through Zendesk's API: tickets opened, changed and solved, public replies and internal notes, search and its export past 1000 results, every list walked by cursor, validation errors named by field, 429s waited out, and webhook signatures checked.2 views · 11 downloadsApple push notifications apns.qwer · 1.0.0Send push notifications to iPhones through APNs over HTTP/2: alert and background payloads built and sized, an ES256 provider token made and renewed, collapse ids and push types set, many devices on one kept connection, and Apple's reasons such as BadDeviceToken and Unregistered read back so dead tokens can be dropped.4 views · 6 downloadsBigCommerce bigcommerce.qwer · 1.0.0A BigCommerce store run from outside it: the catalog on V3 and its data and meta envelope, orders on V2 with bare lists and an empty 204, both shapes of error, batches of ten with their partial failures, the rate-limit headers waited out and reported, and an app's signed payloads checked in both the old form and the JWT.3 views · 7 downloadsThread 0 posts
On GitHub API: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.