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