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