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_catalogue(&self) -> &'static [&'static str] {
930
931 &["path", "url", "contributor"]
932
933 }
934
935
936 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
937 match key {
938 "path" => Some(Value::Str(self.locator(node))),
939 "url" => {
940 let d = self.data(node)?;
941 Some(Value::Str(d.get("html_url")?.as_str()?.to_string()))
942 }
943 _ => None,
944 }
945 }
946
947 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
950 let kind_key = match &self.nodes.borrow()[node.0 as usize].kind {
951 Kind::Repo { key } => Some(key.clone()),
952 Kind::Item => None,
953 _ => return None,
954 };
955 match (kind_key, property) {
956 (Some(key), "owner") => {
957 let d = self.repo_data(node, &key, false)?;
958 let login = d.pointer("/owner/login")?.as_str()?;
959 Some(self.user_node(login, d.get("owner")))
960 }
961 (Some(key), "parent") => {
962 let d = self.repo_data(node, &key, false)?;
963 let d = if d.get("parent").is_none()
964 && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
965 {
966 self.repo_data(node, &key, true)?
967 } else {
968 d
969 };
970 let pkey = d.pointer("/parent/full_name")?.as_str()?.to_string();
971 Some(self.repo_node(&pkey, d.get("parent")))
972 }
973 (None, "author") => {
974 let d = self.data(node)?;
975 self.author_of(&d)
976 }
977 _ => None,
978 }
979 }
980
981 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
985 enum Shape {
986 User(String),
987 Repo(String),
988 Item,
989 }
990 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
991 Kind::User { login } => Shape::User(login.clone()),
992 Kind::Repo { key } => Shape::Repo(key.clone()),
993 Kind::Item => Shape::Item,
994 _ => return Vec::new(),
995 };
996 let mut out = Vec::new();
997 match shape {
998 Shape::User(login) => {
999 for u in self.call_paged(&format!("/users/{login}/following")) {
1000 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1001 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
1002 }
1003 }
1004 for r in self.call_paged(&format!("/users/{login}/starred")) {
1005 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
1006 out.push(("starred".to_string(), self.repo_node(k, Some(&r))));
1007 }
1008 }
1009 for o in self.call_paged(&format!("/users/{login}/orgs")) {
1010 if let Some(l) = o.get("login").and_then(|v| v.as_str()) {
1011 out.push(("org".to_string(), self.user_node(l, Some(&o))));
1012 }
1013 }
1014 }
1015 Shape::Repo(key) => {
1016 if let Some(n) = self.resolve(node, "owner", None) {
1017 out.push(("owner".to_string(), n));
1018 }
1019 if let Some(n) = self.resolve(node, "parent", None) {
1020 out.push(("parent".to_string(), n));
1021 }
1022 for c in self.call_paged(&format!("/repos/{key}/contributors")) {
1023 let Some(l) = c.get("login").and_then(|v| v.as_str()) else {
1024 continue;
1025 };
1026 if let Some(n) = c.get("contributions").and_then(|v| v.as_i64()) {
1027 self.contributions
1028 .borrow_mut()
1029 .insert(format!("{key}@{l}"), n);
1030 }
1031 out.push(("contributor".to_string(), self.user_node(l, Some(&c))));
1032 }
1033 }
1034 Shape::Item => {
1035 let Some(d) = self.data(node) else {
1036 return out;
1037 };
1038 if let Some(n) = self.author_of(&d) {
1039 out.push(("author".to_string(), n));
1040 }
1041 if let Some(assignees) = d.get("assignees").and_then(|v| v.as_array()) {
1042 for a in assignees {
1043 if let Some(l) = a.get("login").and_then(|v| v.as_str()) {
1044 out.push(("assignee".to_string(), self.user_node(l, Some(a))));
1045 }
1046 }
1047 }
1048 }
1049 }
1050 out
1051 }
1052
1053 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1058 enum Shape {
1059 User(String),
1060 Repo(String),
1061 }
1062 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
1063 Kind::User { login } => Shape::User(login.clone()),
1064 Kind::Repo { key } => Shape::Repo(key.clone()),
1065 _ => return Vec::new(),
1066 };
1067 let mut out = Vec::new();
1068 match shape {
1069 Shape::User(login) => {
1070 for u in self.call_paged(&format!("/users/{login}/followers")) {
1071 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1072 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
1073 }
1074 }
1075 let is_org = self.traits(node).contains(&"organization".to_string());
1076 if is_org {
1077 for m in self.call_paged(&format!("/orgs/{login}/members")) {
1078 if let Some(l) = m.get("login").and_then(|v| v.as_str()) {
1079 out.push(("org".to_string(), self.user_node(l, Some(&m))));
1080 }
1081 }
1082 }
1083 }
1084 Shape::Repo(key) => {
1085 for u in self.call_paged(&format!("/repos/{key}/stargazers")) {
1086 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1087 out.push(("starred".to_string(), self.user_node(l, Some(&u))));
1088 }
1089 }
1090 for r in self.call_paged(&format!("/repos/{key}/forks")) {
1091 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
1092 out.push(("parent".to_string(), self.repo_node(k, Some(&r))));
1093 }
1094 }
1095 }
1096 }
1097 out
1098 }
1099
1100 fn link_property(
1103 &self,
1104 source: NodeId,
1105 label: &str,
1106 target: NodeId,
1107 name: &str,
1108 ) -> Option<Value> {
1109 if label != "contributor" || name != "contributions" {
1110 return None;
1111 }
1112 let key = match &self.nodes.borrow()[source.0 as usize].kind {
1113 Kind::Repo { key } => key.clone(),
1114 _ => return None,
1115 };
1116 let login = match &self.nodes.borrow()[target.0 as usize].kind {
1117 Kind::User { login } => login.clone(),
1118 _ => return None,
1119 };
1120 self.contributions
1121 .borrow()
1122 .get(&format!("{key}@{login}"))
1123 .copied()
1124 .map(Value::Int)
1125 }
1126}
1127
1128#[cfg(test)]
1129mod tests {
1130 use super::*;
1131 use serde_json::json;
1132
1133 #[test]
1134 fn base64_roundtrip() {
1135 assert_eq!(
1136 base64_decode("aGVsbG8gcXVhcmI=").as_deref(),
1137 Some(b"hello quarb".as_slice())
1138 );
1139 assert_eq!(
1140 base64_decode("aGVsbG8g\ncXVhcmI=\n").as_deref(),
1141 Some(b"hello quarb".as_slice())
1142 );
1143 assert!(base64_decode("not!base64").is_none());
1144 }
1145
1146 #[test]
1147 fn timestamps_become_instants() {
1148 assert!(matches!(
1149 str_value("2011-01-25T18:44:36Z"),
1150 Value::Instant { .. }
1151 ));
1152 assert!(matches!(str_value("v1.0.0"), Value::Str(_)));
1153 }
1154
1155 #[test]
1156 fn item_traits_from_shape() {
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 issue = a.push_node(
1174 Kind::Item,
1175 Some("1".into()),
1176 None,
1177 Some(json!({"number": 1, "state": "open",
1178 "labels": [{"name": "bug"}, {"name": "good first issue"}]})),
1179 );
1180 assert_eq!(
1181 a.traits(issue),
1182 ["issue", "open", "label:bug", "label:good first issue"]
1183 );
1184 let pull = a.push_node(
1185 Kind::Item,
1186 Some("2".into()),
1187 None,
1188 Some(json!({"number": 2, "state": "closed", "head": {"ref": "x"},
1189 "merged_at": "2026-01-01T00:00:00Z", "labels": []})),
1190 );
1191 assert_eq!(a.traits(pull), ["pull", "closed", "merged"]);
1192 let release = a.push_node(
1193 Kind::Item,
1194 Some("v1".into()),
1195 None,
1196 Some(json!({"tag_name": "v1", "prerelease": true})),
1197 );
1198 assert_eq!(a.traits(release), ["release", "prerelease"]);
1199 }
1200
1201 #[test]
1202 fn repo_traits_include_flags_and_topics() {
1203 let a = GithubAdapter {
1204 gh: "false".into(),
1205 anchor: Vec::new(),
1206 nodes: RefCell::new(vec![Node {
1207 kind: Kind::Root,
1208 name: None,
1209 parent: None,
1210 children: RefCell::new(None),
1211 data: RefCell::new(None),
1212 }]),
1213 users: RefCell::new(HashMap::new()),
1214 repos: RefCell::new(HashMap::new()),
1215 items: RefCell::new(HashMap::new()),
1216 contributions: RefCell::new(HashMap::new()),
1217 contents: RefCell::new(HashMap::new()),
1218 };
1219 let r = a.push_node(
1220 Kind::Repo { key: "o/r".into() },
1221 Some("r".into()),
1222 None,
1223 Some(json!({"fork": true, "archived": false,
1224 "topics": ["cli", "rust"]})),
1225 );
1226 assert_eq!(a.traits(r), ["repo", "fork", "topic:cli", "topic:rust"]);
1227 }
1228
1229 #[test]
1230 fn target_needs_the_scheme() {
1231 assert!(matches!(
1232 GithubAdapter::connect("gh:torvalds"),
1233 Err(GithubError::Target(_))
1234 ));
1235 }
1236}