Skip to main content

quarb_github/
lib.rs

1//! GitHub API adapter for the Quarb query engine.
2//!
3//! GitHub is three shapes at once, and the arbor keeps each in
4//! its native register. The **tree** mirrors github.com URLs:
5//! users and organizations at the root (addressed by login —
6//! the root does not enumerate GitHub), their repositories as
7//! direct children (`/torvalds/linux`), and each repository's
8//! sections below (`files`, `issues`, `pulls`, `releases`).
9//! File content, issue bodies, and release notes are node
10//! *values*. The **social graph** is edge fabric, every label
11//! API-backed in both directions: `follows` (`<-follows` are
12//! the followers), `starred` (`<-starred` the stargazers),
13//! `owner`, `parent` (`<-parent` the forks), `author` /
14//! `assignee`, `org` (`<-org` the members), and `contributor`
15//! — whose edge carries data: `$-::contributions`. The
16//! **metadata** splits by kind: counts and scalars are
17//! properties (`::stars`, `::language`, timestamps as
18//! instants), while boolean flags (`<fork>`, `<archived>`,
19//! `<open>`, `<merged>`), issue labels (`<bug>`), and repo
20//! topics (`<cli>`) are traits — unary facts filter, values
21//! compare.
22//!
23//! Direct addressing skips enumeration: `/rust-lang/rust` is
24//! one GET, `/rust-lang/rust/issues/12345` another (closed
25//! issues included — enumeration lists open ones, the API's
26//! default). Everything loads lazily and is cached for the
27//! session; listing costs one paginated sweep per touched
28//! container. Read-only — the adapter only ever GETs.
29//!
30//! **Transport and auth**: the adapter shells out to `gh api`,
31//! so authentication, hosts, and rate limits behave exactly as
32//! the gh CLI does. Target: `github:` (whole GitHub, address
33//! from the root), `github:OWNER`, or `github:OWNER/REPO` to
34//! anchor the arbor. `QUARB_GH` overrides the binary.
35
36use 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/// An error connecting to or reading GitHub.
44#[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/// A repository's fixed sections.
53#[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
73/// What a node is. Entity JSON hangs off the node (lazily
74/// completed for users and repositories, whose edge-listing
75/// appearances are partial objects).
76enum Kind {
77    Root,
78    /// A user or organization, by login.
79    User {
80        login: String,
81    },
82    /// A repository; `key` is `owner/name`.
83    Repo {
84        key: String,
85    },
86    Section {
87        repo: String,
88        sec: Sec,
89    },
90    /// An issue, pull request, or release (told apart by their
91    /// JSON: releases carry `tag_name`, pulls `head` or
92    /// `pull_request`).
93    Item,
94    /// A directory in a repository's default-branch tree.
95    Dir {
96        repo: String,
97        path: String,
98    },
99    /// A file (or symlink/submodule); content is the value.
100    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
114/// A string value; RFC 3339 timestamps become instants.
115fn 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
140/// Standard base64 (the contents API's encoding; padding
141/// required, whitespace ignored).
142fn 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
166/// GitHub, exposed as an arbor.
167pub struct GithubAdapter {
168    gh: String,
169    /// The anchor's root children (empty for a bare `github:`).
170    anchor: Vec<NodeId>,
171    nodes: RefCell<Vec<Node>>,
172    users: RefCell<HashMap<String, NodeId>>,
173    repos: RefCell<HashMap<String, NodeId>>,
174    /// `owner/name#number` → issue/pull node.
175    items: RefCell<HashMap<String, NodeId>>,
176    /// `owner/name@login` → contributions (edge data).
177    contributions: RefCell<HashMap<String, i64>>,
178    /// `owner/name:path` → decoded file content.
179    contents: RefCell<HashMap<String, Option<String>>>,
180}
181
182impl GithubAdapter {
183    /// Connect to `github:`, `github:OWNER`, or
184    /// `github:OWNER/REPO`.
185    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                // Probe: auth and reachability surface here.
210                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    /// A human-readable locator: the github.com-shaped path.
232    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    /// A fully paginated array endpoint. A failure on the first
261    /// page is an empty listing (the `children` contract); a
262    /// failure mid-pagination fails loud rather than cache a
263    /// silently-short answer.
264    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 => {
273                    panic!("github: listing {path} truncated mid-pagination: {e}")
274                }
275                Err(_) => break,
276            };
277            let n = items.len();
278            out.extend(items);
279            if n < 100 {
280                break;
281            }
282        }
283        out
284    }
285
286    fn push_node(
287        &self,
288        kind: Kind,
289        name: Option<String>,
290        parent: Option<NodeId>,
291        data: Option<Json>,
292    ) -> NodeId {
293        let mut nodes = self.nodes.borrow_mut();
294        let id = NodeId(nodes.len() as u64);
295        nodes.push(Node {
296            kind,
297            name,
298            parent,
299            children: RefCell::new(None),
300            data: RefCell::new(data.map(Rc::new)),
301        });
302        id
303    }
304
305    fn data(&self, node: NodeId) -> Option<Rc<Json>> {
306        self.nodes.borrow()[node.0 as usize].data.borrow().clone()
307    }
308
309    fn set_data(&self, node: NodeId, data: Json) -> Rc<Json> {
310        let rc = Rc::new(data);
311        *self.nodes.borrow()[node.0 as usize].data.borrow_mut() = Some(rc.clone());
312        rc
313    }
314
315    /// The user node for a login, interned once; `seed` is a
316    /// possibly-partial object from an edge listing.
317    fn user_node(&self, login: &str, seed: Option<&Json>) -> NodeId {
318        if let Some(&n) = self.users.borrow().get(login) {
319            return n;
320        }
321        let n = self.push_node(
322            Kind::User {
323                login: login.to_string(),
324            },
325            Some(login.to_string()),
326            Some(NodeId(0)),
327            seed.cloned(),
328        );
329        self.users.borrow_mut().insert(login.to_string(), n);
330        n
331    }
332
333    /// A user's data, completed to the full profile when the
334    /// cached object is a partial edge-listing one (partials
335    /// lack `created_at`).
336    fn user_data(&self, node: NodeId, login: &str) -> Option<Rc<Json>> {
337        if let Some(d) = self.data(node)
338            && d.get("created_at").is_some()
339        {
340            return Some(d);
341        }
342        let d = self.call(&format!("/users/{login}")).ok()?;
343        Some(self.set_data(node, d))
344    }
345
346    fn fetch_user(&self, login: &str) -> Option<NodeId> {
347        let n = self.user_node(login, None);
348        self.user_data(n, login).map(|_| n)
349    }
350
351    /// The repo node for `owner/name`, interned once.
352    fn repo_node(&self, key: &str, seed: Option<&Json>) -> NodeId {
353        if let Some(&n) = self.repos.borrow().get(key) {
354            return n;
355        }
356        let (owner, name) = key.split_once('/').unwrap_or((key, key));
357        let parent = self.user_node(owner, None);
358        let n = self.push_node(
359            Kind::Repo {
360                key: key.to_string(),
361            },
362            Some(name.to_string()),
363            Some(parent),
364            seed.cloned(),
365        );
366        self.repos.borrow_mut().insert(key.to_string(), n);
367        n
368    }
369
370    /// A repo's data; refetched singly when a needed field is
371    /// missing from a listing object (`full` forces that).
372    fn repo_data(&self, node: NodeId, key: &str, full: bool) -> Option<Rc<Json>> {
373        if let Some(d) = self.data(node)
374            && (!full || d.get("subscribers_count").is_some())
375        {
376            return Some(d);
377        }
378        let d = self.call(&format!("/repos/{key}")).ok()?;
379        Some(self.set_data(node, d))
380    }
381
382    fn fetch_repo(&self, key: &str) -> Option<NodeId> {
383        let n = self.repo_node(key, None);
384        self.repo_data(n, key, true).map(|_| n)
385    }
386
387    /// An issue/pull/release node, interned by a repo-scoped key.
388    fn item_node(&self, key: String, name: String, parent: Option<NodeId>, data: Json) -> NodeId {
389        if let Some(&n) = self.items.borrow().get(&key) {
390            return n;
391        }
392        let n = self.push_node(Kind::Item, Some(name), parent, Some(data));
393        self.items.borrow_mut().insert(key, n);
394        n
395    }
396
397    fn section_node(&self, repo_node: NodeId, repo: &str, sec: Sec) -> NodeId {
398        self.push_node(
399            Kind::Section {
400                repo: repo.to_string(),
401                sec,
402            },
403            Some(sec.name().to_string()),
404            Some(repo_node),
405            None,
406        )
407    }
408
409    /// A directory listing (the contents API), as Dir/File nodes.
410    fn dir_children(&self, parent: NodeId, repo: &str, path: &str) -> Vec<NodeId> {
411        let url = match path.is_empty() {
412            true => format!("/repos/{repo}/contents/"),
413            false => format!("/repos/{repo}/contents/{path}"),
414        };
415        let Ok(Json::Array(entries)) = self.call(&url) else {
416            return Vec::new();
417        };
418        entries
419            .iter()
420            .filter_map(|e| {
421                let name = e.get("name")?.as_str()?.to_string();
422                let epath = e.get("path")?.as_str()?.to_string();
423                let kind = match e.get("type")?.as_str()? {
424                    "dir" => Kind::Dir {
425                        repo: repo.to_string(),
426                        path: epath,
427                    },
428                    _ => Kind::File {
429                        repo: repo.to_string(),
430                        path: epath,
431                    },
432                };
433                Some(self.push_node(kind, Some(name), Some(parent), Some(e.clone())))
434            })
435            .collect()
436    }
437
438    /// A file's decoded content (one GET, cached; `None` for
439    /// binary or oversized files).
440    fn file_content(&self, repo: &str, path: &str) -> Option<String> {
441        let key = format!("{repo}:{path}");
442        if let Some(c) = self.contents.borrow().get(&key) {
443            return c.clone();
444        }
445        let content = self
446            .call(&format!("/repos/{repo}/contents/{path}"))
447            .ok()
448            .and_then(|d| {
449                let b64 = d.get("content")?.as_str()?.to_string();
450                String::from_utf8(base64_decode(&b64)?).ok()
451            });
452        self.contents.borrow_mut().insert(key, content.clone());
453        content
454    }
455
456    /// List a section's items (issues and pulls list the open
457    /// ones — the API's default; direct addressing reaches any).
458    fn section_children(&self, node: NodeId, repo: &str, sec: Sec) -> Vec<NodeId> {
459        match sec {
460            Sec::Files => self.dir_children(node, repo, ""),
461            Sec::Issues => self
462                .call_paged(&format!("/repos/{repo}/issues"))
463                .iter()
464                // The issues listing interleaves pull requests.
465                .filter(|i| i.get("pull_request").is_none())
466                .filter_map(|i| {
467                    let num = i.get("number")?.as_i64()?;
468                    Some(self.item_node(
469                        format!("{repo}#{num}"),
470                        num.to_string(),
471                        Some(node),
472                        i.clone(),
473                    ))
474                })
475                .collect(),
476            Sec::Pulls => self
477                .call_paged(&format!("/repos/{repo}/pulls"))
478                .iter()
479                .filter_map(|i| {
480                    let num = i.get("number")?.as_i64()?;
481                    Some(self.item_node(
482                        format!("{repo}#{num}"),
483                        num.to_string(),
484                        Some(node),
485                        i.clone(),
486                    ))
487                })
488                .collect(),
489            Sec::Releases => self
490                .call_paged(&format!("/repos/{repo}/releases"))
491                .iter()
492                .filter_map(|r| {
493                    let tag = r.get("tag_name")?.as_str()?.to_string();
494                    Some(self.item_node(format!("{repo}@{tag}"), tag, Some(node), r.clone()))
495                })
496                .collect(),
497        }
498    }
499
500    /// The item's author edge target.
501    fn author_of(&self, data: &Json) -> Option<NodeId> {
502        let u = data.get("user").or_else(|| data.get("author"))?;
503        let login = u.get("login")?.as_str()?;
504        Some(self.user_node(login, Some(u)))
505    }
506}
507
508impl AstAdapter for GithubAdapter {
509    fn root(&self) -> NodeId {
510        NodeId(0)
511    }
512
513    fn children(&self, node: NodeId) -> Vec<NodeId> {
514        if let Some(c) = self.nodes.borrow()[node.0 as usize]
515            .children
516            .borrow()
517            .as_ref()
518        {
519            return c.clone();
520        }
521        enum Plan {
522            Root,
523            User(String),
524            Repo(String),
525            Section(String, Sec),
526            Dir(String, String),
527            Leaf,
528        }
529        let plan = match &self.nodes.borrow()[node.0 as usize].kind {
530            Kind::Root => Plan::Root,
531            Kind::User { login } => Plan::User(login.clone()),
532            Kind::Repo { key } => Plan::Repo(key.clone()),
533            Kind::Section { repo, sec } => Plan::Section(repo.clone(), *sec),
534            Kind::Dir { repo, path } => Plan::Dir(repo.clone(), path.clone()),
535            Kind::Item | Kind::File { .. } => Plan::Leaf,
536        };
537        let ids = match plan {
538            // The root does not enumerate GitHub; an anchored
539            // target roots its entity here.
540            Plan::Root => self.anchor.clone(),
541            Plan::User(login) => self
542                .call_paged(&format!("/users/{login}/repos"))
543                .iter()
544                .filter_map(|r| {
545                    let key = r.get("full_name")?.as_str()?;
546                    Some(self.repo_node(key, Some(r)))
547                })
548                .collect(),
549            Plan::Repo(key) => Sec::ALL
550                .iter()
551                .map(|&sec| self.section_node(node, &key, sec))
552                .collect(),
553            Plan::Section(repo, sec) => self.section_children(node, &repo, sec),
554            Plan::Dir(repo, path) => self.dir_children(node, &repo, &path),
555            Plan::Leaf => Vec::new(),
556        };
557        *self.nodes.borrow()[node.0 as usize].children.borrow_mut() = Some(ids.clone());
558        ids
559    }
560
561    /// Direct addressing: a login at the root, a repository
562    /// under its owner, an issue or pull by number (closed ones
563    /// included) — one GET each, no enumeration.
564    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
565        enum Plan {
566            Root,
567            User(String),
568            Section(String, Sec),
569            Other,
570        }
571        let plan = match &self.nodes.borrow()[node.0 as usize].kind {
572            Kind::Root => Plan::Root,
573            Kind::User { login } => Plan::User(login.clone()),
574            Kind::Section {
575                repo,
576                sec: sec @ (Sec::Issues | Sec::Pulls),
577            } => Plan::Section(repo.clone(), *sec),
578            _ => Plan::Other,
579        };
580        match plan {
581            Plan::Root => {
582                if let Some(&n) = self.users.borrow().get(name) {
583                    return vec![n];
584                }
585                self.fetch_user(name).into_iter().collect()
586            }
587            Plan::User(login) => {
588                let key = format!("{login}/{name}");
589                if let Some(&n) = self.repos.borrow().get(&key) {
590                    return vec![n];
591                }
592                self.fetch_repo(&key).into_iter().collect()
593            }
594            Plan::Section(repo, sec) => {
595                let Ok(num) = name.parse::<i64>() else {
596                    return Vec::new();
597                };
598                if let Some(&n) = self.items.borrow().get(&format!("{repo}#{num}")) {
599                    return vec![n];
600                }
601                let path = match sec {
602                    Sec::Pulls => format!("/repos/{repo}/pulls/{num}"),
603                    _ => format!("/repos/{repo}/issues/{num}"),
604                };
605                let Ok(d) = self.call(&path) else {
606                    return Vec::new();
607                };
608                vec![self.item_node(format!("{repo}#{num}"), num.to_string(), Some(node), d)]
609            }
610            Plan::Other => {
611                // Default: enumerate and filter by name.
612                self.children(node)
613                    .into_iter()
614                    .filter(|&c| self.name(c).as_deref() == Some(name))
615                    .collect()
616            }
617        }
618    }
619
620    fn name(&self, node: NodeId) -> Option<String> {
621        self.nodes.borrow()[node.0 as usize].name.clone()
622    }
623
624    fn parent(&self, node: NodeId) -> Option<NodeId> {
625        self.nodes.borrow()[node.0 as usize].parent
626    }
627
628    /// Type traits (`<user>`, `<repo>`, `<issue>`…), boolean
629    /// flags (`<fork>`, `<archived>`, `<open>`, `<merged>`,
630    /// `<draft>`, `<prerelease>`), issue labels verbatim
631    /// (`<bug>`), and repository topics (`<cli>`).
632    fn traits(&self, node: NodeId) -> Vec<String> {
633        enum Shape {
634            Fixed(&'static str),
635            User(String),
636            Repo,
637            Item,
638            File,
639        }
640        let shape = match &self.nodes.borrow()[node.0 as usize].kind {
641            Kind::Root => return Vec::new(),
642            Kind::Section { .. } => Shape::Fixed("section"),
643            Kind::Dir { .. } => Shape::Fixed("dir"),
644            Kind::User { login } => Shape::User(login.clone()),
645            Kind::Repo { .. } => Shape::Repo,
646            Kind::Item => Shape::Item,
647            Kind::File { .. } => Shape::File,
648        };
649        let data = self.data(node);
650        let d = data.as_deref();
651        let flag = |k: &str| d.and_then(|d| d.get(k)).and_then(|v| v.as_bool()) == Some(true);
652        let set = |k: &str| d.and_then(|d| d.get(k)).map(|v| !v.is_null()) == Some(true);
653        match shape {
654            Shape::Fixed(t) => vec![t.to_string()],
655            Shape::User(login) => {
656                // The type rides on even partial objects; fetch
657                // only if there is no object at all.
658                let t = d
659                    .and_then(|d| d.get("type"))
660                    .and_then(|v| v.as_str())
661                    .map(str::to_string)
662                    .or_else(|| {
663                        let n = *self.users.borrow().get(&login)?;
664                        let d = self.user_data(n, &login)?;
665                        Some(d.get("type")?.as_str()?.to_string())
666                    })
667                    .unwrap_or_else(|| "User".to_string());
668                // An organization also answers <org> (the
669                // universal shorthand) and the forge-neutral
670                // <group> (GitLab's native word), so container
671                // filters read the same on both forges.
672                match t.as_str() {
673                    "Organization" => vec![
674                        "organization".to_string(),
675                        "org".to_string(),
676                        "group".to_string(),
677                    ],
678                    _ => vec![t.to_lowercase()],
679                }
680            }
681            Shape::Repo => {
682                let mut ts = vec!["repo".to_string()];
683                for f in ["fork", "archived", "private", "is_template"] {
684                    if flag(f) {
685                        ts.push(f.trim_start_matches("is_").to_string());
686                    }
687                }
688                if let Some(topics) = d.and_then(|d| d.get("topics")).and_then(|v| v.as_array()) {
689                    ts.extend(topics.iter().filter_map(|t| t.as_str().map(str::to_string)));
690                }
691                ts
692            }
693            Shape::Item => {
694                let mut ts = Vec::new();
695                let kind = if d.is_some_and(|d| d.get("tag_name").is_some()) {
696                    "release"
697                } else if d
698                    .is_some_and(|d| d.get("head").is_some() || d.get("pull_request").is_some())
699                {
700                    "pull"
701                } else {
702                    "issue"
703                };
704                ts.push(kind.to_string());
705                if let Some(state) = d.and_then(|d| d.get("state")).and_then(|v| v.as_str()) {
706                    ts.push(state.to_string());
707                }
708                if set("merged_at") {
709                    ts.push("merged".to_string());
710                }
711                if flag("draft") {
712                    ts.push("draft".to_string());
713                }
714                if flag("prerelease") {
715                    ts.push("prerelease".to_string());
716                }
717                if let Some(labels) = d.and_then(|d| d.get("labels")).and_then(|v| v.as_array()) {
718                    ts.extend(labels.iter().filter_map(|l| {
719                        l.get("name").and_then(|v| v.as_str()).map(str::to_string)
720                    }));
721                }
722                ts
723            }
724            Shape::File => {
725                let t = d
726                    .and_then(|d| d.get("type"))
727                    .and_then(|v| v.as_str())
728                    .unwrap_or("file");
729                vec![t.to_string()]
730            }
731        }
732    }
733
734    /// Curated scalars per kind; timestamps answer as instants,
735    /// `::topics` and `::labels` as lists.
736    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
737        enum Shape {
738            User(String),
739            Repo(String),
740            Item,
741            Entry,
742        }
743        let shape = match &self.nodes.borrow()[node.0 as usize].kind {
744            Kind::User { login } => Shape::User(login.clone()),
745            Kind::Repo { key } => Shape::Repo(key.clone()),
746            Kind::Item => Shape::Item,
747            Kind::File { .. } | Kind::Dir { .. } => Shape::Entry,
748            _ => return None,
749        };
750        let at = |d: &Json, ptr: &str| d.pointer(ptr).and_then(json_scalar);
751        match shape {
752            Shape::User(login) => {
753                let d = self.user_data(node, &login)?;
754                let key = match name {
755                    "login" => "/login",
756                    "name" => "/name",
757                    "bio" => "/bio",
758                    "company" => "/company",
759                    "location" => "/location",
760                    "blog" => "/blog",
761                    "email" => "/email",
762                    "followers" => "/followers",
763                    "following" => "/following",
764                    "repos" => "/public_repos",
765                    "gists" => "/public_gists",
766                    "created" => "/created_at",
767                    "updated" => "/updated_at",
768                    "type" => "/type",
769                    _ => return None,
770                };
771                at(&d, key)
772            }
773            Shape::Repo(rkey) => {
774                // Listing objects cover most reads; watchers and
775                // license force the full object.
776                let full = matches!(name, "watchers" | "license");
777                let d = self.repo_data(node, &rkey, full)?;
778                match name {
779                    "topics" => {
780                        let ts = d.get("topics")?.as_array()?;
781                        Some(Value::List(
782                            ts.iter()
783                                .filter_map(|t| Some(Value::Str(t.as_str()?.to_string())))
784                                .collect(),
785                        ))
786                    }
787                    "owner" => at(&d, "/owner/login"),
788                    // A listing object omits `parent`; a fork
789                    // completes itself before answering.
790                    "parent" => {
791                        let d = if d.get("parent").is_none()
792                            && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
793                        {
794                            self.repo_data(node, &rkey, true)?
795                        } else {
796                            d
797                        };
798                        at(&d, "/parent/full_name")
799                    }
800                    "license" => at(&d, "/license/spdx_id"),
801                    _ => {
802                        let key = match name {
803                            "name" => "/name",
804                            "full-name" => "/full_name",
805                            "description" => "/description",
806                            "stars" => "/stargazers_count",
807                            "forks" => "/forks_count",
808                            "watchers" => "/subscribers_count",
809                            "open-issues" => "/open_issues_count",
810                            "language" => "/language",
811                            "default-branch" => "/default_branch",
812                            "homepage" => "/homepage",
813                            "size" => "/size",
814                            "created" => "/created_at",
815                            "updated" => "/updated_at",
816                            "pushed" => "/pushed_at",
817                            _ => return None,
818                        };
819                        at(&d, key)
820                    }
821                }
822            }
823            Shape::Item => {
824                let d = self.data(node)?;
825                match name {
826                    "labels" => {
827                        let ls = d.get("labels")?.as_array()?;
828                        Some(Value::List(
829                            ls.iter()
830                                .filter_map(|l| {
831                                    Some(Value::Str(l.get("name")?.as_str()?.to_string()))
832                                })
833                                .collect(),
834                        ))
835                    }
836                    "author" => at(&d, "/user/login").or_else(|| at(&d, "/author/login")),
837                    "milestone" => at(&d, "/milestone/title"),
838                    "base" => at(&d, "/base/ref"),
839                    "head" => at(&d, "/head/ref"),
840                    _ => {
841                        let key = match name {
842                            "number" => "/number",
843                            "title" => "/title",
844                            "state" => "/state",
845                            "comments" => "/comments",
846                            "created" => "/created_at",
847                            "updated" => "/updated_at",
848                            "closed" => "/closed_at",
849                            "merged" => "/merged_at",
850                            "tag" => "/tag_name",
851                            "name" => "/name",
852                            "published" => "/published_at",
853                            _ => return None,
854                        };
855                        at(&d, key)
856                    }
857                }
858            }
859            Shape::Entry => {
860                let d = self.data(node)?;
861                match name {
862                    "size" => at(&d, "/size"),
863                    "type" => at(&d, "/type"),
864                    _ => None,
865                }
866            }
867        }
868    }
869
870    /// A file's decoded content; an issue's, pull's, or
871    /// release's body text.
872    fn default_value(&self, node: NodeId) -> Option<Value> {
873        let (repo, path) = match &self.nodes.borrow()[node.0 as usize].kind {
874            Kind::File { repo, path } => (repo.clone(), path.clone()),
875            Kind::Item => {
876                let d = self.data(node)?;
877                let body = d.get("body")?.as_str()?;
878                return Some(Value::Str(body.to_string()));
879            }
880            _ => return None,
881        };
882        self.file_content(&repo, &path).map(Value::Str)
883    }
884
885    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
886        match key {
887            "path" => Some(Value::Str(self.locator(node))),
888            "url" => {
889                let d = self.data(node)?;
890                Some(Value::Str(d.get("html_url")?.as_str()?.to_string()))
891            }
892            _ => None,
893        }
894    }
895
896    /// `::owner~>`, `::parent~>` (a fork's upstream),
897    /// `::author~>`.
898    fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
899        let kind_key = match &self.nodes.borrow()[node.0 as usize].kind {
900            Kind::Repo { key } => Some(key.clone()),
901            Kind::Item => None,
902            _ => return None,
903        };
904        match (kind_key, property) {
905            (Some(key), "owner") => {
906                let d = self.repo_data(node, &key, false)?;
907                let login = d.pointer("/owner/login")?.as_str()?;
908                Some(self.user_node(login, d.get("owner")))
909            }
910            (Some(key), "parent") => {
911                let d = self.repo_data(node, &key, false)?;
912                let d = if d.get("parent").is_none()
913                    && d.get("fork").and_then(|v| v.as_bool()) == Some(true)
914                {
915                    self.repo_data(node, &key, true)?
916                } else {
917                    d
918                };
919                let pkey = d.pointer("/parent/full_name")?.as_str()?.to_string();
920                Some(self.repo_node(&pkey, d.get("parent")))
921            }
922            (None, "author") => {
923                let d = self.data(node)?;
924                self.author_of(&d)
925            }
926            _ => None,
927        }
928    }
929
930    /// The outgoing fabric: `follows`, `starred`, and `org`
931    /// from a user; `owner`, `parent`, and `contributor` from a
932    /// repository; `author` and `assignee` from an item.
933    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
934        enum Shape {
935            User(String),
936            Repo(String),
937            Item,
938        }
939        let shape = match &self.nodes.borrow()[node.0 as usize].kind {
940            Kind::User { login } => Shape::User(login.clone()),
941            Kind::Repo { key } => Shape::Repo(key.clone()),
942            Kind::Item => Shape::Item,
943            _ => return Vec::new(),
944        };
945        let mut out = Vec::new();
946        match shape {
947            Shape::User(login) => {
948                for u in self.call_paged(&format!("/users/{login}/following")) {
949                    if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
950                        out.push(("follows".to_string(), self.user_node(l, Some(&u))));
951                    }
952                }
953                for r in self.call_paged(&format!("/users/{login}/starred")) {
954                    if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
955                        out.push(("starred".to_string(), self.repo_node(k, Some(&r))));
956                    }
957                }
958                for o in self.call_paged(&format!("/users/{login}/orgs")) {
959                    if let Some(l) = o.get("login").and_then(|v| v.as_str()) {
960                        out.push(("org".to_string(), self.user_node(l, Some(&o))));
961                    }
962                }
963            }
964            Shape::Repo(key) => {
965                if let Some(n) = self.resolve(node, "owner", None) {
966                    out.push(("owner".to_string(), n));
967                }
968                if let Some(n) = self.resolve(node, "parent", None) {
969                    out.push(("parent".to_string(), n));
970                }
971                for c in self.call_paged(&format!("/repos/{key}/contributors")) {
972                    let Some(l) = c.get("login").and_then(|v| v.as_str()) else {
973                        continue;
974                    };
975                    if let Some(n) = c.get("contributions").and_then(|v| v.as_i64()) {
976                        self.contributions
977                            .borrow_mut()
978                            .insert(format!("{key}@{l}"), n);
979                    }
980                    out.push(("contributor".to_string(), self.user_node(l, Some(&c))));
981                }
982            }
983            Shape::Item => {
984                let Some(d) = self.data(node) else {
985                    return out;
986                };
987                if let Some(n) = self.author_of(&d) {
988                    out.push(("author".to_string(), n));
989                }
990                if let Some(assignees) = d.get("assignees").and_then(|v| v.as_array()) {
991                    for a in assignees {
992                        if let Some(l) = a.get("login").and_then(|v| v.as_str()) {
993                            out.push(("assignee".to_string(), self.user_node(l, Some(a))));
994                        }
995                    }
996                }
997            }
998        }
999        out
1000    }
1001
1002    /// The incoming fabric, from the API's own reverse indexes:
1003    /// a user's followers (`<-follows`), an org's members
1004    /// (`<-org`), a repository's stargazers (`<-starred`) and
1005    /// forks (`<-parent`).
1006    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1007        enum Shape {
1008            User(String),
1009            Repo(String),
1010        }
1011        let shape = match &self.nodes.borrow()[node.0 as usize].kind {
1012            Kind::User { login } => Shape::User(login.clone()),
1013            Kind::Repo { key } => Shape::Repo(key.clone()),
1014            _ => return Vec::new(),
1015        };
1016        let mut out = Vec::new();
1017        match shape {
1018            Shape::User(login) => {
1019                for u in self.call_paged(&format!("/users/{login}/followers")) {
1020                    if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1021                        out.push(("follows".to_string(), self.user_node(l, Some(&u))));
1022                    }
1023                }
1024                let is_org = self.traits(node).contains(&"organization".to_string());
1025                if is_org {
1026                    for m in self.call_paged(&format!("/orgs/{login}/members")) {
1027                        if let Some(l) = m.get("login").and_then(|v| v.as_str()) {
1028                            out.push(("org".to_string(), self.user_node(l, Some(&m))));
1029                        }
1030                    }
1031                }
1032            }
1033            Shape::Repo(key) => {
1034                for u in self.call_paged(&format!("/repos/{key}/stargazers")) {
1035                    if let Some(l) = u.get("login").and_then(|v| v.as_str()) {
1036                        out.push(("starred".to_string(), self.user_node(l, Some(&u))));
1037                    }
1038                }
1039                for r in self.call_paged(&format!("/repos/{key}/forks")) {
1040                    if let Some(k) = r.get("full_name").and_then(|v| v.as_str()) {
1041                        out.push(("parent".to_string(), self.repo_node(k, Some(&r))));
1042                    }
1043                }
1044            }
1045        }
1046        out
1047    }
1048
1049    /// The contributor edge carries its commit count:
1050    /// `$-::contributions`.
1051    fn link_property(
1052        &self,
1053        source: NodeId,
1054        label: &str,
1055        target: NodeId,
1056        name: &str,
1057    ) -> Option<Value> {
1058        if label != "contributor" || name != "contributions" {
1059            return None;
1060        }
1061        let key = match &self.nodes.borrow()[source.0 as usize].kind {
1062            Kind::Repo { key } => key.clone(),
1063            _ => return None,
1064        };
1065        let login = match &self.nodes.borrow()[target.0 as usize].kind {
1066            Kind::User { login } => login.clone(),
1067            _ => return None,
1068        };
1069        self.contributions
1070            .borrow()
1071            .get(&format!("{key}@{login}"))
1072            .copied()
1073            .map(Value::Int)
1074    }
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079    use super::*;
1080    use serde_json::json;
1081
1082    #[test]
1083    fn base64_roundtrip() {
1084        assert_eq!(
1085            base64_decode("aGVsbG8gcXVhcmI=").as_deref(),
1086            Some(b"hello quarb".as_slice())
1087        );
1088        assert_eq!(
1089            base64_decode("aGVsbG8g\ncXVhcmI=\n").as_deref(),
1090            Some(b"hello quarb".as_slice())
1091        );
1092        assert!(base64_decode("not!base64").is_none());
1093    }
1094
1095    #[test]
1096    fn timestamps_become_instants() {
1097        assert!(matches!(
1098            str_value("2011-01-25T18:44:36Z"),
1099            Value::Instant { .. }
1100        ));
1101        assert!(matches!(str_value("v1.0.0"), Value::Str(_)));
1102    }
1103
1104    #[test]
1105    fn item_traits_from_shape() {
1106        let a = GithubAdapter {
1107            gh: "false".into(),
1108            anchor: Vec::new(),
1109            nodes: RefCell::new(vec![Node {
1110                kind: Kind::Root,
1111                name: None,
1112                parent: None,
1113                children: RefCell::new(None),
1114                data: RefCell::new(None),
1115            }]),
1116            users: RefCell::new(HashMap::new()),
1117            repos: RefCell::new(HashMap::new()),
1118            items: RefCell::new(HashMap::new()),
1119            contributions: RefCell::new(HashMap::new()),
1120            contents: RefCell::new(HashMap::new()),
1121        };
1122        let issue = a.push_node(
1123            Kind::Item,
1124            Some("1".into()),
1125            None,
1126            Some(json!({"number": 1, "state": "open",
1127                "labels": [{"name": "bug"}, {"name": "good first issue"}]})),
1128        );
1129        assert_eq!(
1130            a.traits(issue),
1131            ["issue", "open", "bug", "good first issue"]
1132        );
1133        let pull = a.push_node(
1134            Kind::Item,
1135            Some("2".into()),
1136            None,
1137            Some(json!({"number": 2, "state": "closed", "head": {"ref": "x"},
1138                "merged_at": "2026-01-01T00:00:00Z", "labels": []})),
1139        );
1140        assert_eq!(a.traits(pull), ["pull", "closed", "merged"]);
1141        let release = a.push_node(
1142            Kind::Item,
1143            Some("v1".into()),
1144            None,
1145            Some(json!({"tag_name": "v1", "prerelease": true})),
1146        );
1147        assert_eq!(a.traits(release), ["release", "prerelease"]);
1148    }
1149
1150    #[test]
1151    fn repo_traits_include_flags_and_topics() {
1152        let a = GithubAdapter {
1153            gh: "false".into(),
1154            anchor: Vec::new(),
1155            nodes: RefCell::new(vec![Node {
1156                kind: Kind::Root,
1157                name: None,
1158                parent: None,
1159                children: RefCell::new(None),
1160                data: RefCell::new(None),
1161            }]),
1162            users: RefCell::new(HashMap::new()),
1163            repos: RefCell::new(HashMap::new()),
1164            items: RefCell::new(HashMap::new()),
1165            contributions: RefCell::new(HashMap::new()),
1166            contents: RefCell::new(HashMap::new()),
1167        };
1168        let r = a.push_node(
1169            Kind::Repo { key: "o/r".into() },
1170            Some("r".into()),
1171            None,
1172            Some(json!({"fork": true, "archived": false,
1173                "topics": ["cli", "rust"]})),
1174        );
1175        assert_eq!(a.traits(r), ["repo", "fork", "cli", "rust"]);
1176    }
1177
1178    #[test]
1179    fn target_needs_the_scheme() {
1180        assert!(matches!(
1181            GithubAdapter::connect("gh:torvalds"),
1182            Err(GithubError::Target(_))
1183        ));
1184    }
1185}