Skip to main content

quarb_github/
lib.rs

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