Skip to main content

quarb_gitlab/
lib.rs

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