Skip to main content

stryke/
builtins_github.rs

1//! GitHub REST API primitives — pragmatic wrappers around `api.github.com`.
2//!
3//! Designed for parallel-map workflows:
4//! ```stryke
5//! my @repos = gh_repos("MenkeTechnologies")
6//! my @stars = pmap { gh_repo($_->{full_name})->{stargazers_count} } @repos
7//! ```
8//!
9//! All builtins authenticate via the `GITHUB_TOKEN` environment variable
10//! when present (5000 req/hour); otherwise fall back to unauthenticated
11//! access (60 req/hour). List endpoints auto-paginate up to a safety cap
12//! (`GH_MAX_PAGES`, default 10 = up to 1000 items at per_page=100).
13//!
14//! Builtins:
15//!   gh_get(PATH, [opts])              — generic GET, parsed JSON
16//!   gh_user(USER)                     — /users/USER
17//!   gh_org(ORG)                       — /orgs/ORG
18//!   gh_repo(OWNER, REPO)              — /repos/OWNER/REPO
19//!   gh_repos(USER)                    — /users/USER/repos          (paginated)
20//!   gh_org_repos(ORG)                 — /orgs/ORG/repos            (paginated)
21//!   gh_starred(USER)                  — /users/USER/starred        (paginated)
22//!   gh_followers(USER)                — /users/USER/followers      (paginated)
23//!   gh_following(USER)                — /users/USER/following      (paginated)
24//!   gh_gists(USER)                    — /users/USER/gists          (paginated)
25//!   gh_gist(ID)                       — /gists/ID
26//!   gh_issues(OWNER, REPO)            — /repos/OWNER/REPO/issues   (paginated)
27//!   gh_prs(OWNER, REPO)               — /repos/OWNER/REPO/pulls    (paginated)
28//!   gh_commits(OWNER, REPO)           — /repos/OWNER/REPO/commits  (paginated)
29//!   gh_branches(OWNER, REPO)          — /repos/OWNER/REPO/branches (paginated)
30//!   gh_tags(OWNER, REPO)              — /repos/OWNER/REPO/tags     (paginated)
31//!   gh_releases(OWNER, REPO)          — /repos/OWNER/REPO/releases (paginated)
32//!   gh_contributors(OWNER, REPO)      — /repos/OWNER/REPO/contributors (paginated)
33//!   gh_forks(OWNER, REPO)             — /repos/OWNER/REPO/forks    (paginated)
34//!   gh_stargazers(OWNER, REPO)        — /repos/OWNER/REPO/stargazers (paginated)
35//!   gh_topics(OWNER, REPO)            — array of topic names
36//!   gh_languages(OWNER, REPO)         — { language => bytes } hashref
37//!   gh_readme(OWNER, REPO)            — decoded README content (string)
38//!   gh_workflows(OWNER, REPO)         — workflows array
39//!   gh_runs(OWNER, REPO)              — workflow runs array
40//!   gh_search_repos(QUERY)            — /search/repositories       (paginated)
41//!   gh_search_users(QUERY)            — /search/users              (paginated)
42//!   gh_search_code(QUERY)             — /search/code               (paginated)
43//!   gh_search_issues(QUERY)           — /search/issues             (paginated)
44//!   gh_rate_limit()                   — /rate_limit
45//!   gh_meta()                         — /meta
46//!   gh_zen()                          — /zen (plain-text string)
47//!   gh_emojis()                       — /emojis (hashref)
48//!
49//! Errors: network / 4xx / 5xx → runtime error. 404 returns `undef` so callers
50//! can `pmap { gh_repo(...) }` over a list including dead names without
51//! aborting the whole pipeline.
52
53use crate::error::{StrykeError, StrykeResult};
54use crate::value::StrykeValue;
55use indexmap::IndexMap;
56use parking_lot::RwLock;
57use std::sync::Arc;
58use std::time::Duration;
59
60// ── helpers ────────────────────────────────────────────────────────────
61
62const API_ROOT: &str = "https://api.github.com";
63const USER_AGENT: &str = "strykelang-gh-builtins";
64const DEFAULT_MAX_PAGES: usize = 10;
65
66fn arg_str(args: &[StrykeValue], i: usize) -> String {
67    args.get(i).map(|v| v.to_string()).unwrap_or_default()
68}
69
70fn json_to_perl(v: serde_json::Value) -> StrykeValue {
71    match v {
72        serde_json::Value::Null => StrykeValue::UNDEF,
73        serde_json::Value::Bool(b) => StrykeValue::integer(i64::from(b)),
74        serde_json::Value::Number(n) => {
75            if let Some(i) = n.as_i64() {
76                StrykeValue::integer(i)
77            } else if let Some(u) = n.as_u64() {
78                StrykeValue::integer(u as i64)
79            } else {
80                StrykeValue::float(n.as_f64().unwrap_or(0.0))
81            }
82        }
83        serde_json::Value::String(s) => StrykeValue::string(s),
84        serde_json::Value::Array(a) => StrykeValue::array_ref(Arc::new(RwLock::new(
85            a.into_iter().map(json_to_perl).collect(),
86        ))),
87        serde_json::Value::Object(o) => {
88            let mut map = IndexMap::new();
89            for (k, v) in o {
90                map.insert(k, json_to_perl(v));
91            }
92            StrykeValue::hash_ref(Arc::new(RwLock::new(map)))
93        }
94    }
95}
96
97fn agent() -> ureq::Agent {
98    ureq::AgentBuilder::new()
99        .timeout(Duration::from_secs(30))
100        .build()
101}
102
103fn prepare_request(req: ureq::Request) -> ureq::Request {
104    let req = req
105        .set("Accept", "application/vnd.github+json")
106        .set("User-Agent", USER_AGENT)
107        .set("X-GitHub-Api-Version", "2022-11-28");
108    if let Ok(token) = std::env::var("GITHUB_TOKEN") {
109        if !token.is_empty() {
110            return req.set("Authorization", &format!("Bearer {}", token));
111        }
112    }
113    req
114}
115
116fn build_url(path: &str) -> String {
117    if path.starts_with("http://") || path.starts_with("https://") {
118        path.to_string()
119    } else if let Some(rest) = path.strip_prefix('/') {
120        format!("{}/{}", API_ROOT, rest)
121    } else {
122        format!("{}/{}", API_ROOT, path)
123    }
124}
125
126fn max_pages() -> usize {
127    std::env::var("GH_MAX_PAGES")
128        .ok()
129        .and_then(|s| s.parse::<usize>().ok())
130        .filter(|n| *n > 0)
131        .unwrap_or(DEFAULT_MAX_PAGES)
132}
133
134fn http_get_json(url: &str) -> StrykeResult<Option<serde_json::Value>> {
135    let req = prepare_request(agent().get(url));
136    match req.call() {
137        Ok(resp) => {
138            let body = resp
139                .into_string()
140                .map_err(|e| StrykeError::runtime(format!("gh: read body: {}", e), 0))?;
141            if body.is_empty() {
142                return Ok(Some(serde_json::Value::Null));
143            }
144            let v: serde_json::Value = serde_json::from_str(&body)
145                .map_err(|e| StrykeError::runtime(format!("gh: parse json: {}", e), 0))?;
146            Ok(Some(v))
147        }
148        Err(ureq::Error::Status(404, _)) => Ok(None),
149        Err(ureq::Error::Status(code, resp)) => {
150            let body = resp.into_string().unwrap_or_default();
151            let snippet = body.chars().take(200).collect::<String>();
152            Err(StrykeError::runtime(
153                format!("gh: HTTP {}: {}", code, snippet),
154                0,
155            ))
156        }
157        Err(e) => Err(StrykeError::runtime(format!("gh: {}", e), 0)),
158    }
159}
160
161fn http_get_text(url: &str) -> StrykeResult<Option<String>> {
162    let req = prepare_request(agent().get(url));
163    match req.call() {
164        Ok(resp) => resp
165            .into_string()
166            .map(Some)
167            .map_err(|e| StrykeError::runtime(format!("gh: read body: {}", e), 0)),
168        Err(ureq::Error::Status(404, _)) => Ok(None),
169        Err(ureq::Error::Status(code, resp)) => {
170            let body = resp.into_string().unwrap_or_default();
171            let snippet = body.chars().take(200).collect::<String>();
172            Err(StrykeError::runtime(
173                format!("gh: HTTP {}: {}", code, snippet),
174                0,
175            ))
176        }
177        Err(e) => Err(StrykeError::runtime(format!("gh: {}", e), 0)),
178    }
179}
180
181/// Fetch a single endpoint and return the parsed JSON as a StrykeValue.
182/// 404 → undef.
183fn single(path: &str) -> StrykeResult<StrykeValue> {
184    let url = build_url(path);
185    match http_get_json(&url)? {
186        Some(v) => Ok(json_to_perl(v)),
187        None => Ok(StrykeValue::UNDEF),
188    }
189}
190
191/// Fetch a paginated list endpoint, concatenating page results into a
192/// single flat list (Perl list context — `my @r = gh_repos(...)` works).
193/// Stops on first empty/short page or hitting `max_pages`. 404 → empty list.
194fn paginated(path: &str) -> StrykeResult<StrykeValue> {
195    let per_page = 100usize;
196    let cap = max_pages();
197    let mut all: Vec<StrykeValue> = Vec::new();
198    let join = if path.contains('?') { '&' } else { '?' };
199    for page in 1..=cap {
200        let url = build_url(&format!(
201            "{}{}per_page={}&page={}",
202            path, join, per_page, page
203        ));
204        let Some(v) = http_get_json(&url)? else {
205            break;
206        };
207        match v {
208            serde_json::Value::Array(items) => {
209                let n = items.len();
210                all.extend(items.into_iter().map(json_to_perl));
211                if n < per_page {
212                    break;
213                }
214            }
215            // Search endpoints wrap results in { items: [...], total_count }
216            serde_json::Value::Object(ref o) if o.contains_key("items") => {
217                let Some(serde_json::Value::Array(items)) = o.get("items").cloned() else {
218                    break;
219                };
220                let n = items.len();
221                all.extend(items.into_iter().map(json_to_perl));
222                if n < per_page {
223                    break;
224                }
225            }
226            _ => break,
227        }
228    }
229    Ok(StrykeValue::array(all))
230}
231
232fn url_encode(s: &str) -> String {
233    let mut out = String::with_capacity(s.len());
234    for b in s.bytes() {
235        match b {
236            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
237                out.push(b as char);
238            }
239            _ => out.push_str(&format!("%{:02X}", b)),
240        }
241    }
242    out
243}
244
245// ── generic ────────────────────────────────────────────────────────────
246
247/// `gh_get(PATH)` — GET an arbitrary GitHub REST endpoint. `PATH` can be
248/// a relative path (`/users/MenkeTechnologies`) or a full URL. Returns
249/// parsed JSON as a stryke value; 404 → `undef`.
250pub fn gh_get(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
251    single(&arg_str(args, 0))
252}
253
254// ── user / org ─────────────────────────────────────────────────────────
255
256pub fn gh_user(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
257    single(&format!("/users/{}", arg_str(args, 0)))
258}
259
260pub fn gh_org(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
261    single(&format!("/orgs/{}", arg_str(args, 0)))
262}
263
264pub fn gh_followers(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
265    paginated(&format!("/users/{}/followers", arg_str(args, 0)))
266}
267
268pub fn gh_following(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
269    paginated(&format!("/users/{}/following", arg_str(args, 0)))
270}
271
272// ── repos ──────────────────────────────────────────────────────────────
273
274pub fn gh_repo(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
275    let s = arg_str(args, 0);
276    // Accept either `gh_repo("owner/repo")` or `gh_repo("owner", "repo")`
277    let path = if let Some((owner, repo)) = s.split_once('/') {
278        format!("/repos/{}/{}", owner, repo)
279    } else {
280        format!("/repos/{}/{}", s, arg_str(args, 1))
281    };
282    single(&path)
283}
284
285pub fn gh_repos(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
286    paginated(&format!("/users/{}/repos", arg_str(args, 0)))
287}
288
289pub fn gh_org_repos(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
290    paginated(&format!("/orgs/{}/repos", arg_str(args, 0)))
291}
292
293pub fn gh_starred(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
294    paginated(&format!("/users/{}/starred", arg_str(args, 0)))
295}
296
297// ── gists ──────────────────────────────────────────────────────────────
298
299pub fn gh_gists(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
300    paginated(&format!("/users/{}/gists", arg_str(args, 0)))
301}
302
303pub fn gh_gist(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
304    single(&format!("/gists/{}", arg_str(args, 0)))
305}
306
307// ── repo-scoped collections ────────────────────────────────────────────
308
309fn split_owner_repo(args: &[StrykeValue]) -> (String, String) {
310    let a = arg_str(args, 0);
311    if let Some((o, r)) = a.split_once('/') {
312        (o.to_string(), r.to_string())
313    } else {
314        (a, arg_str(args, 1))
315    }
316}
317
318pub fn gh_issues(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
319    let (o, r) = split_owner_repo(args);
320    paginated(&format!("/repos/{}/{}/issues", o, r))
321}
322
323pub fn gh_prs(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
324    let (o, r) = split_owner_repo(args);
325    paginated(&format!("/repos/{}/{}/pulls", o, r))
326}
327
328pub fn gh_commits(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
329    let (o, r) = split_owner_repo(args);
330    paginated(&format!("/repos/{}/{}/commits", o, r))
331}
332
333pub fn gh_branches(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
334    let (o, r) = split_owner_repo(args);
335    paginated(&format!("/repos/{}/{}/branches", o, r))
336}
337
338pub fn gh_tags(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
339    let (o, r) = split_owner_repo(args);
340    paginated(&format!("/repos/{}/{}/tags", o, r))
341}
342
343pub fn gh_releases(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
344    let (o, r) = split_owner_repo(args);
345    paginated(&format!("/repos/{}/{}/releases", o, r))
346}
347
348pub fn gh_contributors(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
349    let (o, r) = split_owner_repo(args);
350    paginated(&format!("/repos/{}/{}/contributors", o, r))
351}
352
353pub fn gh_forks(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
354    let (o, r) = split_owner_repo(args);
355    paginated(&format!("/repos/{}/{}/forks", o, r))
356}
357
358pub fn gh_stargazers(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
359    let (o, r) = split_owner_repo(args);
360    paginated(&format!("/repos/{}/{}/stargazers", o, r))
361}
362
363pub fn gh_workflows(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
364    let (o, r) = split_owner_repo(args);
365    match single(&format!("/repos/{}/{}/actions/workflows", o, r))? {
366        v if v.is_undef() => Ok(StrykeValue::array_ref(Arc::new(RwLock::new(vec![])))),
367        v => {
368            let ws = v
369                .as_hash_ref()
370                .and_then(|h| h.read().get("workflows").cloned())
371                .unwrap_or(StrykeValue::UNDEF);
372            Ok(ws)
373        }
374    }
375}
376
377pub fn gh_runs(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
378    let (o, r) = split_owner_repo(args);
379    match single(&format!("/repos/{}/{}/actions/runs", o, r))? {
380        v if v.is_undef() => Ok(StrykeValue::array_ref(Arc::new(RwLock::new(vec![])))),
381        v => {
382            let runs = v
383                .as_hash_ref()
384                .and_then(|h| h.read().get("workflow_runs").cloned())
385                .unwrap_or(StrykeValue::UNDEF);
386            Ok(runs)
387        }
388    }
389}
390
391/// `gh_topics(OWNER, REPO)` — returns an arrayref of topic name strings.
392pub fn gh_topics(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
393    let (o, r) = split_owner_repo(args);
394    let v = single(&format!("/repos/{}/{}/topics", o, r))?;
395    if v.is_undef() {
396        return Ok(StrykeValue::array_ref(Arc::new(RwLock::new(vec![]))));
397    }
398    let names = v
399        .as_hash_ref()
400        .and_then(|h| h.read().get("names").cloned())
401        .unwrap_or(StrykeValue::UNDEF);
402    Ok(names)
403}
404
405/// `gh_languages(OWNER, REPO)` — `{ language => bytes }` hashref.
406pub fn gh_languages(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
407    let (o, r) = split_owner_repo(args);
408    single(&format!("/repos/{}/{}/languages", o, r))
409}
410
411/// `gh_readme(OWNER, REPO)` — base64-decoded README content as a UTF-8 string.
412pub fn gh_readme(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
413    let (o, r) = split_owner_repo(args);
414    let v = single(&format!("/repos/{}/{}/readme", o, r))?;
415    if v.is_undef() {
416        return Ok(StrykeValue::UNDEF);
417    }
418    let h = match v.as_hash_ref() {
419        Some(h) => h,
420        None => return Ok(StrykeValue::UNDEF),
421    };
422    let guard = h.read();
423    let encoding = guard
424        .get("encoding")
425        .map(|v| v.to_string())
426        .unwrap_or_default();
427    let content = guard
428        .get("content")
429        .map(|v| v.to_string())
430        .unwrap_or_default();
431    drop(guard);
432    if encoding == "base64" {
433        let cleaned: String = content.chars().filter(|c| !c.is_whitespace()).collect();
434        use base64::Engine;
435        match base64::engine::general_purpose::STANDARD.decode(cleaned.as_bytes()) {
436            Ok(bytes) => Ok(StrykeValue::string(
437                String::from_utf8_lossy(&bytes).into_owned(),
438            )),
439            Err(_) => Ok(StrykeValue::string(content)),
440        }
441    } else {
442        Ok(StrykeValue::string(content))
443    }
444}
445
446// ── search ─────────────────────────────────────────────────────────────
447
448pub fn gh_search_repos(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
449    let q = url_encode(&arg_str(args, 0));
450    paginated(&format!("/search/repositories?q={}", q))
451}
452
453pub fn gh_search_users(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
454    let q = url_encode(&arg_str(args, 0));
455    paginated(&format!("/search/users?q={}", q))
456}
457
458pub fn gh_search_code(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
459    let q = url_encode(&arg_str(args, 0));
460    paginated(&format!("/search/code?q={}", q))
461}
462
463pub fn gh_search_issues(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
464    let q = url_encode(&arg_str(args, 0));
465    paginated(&format!("/search/issues?q={}", q))
466}
467
468// ── meta ───────────────────────────────────────────────────────────────
469
470pub fn gh_rate_limit(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
471    single("/rate_limit")
472}
473
474pub fn gh_meta(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
475    single("/meta")
476}
477
478pub fn gh_emojis(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
479    single("/emojis")
480}
481
482/// `gh_zen()` — GitHub's "zen" endpoint. Returns a plain-text string,
483/// not JSON.
484pub fn gh_zen(_args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
485    let url = build_url("/zen");
486    match http_get_text(&url)? {
487        Some(s) => Ok(StrykeValue::string(s)),
488        None => Ok(StrykeValue::UNDEF),
489    }
490}