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