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