1use 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#[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#[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
82enum Kind {
84 Root,
85 Group { path: String },
87 Project { path: String },
89 User { username: String },
91 Section { proj: String, sec: Sec },
92 Item,
95 Pipeline { proj: String, id: i64 },
97 Job,
98 Dir { proj: String, path: String },
100 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
112fn enc(path: &str) -> String {
114 path.replace('/', "%2F")
115}
116
117fn 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
143fn 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
168fn 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
180pub struct GitlabAdapter {
182 glab: String,
183 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 items: RefCell<HashMap<String, NodeId>>,
191 full: RefCell<HashSet<String>>,
193 access: RefCell<HashMap<String, i64>>,
195 contents: RefCell<HashMap<String, Option<String>>>,
197}
198
199impl GitlabAdapter {
200 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 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 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 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 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 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 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 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 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 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 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 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 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 Plan::Root => self.anchor.clone(),
650 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 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 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 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 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 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 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 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 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 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 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}