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