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> {
664 enum Shape {
665 Fixed(&'static str),
666 User(String),
667 Repo,
668 Item,
669 File,
670 }
671 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
672 Kind::Root => Shape::Fixed("forge"),
673 Kind::Section { .. } => Shape::Fixed("section"),
674 Kind::Dir { .. } => Shape::Fixed("dir"),
675 Kind::User { login } => Shape::User(login.clone()),
676 Kind::Repo { .. } => Shape::Repo,
677 Kind::Item => Shape::Item,
678 Kind::File { .. } => Shape::File,
679 };
680 let data = self.data(node);
681 let d = data.as_deref();
682 let flag = |k: &str| d.and_then(|d| d.get(k)).and_then(|v| v.as_bool()) == Some(true);
683 let set = |k: &str| d.and_then(|d| d.get(k)).map(|v| !v.is_null()) == Some(true);
684 match shape {
685 Shape::Fixed(t) => vec![t.to_string()],
686 Shape::User(login) => {
687 let t = d
690 .and_then(|d| d.get("type"))
691 .and_then(|v| v.as_str())
692 .map(str::to_string)
693 .or_else(|| {
694 let n = *self.users.borrow().get(&login)?;
695 let d = self.user_data(n, &login)?;
696 Some(d.get("type")?.as_str()?.to_string())
697 })
698 .unwrap_or_else(|| "User".to_string());
699 match t.as_str() {
704 "Organization" => vec![
705 "organization".to_string(),
706 "org".to_string(),
707 "group".to_string(),
708 ],
709 _ => vec![t.to_lowercase()],
710 }
711 }
712 Shape::Repo => {
713 let mut ts = vec!["repo".to_string()];
714 for f in ["fork", "archived", "private", "is_template"] {
715 if flag(f) {
716 ts.push(f.trim_start_matches("is_").to_string());
717 }
718 }
719 if let Some(topics) = d.and_then(|d| d.get("topics")).and_then(|v| v.as_array()) {
720 ts.extend(
721 topics
722 .iter()
723 .filter_map(|t| t.as_str().map(|t| format!("topic:{t}"))),
724 );
725 }
726 ts
727 }
728 Shape::Item => {
729 let mut ts = Vec::new();
730 let kind = if d.is_some_and(|d| d.get("tag_name").is_some()) {
731 "release"
732 } else if d
733 .is_some_and(|d| d.get("head").is_some() || d.get("pull_request").is_some())
734 {
735 "pull"
736 } else {
737 "issue"
738 };
739 ts.push(kind.to_string());
740 if let Some(state) = d.and_then(|d| d.get("state")).and_then(|v| v.as_str()) {
741 ts.push(state.to_string());
742 }
743 if set("merged_at") {
744 ts.push("merged".to_string());
745 }
746 if flag("draft") {
747 ts.push("draft".to_string());
748 }
749 if flag("prerelease") {
750 ts.push("prerelease".to_string());
751 }
752 if let Some(labels) = d.and_then(|d| d.get("labels")).and_then(|v| v.as_array()) {
753 ts.extend(labels.iter().filter_map(|l| {
754 l.get("name")
755 .and_then(|v| v.as_str())
756 .map(|l| format!("label:{l}"))
757 }));
758 }
759 ts
760 }
761 Shape::File => {
762 let t = d
763 .and_then(|d| d.get("type"))
764 .and_then(|v| v.as_str())
765 .unwrap_or("file");
766 vec![t.to_string()]
767 }
768 }
769 }
770
771 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
774 enum Shape {
775 User(String),
776 Repo(String),
777 Item,
778 Entry,
779 }
780 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
781 Kind::User { login } => Shape::User(login.clone()),
782 Kind::Repo { key } => Shape::Repo(key.clone()),
783 Kind::Item => Shape::Item,
784 Kind::File { .. } | Kind::Dir { .. } => Shape::Entry,
785 _ => return None,
786 };
787 let at = |d: &Json, ptr: &str| d.pointer(ptr).and_then(json_scalar);
788 match shape {
789 Shape::User(login) => {
790 let d = self.user_data(node, &login)?;
791 let key = match name {
792 "login" => "/login",
793 "name" => "/name",
794 "bio" => "/bio",
795 "company" => "/company",
796 "location" => "/location",
797 "blog" => "/blog",
798 "email" => "/email",
799 "followers" => "/followers",
800 "following" => "/following",
801 "repos" => "/public_repos",
802 "gists" => "/public_gists",
803 "created" => "/created_at",
804 "updated" => "/updated_at",
805 "type" => "/type",
806 _ => return None,
807 };
808 at(&d, key)
809 }
810 Shape::Repo(rkey) => {
811 let full = matches!(name, "watchers" | "license");
814 let d = self.repo_data(node, &rkey, full)?;
815 match name {
816 "topics" => {
817 let ts = d.get("topics")?.as_array()?;
818 Some(Value::list(
819 ts.iter()
820 .filter_map(|t| Some(Value::Str(t.as_str()?.to_string())))
821 .collect(),
822 ))
823 }
824 "owner" => at(&d, "/owner/login"),
825 "parent" => {
828 let d = if d.get("parent").is_none()
829 && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
830 {
831 self.repo_data(node, &rkey, true)?
832 } else {
833 d
834 };
835 at(&d, "/parent/full_name")
836 }
837 "license" => at(&d, "/license/spdx_id"),
838 _ => {
839 let key = match name {
840 "name" => "/name",
841 "full-name" => "/full_name",
842 "description" => "/description",
843 "stars" => "/stargazers_count",
844 "forks" => "/forks_count",
845 "watchers" => "/subscribers_count",
846 "open-issues" => "/open_issues_count",
847 "language" => "/language",
848 "default-branch" => "/default_branch",
849 "homepage" => "/homepage",
850 "size" => return at(&d, "/size").map(kib),
854 "created" => "/created_at",
855 "updated" => "/updated_at",
856 "pushed" => "/pushed_at",
857 _ => return None,
858 };
859 at(&d, key)
860 }
861 }
862 }
863 Shape::Item => {
864 let d = self.data(node)?;
865 match name {
866 "labels" => {
867 let ls = d.get("labels")?.as_array()?;
868 Some(Value::list(
869 ls.iter()
870 .filter_map(|l| {
871 Some(Value::Str(l.get("name")?.as_str()?.to_string()))
872 })
873 .collect(),
874 ))
875 }
876 "author" => at(&d, "/user/login").or_else(|| at(&d, "/author/login")),
877 "milestone" => at(&d, "/milestone/title"),
878 "base" => at(&d, "/base/ref"),
879 "head" => at(&d, "/head/ref"),
880 _ => {
881 let key = match name {
882 "number" => "/number",
883 "title" => "/title",
884 "state" => "/state",
885 "comments" => "/comments",
886 "created" => "/created_at",
887 "updated" => "/updated_at",
888 "closed" => "/closed_at",
889 "merged" => "/merged_at",
890 "tag" => "/tag_name",
891 "name" => "/name",
892 "published" => "/published_at",
893 _ => return None,
894 };
895 at(&d, key)
896 }
897 }
898 }
899 Shape::Entry => {
900 let d = self.data(node)?;
901 match name {
902 "size" => at(&d, "/size").map(bytes),
904 "type" => at(&d, "/type"),
905 _ => None,
906 }
907 }
908 }
909 }
910
911 fn default_value(&self, node: NodeId) -> Option<Value> {
914 let (repo, path) = match &self.nodes.borrow()[node.0 as usize].kind {
915 Kind::File { repo, path } => (repo.clone(), path.clone()),
916 Kind::Item => {
917 let d = self.data(node)?;
918 let body = d.get("body")?.as_str()?;
919 return Some(Value::Str(body.to_string()));
920 }
921 _ => return None,
922 };
923 self.file_content(&repo, &path).map(Value::Str)
924 }
925
926 fn metadata_catalogue(&self) -> &'static [&'static str] {
931 &["path", "url", "contributor"]
932 }
933
934 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
935 match key {
936 "path" => Some(Value::Str(self.locator(node))),
937 "url" => {
938 let d = self.data(node)?;
939 Some(Value::Str(d.get("html_url")?.as_str()?.to_string()))
940 }
941 _ => None,
942 }
943 }
944
945 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
948 let kind_key = match &self.nodes.borrow()[node.0 as usize].kind {
949 Kind::Repo { key } => Some(key.clone()),
950 Kind::Item => None,
951 _ => return None,
952 };
953 match (kind_key, property) {
954 (Some(key), "owner") => {
955 let d = self.repo_data(node, &key, false)?;
956 let login = d.pointer("/owner/login")?.as_str()?;
957 Some(self.user_node(login, d.get("owner")))
958 }
959 (Some(key), "parent") => {
960 let d = self.repo_data(node, &key, false)?;
961 let d = if d.get("parent").is_none()
962 && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
963 {
964 self.repo_data(node, &key, true)?
965 } else {
966 d
967 };
968 let pkey = d.pointer("/parent/full_name")?.as_str()?.to_string();
969 Some(self.repo_node(&pkey, d.get("parent")))
970 }
971 (None, "author") => {
972 let d = self.data(node)?;
973 self.author_of(&d)
974 }
975 _ => None,
976 }
977 }
978
979 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
983 enum Shape {
984 User(String),
985 Repo(String),
986 Item,
987 }
988 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
989 Kind::User { login } => Shape::User(login.clone()),
990 Kind::Repo { key } => Shape::Repo(key.clone()),
991 Kind::Item => Shape::Item,
992 _ => return Vec::new(),
993 };
994 let mut out = Vec::new();
995 match shape {
996 Shape::User(login) => {
997 for u in self.call_paged(&format!("/users/{login}/following")) {
998 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
999 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
1000 }
1001 }
1002 for r in self.call_paged(&format!("/users/{login}/starred")) {
1003 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
1004 out.push(("starred".to_string(), self.repo_node(k, Some(&r))));
1005 }
1006 }
1007 for o in self.call_paged(&format!("/users/{login}/orgs")) {
1008 if let Some(l) = o.get("login").and_then(|v| v.as_str()) {
1009 out.push(("org".to_string(), self.user_node(l, Some(&o))));
1010 }
1011 }
1012 }
1013 Shape::Repo(key) => {
1014 if let Some(n) = self.resolve(node, "owner", None) {
1015 out.push(("owner".to_string(), n));
1016 }
1017 if let Some(n) = self.resolve(node, "parent", None) {
1018 out.push(("parent".to_string(), n));
1019 }
1020 for c in self.call_paged(&format!("/repos/{key}/contributors")) {
1021 let Some(l) = c.get("login").and_then(|v| v.as_str()) else {
1022 continue;
1023 };
1024 if let Some(n) = c.get("contributions").and_then(|v| v.as_i64()) {
1025 self.contributions
1026 .borrow_mut()
1027 .insert(format!("{key}@{l}"), n);
1028 }
1029 out.push(("contributor".to_string(), self.user_node(l, Some(&c))));
1030 }
1031 }
1032 Shape::Item => {
1033 let Some(d) = self.data(node) else {
1034 return out;
1035 };
1036 if let Some(n) = self.author_of(&d) {
1037 out.push(("author".to_string(), n));
1038 }
1039 if let Some(assignees) = d.get("assignees").and_then(|v| v.as_array()) {
1040 for a in assignees {
1041 if let Some(l) = a.get("login").and_then(|v| v.as_str()) {
1042 out.push(("assignee".to_string(), self.user_node(l, Some(a))));
1043 }
1044 }
1045 }
1046 }
1047 }
1048 out
1049 }
1050
1051 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1056 enum Shape {
1057 User(String),
1058 Repo(String),
1059 }
1060 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
1061 Kind::User { login } => Shape::User(login.clone()),
1062 Kind::Repo { key } => Shape::Repo(key.clone()),
1063 _ => return Vec::new(),
1064 };
1065 let mut out = Vec::new();
1066 match shape {
1067 Shape::User(login) => {
1068 for u in self.call_paged(&format!("/users/{login}/followers")) {
1069 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1070 out.push(("follows".to_string(), self.user_node(l, Some(&u))));
1071 }
1072 }
1073 let is_org = self.traits(node).contains(&"organization".to_string());
1074 if is_org {
1075 for m in self.call_paged(&format!("/orgs/{login}/members")) {
1076 if let Some(l) = m.get("login").and_then(|v| v.as_str()) {
1077 out.push(("org".to_string(), self.user_node(l, Some(&m))));
1078 }
1079 }
1080 }
1081 }
1082 Shape::Repo(key) => {
1083 for u in self.call_paged(&format!("/repos/{key}/stargazers")) {
1084 if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1085 out.push(("starred".to_string(), self.user_node(l, Some(&u))));
1086 }
1087 }
1088 for r in self.call_paged(&format!("/repos/{key}/forks")) {
1089 if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
1090 out.push(("parent".to_string(), self.repo_node(k, Some(&r))));
1091 }
1092 }
1093 }
1094 }
1095 out
1096 }
1097
1098 fn link_property(
1101 &self,
1102 source: NodeId,
1103 label: &str,
1104 target: NodeId,
1105 name: &str,
1106 ) -> Option<Value> {
1107 if label != "contributor" || name != "contributions" {
1108 return None;
1109 }
1110 let key = match &self.nodes.borrow()[source.0 as usize].kind {
1111 Kind::Repo { key } => key.clone(),
1112 _ => return None,
1113 };
1114 let login = match &self.nodes.borrow()[target.0 as usize].kind {
1115 Kind::User { login } => login.clone(),
1116 _ => return None,
1117 };
1118 self.contributions
1119 .borrow()
1120 .get(&format!("{key}@{login}"))
1121 .copied()
1122 .map(Value::Int)
1123 }
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128 use super::*;
1129 use serde_json::json;
1130
1131 #[test]
1132 fn base64_roundtrip() {
1133 assert_eq!(
1134 base64_decode("aGVsbG8gcXVhcmI=").as_deref(),
1135 Some(b"hello quarb".as_slice())
1136 );
1137 assert_eq!(
1138 base64_decode("aGVsbG8g\ncXVhcmI=\n").as_deref(),
1139 Some(b"hello quarb".as_slice())
1140 );
1141 assert!(base64_decode("not!base64").is_none());
1142 }
1143
1144 #[test]
1145 fn timestamps_become_instants() {
1146 assert!(matches!(
1147 str_value("2011-01-25T18:44:36Z"),
1148 Value::Instant { .. }
1149 ));
1150 assert!(matches!(str_value("v1.0.0"), Value::Str(_)));
1151 }
1152
1153 #[test]
1154 fn item_traits_from_shape() {
1155 let a = GithubAdapter {
1156 gh: "false".into(),
1157 anchor: Vec::new(),
1158 nodes: RefCell::new(vec![Node {
1159 kind: Kind::Root,
1160 name: None,
1161 parent: None,
1162 children: RefCell::new(None),
1163 data: RefCell::new(None),
1164 }]),
1165 users: RefCell::new(HashMap::new()),
1166 repos: RefCell::new(HashMap::new()),
1167 items: RefCell::new(HashMap::new()),
1168 contributions: RefCell::new(HashMap::new()),
1169 contents: RefCell::new(HashMap::new()),
1170 };
1171 let issue = a.push_node(
1172 Kind::Item,
1173 Some("1".into()),
1174 None,
1175 Some(json!({"number": 1, "state": "open",
1176 "labels": [{"name": "bug"}, {"name": "good first issue"}]})),
1177 );
1178 assert_eq!(
1179 a.traits(issue),
1180 ["issue", "open", "label:bug", "label:good first issue"]
1181 );
1182 let pull = a.push_node(
1183 Kind::Item,
1184 Some("2".into()),
1185 None,
1186 Some(json!({"number": 2, "state": "closed", "head": {"ref": "x"},
1187 "merged_at": "2026-01-01T00:00:00Z", "labels": []})),
1188 );
1189 assert_eq!(a.traits(pull), ["pull", "closed", "merged"]);
1190 let release = a.push_node(
1191 Kind::Item,
1192 Some("v1".into()),
1193 None,
1194 Some(json!({"tag_name": "v1", "prerelease": true})),
1195 );
1196 assert_eq!(a.traits(release), ["release", "prerelease"]);
1197 }
1198
1199 #[test]
1200 fn repo_traits_include_flags_and_topics() {
1201 let a = GithubAdapter {
1202 gh: "false".into(),
1203 anchor: Vec::new(),
1204 nodes: RefCell::new(vec![Node {
1205 kind: Kind::Root,
1206 name: None,
1207 parent: None,
1208 children: RefCell::new(None),
1209 data: RefCell::new(None),
1210 }]),
1211 users: RefCell::new(HashMap::new()),
1212 repos: RefCell::new(HashMap::new()),
1213 items: RefCell::new(HashMap::new()),
1214 contributions: RefCell::new(HashMap::new()),
1215 contents: RefCell::new(HashMap::new()),
1216 };
1217 let r = a.push_node(
1218 Kind::Repo { key: "o/r".into() },
1219 Some("r".into()),
1220 None,
1221 Some(json!({"fork": true, "archived": false,
1222 "topics": ["cli", "rust"]})),
1223 );
1224 assert_eq!(a.traits(r), ["repo", "fork", "topic:cli", "topic:rust"]);
1225 }
1226
1227 #[test]
1228 fn target_needs_the_scheme() {
1229 assert!(matches!(
1230 GithubAdapter::connect("gh:torvalds"),
1231 Err(GithubError::Target(_))
1232 ));
1233 }
1234}