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