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 => {
275 quarb::record_refusal(format!(
276 "github: listing {path} truncated at page {page}: {e}"
277 ));
278 break;
279 }
280 Err(_) => break,
281 };
282 let n = items.len();
283 out.extend(items);
284 if n < 100 {
285 break;
286 }
287 }
288 out
289 }
290
291 fn push_node(
292 &self,
293 kind: Kind,
294 name: Option<String>,
295 parent: Option<NodeId>,
296 data: Option<Json>,
297 ) -> NodeId {
298 let mut nodes = self.nodes.borrow_mut();
299 let id = NodeId(nodes.len() as u64);
300 nodes.push(Node {
301 kind,
302 name,
303 parent,
304 children: RefCell::new(None),
305 data: RefCell::new(data.map(Rc::new)),
306 });
307 id
308 }
309
310 fn data(&self, node: NodeId) -> Option<Rc<Json>> {
311 self.nodes.borrow()[node.0 as usize].data.borrow().clone()
312 }
313
314 fn set_data(&self, node: NodeId, data: Json) -> Rc<Json> {
315 let rc = Rc::new(data);
316 *self.nodes.borrow()[node.0 as usize].data.borrow_mut() = Some(rc.clone());
317 rc
318 }
319
320 fn user_node(&self, login: &str, seed: Option<&Json>) -> NodeId {
323 if let Some(&n) = self.users.borrow().get(login) {
324 return n;
325 }
326 let n = self.push_node(
327 Kind::User {
328 login: login.to_string(),
329 },
330 Some(login.to_string()),
331 Some(NodeId(0)),
332 seed.cloned(),
333 );
334 self.users.borrow_mut().insert(login.to_string(), n);
335 n
336 }
337
338 fn user_data(&self, node: NodeId, login: &str) -> Option<Rc<Json>> {
342 if let Some(d) = self.data(node)
343 && d.get("created_at").is_some()
344 {
345 return Some(d);
346 }
347 let d = self.call(&format!("/users/{login}")).ok()?;
348 Some(self.set_data(node, d))
349 }
350
351 fn fetch_user(&self, login: &str) -> Option<NodeId> {
352 let n = self.user_node(login, None);
353 self.user_data(n, login).map(|_| n)
354 }
355
356 fn repo_node(&self, key: &str, seed: Option<&Json>) -> NodeId {
358 if let Some(&n) = self.repos.borrow().get(key) {
359 return n;
360 }
361 let (owner, name) = key.split_once('/').unwrap_or((key, key));
362 let parent = self.user_node(owner, None);
363 let n = self.push_node(
364 Kind::Repo {
365 key: key.to_string(),
366 },
367 Some(name.to_string()),
368 Some(parent),
369 seed.cloned(),
370 );
371 self.repos.borrow_mut().insert(key.to_string(), n);
372 n
373 }
374
375 fn repo_data(&self, node: NodeId, key: &str, full: bool) -> Option<Rc<Json>> {
378 if let Some(d) = self.data(node)
379 && (!full || d.get("subscribers_count").is_some())
380 {
381 return Some(d);
382 }
383 let d = self.call(&format!("/repos/{key}")).ok()?;
384 Some(self.set_data(node, d))
385 }
386
387 fn fetch_repo(&self, key: &str) -> Option<NodeId> {
388 let n = self.repo_node(key, None);
389 self.repo_data(n, key, true).map(|_| n)
390 }
391
392 fn item_node(&self, key: String, name: String, parent: Option<NodeId>, data: Json) -> NodeId {
394 if let Some(&n) = self.items.borrow().get(&key) {
395 return n;
396 }
397 let n = self.push_node(Kind::Item, Some(name), parent, Some(data));
398 self.items.borrow_mut().insert(key, n);
399 n
400 }
401
402 fn section_node(&self, repo_node: NodeId, repo: &str, sec: Sec) -> NodeId {
403 self.push_node(
404 Kind::Section {
405 repo: repo.to_string(),
406 sec,
407 },
408 Some(sec.name().to_string()),
409 Some(repo_node),
410 None,
411 )
412 }
413
414 fn dir_children(&self, parent: NodeId, repo: &str, path: &str) -> Vec<NodeId> {
416 let url = match path.is_empty() {
417 true => format!("/repos/{repo}/contents/"),
418 false => format!("/repos/{repo}/contents/{path}"),
419 };
420 let Ok(Json::Array(entries)) = self.call(&url) else {
421 return Vec::new();
422 };
423 entries
424 .iter()
425 .filter_map(|e| {
426 let name = e.get("name")?.as_str()?.to_string();
427 let epath = e.get("path")?.as_str()?.to_string();
428 let kind = match e.get("type")?.as_str()? {
429 "dir" => Kind::Dir {
430 repo: repo.to_string(),
431 path: epath,
432 },
433 _ => Kind::File {
434 repo: repo.to_string(),
435 path: epath,
436 },
437 };
438 Some(self.push_node(kind, Some(name), Some(parent), Some(e.clone())))
439 })
440 .collect()
441 }
442
443 fn file_content(&self, repo: &str, path: &str) -> Option<String> {
446 let key = format!("{repo}:{path}");
447 if let Some(c) = self.contents.borrow().get(&key) {
448 return c.clone();
449 }
450 let content = self
451 .call(&format!("/repos/{repo}/contents/{path}"))
452 .ok()
453 .and_then(|d| {
454 let b64 = d.get("content")?.as_str()?.to_string();
455 String::from_utf8(base64_decode(&b64)?).ok()
456 });
457 self.contents.borrow_mut().insert(key, content.clone());
458 content
459 }
460
461 fn section_children(&self, node: NodeId, repo: &str, sec: Sec) -> Vec<NodeId> {
464 match sec {
465 Sec::Files => self.dir_children(node, repo, ""),
466 Sec::Issues => self
467 .call_paged(&format!("/repos/{repo}/issues"))
468 .iter()
469 .filter(|i| i.get("pull_request").is_none())
471 .filter_map(|i| {
472 let num = i.get("number")?.as_i64()?;
473 Some(self.item_node(
474 format!("{repo}#{num}"),
475 num.to_string(),
476 Some(node),
477 i.clone(),
478 ))
479 })
480 .collect(),
481 Sec::Pulls => self
482 .call_paged(&format!("/repos/{repo}/pulls"))
483 .iter()
484 .filter_map(|i| {
485 let num = i.get("number")?.as_i64()?;
486 Some(self.item_node(
487 format!("{repo}#{num}"),
488 num.to_string(),
489 Some(node),
490 i.clone(),
491 ))
492 })
493 .collect(),
494 Sec::Releases => self
495 .call_paged(&format!("/repos/{repo}/releases"))
496 .iter()
497 .filter_map(|r| {
498 let tag = r.get("tag_name")?.as_str()?.to_string();
499 Some(self.item_node(format!("{repo}@{tag}"), tag, Some(node), r.clone()))
500 })
501 .collect(),
502 }
503 }
504
505 fn author_of(&self, data: &Json) -> Option<NodeId> {
507 let u = data.get("user").or_else(|| data.get("author"))?;
508 let login = u.get("login")?.as_str()?;
509 Some(self.user_node(login, Some(u)))
510 }
511}
512
513impl AstAdapter for GithubAdapter {
514 fn root(&self) -> NodeId {
515 NodeId(0)
516 }
517
518 fn children(&self, node: NodeId) -> Vec<NodeId> {
519 if let Some(c) = self.nodes.borrow()[node.0 as usize]
520 .children
521 .borrow()
522 .as_ref()
523 {
524 return c.clone();
525 }
526 enum Plan {
527 Root,
528 User(String),
529 Repo(String),
530 Section(String, Sec),
531 Dir(String, String),
532 Leaf,
533 }
534 let plan = match &self.nodes.borrow()[node.0 as usize].kind {
535 Kind::Root => Plan::Root,
536 Kind::User { login } => Plan::User(login.clone()),
537 Kind::Repo { key } => Plan::Repo(key.clone()),
538 Kind::Section { repo, sec } => Plan::Section(repo.clone(), *sec),
539 Kind::Dir { repo, path } => Plan::Dir(repo.clone(), path.clone()),
540 Kind::Item | Kind::File { .. } => Plan::Leaf,
541 };
542 let ids = match plan {
543 Plan::Root => self.anchor.clone(),
546 Plan::User(login) => self
547 .call_paged(&format!("/users/{login}/repos"))
548 .iter()
549 .filter_map(|r| {
550 let key = r.get("full_name")?.as_str()?;
551 Some(self.repo_node(key, Some(r)))
552 })
553 .collect(),
554 Plan::Repo(key) => Sec::ALL
555 .iter()
556 .map(|&sec| self.section_node(node, &key, sec))
557 .collect(),
558 Plan::Section(repo, sec) => self.section_children(node, &repo, sec),
559 Plan::Dir(repo, path) => self.dir_children(node, &repo, &path),
560 Plan::Leaf => Vec::new(),
561 };
562 *self.nodes.borrow()[node.0 as usize].children.borrow_mut() = Some(ids.clone());
563 ids
564 }
565
566 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
570 enum Plan {
571 Root,
572 User(String),
573 Section(String, Sec),
574 Other,
575 }
576 let plan = match &self.nodes.borrow()[node.0 as usize].kind {
577 Kind::Root => Plan::Root,
578 Kind::User { login } => Plan::User(login.clone()),
579 Kind::Section {
580 repo,
581 sec: sec @ (Sec::Issues | Sec::Pulls),
582 } => Plan::Section(repo.clone(), *sec),
583 _ => Plan::Other,
584 };
585 match plan {
586 Plan::Root => {
587 if let Some(&n) = self.users.borrow().get(name) {
588 return vec![n];
589 }
590 self.fetch_user(name).into_iter().collect()
591 }
592 Plan::User(login) => {
593 let key = format!("{login}/{name}");
594 if let Some(&n) = self.repos.borrow().get(&key) {
595 return vec![n];
596 }
597 self.fetch_repo(&key).into_iter().collect()
598 }
599 Plan::Section(repo, sec) => {
600 let Ok(num) = name.parse::<i64>() else {
601 return Vec::new();
602 };
603 if let Some(&n) = self.items.borrow().get(&format!("{repo}#{num}")) {
604 return vec![n];
605 }
606 let path = match sec {
607 Sec::Pulls => format!("/repos/{repo}/pulls/{num}"),
608 _ => format!("/repos/{repo}/issues/{num}"),
609 };
610 let Ok(d) = self.call(&path) else {
611 return Vec::new();
612 };
613 vec![self.item_node(format!("{repo}#{num}"), num.to_string(), Some(node), d)]
614 }
615 Plan::Other => {
616 self.children(node)
618 .into_iter()
619 .filter(|&c| self.name(c).as_deref() == Some(name))
620 .collect()
621 }
622 }
623 }
624
625 fn name(&self, node: NodeId) -> Option<String> {
626 self.nodes.borrow()[node.0 as usize].name.clone()
627 }
628
629 fn parent(&self, node: NodeId) -> Option<NodeId> {
630 self.nodes.borrow()[node.0 as usize].parent
631 }
632
633 fn traits(&self, node: NodeId) -> Vec<String> {
638 enum Shape {
639 Fixed(&'static str),
640 User(String),
641 Repo,
642 Item,
643 File,
644 }
645 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
646 Kind::Root => return Vec::new(),
647 Kind::Section { .. } => Shape::Fixed("section"),
648 Kind::Dir { .. } => Shape::Fixed("dir"),
649 Kind::User { login } => Shape::User(login.clone()),
650 Kind::Repo { .. } => Shape::Repo,
651 Kind::Item => Shape::Item,
652 Kind::File { .. } => Shape::File,
653 };
654 let data = self.data(node);
655 let d = data.as_deref();
656 let flag = |k: &str| d.and_then(|d| d.get(k)).and_then(|v| v.as_bool()) == Some(true);
657 let set = |k: &str| d.and_then(|d| d.get(k)).map(|v| !v.is_null()) == Some(true);
658 match shape {
659 Shape::Fixed(t) => vec![t.to_string()],
660 Shape::User(login) => {
661 let t = d
664 .and_then(|d| d.get("type"))
665 .and_then(|v| v.as_str())
666 .map(str::to_string)
667 .or_else(|| {
668 let n = *self.users.borrow().get(&login)?;
669 let d = self.user_data(n, &login)?;
670 Some(d.get("type")?.as_str()?.to_string())
671 })
672 .unwrap_or_else(|| "User".to_string());
673 match t.as_str() {
678 "Organization" => vec![
679 "organization".to_string(),
680 "org".to_string(),
681 "group".to_string(),
682 ],
683 _ => vec![t.to_lowercase()],
684 }
685 }
686 Shape::Repo => {
687 let mut ts = vec!["repo".to_string()];
688 for f in ["fork", "archived", "private", "is_template"] {
689 if flag(f) {
690 ts.push(f.trim_start_matches("is_").to_string());
691 }
692 }
693 if let Some(topics) = d.and_then(|d| d.get("topics")).and_then(|v| v.as_array()) {
694 ts.extend(topics.iter().filter_map(|t| t.as_str().map(str::to_string)));
695 }
696 ts
697 }
698 Shape::Item => {
699 let mut ts = Vec::new();
700 let kind = if d.is_some_and(|d| d.get("tag_name").is_some()) {
701 "release"
702 } else if d
703 .is_some_and(|d| d.get("head").is_some() || d.get("pull_request").is_some())
704 {
705 "pull"
706 } else {
707 "issue"
708 };
709 ts.push(kind.to_string());
710 if let Some(state) = d.and_then(|d| d.get("state")).and_then(|v| v.as_str()) {
711 ts.push(state.to_string());
712 }
713 if set("merged_at") {
714 ts.push("merged".to_string());
715 }
716 if flag("draft") {
717 ts.push("draft".to_string());
718 }
719 if flag("prerelease") {
720 ts.push("prerelease".to_string());
721 }
722 if let Some(labels) = d.and_then(|d| d.get("labels")).and_then(|v| v.as_array()) {
723 ts.extend(labels.iter().filter_map(|l| {
724 l.get("name").and_then(|v| v.as_str()).map(str::to_string)
725 }));
726 }
727 ts
728 }
729 Shape::File => {
730 let t = d
731 .and_then(|d| d.get("type"))
732 .and_then(|v| v.as_str())
733 .unwrap_or("file");
734 vec![t.to_string()]
735 }
736 }
737 }
738
739 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
742 enum Shape {
743 User(String),
744 Repo(String),
745 Item,
746 Entry,
747 }
748 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
749 Kind::User { login } => Shape::User(login.clone()),
750 Kind::Repo { key } => Shape::Repo(key.clone()),
751 Kind::Item => Shape::Item,
752 Kind::File { .. } | Kind::Dir { .. } => Shape::Entry,
753 _ => return None,
754 };
755 let at = |d: &Json, ptr: &str| d.pointer(ptr).and_then(json_scalar);
756 match shape {
757 Shape::User(login) => {
758 let d = self.user_data(node, &login)?;
759 let key = match name {
760 "login" => "/login",
761 "name" => "/name",
762 "bio" => "/bio",
763 "company" => "/company",
764 "location" => "/location",
765 "blog" => "/blog",
766 "email" => "/email",
767 "followers" => "/followers",
768 "following" => "/following",
769 "repos" => "/public_repos",
770 "gists" => "/public_gists",
771 "created" => "/created_at",
772 "updated" => "/updated_at",
773 "type" => "/type",
774 _ => return None,
775 };
776 at(&d, key)
777 }
778 Shape::Repo(rkey) => {
779 let full = matches!(name, "watchers" | "license");
782 let d = self.repo_data(node, &rkey, full)?;
783 match name {
784 "topics" => {
785 let ts = d.get("topics")?.as_array()?;
786 Some(Value::list(
787 ts.iter()
788 .filter_map(|t| Some(Value::Str(t.as_str()?.to_string())))
789 .collect(),
790 ))
791 }
792 "owner" => at(&d, "/owner/login"),
793 "parent" => {
796 let d = if d.get("parent").is_none()
797 && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
798 {
799 self.repo_data(node, &rkey, true)?
800 } else {
801 d
802 };
803 at(&d, "/parent/full_name")
804 }
805 "license" => at(&d, "/license/spdx_id"),
806 _ => {
807 let key = match name {
808 "name" => "/name",
809 "full-name" => "/full_name",
810 "description" => "/description",
811 "stars" => "/stargazers_count",
812 "forks" => "/forks_count",
813 "watchers" => "/subscribers_count",
814 "open-issues" => "/open_issues_count",
815 "language" => "/language",
816 "default-branch" => "/default_branch",
817 "homepage" => "/homepage",
818 "size" => "/size",
819 "created" => "/created_at",
820 "updated" => "/updated_at",
821 "pushed" => "/pushed_at",
822 _ => return None,
823 };
824 at(&d, key)
825 }
826 }
827 }
828 Shape::Item => {
829 let d = self.data(node)?;
830 match name {
831 "labels" => {
832 let ls = d.get("labels")?.as_array()?;
833 Some(Value::list(
834 ls.iter()
835 .filter_map(|l| {
836 Some(Value::Str(l.get("name")?.as_str()?.to_string()))
837 })
838 .collect(),
839 ))
840 }
841 "author" => at(&d, "/user/login").or_else(|| at(&d, "/author/login")),
842 "milestone" => at(&d, "/milestone/title"),
843 "base" => at(&d, "/base/ref"),
844 "head" => at(&d, "/head/ref"),
845 _ => {
846 let key = match name {
847 "number" => "/number",
848 "title" => "/title",
849 "state" => "/state",
850 "comments" => "/comments",
851 "created" => "/created_at",
852 "updated" => "/updated_at",
853 "closed" => "/closed_at",
854 "merged" => "/merged_at",
855 "tag" => "/tag_name",
856 "name" => "/name",
857 "published" => "/published_at",
858 _ => return None,
859 };
860 at(&d, key)
861 }
862 }
863 }
864 Shape::Entry => {
865 let d = self.data(node)?;
866 match name {
867 "size" => at(&d, "/size"),
868 "type" => at(&d, "/type"),
869 _ => None,
870 }
871 }
872 }
873 }
874
875 fn default_value(&self, node: NodeId) -> Option<Value> {
878 let (repo, path) = match &self.nodes.borrow()[node.0 as usize].kind {
879 Kind::File { repo, path } => (repo.clone(), path.clone()),
880 Kind::Item => {
881 let d = self.data(node)?;
882 let body = d.get("body")?.as_str()?;
883 return Some(Value::Str(body.to_string()));
884 }
885 _ => return None,
886 };
887 self.file_content(&repo, &path).map(Value::Str)
888 }
889
890 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
891 match key {
892 "path" => Some(Value::Str(self.locator(node))),
893 "url" => {
894 let d = self.data(node)?;
895 Some(Value::Str(d.get("html_url")?.as_str()?.to_string()))
896 }
897 _ => None,
898 }
899 }
900
901 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
904 let kind_key = match &self.nodes.borrow()[node.0 as usize].kind {
905 Kind::Repo { key } => Some(key.clone()),
906 Kind::Item => None,
907 _ => return None,
908 };
909 match (kind_key, property) {
910 (Some(key), "owner") => {
911 let d = self.repo_data(node, &key, false)?;
912 let login = d.pointer("/owner/login")?.as_str()?;
913 Some(self.user_node(login, d.get("owner")))
914 }
915 (Some(key), "parent") => {
916 let d = self.repo_data(node, &key, false)?;
917 let d = if d.get("parent").is_none()
918 && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
919 {
920 self.repo_data(node, &key, true)?
921 } else {
922 d
923 };
924 let pkey = d.pointer("/parent/full_name")?.as_str()?.to_string();
925 Some(self.repo_node(&pkey, d.get("parent")))
926 }
927 (None, "author") => {
928 let d = self.data(node)?;
929 self.author_of(&d)
930 }
931 _ => None,
932 }
933 }
934
935 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
939 enum Shape {
940 User(String),
941 Repo(String),
942 Item,
943 }
944 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
945 Kind::User { login } => Shape::User(login.clone()),
946 Kind::Repo { key } => Shape::Repo(key.clone()),
947 Kind::Item => Shape::Item,
948 _ => return Vec::new(),
949 };
950 let mut out = Vec::new();
951 match shape {
952 Shape::User(login) => {
953 for u in self.call_paged(&format!("/users/{login}/following")) {
954 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
955 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
956 }
957 }
958 for r in self.call_paged(&format!("/users/{login}/starred")) {
959 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
960 out.push(("starred".to_string(), self.repo_node(k, Some(&r))));
961 }
962 }
963 for o in self.call_paged(&format!("/users/{login}/orgs")) {
964 if let Some(l) = o.get("login").and_then(|v| v.as_str()) {
965 out.push(("org".to_string(), self.user_node(l, Some(&o))));
966 }
967 }
968 }
969 Shape::Repo(key) => {
970 if let Some(n) = self.resolve(node, "owner", None) {
971 out.push(("owner".to_string(), n));
972 }
973 if let Some(n) = self.resolve(node, "parent", None) {
974 out.push(("parent".to_string(), n));
975 }
976 for c in self.call_paged(&format!("/repos/{key}/contributors")) {
977 let Some(l) = c.get("login").and_then(|v| v.as_str()) else {
978 continue;
979 };
980 if let Some(n) = c.get("contributions").and_then(|v| v.as_i64()) {
981 self.contributions
982 .borrow_mut()
983 .insert(format!("{key}@{l}"), n);
984 }
985 out.push(("contributor".to_string(), self.user_node(l, Some(&c))));
986 }
987 }
988 Shape::Item => {
989 let Some(d) = self.data(node) else {
990 return out;
991 };
992 if let Some(n) = self.author_of(&d) {
993 out.push(("author".to_string(), n));
994 }
995 if let Some(assignees) = d.get("assignees").and_then(|v| v.as_array()) {
996 for a in assignees {
997 if let Some(l) = a.get("login").and_then(|v| v.as_str()) {
998 out.push(("assignee".to_string(), self.user_node(l, Some(a))));
999 }
1000 }
1001 }
1002 }
1003 }
1004 out
1005 }
1006
1007 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1012 enum Shape {
1013 User(String),
1014 Repo(String),
1015 }
1016 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
1017 Kind::User { login } => Shape::User(login.clone()),
1018 Kind::Repo { key } => Shape::Repo(key.clone()),
1019 _ => return Vec::new(),
1020 };
1021 let mut out = Vec::new();
1022 match shape {
1023 Shape::User(login) => {
1024 for u in self.call_paged(&format!("/users/{login}/followers")) {
1025 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1026 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
1027 }
1028 }
1029 let is_org = self.traits(node).contains(&"organization".to_string());
1030 if is_org {
1031 for m in self.call_paged(&format!("/orgs/{login}/members")) {
1032 if let Some(l) = m.get("login").and_then(|v| v.as_str()) {
1033 out.push(("org".to_string(), self.user_node(l, Some(&m))));
1034 }
1035 }
1036 }
1037 }
1038 Shape::Repo(key) => {
1039 for u in self.call_paged(&format!("/repos/{key}/stargazers")) {
1040 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1041 out.push(("starred".to_string(), self.user_node(l, Some(&u))));
1042 }
1043 }
1044 for r in self.call_paged(&format!("/repos/{key}/forks")) {
1045 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
1046 out.push(("parent".to_string(), self.repo_node(k, Some(&r))));
1047 }
1048 }
1049 }
1050 }
1051 out
1052 }
1053
1054 fn link_property(
1057 &self,
1058 source: NodeId,
1059 label: &str,
1060 target: NodeId,
1061 name: &str,
1062 ) -> Option<Value> {
1063 if label != "contributor" || name != "contributions" {
1064 return None;
1065 }
1066 let key = match &self.nodes.borrow()[source.0 as usize].kind {
1067 Kind::Repo { key } => key.clone(),
1068 _ => return None,
1069 };
1070 let login = match &self.nodes.borrow()[target.0 as usize].kind {
1071 Kind::User { login } => login.clone(),
1072 _ => return None,
1073 };
1074 self.contributions
1075 .borrow()
1076 .get(&format!("{key}@{login}"))
1077 .copied()
1078 .map(Value::Int)
1079 }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084 use super::*;
1085 use serde_json::json;
1086
1087 #[test]
1088 fn base64_roundtrip() {
1089 assert_eq!(
1090 base64_decode("aGVsbG8gcXVhcmI=").as_deref(),
1091 Some(b"hello quarb".as_slice())
1092 );
1093 assert_eq!(
1094 base64_decode("aGVsbG8g\ncXVhcmI=\n").as_deref(),
1095 Some(b"hello quarb".as_slice())
1096 );
1097 assert!(base64_decode("not!base64").is_none());
1098 }
1099
1100 #[test]
1101 fn timestamps_become_instants() {
1102 assert!(matches!(
1103 str_value("2011-01-25T18:44:36Z"),
1104 Value::Instant { .. }
1105 ));
1106 assert!(matches!(str_value("v1.0.0"), Value::Str(_)));
1107 }
1108
1109 #[test]
1110 fn item_traits_from_shape() {
1111 let a = GithubAdapter {
1112 gh: "false".into(),
1113 anchor: Vec::new(),
1114 nodes: RefCell::new(vec![Node {
1115 kind: Kind::Root,
1116 name: None,
1117 parent: None,
1118 children: RefCell::new(None),
1119 data: RefCell::new(None),
1120 }]),
1121 users: RefCell::new(HashMap::new()),
1122 repos: RefCell::new(HashMap::new()),
1123 items: RefCell::new(HashMap::new()),
1124 contributions: RefCell::new(HashMap::new()),
1125 contents: RefCell::new(HashMap::new()),
1126 };
1127 let issue = a.push_node(
1128 Kind::Item,
1129 Some("1".into()),
1130 None,
1131 Some(json!({"number": 1, "state": "open",
1132 "labels": [{"name": "bug"}, {"name": "good first issue"}]})),
1133 );
1134 assert_eq!(
1135 a.traits(issue),
1136 ["issue", "open", "bug", "good first issue"]
1137 );
1138 let pull = a.push_node(
1139 Kind::Item,
1140 Some("2".into()),
1141 None,
1142 Some(json!({"number": 2, "state": "closed", "head": {"ref": "x"},
1143 "merged_at": "2026-01-01T00:00:00Z", "labels": []})),
1144 );
1145 assert_eq!(a.traits(pull), ["pull", "closed", "merged"]);
1146 let release = a.push_node(
1147 Kind::Item,
1148 Some("v1".into()),
1149 None,
1150 Some(json!({"tag_name": "v1", "prerelease": true})),
1151 );
1152 assert_eq!(a.traits(release), ["release", "prerelease"]);
1153 }
1154
1155 #[test]
1156 fn repo_traits_include_flags_and_topics() {
1157 let a = GithubAdapter {
1158 gh: "false".into(),
1159 anchor: Vec::new(),
1160 nodes: RefCell::new(vec![Node {
1161 kind: Kind::Root,
1162 name: None,
1163 parent: None,
1164 children: RefCell::new(None),
1165 data: RefCell::new(None),
1166 }]),
1167 users: RefCell::new(HashMap::new()),
1168 repos: RefCell::new(HashMap::new()),
1169 items: RefCell::new(HashMap::new()),
1170 contributions: RefCell::new(HashMap::new()),
1171 contents: RefCell::new(HashMap::new()),
1172 };
1173 let r = a.push_node(
1174 Kind::Repo { key: "o/r".into() },
1175 Some("r".into()),
1176 None,
1177 Some(json!({"fork": true, "archived": false,
1178 "topics": ["cli", "rust"]})),
1179 );
1180 assert_eq!(a.traits(r), ["repo", "fork", "cli", "rust"]);
1181 }
1182
1183 #[test]
1184 fn target_needs_the_scheme() {
1185 assert!(matches!(
1186 GithubAdapter::connect("gh:torvalds"),
1187 Err(GithubError::Target(_))
1188 ));
1189 }
1190}