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