Skip to main content

quarb_gitlab/
lib.rs

1//! GitLab API adapter for the Quarb query engine.
2//!
3//! GitLab's defining shape is the **deep tree** — groups nest
4//! arbitrarily, and a group's children mix subgroups and
5//! projects — which the REST API makes you walk by URL-encoded
6//! full paths and numeric ids. The arbor restores it to what it
7//! is: `/tesslab/instruments/gauge/issues/7` addresses a project
8//! three levels down and its issue in one path, each segment one
9//! GET. Under a project: `files` (the default branch's tree,
10//! content as the node value), `issues` and `mrs` (the open
11//! sets; direct addressing by iid reaches any state),
12//! `releases`, and `pipelines` (jobs as children — CI is half of
13//! what a GitLab project is).
14//!
15//! GitLab is about the code, not the social graph, and the edge
16//! fabric follows: no follows, stars as a *count* (`::stars`).
17//! The relation that matters is **membership** — `->member`
18//! edges on groups and projects carry their access level as edge
19//! data (`$-::access`, `$-::role`) — plus `parent` on forks
20//! (`<-parent` enumerates a project's forks) and `author` /
21//! `assignee` / `reviewer` on issues and merge requests.
22//!
23//! Metadata splits by register: comparable values are properties
24//! (`::stars`, `::open-issues`, timestamps as instants); unary
25//! facts are traits — visibility (`<private>`), `<archived>`,
26//! `<fork>`, project topics, issue/MR labels verbatim (scoped
27//! labels included), `<draft>`, `<confidential>`, and
28//! pipeline/job statuses (`<failed>`).
29//!
30//! **Transport and auth**: the adapter shells out to `glab api`,
31//! so hosts (`GITLAB_HOST`), tokens, and self-managed instances
32//! behave exactly as the glab CLI does. Read-only — only GETs.
33//! Target: `gitlab:` (address from the root), or
34//! `gitlab:PATH` to anchor a group, project, or user namespace.
35//! `QUARB_GLAB` overrides the binary.
36
37use quarb::temporal::parse_iso;
38use quarb::{AstAdapter, NodeId, Value};
39use serde_json::Value as Json;
40use std::cell::RefCell;
41use std::collections::{HashMap, HashSet};
42use std::rc::Rc;
43
44/// An error connecting to or reading GitLab.
45#[derive(Debug, thiserror::Error)]
46pub enum GitlabError {
47    #[error("glab: {0}")]
48    Glab(String),
49    #[error("gitlab target: {0} (expected gitlab:[PATH])")]
50    Target(String),
51}
52
53/// A project's fixed sections.
54#[derive(Clone, Copy, PartialEq)]
55enum Sec {
56    Files,
57    Issues,
58    Mrs,
59    Releases,
60    Pipelines,
61}
62
63impl Sec {
64    const ALL: [Sec; 5] = [
65        Sec::Files,
66        Sec::Issues,
67        Sec::Mrs,
68        Sec::Releases,
69        Sec::Pipelines,
70    ];
71    fn name(self) -> &'static str {
72        match self {
73            Sec::Files => "files",
74            Sec::Issues => "issues",
75            Sec::Mrs => "mrs",
76            Sec::Releases => "releases",
77            Sec::Pipelines => "pipelines",
78        }
79    }
80}
81
82/// What a node is.
83enum Kind {
84    Root,
85    /// A group or subgroup, by full path.
86    Group {
87        path: String,
88    },
89    /// A project, by full path (`path_with_namespace`).
90    Project {
91        path: String,
92    },
93    /// A user (a personal namespace), by username.
94    User {
95        username: String,
96    },
97    Section {
98        proj: String,
99        sec: Sec,
100    },
101    /// An issue, merge request, or release (told apart by their
102    /// JSON: releases carry `tag_name`, MRs `source_branch`).
103    Item,
104    /// A pipeline; jobs are its children.
105    Pipeline {
106        proj: String,
107        id: i64,
108    },
109    Job,
110    /// A directory in the default branch's tree.
111    Dir {
112        proj: String,
113        path: String,
114    },
115    /// A file (blob); content is the value.
116    File {
117        proj: String,
118        path: String,
119    },
120}
121
122struct Node {
123    kind: Kind,
124    name: Option<String>,
125    parent: Option<NodeId>,
126    children: RefCell<Option<Vec<NodeId>>>,
127    data: RefCell<Option<Rc<Json>>>,
128}
129
130/// URL-encode a full path for the API (`a/b/c` → `a%2Fb%2Fc`).
131fn enc(path: &str) -> String {
132    path.replace('/', "%2F")
133}
134
135/// A string value; RFC 3339 timestamps become instants.
136fn str_value(s: &str) -> Value {
137    if s.contains('T')
138        && let Some((secs, nanos, offset_min)) = parse_iso(s)
139    {
140        return Value::Instant {
141            secs,
142            nanos,
143            offset_min,
144        };
145    }
146    Value::Str(s.to_string())
147}
148
149fn json_scalar(v: &Json) -> Option<Value> {
150    match v {
151        Json::String(s) => Some(str_value(s)),
152        Json::Number(n) => Some(match n.as_i64() {
153            Some(i) => Value::Int(i),
154            None => Value::Float(n.as_f64()?),
155        }),
156        Json::Bool(b) => Some(Value::Bool(*b)),
157        _ => None,
158    }
159}
160
161/// Standard base64 (the files API's encoding).
162fn base64_decode(s: &str) -> Option<Vec<u8>> {
163    let mut out = Vec::new();
164    let mut acc: u32 = 0;
165    let mut bits = 0;
166    for c in s.bytes() {
167        let v = match c {
168            b'A'..=b'Z' => c - b'A',
169            b'a'..=b'z' => c - b'a' + 26,
170            b'0'..=b'9' => c - b'0' + 52,
171            b'+' => 62,
172            b'/' => 63,
173            b'=' | b'\n' | b'\r' | b' ' => continue,
174            _ => return None,
175        };
176        acc = (acc << 6) | v as u32;
177        bits += 6;
178        if bits >= 8 {
179            bits -= 8;
180            out.push((acc >> bits) as u8);
181        }
182    }
183    Some(out)
184}
185
186/// The role name for a GitLab access level.
187fn role_name(level: i64) -> &'static str {
188    match level {
189        50.. => "owner",
190        40..=49 => "maintainer",
191        30..=39 => "developer",
192        20..=29 => "reporter",
193        10..=19 => "guest",
194        _ => "minimal",
195    }
196}
197
198/// GitLab, exposed as an arbor.
199pub struct GitlabAdapter {
200    glab: String,
201    /// The anchor's root children (empty for a bare `gitlab:`).
202    anchor: Vec<NodeId>,
203    nodes: RefCell<Vec<Node>>,
204    groups: RefCell<HashMap<String, NodeId>>,
205    projects: RefCell<HashMap<String, NodeId>>,
206    users: RefCell<HashMap<String, NodeId>>,
207    /// `project!kind!iid-or-tag` → item node.
208    items: RefCell<HashMap<String, NodeId>>,
209    /// Projects whose full (single-GET) object is cached.
210    full: RefCell<HashSet<String>>,
211    /// `scope-path@username` → access level (member edge data).
212    access: RefCell<HashMap<String, i64>>,
213    /// `project:path` → decoded file content.
214    contents: RefCell<HashMap<String, Option<String>>>,
215}
216
217impl GitlabAdapter {
218    /// Connect to `gitlab:` or `gitlab:PATH` (a group, project,
219    /// or user namespace to anchor).
220    pub fn connect(target: &str) -> Result<Self, GitlabError> {
221        let anchor = target
222            .strip_prefix("gitlab:")
223            .ok_or_else(|| GitlabError::Target(target.to_string()))?;
224        let glab = std::env::var("QUARB_GLAB").unwrap_or_else(|_| "glab".to_string());
225        let adapter = GitlabAdapter {
226            glab,
227            anchor: Vec::new(),
228            nodes: RefCell::new(vec![Node {
229                kind: Kind::Root,
230                name: None,
231                parent: None,
232                children: RefCell::new(None),
233                data: RefCell::new(None),
234            }]),
235            groups: RefCell::new(HashMap::new()),
236            projects: RefCell::new(HashMap::new()),
237            users: RefCell::new(HashMap::new()),
238            items: RefCell::new(HashMap::new()),
239            full: RefCell::new(HashSet::new()),
240            access: RefCell::new(HashMap::new()),
241            contents: RefCell::new(HashMap::new()),
242        };
243        let mut adapter = adapter;
244        if anchor.is_empty() {
245            // Probe: auth and reachability surface here.
246            adapter
247                .call("version")
248                .map_err(|e| GitlabError::Glab(format!("{e} (is `glab auth login` done?)")))?;
249        } else {
250            let n = adapter
251                .fetch_project(anchor)
252                .or_else(|| adapter.fetch_group(anchor))
253                .or_else(|| adapter.fetch_user(anchor))
254                .ok_or_else(|| {
255                    GitlabError::Glab(format!("no such project, group, or user: {anchor}"))
256                })?;
257            adapter.anchor = vec![n];
258        }
259        Ok(adapter)
260    }
261
262    /// A human-readable locator: the gitlab.com-shaped path.
263    pub fn locator(&self, node: NodeId) -> String {
264        let nodes = self.nodes.borrow();
265        let mut parts = Vec::new();
266        let mut cur = Some(node);
267        while let Some(n) = cur {
268            if let Some(name) = &nodes[n.0 as usize].name {
269                parts.push(name.clone());
270            }
271            cur = nodes[n.0 as usize].parent;
272        }
273        parts.reverse();
274        format!("/{}", parts.join("/"))
275    }
276
277    fn call(&self, path: &str) -> Result<Json, String> {
278        let out = std::process::Command::new(&self.glab)
279            .args(["api", path])
280            .output()
281            .map_err(|e| format!("running {}: {e}", self.glab))?;
282        if !out.status.success() {
283            return Err(format!(
284                "api {path}: {}",
285                String::from_utf8_lossy(&out.stderr).trim()
286            ));
287        }
288        serde_json::from_slice(&out.stdout).map_err(|e| format!("decoding {path}: {e}"))
289    }
290
291    /// A fully paginated array endpoint; first-page failure is an
292    /// empty listing, mid-pagination failure fails loud.
293    fn call_paged(&self, path: &str) -> Vec<Json> {
294        let sep = if path.contains('?') { '&' } else { '?' };
295        let mut out = Vec::new();
296        for page in 1.. {
297            let url = format!("{path}{sep}per_page=100&page={page}");
298            let items = match self.call(&url) {
299                Ok(Json::Array(a)) => a,
300                Ok(_) => break,
301                Err(e) if page > 1 => {
302                    panic!("gitlab: listing {path} truncated mid-pagination: {e}")
303                }
304                Err(_) => break,
305            };
306            let n = items.len();
307            out.extend(items);
308            if n < 100 {
309                break;
310            }
311        }
312        out
313    }
314
315    fn push_node(
316        &self,
317        kind: Kind,
318        name: Option<String>,
319        parent: Option<NodeId>,
320        data: Option<Json>,
321    ) -> NodeId {
322        let mut nodes = self.nodes.borrow_mut();
323        let id = NodeId(nodes.len() as u64);
324        nodes.push(Node {
325            kind,
326            name,
327            parent,
328            children: RefCell::new(None),
329            data: RefCell::new(data.map(Rc::new)),
330        });
331        id
332    }
333
334    fn data(&self, node: NodeId) -> Option<Rc<Json>> {
335        self.nodes.borrow()[node.0 as usize].data.borrow().clone()
336    }
337
338    fn set_data(&self, node: NodeId, data: Json) -> Rc<Json> {
339        let rc = Rc::new(data);
340        *self.nodes.borrow()[node.0 as usize].data.borrow_mut() = Some(rc.clone());
341        rc
342    }
343
344    /// The group node for a full path, interned once; ancestors
345    /// intern as groups too (only a namespace root can be a
346    /// user), so locators are whole from any entry point.
347    fn group_node(&self, path: &str, seed: Option<&Json>) -> NodeId {
348        if let Some(&n) = self.groups.borrow().get(path) {
349            return n;
350        }
351        let (parent, name) = match path.rsplit_once('/') {
352            Some((pp, name)) => (self.group_node(pp, None), name),
353            None => (NodeId(0), path),
354        };
355        let n = self.push_node(
356            Kind::Group {
357                path: path.to_string(),
358            },
359            Some(name.to_string()),
360            Some(parent),
361            seed.cloned(),
362        );
363        self.groups.borrow_mut().insert(path.to_string(), n);
364        n
365    }
366
367    fn group_data(&self, node: NodeId, path: &str) -> Option<Rc<Json>> {
368        if let Some(d) = self.data(node) {
369            return Some(d);
370        }
371        let d = self.call(&format!("groups/{}", enc(path))).ok()?;
372        Some(self.set_data(node, d))
373    }
374
375    fn fetch_group(&self, path: &str) -> Option<NodeId> {
376        let d = self.call(&format!("groups/{}", enc(path))).ok()?;
377        let full = d.get("full_path")?.as_str()?.to_string();
378        let n = self.group_node(&full, Some(&d));
379        self.set_data(n, d);
380        Some(n)
381    }
382
383    /// The user node for a username, interned once at the root.
384    fn user_node(&self, username: &str, seed: Option<&Json>) -> NodeId {
385        if let Some(&n) = self.users.borrow().get(username) {
386            return n;
387        }
388        let n = self.push_node(
389            Kind::User {
390                username: username.to_string(),
391            },
392            Some(username.to_string()),
393            Some(NodeId(0)),
394            seed.cloned(),
395        );
396        self.users.borrow_mut().insert(username.to_string(), n);
397        n
398    }
399
400    /// A user's data (the lookup endpoint answers by username).
401    fn user_data(&self, node: NodeId, username: &str) -> Option<Rc<Json>> {
402        if let Some(d) = self.data(node)
403            && d.get("id").is_some()
404        {
405            return Some(d);
406        }
407        let list = self.call(&format!("users?username={username}")).ok()?;
408        let d = list.as_array()?.first()?.clone();
409        Some(self.set_data(node, d))
410    }
411
412    fn fetch_user(&self, username: &str) -> Option<NodeId> {
413        if username.contains('/') {
414            return None;
415        }
416        let n = self.user_node(username, None);
417        self.user_data(n, username).map(|_| n)
418    }
419
420    /// The project node for a full path; its parent chain is the
421    /// namespace (a user for personal projects, groups
422    /// otherwise).
423    fn project_node(&self, path: &str, seed: Option<&Json>) -> NodeId {
424        if let Some(&n) = self.projects.borrow().get(path) {
425            return n;
426        }
427        let (ns, name) = match path.rsplit_once('/') {
428            Some((ns, name)) => (ns, name),
429            None => ("", path),
430        };
431        let ns_is_user = seed
432            .and_then(|d| d.pointer("/namespace/kind"))
433            .and_then(|v| v.as_str())
434            == Some("user");
435        let parent = if ns.is_empty() {
436            NodeId(0)
437        } else if ns_is_user && !ns.contains('/') {
438            self.user_node(ns, None)
439        } else {
440            self.group_node(ns, None)
441        };
442        let n = self.push_node(
443            Kind::Project {
444                path: path.to_string(),
445            },
446            Some(name.to_string()),
447            Some(parent),
448            seed.cloned(),
449        );
450        self.projects.borrow_mut().insert(path.to_string(), n);
451        n
452    }
453
454    /// A project's data; `full` forces the single-GET object
455    /// (listing objects omit `forked_from_project` and friends).
456    fn project_data(&self, node: NodeId, path: &str, full: bool) -> Option<Rc<Json>> {
457        if let Some(d) = self.data(node)
458            && (!full || self.full.borrow().contains(path))
459        {
460            return Some(d);
461        }
462        let d = self.call(&format!("projects/{}", enc(path))).ok()?;
463        self.full.borrow_mut().insert(path.to_string());
464        Some(self.set_data(node, d))
465    }
466
467    fn fetch_project(&self, path: &str) -> Option<NodeId> {
468        if !path.contains('/') {
469            return None;
470        }
471        let d = self.call(&format!("projects/{}", enc(path))).ok()?;
472        let full = d.get("path_with_namespace")?.as_str()?.to_string();
473        let n = self.project_node(&full, Some(&d));
474        self.set_data(n, d);
475        self.full.borrow_mut().insert(full);
476        Some(n)
477    }
478
479    fn item_node(&self, key: String, name: String, parent: Option<NodeId>, data: Json) -> NodeId {
480        if let Some(&n) = self.items.borrow().get(&key) {
481            return n;
482        }
483        let n = self.push_node(Kind::Item, Some(name), parent, Some(data));
484        self.items.borrow_mut().insert(key, n);
485        n
486    }
487
488    /// A directory listing from the repository tree API.
489    fn tree_children(&self, parent: NodeId, proj: &str, path: &str) -> Vec<NodeId> {
490        let url = match path.is_empty() {
491            true => format!("projects/{}/repository/tree", enc(proj)),
492            false => format!("projects/{}/repository/tree?path={}", enc(proj), enc(path)),
493        };
494        self.call_paged(&url)
495            .iter()
496            .filter_map(|e| {
497                let name = e.get("name")?.as_str()?.to_string();
498                let epath = e.get("path")?.as_str()?.to_string();
499                let kind = match e.get("type")?.as_str()? {
500                    "tree" => Kind::Dir {
501                        proj: proj.to_string(),
502                        path: epath,
503                    },
504                    _ => Kind::File {
505                        proj: proj.to_string(),
506                        path: epath,
507                    },
508                };
509                Some(self.push_node(kind, Some(name), Some(parent), Some(e.clone())))
510            })
511            .collect()
512    }
513
514    /// A file's decoded content (one GET, cached).
515    fn file_content(&self, proj: &str, path: &str) -> Option<String> {
516        let key = format!("{proj}:{path}");
517        if let Some(c) = self.contents.borrow().get(&key) {
518            return c.clone();
519        }
520        let ref_q = self
521            .projects
522            .borrow()
523            .get(proj)
524            .copied()
525            .and_then(|n| self.data(n))
526            .and_then(|d| Some(d.get("default_branch")?.as_str()?.to_string()))
527            .unwrap_or_else(|| "HEAD".to_string());
528        let content = self
529            .call(&format!(
530                "projects/{}/repository/files/{}?ref={ref_q}",
531                enc(proj),
532                enc(path)
533            ))
534            .ok()
535            .and_then(|d| {
536                let b64 = d.get("content")?.as_str()?.to_string();
537                String::from_utf8(base64_decode(&b64)?).ok()
538            });
539        self.contents.borrow_mut().insert(key, content.clone());
540        content
541    }
542
543    /// List a section's items (issues and MRs list the open
544    /// sets; direct addressing by iid reaches any state).
545    fn section_children(&self, node: NodeId, proj: &str, sec: Sec) -> Vec<NodeId> {
546        let p = enc(proj);
547        match sec {
548            Sec::Files => self.tree_children(node, proj, ""),
549            Sec::Issues => self
550                .call_paged(&format!("projects/{p}/issues?state=opened"))
551                .iter()
552                .filter_map(|i| {
553                    let iid = i.get("iid")?.as_i64()?;
554                    Some(self.item_node(
555                        format!("{proj}!i!{iid}"),
556                        iid.to_string(),
557                        Some(node),
558                        i.clone(),
559                    ))
560                })
561                .collect(),
562            Sec::Mrs => self
563                .call_paged(&format!("projects/{p}/merge_requests?state=opened"))
564                .iter()
565                .filter_map(|m| {
566                    let iid = m.get("iid")?.as_i64()?;
567                    Some(self.item_node(
568                        format!("{proj}!m!{iid}"),
569                        iid.to_string(),
570                        Some(node),
571                        m.clone(),
572                    ))
573                })
574                .collect(),
575            Sec::Releases => self
576                .call_paged(&format!("projects/{p}/releases"))
577                .iter()
578                .filter_map(|r| {
579                    let tag = r.get("tag_name")?.as_str()?.to_string();
580                    Some(self.item_node(format!("{proj}!r!{tag}"), tag, Some(node), r.clone()))
581                })
582                .collect(),
583            Sec::Pipelines => self
584                .call_paged(&format!("projects/{p}/pipelines"))
585                .iter()
586                .filter_map(|pl| {
587                    let id = pl.get("id")?.as_i64()?;
588                    Some(self.push_node(
589                        Kind::Pipeline {
590                            proj: proj.to_string(),
591                            id,
592                        },
593                        Some(id.to_string()),
594                        Some(node),
595                        Some(pl.clone()),
596                    ))
597                })
598                .collect(),
599        }
600    }
601
602    fn author_of(&self, data: &Json) -> Option<NodeId> {
603        let u = data.get("author")?;
604        let username = u.get("username")?.as_str()?;
605        Some(self.user_node(username, Some(u)))
606    }
607
608    /// Member edges for a group or project, recording access
609    /// levels as edge data.
610    fn member_links(&self, scope_api: &str, scope_path: &str) -> Vec<(String, NodeId)> {
611        self.call_paged(&format!("{scope_api}/{}/members", enc(scope_path)))
612            .iter()
613            .filter_map(|m| {
614                let username = m.get("username")?.as_str()?;
615                if let Some(level) = m.get("access_level").and_then(|v| v.as_i64()) {
616                    self.access
617                        .borrow_mut()
618                        .insert(format!("{scope_path}@{username}"), level);
619                }
620                Some(("member".to_string(), self.user_node(username, Some(m))))
621            })
622            .collect()
623    }
624}
625
626impl AstAdapter for GitlabAdapter {
627    fn root(&self) -> NodeId {
628        NodeId(0)
629    }
630
631    fn children(&self, node: NodeId) -> Vec<NodeId> {
632        if let Some(c) = self.nodes.borrow()[node.0 as usize]
633            .children
634            .borrow()
635            .as_ref()
636        {
637            return c.clone();
638        }
639        enum Plan {
640            Root,
641            Group(String),
642            User(String),
643            Project(String),
644            Section(String, Sec),
645            Pipeline(String, i64),
646            Dir(String, String),
647            Leaf,
648        }
649        let plan = match &self.nodes.borrow()[node.0 as usize].kind {
650            Kind::Root => Plan::Root,
651            Kind::Group { path } => Plan::Group(path.clone()),
652            Kind::User { username } => Plan::User(username.clone()),
653            Kind::Project { path } => Plan::Project(path.clone()),
654            Kind::Section { proj, sec } => Plan::Section(proj.clone(), *sec),
655            Kind::Pipeline { proj, id } => Plan::Pipeline(proj.clone(), *id),
656            Kind::Dir { proj, path } => Plan::Dir(proj.clone(), path.clone()),
657            Kind::Item | Kind::Job | Kind::File { .. } => Plan::Leaf,
658        };
659        let ids = match plan {
660            // The root does not enumerate GitLab; an anchored
661            // target roots its entity.
662            Plan::Root => self.anchor.clone(),
663            // A group's children mix subgroups and projects,
664            // each sorted by name.
665            Plan::Group(path) => {
666                let p = enc(&path);
667                let mut subs: Vec<(String, Json)> = self
668                    .call_paged(&format!("groups/{p}/subgroups?all_available=true"))
669                    .into_iter()
670                    .filter_map(|g| Some((g.get("full_path")?.as_str()?.to_string(), g)))
671                    .collect();
672                subs.sort_by(|a, b| a.0.cmp(&b.0));
673                let mut projs: Vec<(String, Json)> = self
674                    .call_paged(&format!("groups/{p}/projects"))
675                    .into_iter()
676                    .filter_map(|pr| {
677                        Some((pr.get("path_with_namespace")?.as_str()?.to_string(), pr))
678                    })
679                    .collect();
680                projs.sort_by(|a, b| a.0.cmp(&b.0));
681                let mut ids: Vec<NodeId> = subs
682                    .iter()
683                    .map(|(fp, g)| {
684                        let n = self.group_node(fp, Some(g));
685                        self.set_data(n, g.clone());
686                        n
687                    })
688                    .collect();
689                ids.extend(projs.iter().map(|(fp, pr)| self.project_node(fp, Some(pr))));
690                ids
691            }
692            Plan::User(username) => {
693                let Some(d) = self.users.borrow().get(&username).copied() else {
694                    return Vec::new();
695                };
696                let Some(data) = self.user_data(d, &username) else {
697                    return Vec::new();
698                };
699                let Some(id) = data.get("id").and_then(|v| v.as_i64()) else {
700                    return Vec::new();
701                };
702                let mut projs: Vec<(String, Json)> = self
703                    .call_paged(&format!("users/{id}/projects"))
704                    .into_iter()
705                    .filter_map(|pr| {
706                        Some((pr.get("path_with_namespace")?.as_str()?.to_string(), pr))
707                    })
708                    .collect();
709                projs.sort_by(|a, b| a.0.cmp(&b.0));
710                projs
711                    .iter()
712                    .map(|(fp, pr)| self.project_node(fp, Some(pr)))
713                    .collect()
714            }
715            Plan::Project(path) => Sec::ALL
716                .iter()
717                .map(|&sec| {
718                    self.push_node(
719                        Kind::Section {
720                            proj: path.clone(),
721                            sec,
722                        },
723                        Some(sec.name().to_string()),
724                        Some(node),
725                        None,
726                    )
727                })
728                .collect(),
729            Plan::Section(proj, sec) => self.section_children(node, &proj, sec),
730            Plan::Pipeline(proj, id) => self
731                .call_paged(&format!("projects/{}/pipelines/{id}/jobs", enc(&proj)))
732                .iter()
733                .filter_map(|j| {
734                    let name = j.get("name")?.as_str()?.to_string();
735                    Some(self.push_node(Kind::Job, Some(name), Some(node), Some(j.clone())))
736                })
737                .collect(),
738            Plan::Dir(proj, path) => self.tree_children(node, &proj, &path),
739            Plan::Leaf => Vec::new(),
740        };
741        *self.nodes.borrow()[node.0 as usize].children.borrow_mut() = Some(ids.clone());
742        ids
743    }
744
745    /// Direct addressing, one GET per segment: a group or user
746    /// at the root, a subgroup or project under a group, an
747    /// issue/MR by iid (any state), a pipeline by id.
748    fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
749        enum Plan {
750            Root,
751            Group(String),
752            User(String),
753            Section(String, Sec),
754            Other,
755        }
756        let plan = match &self.nodes.borrow()[node.0 as usize].kind {
757            Kind::Root => Plan::Root,
758            Kind::Group { path } => Plan::Group(path.clone()),
759            Kind::User { username } => Plan::User(username.clone()),
760            Kind::Section {
761                proj,
762                sec: sec @ (Sec::Issues | Sec::Mrs | Sec::Pipelines),
763            } => Plan::Section(proj.clone(), *sec),
764            _ => Plan::Other,
765        };
766        match plan {
767            Plan::Root => {
768                if let Some(&n) = self.groups.borrow().get(name) {
769                    return vec![n];
770                }
771                if let Some(&n) = self.users.borrow().get(name) {
772                    return vec![n];
773                }
774                self.fetch_group(name)
775                    .or_else(|| self.fetch_user(name))
776                    .into_iter()
777                    .collect()
778            }
779            Plan::Group(path) => {
780                let child = format!("{path}/{name}");
781                if let Some(&n) = self.projects.borrow().get(&child) {
782                    return vec![n];
783                }
784                if let Some(&n) = self.groups.borrow().get(&child) {
785                    return vec![n];
786                }
787                self.fetch_project(&child)
788                    .or_else(|| self.fetch_group(&child))
789                    .into_iter()
790                    .collect()
791            }
792            Plan::User(username) => {
793                let child = format!("{username}/{name}");
794                if let Some(&n) = self.projects.borrow().get(&child) {
795                    return vec![n];
796                }
797                self.fetch_project(&child).into_iter().collect()
798            }
799            Plan::Section(proj, sec) => {
800                let Ok(num) = name.parse::<i64>() else {
801                    return Vec::new();
802                };
803                let (tag, api) = match sec {
804                    Sec::Mrs => ('m', "merge_requests"),
805                    Sec::Pipelines => ('p', "pipelines"),
806                    _ => ('i', "issues"),
807                };
808                if sec == Sec::Pipelines {
809                    let Ok(d) = self.call(&format!("projects/{}/pipelines/{num}", enc(&proj)))
810                    else {
811                        return Vec::new();
812                    };
813                    return vec![self.push_node(
814                        Kind::Pipeline {
815                            proj: proj.clone(),
816                            id: num,
817                        },
818                        Some(num.to_string()),
819                        Some(node),
820                        Some(d),
821                    )];
822                }
823                let key = format!("{proj}!{tag}!{num}");
824                if let Some(&n) = self.items.borrow().get(&key) {
825                    return vec![n];
826                }
827                let Ok(d) = self.call(&format!("projects/{}/{api}/{num}", enc(&proj))) else {
828                    return Vec::new();
829                };
830                vec![self.item_node(key, num.to_string(), Some(node), d)]
831            }
832            Plan::Other => self
833                .children(node)
834                .into_iter()
835                .filter(|&c| self.name(c).as_deref() == Some(name))
836                .collect(),
837        }
838    }
839
840    fn name(&self, node: NodeId) -> Option<String> {
841        self.nodes.borrow()[node.0 as usize].name.clone()
842    }
843
844    fn parent(&self, node: NodeId) -> Option<NodeId> {
845        self.nodes.borrow()[node.0 as usize].parent
846    }
847
848    /// Kinds, visibility, flags, topics, labels (scoped labels
849    /// included), and pipeline/job statuses, all as traits.
850    fn traits(&self, node: NodeId) -> Vec<String> {
851        enum Shape {
852            Fixed(&'static str),
853            Group,
854            Project,
855            Item,
856            Status(&'static str),
857            Entry(&'static str),
858        }
859        let shape = match &self.nodes.borrow()[node.0 as usize].kind {
860            Kind::Root => return Vec::new(),
861            Kind::Section { .. } => Shape::Fixed("section"),
862            Kind::User { .. } => Shape::Fixed("user"),
863            Kind::Group { .. } => Shape::Group,
864            Kind::Project { .. } => Shape::Project,
865            Kind::Item => Shape::Item,
866            Kind::Pipeline { .. } => Shape::Status("pipeline"),
867            Kind::Job => Shape::Status("job"),
868            Kind::Dir { .. } => Shape::Entry("dir"),
869            Kind::File { .. } => Shape::Entry("file"),
870        };
871        let data = self.data(node);
872        let d = data.as_deref();
873        let s = |k: &str| {
874            d.and_then(|d| d.get(k))
875                .and_then(|v| v.as_str())
876                .map(str::to_string)
877        };
878        let flag = |k: &str| d.and_then(|d| d.get(k)).and_then(|v| v.as_bool()) == Some(true);
879        match shape {
880            Shape::Fixed(t) => vec![t.to_string()],
881            Shape::Group => {
882                let mut ts = vec!["group".to_string()];
883                ts.extend(s("visibility"));
884                ts
885            }
886            Shape::Project => {
887                // "project" is GitLab lingo; the rest of the
888                // world says repository, so <repo> matches too —
889                // //*<repo> sweeps both forges identically.
890                let mut ts = vec!["project".to_string(), "repo".to_string()];
891                ts.extend(s("visibility"));
892                if flag("archived") {
893                    ts.push("archived".to_string());
894                }
895                if d.is_some_and(|d| d.get("forked_from_project").is_some_and(|v| !v.is_null())) {
896                    ts.push("fork".to_string());
897                }
898                if let Some(topics) = d.and_then(|d| d.get("topics")).and_then(|v| v.as_array()) {
899                    ts.extend(topics.iter().filter_map(|t| t.as_str().map(str::to_string)));
900                }
901                ts
902            }
903            Shape::Item => {
904                let mut ts = Vec::new();
905                let kind = if d.is_some_and(|d| d.get("tag_name").is_some()) {
906                    "release"
907                } else if d.is_some_and(|d| d.get("source_branch").is_some()) {
908                    "mr"
909                } else {
910                    "issue"
911                };
912                ts.push(kind.to_string());
913                ts.extend(s("state"));
914                if flag("draft") {
915                    ts.push("draft".to_string());
916                }
917                if flag("confidential") {
918                    ts.push("confidential".to_string());
919                }
920                if flag("upcoming_release") {
921                    ts.push("upcoming".to_string());
922                }
923                if let Some(labels) = d.and_then(|d| d.get("labels")).and_then(|v| v.as_array()) {
924                    ts.extend(labels.iter().filter_map(|l| l.as_str().map(str::to_string)));
925                }
926                ts
927            }
928            Shape::Status(kind) => {
929                let mut ts = vec![kind.to_string()];
930                ts.extend(s("status"));
931                ts
932            }
933            Shape::Entry(t) => {
934                // A submodule entry is type "commit" in the tree.
935                if d.and_then(|d| d.get("type")).and_then(|v| v.as_str()) == Some("commit") {
936                    return vec!["submodule".to_string()];
937                }
938                vec![t.to_string()]
939            }
940        }
941    }
942
943    /// Curated scalars per kind; timestamps as instants,
944    /// `::topics` and `::labels` as lists.
945    fn property(&self, node: NodeId, name: &str) -> Option<Value> {
946        enum Shape {
947            Group(String),
948            Project(String),
949            User(String),
950            Data,
951        }
952        let shape = match &self.nodes.borrow()[node.0 as usize].kind {
953            Kind::Group { path } => Shape::Group(path.clone()),
954            Kind::Project { path } => Shape::Project(path.clone()),
955            Kind::User { username } => Shape::User(username.clone()),
956            Kind::Item | Kind::Pipeline { .. } | Kind::Job | Kind::File { .. } => Shape::Data,
957            _ => return None,
958        };
959        let at = |d: &Json, ptr: &str| d.pointer(ptr).and_then(json_scalar);
960        match shape {
961            Shape::Group(path) => {
962                let d = self.group_data(node, &path)?;
963                let key = match name {
964                    "name" => "/name",
965                    "path" => "/path",
966                    "full-path" => "/full_path",
967                    "description" => "/description",
968                    "visibility" => "/visibility",
969                    "created" => "/created_at",
970                    _ => return None,
971                };
972                at(&d, key)
973            }
974            Shape::Project(path) => {
975                let full = matches!(name, "parent" | "stars" | "forks" | "open-issues");
976                let d = self.project_data(node, &path, full)?;
977                match name {
978                    "topics" => {
979                        let ts = d.get("topics")?.as_array()?;
980                        Some(Value::List(
981                            ts.iter()
982                                .filter_map(|t| Some(Value::Str(t.as_str()?.to_string())))
983                                .collect(),
984                        ))
985                    }
986                    "parent" => at(&d, "/forked_from_project/path_with_namespace"),
987                    _ => {
988                        let key = match name {
989                            "name" => "/name",
990                            "path" => "/path",
991                            "full-path" => "/path_with_namespace",
992                            "description" => "/description",
993                            "stars" => "/star_count",
994                            "forks" => "/forks_count",
995                            "open-issues" => "/open_issues_count",
996                            "default-branch" => "/default_branch",
997                            "visibility" => "/visibility",
998                            "created" => "/created_at",
999                            "activity" => "/last_activity_at",
1000                            _ => return None,
1001                        };
1002                        at(&d, key)
1003                    }
1004                }
1005            }
1006            Shape::User(username) => {
1007                let d = self.user_data(node, &username)?;
1008                let key = match name {
1009                    "username" => "/username",
1010                    "name" => "/name",
1011                    "state" => "/state",
1012                    _ => return None,
1013                };
1014                at(&d, key)
1015            }
1016            Shape::Data => {
1017                let d = self.data(node)?;
1018                match name {
1019                    "labels" => {
1020                        let ls = d.get("labels")?.as_array()?;
1021                        Some(Value::List(
1022                            ls.iter()
1023                                .filter_map(|l| Some(Value::Str(l.as_str()?.to_string())))
1024                                .collect(),
1025                        ))
1026                    }
1027                    "author" => at(&d, "/author/username"),
1028                    "milestone" => at(&d, "/milestone/title"),
1029                    _ => {
1030                        let key = match name {
1031                            "iid" => "/iid",
1032                            "title" => "/title",
1033                            "state" => "/state",
1034                            "comments" => "/user_notes_count",
1035                            "upvotes" => "/upvotes",
1036                            "weight" => "/weight",
1037                            "created" => "/created_at",
1038                            "updated" => "/updated_at",
1039                            "closed" => "/closed_at",
1040                            "merged" => "/merged_at",
1041                            "source-branch" => "/source_branch",
1042                            "target-branch" => "/target_branch",
1043                            "sha" => "/sha",
1044                            "tag" => "/tag_name",
1045                            "name" => "/name",
1046                            "released" => "/released_at",
1047                            "status" => "/status",
1048                            "ref" => "/ref",
1049                            "source" => "/source",
1050                            "duration" => "/duration",
1051                            "stage" => "/stage",
1052                            "size" => "/size",
1053                            _ => return None,
1054                        };
1055                        at(&d, key)
1056                    }
1057                }
1058            }
1059        }
1060    }
1061
1062    /// A file's decoded content; an issue's, MR's, or release's
1063    /// description text.
1064    fn default_value(&self, node: NodeId) -> Option<Value> {
1065        let (proj, path) = match &self.nodes.borrow()[node.0 as usize].kind {
1066            Kind::File { proj, path } => (proj.clone(), path.clone()),
1067            Kind::Item => {
1068                let d = self.data(node)?;
1069                let body = d.get("description")?.as_str()?;
1070                return Some(Value::Str(body.to_string()));
1071            }
1072            _ => return None,
1073        };
1074        self.file_content(&proj, &path).map(Value::Str)
1075    }
1076
1077    fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
1078        match key {
1079            "path" => Some(Value::Str(self.locator(node))),
1080            "url" => {
1081                let d = self.data(node)?;
1082                Some(Value::Str(d.get("web_url")?.as_str()?.to_string()))
1083            }
1084            _ => None,
1085        }
1086    }
1087
1088    /// `::parent~>` (a fork's upstream), `::author~>`.
1089    fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
1090        let proj = match &self.nodes.borrow()[node.0 as usize].kind {
1091            Kind::Project { path } => Some(path.clone()),
1092            Kind::Item => None,
1093            _ => return None,
1094        };
1095        match (proj, property) {
1096            (Some(path), "parent") => {
1097                let d = self.project_data(node, &path, true)?;
1098                let pkey = d
1099                    .pointer("/forked_from_project/path_with_namespace")?
1100                    .as_str()?
1101                    .to_string();
1102                Some(self.project_node(&pkey, d.get("forked_from_project")))
1103            }
1104            (None, "author") => {
1105                let d = self.data(node)?;
1106                self.author_of(&d)
1107            }
1108            _ => None,
1109        }
1110    }
1111
1112    /// `member` edges (with access-level edge data) from groups
1113    /// and projects; `parent` from forks; `author` / `assignee` /
1114    /// `reviewer` from issues and merge requests.
1115    fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
1116        enum Shape {
1117            Group(String),
1118            Project(String),
1119            Item,
1120        }
1121        let shape = match &self.nodes.borrow()[node.0 as usize].kind {
1122            Kind::Group { path } => Shape::Group(path.clone()),
1123            Kind::Project { path } => Shape::Project(path.clone()),
1124            Kind::Item => Shape::Item,
1125            _ => return Vec::new(),
1126        };
1127        let mut out = Vec::new();
1128        match shape {
1129            Shape::Group(path) => out.extend(self.member_links("groups", &path)),
1130            Shape::Project(path) => {
1131                if let Some(n) = self.resolve(node, "parent", None) {
1132                    out.push(("parent".to_string(), n));
1133                }
1134                out.extend(self.member_links("projects", &path));
1135            }
1136            Shape::Item => {
1137                let Some(d) = self.data(node) else {
1138                    return out;
1139                };
1140                if let Some(n) = self.author_of(&d) {
1141                    out.push(("author".to_string(), n));
1142                }
1143                for key in ["assignees", "reviewers"] {
1144                    let label = key.trim_end_matches('s');
1145                    if let Some(us) = d.get(key).and_then(|v| v.as_array()) {
1146                        for u in us {
1147                            if let Some(username) = u.get("username").and_then(|v| v.as_str()) {
1148                                out.push((label.to_string(), self.user_node(username, Some(u))));
1149                            }
1150                        }
1151                    }
1152                }
1153            }
1154        }
1155        out
1156    }
1157
1158    /// A project's forks: `<-parent`.
1159    fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1160        let path = match &self.nodes.borrow()[node.0 as usize].kind {
1161            Kind::Project { path } => path.clone(),
1162            _ => return Vec::new(),
1163        };
1164        self.call_paged(&format!("projects/{}/forks", enc(&path)))
1165            .iter()
1166            .filter_map(|f| {
1167                let fp = f.get("path_with_namespace")?.as_str()?;
1168                Some(("parent".to_string(), self.project_node(fp, Some(f))))
1169            })
1170            .collect()
1171    }
1172
1173    /// The member edge carries its access level:
1174    /// `$-::access` (the numeric level) and `$-::role`.
1175    fn link_property(
1176        &self,
1177        source: NodeId,
1178        label: &str,
1179        target: NodeId,
1180        name: &str,
1181    ) -> Option<Value> {
1182        if label != "member" || !matches!(name, "access" | "role") {
1183            return None;
1184        }
1185        let scope = match &self.nodes.borrow()[source.0 as usize].kind {
1186            Kind::Group { path } | Kind::Project { path } => path.clone(),
1187            _ => return None,
1188        };
1189        let username = match &self.nodes.borrow()[target.0 as usize].kind {
1190            Kind::User { username } => username.clone(),
1191            _ => return None,
1192        };
1193        let level = *self.access.borrow().get(&format!("{scope}@{username}"))?;
1194        Some(match name {
1195            "access" => Value::Int(level),
1196            _ => Value::Str(role_name(level).to_string()),
1197        })
1198    }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203    use super::*;
1204
1205    #[test]
1206    fn paths_encode() {
1207        assert_eq!(
1208            enc("tesslab/instruments/gauge"),
1209            "tesslab%2Finstruments%2Fgauge"
1210        );
1211    }
1212
1213    #[test]
1214    fn roles_map() {
1215        assert_eq!(role_name(50), "owner");
1216        assert_eq!(role_name(40), "maintainer");
1217        assert_eq!(role_name(30), "developer");
1218        assert_eq!(role_name(20), "reporter");
1219        assert_eq!(role_name(10), "guest");
1220        assert_eq!(role_name(5), "minimal");
1221    }
1222
1223    #[test]
1224    fn timestamps_become_instants() {
1225        assert!(matches!(
1226            str_value("2026-07-20T12:00:00.000Z"),
1227            Value::Instant { .. }
1228        ));
1229        assert!(matches!(str_value("v1.0"), Value::Str(_)));
1230    }
1231
1232    #[test]
1233    fn target_needs_the_scheme() {
1234        assert!(matches!(
1235            GitlabAdapter::connect("gl:tesslab"),
1236            Err(GitlabError::Target(_))
1237        ));
1238    }
1239}