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