1use quarb::temporal::parse_iso;
39use quarb::{AstAdapter, NodeId, Value};
40use serde_json::Value as Json;
41use std::cell::RefCell;
42use std::collections::HashMap;
43use std::rc::Rc;
44
45#[derive(Debug, thiserror::Error)]
47pub enum GithubError {
48 #[error("gh: {0}")]
49 Gh(String),
50 #[error("github target: {0} (expected github:[OWNER[/REPO]])")]
51 Target(String),
52}
53
54#[derive(Clone, Copy, PartialEq)]
56enum Sec {
57 Files,
58 Issues,
59 Pulls,
60 Releases,
61}
62
63impl Sec {
64 const ALL: [Sec; 4] = [Sec::Files, Sec::Issues, Sec::Pulls, Sec::Releases];
65 fn name(self) -> &'static str {
66 match self {
67 Sec::Files => "files",
68 Sec::Issues => "issues",
69 Sec::Pulls => "pulls",
70 Sec::Releases => "releases",
71 }
72 }
73}
74
75enum Kind {
79 Root,
80 User {
82 login: String,
83 },
84 Repo {
86 key: String,
87 },
88 Section {
89 repo: String,
90 sec: Sec,
91 },
92 Item,
96 Dir {
98 repo: String,
99 path: String,
100 },
101 File {
103 repo: String,
104 path: String,
105 },
106}
107
108struct Node {
109 kind: Kind,
110 name: Option<String>,
111 parent: Option<NodeId>,
112 children: RefCell<Option<Vec<NodeId>>>,
113 data: RefCell<Option<Rc<Json>>>,
114}
115
116fn str_value(s: &str) -> Value {
118 if s.contains('T')
119 && let Some((secs, nanos, offset_min)) = parse_iso(s)
120 {
121 return Value::Instant {
122 secs,
123 nanos,
124 offset_min,
125 calendar: Default::default(),
126 };
127 }
128 Value::Str(s.to_string())
129}
130
131fn bytes(v: Value) -> Value {
135 match v {
136 Value::Int(n) => Value::bytes(n),
137 other => other,
138 }
139}
140
141fn kib(v: Value) -> Value {
143 match v {
144 Value::Int(n) => Value::Quantity {
145 value: n as f64 * 8192.0,
146 base: "b".to_string(),
147 written: Some((n as f64, "KiB".to_string())),
148 },
149 other => other,
150 }
151}
152
153fn json_scalar(v: &Json) -> Option<Value> {
154 match v {
155 Json::String(s) => Some(str_value(s)),
156 Json::Number(n) => Some(match n.as_i64() {
157 Some(i) => Value::Int(i),
158 None => Value::Float(n.as_f64()?),
159 }),
160 Json::Bool(b) => Some(Value::Bool(*b)),
161 _ => None,
162 }
163}
164
165fn base64_decode(s: &str) -> Option<Vec<u8>> {
168 let mut out = Vec::new();
169 let mut acc: u32 = 0;
170 let mut bits = 0;
171 for c in s.bytes() {
172 let v = match c {
173 b'A'..=b'Z' => c - b'A',
174 b'a'..=b'z' => c - b'a' + 26,
175 b'0'..=b'9' => c - b'0' + 52,
176 b'+' => 62,
177 b'/' => 63,
178 b'=' | b'\n' | b'\r' | b' ' => continue,
179 _ => return None,
180 };
181 acc = (acc << 6) | v as u32;
182 bits += 6;
183 if bits >= 8 {
184 bits -= 8;
185 out.push((acc >> bits) as u8);
186 }
187 }
188 Some(out)
189}
190
191pub struct GithubAdapter {
193 gh: String,
194 anchor: Vec<NodeId>,
196 nodes: RefCell<Vec<Node>>,
197 users: RefCell<HashMap<String, NodeId>>,
198 repos: RefCell<HashMap<String, NodeId>>,
199 items: RefCell<HashMap<String, NodeId>>,
201 contributions: RefCell<HashMap<String, i64>>,
203 contents: RefCell<HashMap<String, Option<String>>>,
205}
206
207impl GithubAdapter {
208 pub fn connect(target: &str) -> Result<Self, GithubError> {
211 let anchor = target
212 .strip_prefix("github:")
213 .ok_or_else(|| GithubError::Target(target.to_string()))?;
214 let gh = std::env::var("QUARB_GH").unwrap_or_else(|_| "gh".to_string());
215 let adapter = GithubAdapter {
216 gh,
217 anchor: Vec::new(),
218 nodes: RefCell::new(vec![Node {
219 kind: Kind::Root,
220 name: None,
221 parent: None,
222 children: RefCell::new(None),
223 data: RefCell::new(None),
224 }]),
225 users: RefCell::new(HashMap::new()),
226 repos: RefCell::new(HashMap::new()),
227 items: RefCell::new(HashMap::new()),
228 contributions: RefCell::new(HashMap::new()),
229 contents: RefCell::new(HashMap::new()),
230 };
231 let mut adapter = adapter;
232 match anchor.split_once('/') {
233 None if anchor.is_empty() => {
234 adapter
236 .call("/rate_limit")
237 .map_err(|e| GithubError::Gh(format!("{e} (is `gh auth login` done?)")))?;
238 }
239 None => {
240 let u = adapter
241 .fetch_user(anchor)
242 .ok_or_else(|| GithubError::Gh(format!("no such user: {anchor}")))?;
243 adapter.anchor = vec![u];
244 }
245 Some((owner, repo)) => {
246 let key = format!("{owner}/{repo}");
247 let r = adapter
248 .fetch_repo(&key)
249 .ok_or_else(|| GithubError::Gh(format!("no such repository: {key}")))?;
250 adapter.anchor = vec![r];
251 }
252 }
253 Ok(adapter)
254 }
255
256 pub fn locator(&self, node: NodeId) -> String {
258 let nodes = self.nodes.borrow();
259 let mut parts = Vec::new();
260 let mut cur = Some(node);
261 while let Some(n) = cur {
262 if let Some(name) = &nodes[n.0 as usize].name {
263 parts.push(name.clone());
264 }
265 cur = nodes[n.0 as usize].parent;
266 }
267 parts.reverse();
268 format!("/{}", parts.join("/"))
269 }
270
271 fn call(&self, path: &str) -> Result<Json, String> {
272 let out = std::process::Command::new(&self.gh)
273 .args(["api", path])
274 .output()
275 .map_err(|e| format!("running {}: {e}", self.gh))?;
276 if !out.status.success() {
277 return Err(format!(
278 "api {path}: {}",
279 String::from_utf8_lossy(&out.stderr).trim()
280 ));
281 }
282 serde_json::from_slice(&out.stdout).map_err(|e| format!("decoding {path}: {e}"))
283 }
284
285 fn call_paged(&self, path: &str) -> Vec<Json> {
290 let sep = if path.contains('?') { '&' } else { '?' };
291 let mut out = Vec::new();
292 for page in 1.. {
293 let url = format!("{path}{sep}per_page=100&page={page}");
294 let items = match self.call(&url) {
295 Ok(Json::Array(a)) => a,
296 Ok(_) => break,
297 Err(e) if page > 1 => {
300 quarb::record_refusal(format!(
301 "github: listing {path} truncated at page {page}: {e}"
302 ));
303 break;
304 }
305 Err(_) => break,
306 };
307 let n = items.len();
308 out.extend(items);
309 if n < 100 {
310 break;
311 }
312 }
313 out
314 }
315
316 fn push_node(
317 &self,
318 kind: Kind,
319 name: Option<String>,
320 parent: Option<NodeId>,
321 data: Option<Json>,
322 ) -> NodeId {
323 let mut nodes = self.nodes.borrow_mut();
324 let id = NodeId(nodes.len() as u64);
325 nodes.push(Node {
326 kind,
327 name,
328 parent,
329 children: RefCell::new(None),
330 data: RefCell::new(data.map(Rc::new)),
331 });
332 id
333 }
334
335 fn data(&self, node: NodeId) -> Option<Rc<Json>> {
336 self.nodes.borrow()[node.0 as usize].data.borrow().clone()
337 }
338
339 fn set_data(&self, node: NodeId, data: Json) -> Rc<Json> {
340 let rc = Rc::new(data);
341 *self.nodes.borrow()[node.0 as usize].data.borrow_mut() = Some(rc.clone());
342 rc
343 }
344
345 fn user_node(&self, login: &str, seed: Option<&Json>) -> NodeId {
348 if let Some(&n) = self.users.borrow().get(login) {
349 return n;
350 }
351 let n = self.push_node(
352 Kind::User {
353 login: login.to_string(),
354 },
355 Some(login.to_string()),
356 Some(NodeId(0)),
357 seed.cloned(),
358 );
359 self.users.borrow_mut().insert(login.to_string(), n);
360 n
361 }
362
363 fn user_data(&self, node: NodeId, login: &str) -> Option<Rc<Json>> {
367 if let Some(d) = self.data(node)
368 && d.get("created_at").is_some()
369 {
370 return Some(d);
371 }
372 let d = self.call(&format!("/users/{login}")).ok()?;
373 Some(self.set_data(node, d))
374 }
375
376 fn fetch_user(&self, login: &str) -> Option<NodeId> {
377 let n = self.user_node(login, None);
378 self.user_data(n, login).map(|_| n)
379 }
380
381 fn repo_node(&self, key: &str, seed: Option<&Json>) -> NodeId {
383 if let Some(&n) = self.repos.borrow().get(key) {
384 return n;
385 }
386 let (owner, name) = key.split_once('/').unwrap_or((key, key));
387 let parent = self.user_node(owner, None);
388 let n = self.push_node(
389 Kind::Repo {
390 key: key.to_string(),
391 },
392 Some(name.to_string()),
393 Some(parent),
394 seed.cloned(),
395 );
396 self.repos.borrow_mut().insert(key.to_string(), n);
397 n
398 }
399
400 fn repo_data(&self, node: NodeId, key: &str, full: bool) -> Option<Rc<Json>> {
403 if let Some(d) = self.data(node)
404 && (!full || d.get("subscribers_count").is_some())
405 {
406 return Some(d);
407 }
408 let d = self.call(&format!("/repos/{key}")).ok()?;
409 Some(self.set_data(node, d))
410 }
411
412 fn fetch_repo(&self, key: &str) -> Option<NodeId> {
413 let n = self.repo_node(key, None);
414 self.repo_data(n, key, true).map(|_| n)
415 }
416
417 fn item_node(&self, key: String, name: String, parent: Option<NodeId>, data: Json) -> NodeId {
419 if let Some(&n) = self.items.borrow().get(&key) {
420 return n;
421 }
422 let n = self.push_node(Kind::Item, Some(name), parent, Some(data));
423 self.items.borrow_mut().insert(key, n);
424 n
425 }
426
427 fn section_node(&self, repo_node: NodeId, repo: &str, sec: Sec) -> NodeId {
428 self.push_node(
429 Kind::Section {
430 repo: repo.to_string(),
431 sec,
432 },
433 Some(sec.name().to_string()),
434 Some(repo_node),
435 None,
436 )
437 }
438
439 fn dir_children(&self, parent: NodeId, repo: &str, path: &str) -> Vec<NodeId> {
441 let url = match path.is_empty() {
442 true => format!("/repos/{repo}/contents/"),
443 false => format!("/repos/{repo}/contents/{path}"),
444 };
445 let Ok(Json::Array(entries)) = self.call(&url) else {
446 return Vec::new();
447 };
448 entries
449 .iter()
450 .filter_map(|e| {
451 let name = e.get("name")?.as_str()?.to_string();
452 let epath = e.get("path")?.as_str()?.to_string();
453 let kind = match e.get("type")?.as_str()? {
454 "dir" => Kind::Dir {
455 repo: repo.to_string(),
456 path: epath,
457 },
458 _ => Kind::File {
459 repo: repo.to_string(),
460 path: epath,
461 },
462 };
463 Some(self.push_node(kind, Some(name), Some(parent), Some(e.clone())))
464 })
465 .collect()
466 }
467
468 fn file_content(&self, repo: &str, path: &str) -> Option<String> {
471 let key = format!("{repo}:{path}");
472 if let Some(c) = self.contents.borrow().get(&key) {
473 return c.clone();
474 }
475 let content = self
476 .call(&format!("/repos/{repo}/contents/{path}"))
477 .ok()
478 .and_then(|d| {
479 let b64 = d.get("content")?.as_str()?.to_string();
480 String::from_utf8(base64_decode(&b64)?).ok()
481 });
482 self.contents.borrow_mut().insert(key, content.clone());
483 content
484 }
485
486 fn section_children(&self, node: NodeId, repo: &str, sec: Sec) -> Vec<NodeId> {
489 match sec {
490 Sec::Files => self.dir_children(node, repo, ""),
491 Sec::Issues => self
492 .call_paged(&format!("/repos/{repo}/issues"))
493 .iter()
494 .filter(|i| i.get("pull_request").is_none())
496 .filter_map(|i| {
497 let num = i.get("number")?.as_i64()?;
498 Some(self.item_node(
499 format!("{repo}#{num}"),
500 num.to_string(),
501 Some(node),
502 i.clone(),
503 ))
504 })
505 .collect(),
506 Sec::Pulls => self
507 .call_paged(&format!("/repos/{repo}/pulls"))
508 .iter()
509 .filter_map(|i| {
510 let num = i.get("number")?.as_i64()?;
511 Some(self.item_node(
512 format!("{repo}#{num}"),
513 num.to_string(),
514 Some(node),
515 i.clone(),
516 ))
517 })
518 .collect(),
519 Sec::Releases => self
520 .call_paged(&format!("/repos/{repo}/releases"))
521 .iter()
522 .filter_map(|r| {
523 let tag = r.get("tag_name")?.as_str()?.to_string();
524 Some(self.item_node(format!("{repo}@{tag}"), tag, Some(node), r.clone()))
525 })
526 .collect(),
527 }
528 }
529
530 fn author_of(&self, data: &Json) -> Option<NodeId> {
532 let u = data.get("user").or_else(|| data.get("author"))?;
533 let login = u.get("login")?.as_str()?;
534 Some(self.user_node(login, Some(u)))
535 }
536}
537
538impl AstAdapter for GithubAdapter {
539 fn root(&self) -> NodeId {
540 NodeId(0)
541 }
542
543 fn children(&self, node: NodeId) -> Vec<NodeId> {
544 if let Some(c) = self.nodes.borrow()[node.0 as usize]
545 .children
546 .borrow()
547 .as_ref()
548 {
549 return c.clone();
550 }
551 enum Plan {
552 Root,
553 User(String),
554 Repo(String),
555 Section(String, Sec),
556 Dir(String, String),
557 Leaf,
558 }
559 let plan = match &self.nodes.borrow()[node.0 as usize].kind {
560 Kind::Root => Plan::Root,
561 Kind::User { login } => Plan::User(login.clone()),
562 Kind::Repo { key } => Plan::Repo(key.clone()),
563 Kind::Section { repo, sec } => Plan::Section(repo.clone(), *sec),
564 Kind::Dir { repo, path } => Plan::Dir(repo.clone(), path.clone()),
565 Kind::Item | Kind::File { .. } => Plan::Leaf,
566 };
567 let ids = match plan {
568 Plan::Root => self.anchor.clone(),
571 Plan::User(login) => self
572 .call_paged(&format!("/users/{login}/repos"))
573 .iter()
574 .filter_map(|r| {
575 let key = r.get("full_name")?.as_str()?;
576 Some(self.repo_node(key, Some(r)))
577 })
578 .collect(),
579 Plan::Repo(key) => Sec::ALL
580 .iter()
581 .map(|&sec| self.section_node(node, &key, sec))
582 .collect(),
583 Plan::Section(repo, sec) => self.section_children(node, &repo, sec),
584 Plan::Dir(repo, path) => self.dir_children(node, &repo, &path),
585 Plan::Leaf => Vec::new(),
586 };
587 *self.nodes.borrow()[node.0 as usize].children.borrow_mut() = Some(ids.clone());
588 ids
589 }
590
591 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
595 enum Plan {
596 Root,
597 User(String),
598 Section(String, Sec),
599 Other,
600 }
601 let plan = match &self.nodes.borrow()[node.0 as usize].kind {
602 Kind::Root => Plan::Root,
603 Kind::User { login } => Plan::User(login.clone()),
604 Kind::Section {
605 repo,
606 sec: sec @ (Sec::Issues | Sec::Pulls),
607 } => Plan::Section(repo.clone(), *sec),
608 _ => Plan::Other,
609 };
610 match plan {
611 Plan::Root => {
612 if let Some(&n) = self.users.borrow().get(name) {
613 return vec![n];
614 }
615 self.fetch_user(name).into_iter().collect()
616 }
617 Plan::User(login) => {
618 let key = format!("{login}/{name}");
619 if let Some(&n) = self.repos.borrow().get(&key) {
620 return vec![n];
621 }
622 self.fetch_repo(&key).into_iter().collect()
623 }
624 Plan::Section(repo, sec) => {
625 let Ok(num) = name.parse::<i64>() else {
626 return Vec::new();
627 };
628 if let Some(&n) = self.items.borrow().get(&format!("{repo}#{num}")) {
629 return vec![n];
630 }
631 let path = match sec {
632 Sec::Pulls => format!("/repos/{repo}/pulls/{num}"),
633 _ => format!("/repos/{repo}/issues/{num}"),
634 };
635 let Ok(d) = self.call(&path) else {
636 return Vec::new();
637 };
638 vec![self.item_node(format!("{repo}#{num}"), num.to_string(), Some(node), d)]
639 }
640 Plan::Other => {
641 self.children(node)
643 .into_iter()
644 .filter(|&c| self.name(c).as_deref() == Some(name))
645 .collect()
646 }
647 }
648 }
649
650 fn name(&self, node: NodeId) -> Option<String> {
651 self.nodes.borrow()[node.0 as usize].name.clone()
652 }
653
654 fn parent(&self, node: NodeId) -> Option<NodeId> {
655 self.nodes.borrow()[node.0 as usize].parent
656 }
657
658 fn traits(&self, node: NodeId) -> Vec<String> {
663 enum Shape {
664 Fixed(&'static str),
665 User(String),
666 Repo,
667 Item,
668 File,
669 }
670 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
671 Kind::Root => return Vec::new(),
672 Kind::Section { .. } => Shape::Fixed("section"),
673 Kind::Dir { .. } => Shape::Fixed("dir"),
674 Kind::User { login } => Shape::User(login.clone()),
675 Kind::Repo { .. } => Shape::Repo,
676 Kind::Item => Shape::Item,
677 Kind::File { .. } => Shape::File,
678 };
679 let data = self.data(node);
680 let d = data.as_deref();
681 let flag = |k: &str| d.and_then(|d| d.get(k)).and_then(|v| v.as_bool()) == Some(true);
682 let set = |k: &str| d.and_then(|d| d.get(k)).map(|v| !v.is_null()) == Some(true);
683 match shape {
684 Shape::Fixed(t) => vec![t.to_string()],
685 Shape::User(login) => {
686 let t = d
689 .and_then(|d| d.get("type"))
690 .and_then(|v| v.as_str())
691 .map(str::to_string)
692 .or_else(|| {
693 let n = *self.users.borrow().get(&login)?;
694 let d = self.user_data(n, &login)?;
695 Some(d.get("type")?.as_str()?.to_string())
696 })
697 .unwrap_or_else(|| "User".to_string());
698 match t.as_str() {
703 "Organization" => vec![
704 "organization".to_string(),
705 "org".to_string(),
706 "group".to_string(),
707 ],
708 _ => vec![t.to_lowercase()],
709 }
710 }
711 Shape::Repo => {
712 let mut ts = vec!["repo".to_string()];
713 for f in ["fork", "archived", "private", "is_template"] {
714 if flag(f) {
715 ts.push(f.trim_start_matches("is_").to_string());
716 }
717 }
718 if let Some(topics) = d.and_then(|d| d.get("topics")).and_then(|v| v.as_array()) {
719 ts.extend(
720 topics
721 .iter()
722 .filter_map(|t| t.as_str().map(|t| format!("topic:{t}"))),
723 );
724 }
725 ts
726 }
727 Shape::Item => {
728 let mut ts = Vec::new();
729 let kind = if d.is_some_and(|d| d.get("tag_name").is_some()) {
730 "release"
731 } else if d
732 .is_some_and(|d| d.get("head").is_some() || d.get("pull_request").is_some())
733 {
734 "pull"
735 } else {
736 "issue"
737 };
738 ts.push(kind.to_string());
739 if let Some(state) = d.and_then(|d| d.get("state")).and_then(|v| v.as_str()) {
740 ts.push(state.to_string());
741 }
742 if set("merged_at") {
743 ts.push("merged".to_string());
744 }
745 if flag("draft") {
746 ts.push("draft".to_string());
747 }
748 if flag("prerelease") {
749 ts.push("prerelease".to_string());
750 }
751 if let Some(labels) = d.and_then(|d| d.get("labels")).and_then(|v| v.as_array()) {
752 ts.extend(labels.iter().filter_map(|l| {
753 l.get("name")
754 .and_then(|v| v.as_str())
755 .map(|l| format!("label:{l}"))
756 }));
757 }
758 ts
759 }
760 Shape::File => {
761 let t = d
762 .and_then(|d| d.get("type"))
763 .and_then(|v| v.as_str())
764 .unwrap_or("file");
765 vec![t.to_string()]
766 }
767 }
768 }
769
770 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
773 enum Shape {
774 User(String),
775 Repo(String),
776 Item,
777 Entry,
778 }
779 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
780 Kind::User { login } => Shape::User(login.clone()),
781 Kind::Repo { key } => Shape::Repo(key.clone()),
782 Kind::Item => Shape::Item,
783 Kind::File { .. } | Kind::Dir { .. } => Shape::Entry,
784 _ => return None,
785 };
786 let at = |d: &Json, ptr: &str| d.pointer(ptr).and_then(json_scalar);
787 match shape {
788 Shape::User(login) => {
789 let d = self.user_data(node, &login)?;
790 let key = match name {
791 "login" => "/login",
792 "name" => "/name",
793 "bio" => "/bio",
794 "company" => "/company",
795 "location" => "/location",
796 "blog" => "/blog",
797 "email" => "/email",
798 "followers" => "/followers",
799 "following" => "/following",
800 "repos" => "/public_repos",
801 "gists" => "/public_gists",
802 "created" => "/created_at",
803 "updated" => "/updated_at",
804 "type" => "/type",
805 _ => return None,
806 };
807 at(&d, key)
808 }
809 Shape::Repo(rkey) => {
810 let full = matches!(name, "watchers" | "license");
813 let d = self.repo_data(node, &rkey, full)?;
814 match name {
815 "topics" => {
816 let ts = d.get("topics")?.as_array()?;
817 Some(Value::list(
818 ts.iter()
819 .filter_map(|t| Some(Value::Str(t.as_str()?.to_string())))
820 .collect(),
821 ))
822 }
823 "owner" => at(&d, "/owner/login"),
824 "parent" => {
827 let d = if d.get("parent").is_none()
828 && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
829 {
830 self.repo_data(node, &rkey, true)?
831 } else {
832 d
833 };
834 at(&d, "/parent/full_name")
835 }
836 "license" => at(&d, "/license/spdx_id"),
837 _ => {
838 let key = match name {
839 "name" => "/name",
840 "full-name" => "/full_name",
841 "description" => "/description",
842 "stars" => "/stargazers_count",
843 "forks" => "/forks_count",
844 "watchers" => "/subscribers_count",
845 "open-issues" => "/open_issues_count",
846 "language" => "/language",
847 "default-branch" => "/default_branch",
848 "homepage" => "/homepage",
849 "size" => return at(&d, "/size").map(kib),
853 "created" => "/created_at",
854 "updated" => "/updated_at",
855 "pushed" => "/pushed_at",
856 _ => return None,
857 };
858 at(&d, key)
859 }
860 }
861 }
862 Shape::Item => {
863 let d = self.data(node)?;
864 match name {
865 "labels" => {
866 let ls = d.get("labels")?.as_array()?;
867 Some(Value::list(
868 ls.iter()
869 .filter_map(|l| {
870 Some(Value::Str(l.get("name")?.as_str()?.to_string()))
871 })
872 .collect(),
873 ))
874 }
875 "author" => at(&d, "/user/login").or_else(|| at(&d, "/author/login")),
876 "milestone" => at(&d, "/milestone/title"),
877 "base" => at(&d, "/base/ref"),
878 "head" => at(&d, "/head/ref"),
879 _ => {
880 let key = match name {
881 "number" => "/number",
882 "title" => "/title",
883 "state" => "/state",
884 "comments" => "/comments",
885 "created" => "/created_at",
886 "updated" => "/updated_at",
887 "closed" => "/closed_at",
888 "merged" => "/merged_at",
889 "tag" => "/tag_name",
890 "name" => "/name",
891 "published" => "/published_at",
892 _ => return None,
893 };
894 at(&d, key)
895 }
896 }
897 }
898 Shape::Entry => {
899 let d = self.data(node)?;
900 match name {
901 "size" => at(&d, "/size").map(bytes),
903 "type" => at(&d, "/type"),
904 _ => None,
905 }
906 }
907 }
908 }
909
910 fn default_value(&self, node: NodeId) -> Option<Value> {
913 let (repo, path) = match &self.nodes.borrow()[node.0 as usize].kind {
914 Kind::File { repo, path } => (repo.clone(), path.clone()),
915 Kind::Item => {
916 let d = self.data(node)?;
917 let body = d.get("body")?.as_str()?;
918 return Some(Value::Str(body.to_string()));
919 }
920 _ => return None,
921 };
922 self.file_content(&repo, &path).map(Value::Str)
923 }
924
925 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
926 match key {
927 "path" => Some(Value::Str(self.locator(node))),
928 "url" => {
929 let d = self.data(node)?;
930 Some(Value::Str(d.get("html_url")?.as_str()?.to_string()))
931 }
932 _ => None,
933 }
934 }
935
936 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
939 let kind_key = match &self.nodes.borrow()[node.0 as usize].kind {
940 Kind::Repo { key } => Some(key.clone()),
941 Kind::Item => None,
942 _ => return None,
943 };
944 match (kind_key, property) {
945 (Some(key), "owner") => {
946 let d = self.repo_data(node, &key, false)?;
947 let login = d.pointer("/owner/login")?.as_str()?;
948 Some(self.user_node(login, d.get("owner")))
949 }
950 (Some(key), "parent") => {
951 let d = self.repo_data(node, &key, false)?;
952 let d = if d.get("parent").is_none()
953 && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
954 {
955 self.repo_data(node, &key, true)?
956 } else {
957 d
958 };
959 let pkey = d.pointer("/parent/full_name")?.as_str()?.to_string();
960 Some(self.repo_node(&pkey, d.get("parent")))
961 }
962 (None, "author") => {
963 let d = self.data(node)?;
964 self.author_of(&d)
965 }
966 _ => None,
967 }
968 }
969
970 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
974 enum Shape {
975 User(String),
976 Repo(String),
977 Item,
978 }
979 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
980 Kind::User { login } => Shape::User(login.clone()),
981 Kind::Repo { key } => Shape::Repo(key.clone()),
982 Kind::Item => Shape::Item,
983 _ => return Vec::new(),
984 };
985 let mut out = Vec::new();
986 match shape {
987 Shape::User(login) => {
988 for u in self.call_paged(&format!("/users/{login}/following")) {
989 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
990 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
991 }
992 }
993 for r in self.call_paged(&format!("/users/{login}/starred")) {
994 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
995 out.push(("starred".to_string(), self.repo_node(k, Some(&r))));
996 }
997 }
998 for o in self.call_paged(&format!("/users/{login}/orgs")) {
999 if let Some(l) = o.get("login").and_then(|v| v.as_str()) {
1000 out.push(("org".to_string(), self.user_node(l, Some(&o))));
1001 }
1002 }
1003 }
1004 Shape::Repo(key) => {
1005 if let Some(n) = self.resolve(node, "owner", None) {
1006 out.push(("owner".to_string(), n));
1007 }
1008 if let Some(n) = self.resolve(node, "parent", None) {
1009 out.push(("parent".to_string(), n));
1010 }
1011 for c in self.call_paged(&format!("/repos/{key}/contributors")) {
1012 let Some(l) = c.get("login").and_then(|v| v.as_str()) else {
1013 continue;
1014 };
1015 if let Some(n) = c.get("contributions").and_then(|v| v.as_i64()) {
1016 self.contributions
1017 .borrow_mut()
1018 .insert(format!("{key}@{l}"), n);
1019 }
1020 out.push(("contributor".to_string(), self.user_node(l, Some(&c))));
1021 }
1022 }
1023 Shape::Item => {
1024 let Some(d) = self.data(node) else {
1025 return out;
1026 };
1027 if let Some(n) = self.author_of(&d) {
1028 out.push(("author".to_string(), n));
1029 }
1030 if let Some(assignees) = d.get("assignees").and_then(|v| v.as_array()) {
1031 for a in assignees {
1032 if let Some(l) = a.get("login").and_then(|v| v.as_str()) {
1033 out.push(("assignee".to_string(), self.user_node(l, Some(a))));
1034 }
1035 }
1036 }
1037 }
1038 }
1039 out
1040 }
1041
1042 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1047 enum Shape {
1048 User(String),
1049 Repo(String),
1050 }
1051 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
1052 Kind::User { login } => Shape::User(login.clone()),
1053 Kind::Repo { key } => Shape::Repo(key.clone()),
1054 _ => return Vec::new(),
1055 };
1056 let mut out = Vec::new();
1057 match shape {
1058 Shape::User(login) => {
1059 for u in self.call_paged(&format!("/users/{login}/followers")) {
1060 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1061 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
1062 }
1063 }
1064 let is_org = self.traits(node).contains(&"organization".to_string());
1065 if is_org {
1066 for m in self.call_paged(&format!("/orgs/{login}/members")) {
1067 if let Some(l) = m.get("login").and_then(|v| v.as_str()) {
1068 out.push(("org".to_string(), self.user_node(l, Some(&m))));
1069 }
1070 }
1071 }
1072 }
1073 Shape::Repo(key) => {
1074 for u in self.call_paged(&format!("/repos/{key}/stargazers")) {
1075 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1076 out.push(("starred".to_string(), self.user_node(l, Some(&u))));
1077 }
1078 }
1079 for r in self.call_paged(&format!("/repos/{key}/forks")) {
1080 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
1081 out.push(("parent".to_string(), self.repo_node(k, Some(&r))));
1082 }
1083 }
1084 }
1085 }
1086 out
1087 }
1088
1089 fn link_property(
1092 &self,
1093 source: NodeId,
1094 label: &str,
1095 target: NodeId,
1096 name: &str,
1097 ) -> Option<Value> {
1098 if label != "contributor" || name != "contributions" {
1099 return None;
1100 }
1101 let key = match &self.nodes.borrow()[source.0 as usize].kind {
1102 Kind::Repo { key } => key.clone(),
1103 _ => return None,
1104 };
1105 let login = match &self.nodes.borrow()[target.0 as usize].kind {
1106 Kind::User { login } => login.clone(),
1107 _ => return None,
1108 };
1109 self.contributions
1110 .borrow()
1111 .get(&format!("{key}@{login}"))
1112 .copied()
1113 .map(Value::Int)
1114 }
1115}
1116
1117#[cfg(test)]
1118mod tests {
1119 use super::*;
1120 use serde_json::json;
1121
1122 #[test]
1123 fn base64_roundtrip() {
1124 assert_eq!(
1125 base64_decode("aGVsbG8gcXVhcmI=").as_deref(),
1126 Some(b"hello quarb".as_slice())
1127 );
1128 assert_eq!(
1129 base64_decode("aGVsbG8g\ncXVhcmI=\n").as_deref(),
1130 Some(b"hello quarb".as_slice())
1131 );
1132 assert!(base64_decode("not!base64").is_none());
1133 }
1134
1135 #[test]
1136 fn timestamps_become_instants() {
1137 assert!(matches!(
1138 str_value("2011-01-25T18:44:36Z"),
1139 Value::Instant { .. }
1140 ));
1141 assert!(matches!(str_value("v1.0.0"), Value::Str(_)));
1142 }
1143
1144 #[test]
1145 fn item_traits_from_shape() {
1146 let a = GithubAdapter {
1147 gh: "false".into(),
1148 anchor: Vec::new(),
1149 nodes: RefCell::new(vec![Node {
1150 kind: Kind::Root,
1151 name: None,
1152 parent: None,
1153 children: RefCell::new(None),
1154 data: RefCell::new(None),
1155 }]),
1156 users: RefCell::new(HashMap::new()),
1157 repos: RefCell::new(HashMap::new()),
1158 items: RefCell::new(HashMap::new()),
1159 contributions: RefCell::new(HashMap::new()),
1160 contents: RefCell::new(HashMap::new()),
1161 };
1162 let issue = a.push_node(
1163 Kind::Item,
1164 Some("1".into()),
1165 None,
1166 Some(json!({"number": 1, "state": "open",
1167 "labels": [{"name": "bug"}, {"name": "good first issue"}]})),
1168 );
1169 assert_eq!(
1170 a.traits(issue),
1171 ["issue", "open", "label:bug", "label:good first issue"]
1172 );
1173 let pull = a.push_node(
1174 Kind::Item,
1175 Some("2".into()),
1176 None,
1177 Some(json!({"number": 2, "state": "closed", "head": {"ref": "x"},
1178 "merged_at": "2026-01-01T00:00:00Z", "labels": []})),
1179 );
1180 assert_eq!(a.traits(pull), ["pull", "closed", "merged"]);
1181 let release = a.push_node(
1182 Kind::Item,
1183 Some("v1".into()),
1184 None,
1185 Some(json!({"tag_name": "v1", "prerelease": true})),
1186 );
1187 assert_eq!(a.traits(release), ["release", "prerelease"]);
1188 }
1189
1190 #[test]
1191 fn repo_traits_include_flags_and_topics() {
1192 let a = GithubAdapter {
1193 gh: "false".into(),
1194 anchor: Vec::new(),
1195 nodes: RefCell::new(vec![Node {
1196 kind: Kind::Root,
1197 name: None,
1198 parent: None,
1199 children: RefCell::new(None),
1200 data: RefCell::new(None),
1201 }]),
1202 users: RefCell::new(HashMap::new()),
1203 repos: RefCell::new(HashMap::new()),
1204 items: RefCell::new(HashMap::new()),
1205 contributions: RefCell::new(HashMap::new()),
1206 contents: RefCell::new(HashMap::new()),
1207 };
1208 let r = a.push_node(
1209 Kind::Repo { key: "o/r".into() },
1210 Some("r".into()),
1211 None,
1212 Some(json!({"fork": true, "archived": false,
1213 "topics": ["cli", "rust"]})),
1214 );
1215 assert_eq!(a.traits(r), ["repo", "fork", "topic:cli", "topic:rust"]);
1216 }
1217
1218 #[test]
1219 fn target_needs_the_scheme() {
1220 assert!(matches!(
1221 GithubAdapter::connect("gh:torvalds"),
1222 Err(GithubError::Target(_))
1223 ));
1224 }
1225}