1use crate::error::{StrykeError, StrykeResult};
54use crate::value::StrykeValue;
55use indexmap::IndexMap;
56use parking_lot::RwLock;
57use std::sync::Arc;
58use std::time::Duration;
59
60const 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
181fn 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
191fn 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 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
245pub fn gh_get(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
251 single(&arg_str(args, 0))
252}
253
254pub 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
272pub fn gh_repo(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
275 let s = arg_str(args, 0);
276 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
297pub 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
307fn 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
391pub 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
405pub fn gh_languages(args: &[StrykeValue]) -> StrykeResult<StrykeValue> {
407 let (o, r) = split_owner_repo(args);
408 single(&format!("/repos/{}/{}/languages", o, r))
409}
410
411pub 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
446pub 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
468pub 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
482pub 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}