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