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 {
87 path: String,
88 },
89 Project {
91 path: String,
92 },
93 User {
95 username: String,
96 },
97 Section {
98 proj: String,
99 sec: Sec,
100 },
101 Item,
104 Pipeline {
106 proj: String,
107 id: i64,
108 },
109 Job,
110 Dir {
112 proj: String,
113 path: String,
114 },
115 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
130fn enc(path: &str) -> String {
132 path.replace('/', "%2F")
133}
134
135fn 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
161fn 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
186fn 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
198pub struct GitlabAdapter {
200 glab: String,
201 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 items: RefCell<HashMap<String, NodeId>>,
209 full: RefCell<HashSet<String>>,
211 access: RefCell<HashMap<String, i64>>,
213 contents: RefCell<HashMap<String, Option<String>>>,
215}
216
217impl GitlabAdapter {
218 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 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 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 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 Err(e) if page > 1 => {
302 panic!("gitlab: listing {path} truncated mid-pagination: {e}")
303 }
304 Err(_) => break,
305 };
306 let n = items.len();
307 out.extend(items);
308 if n < 100 {
309 break;
310 }
311 }
312 out
313 }
314
315 fn push_node(
316 &self,
317 kind: Kind,
318 name: Option<String>,
319 parent: Option<NodeId>,
320 data: Option<Json>,
321 ) -> NodeId {
322 let mut nodes = self.nodes.borrow_mut();
323 let id = NodeId(nodes.len() as u64);
324 nodes.push(Node {
325 kind,
326 name,
327 parent,
328 children: RefCell::new(None),
329 data: RefCell::new(data.map(Rc::new)),
330 });
331 id
332 }
333
334 fn data(&self, node: NodeId) -> Option<Rc<Json>> {
335 self.nodes.borrow()[node.0 as usize].data.borrow().clone()
336 }
337
338 fn set_data(&self, node: NodeId, data: Json) -> Rc<Json> {
339 let rc = Rc::new(data);
340 *self.nodes.borrow()[node.0 as usize].data.borrow_mut() = Some(rc.clone());
341 rc
342 }
343
344 fn group_node(&self, path: &str, seed: Option<&Json>) -> NodeId {
348 if let Some(&n) = self.groups.borrow().get(path) {
349 return n;
350 }
351 let (parent, name) = match path.rsplit_once('/') {
352 Some((pp, name)) => (self.group_node(pp, None), name),
353 None => (NodeId(0), path),
354 };
355 let n = self.push_node(
356 Kind::Group {
357 path: path.to_string(),
358 },
359 Some(name.to_string()),
360 Some(parent),
361 seed.cloned(),
362 );
363 self.groups.borrow_mut().insert(path.to_string(), n);
364 n
365 }
366
367 fn group_data(&self, node: NodeId, path: &str) -> Option<Rc<Json>> {
368 if let Some(d) = self.data(node) {
369 return Some(d);
370 }
371 let d = self.call(&format!("groups/{}", enc(path))).ok()?;
372 Some(self.set_data(node, d))
373 }
374
375 fn fetch_group(&self, path: &str) -> Option<NodeId> {
376 let d = self.call(&format!("groups/{}", enc(path))).ok()?;
377 let full = d.get("full_path")?.as_str()?.to_string();
378 let n = self.group_node(&full, Some(&d));
379 self.set_data(n, d);
380 Some(n)
381 }
382
383 fn user_node(&self, username: &str, seed: Option<&Json>) -> NodeId {
385 if let Some(&n) = self.users.borrow().get(username) {
386 return n;
387 }
388 let n = self.push_node(
389 Kind::User {
390 username: username.to_string(),
391 },
392 Some(username.to_string()),
393 Some(NodeId(0)),
394 seed.cloned(),
395 );
396 self.users.borrow_mut().insert(username.to_string(), n);
397 n
398 }
399
400 fn user_data(&self, node: NodeId, username: &str) -> Option<Rc<Json>> {
402 if let Some(d) = self.data(node)
403 && d.get("id").is_some()
404 {
405 return Some(d);
406 }
407 let list = self.call(&format!("users?username={username}")).ok()?;
408 let d = list.as_array()?.first()?.clone();
409 Some(self.set_data(node, d))
410 }
411
412 fn fetch_user(&self, username: &str) -> Option<NodeId> {
413 if username.contains('/') {
414 return None;
415 }
416 let n = self.user_node(username, None);
417 self.user_data(n, username).map(|_| n)
418 }
419
420 fn project_node(&self, path: &str, seed: Option<&Json>) -> NodeId {
424 if let Some(&n) = self.projects.borrow().get(path) {
425 return n;
426 }
427 let (ns, name) = match path.rsplit_once('/') {
428 Some((ns, name)) => (ns, name),
429 None => ("", path),
430 };
431 let ns_is_user = seed
432 .and_then(|d| d.pointer("/namespace/kind"))
433 .and_then(|v| v.as_str())
434 == Some("user");
435 let parent = if ns.is_empty() {
436 NodeId(0)
437 } else if ns_is_user && !ns.contains('/') {
438 self.user_node(ns, None)
439 } else {
440 self.group_node(ns, None)
441 };
442 let n = self.push_node(
443 Kind::Project {
444 path: path.to_string(),
445 },
446 Some(name.to_string()),
447 Some(parent),
448 seed.cloned(),
449 );
450 self.projects.borrow_mut().insert(path.to_string(), n);
451 n
452 }
453
454 fn project_data(&self, node: NodeId, path: &str, full: bool) -> Option<Rc<Json>> {
457 if let Some(d) = self.data(node)
458 && (!full || self.full.borrow().contains(path))
459 {
460 return Some(d);
461 }
462 let d = self.call(&format!("projects/{}", enc(path))).ok()?;
463 self.full.borrow_mut().insert(path.to_string());
464 Some(self.set_data(node, d))
465 }
466
467 fn fetch_project(&self, path: &str) -> Option<NodeId> {
468 if !path.contains('/') {
469 return None;
470 }
471 let d = self.call(&format!("projects/{}", enc(path))).ok()?;
472 let full = d.get("path_with_namespace")?.as_str()?.to_string();
473 let n = self.project_node(&full, Some(&d));
474 self.set_data(n, d);
475 self.full.borrow_mut().insert(full);
476 Some(n)
477 }
478
479 fn item_node(&self, key: String, name: String, parent: Option<NodeId>, data: Json) -> NodeId {
480 if let Some(&n) = self.items.borrow().get(&key) {
481 return n;
482 }
483 let n = self.push_node(Kind::Item, Some(name), parent, Some(data));
484 self.items.borrow_mut().insert(key, n);
485 n
486 }
487
488 fn tree_children(&self, parent: NodeId, proj: &str, path: &str) -> Vec<NodeId> {
490 let url = match path.is_empty() {
491 true => format!("projects/{}/repository/tree", enc(proj)),
492 false => format!("projects/{}/repository/tree?path={}", enc(proj), enc(path)),
493 };
494 self.call_paged(&url)
495 .iter()
496 .filter_map(|e| {
497 let name = e.get("name")?.as_str()?.to_string();
498 let epath = e.get("path")?.as_str()?.to_string();
499 let kind = match e.get("type")?.as_str()? {
500 "tree" => Kind::Dir {
501 proj: proj.to_string(),
502 path: epath,
503 },
504 _ => Kind::File {
505 proj: proj.to_string(),
506 path: epath,
507 },
508 };
509 Some(self.push_node(kind, Some(name), Some(parent), Some(e.clone())))
510 })
511 .collect()
512 }
513
514 fn file_content(&self, proj: &str, path: &str) -> Option<String> {
516 let key = format!("{proj}:{path}");
517 if let Some(c) = self.contents.borrow().get(&key) {
518 return c.clone();
519 }
520 let ref_q = self
521 .projects
522 .borrow()
523 .get(proj)
524 .copied()
525 .and_then(|n| self.data(n))
526 .and_then(|d| Some(d.get("default_branch")?.as_str()?.to_string()))
527 .unwrap_or_else(|| "HEAD".to_string());
528 let content = self
529 .call(&format!(
530 "projects/{}/repository/files/{}?ref={ref_q}",
531 enc(proj),
532 enc(path)
533 ))
534 .ok()
535 .and_then(|d| {
536 let b64 = d.get("content")?.as_str()?.to_string();
537 String::from_utf8(base64_decode(&b64)?).ok()
538 });
539 self.contents.borrow_mut().insert(key, content.clone());
540 content
541 }
542
543 fn section_children(&self, node: NodeId, proj: &str, sec: Sec) -> Vec<NodeId> {
546 let p = enc(proj);
547 match sec {
548 Sec::Files => self.tree_children(node, proj, ""),
549 Sec::Issues => self
550 .call_paged(&format!("projects/{p}/issues?state=opened"))
551 .iter()
552 .filter_map(|i| {
553 let iid = i.get("iid")?.as_i64()?;
554 Some(self.item_node(
555 format!("{proj}!i!{iid}"),
556 iid.to_string(),
557 Some(node),
558 i.clone(),
559 ))
560 })
561 .collect(),
562 Sec::Mrs => self
563 .call_paged(&format!("projects/{p}/merge_requests?state=opened"))
564 .iter()
565 .filter_map(|m| {
566 let iid = m.get("iid")?.as_i64()?;
567 Some(self.item_node(
568 format!("{proj}!m!{iid}"),
569 iid.to_string(),
570 Some(node),
571 m.clone(),
572 ))
573 })
574 .collect(),
575 Sec::Releases => self
576 .call_paged(&format!("projects/{p}/releases"))
577 .iter()
578 .filter_map(|r| {
579 let tag = r.get("tag_name")?.as_str()?.to_string();
580 Some(self.item_node(format!("{proj}!r!{tag}"), tag, Some(node), r.clone()))
581 })
582 .collect(),
583 Sec::Pipelines => self
584 .call_paged(&format!("projects/{p}/pipelines"))
585 .iter()
586 .filter_map(|pl| {
587 let id = pl.get("id")?.as_i64()?;
588 Some(self.push_node(
589 Kind::Pipeline {
590 proj: proj.to_string(),
591 id,
592 },
593 Some(id.to_string()),
594 Some(node),
595 Some(pl.clone()),
596 ))
597 })
598 .collect(),
599 }
600 }
601
602 fn author_of(&self, data: &Json) -> Option<NodeId> {
603 let u = data.get("author")?;
604 let username = u.get("username")?.as_str()?;
605 Some(self.user_node(username, Some(u)))
606 }
607
608 fn member_links(&self, scope_api: &str, scope_path: &str) -> Vec<(String, NodeId)> {
611 self.call_paged(&format!("{scope_api}/{}/members", enc(scope_path)))
612 .iter()
613 .filter_map(|m| {
614 let username = m.get("username")?.as_str()?;
615 if let Some(level) = m.get("access_level").and_then(|v| v.as_i64()) {
616 self.access
617 .borrow_mut()
618 .insert(format!("{scope_path}@{username}"), level);
619 }
620 Some(("member".to_string(), self.user_node(username, Some(m))))
621 })
622 .collect()
623 }
624}
625
626impl AstAdapter for GitlabAdapter {
627 fn root(&self) -> NodeId {
628 NodeId(0)
629 }
630
631 fn children(&self, node: NodeId) -> Vec<NodeId> {
632 if let Some(c) = self.nodes.borrow()[node.0 as usize]
633 .children
634 .borrow()
635 .as_ref()
636 {
637 return c.clone();
638 }
639 enum Plan {
640 Root,
641 Group(String),
642 User(String),
643 Project(String),
644 Section(String, Sec),
645 Pipeline(String, i64),
646 Dir(String, String),
647 Leaf,
648 }
649 let plan = match &self.nodes.borrow()[node.0 as usize].kind {
650 Kind::Root => Plan::Root,
651 Kind::Group { path } => Plan::Group(path.clone()),
652 Kind::User { username } => Plan::User(username.clone()),
653 Kind::Project { path } => Plan::Project(path.clone()),
654 Kind::Section { proj, sec } => Plan::Section(proj.clone(), *sec),
655 Kind::Pipeline { proj, id } => Plan::Pipeline(proj.clone(), *id),
656 Kind::Dir { proj, path } => Plan::Dir(proj.clone(), path.clone()),
657 Kind::Item | Kind::Job | Kind::File { .. } => Plan::Leaf,
658 };
659 let ids = match plan {
660 Plan::Root => self.anchor.clone(),
663 Plan::Group(path) => {
666 let p = enc(&path);
667 let mut subs: Vec<(String, Json)> = self
668 .call_paged(&format!("groups/{p}/subgroups?all_available=true"))
669 .into_iter()
670 .filter_map(|g| Some((g.get("full_path")?.as_str()?.to_string(), g)))
671 .collect();
672 subs.sort_by(|a, b| a.0.cmp(&b.0));
673 let mut projs: Vec<(String, Json)> = self
674 .call_paged(&format!("groups/{p}/projects"))
675 .into_iter()
676 .filter_map(|pr| {
677 Some((pr.get("path_with_namespace")?.as_str()?.to_string(), pr))
678 })
679 .collect();
680 projs.sort_by(|a, b| a.0.cmp(&b.0));
681 let mut ids: Vec<NodeId> = subs
682 .iter()
683 .map(|(fp, g)| {
684 let n = self.group_node(fp, Some(g));
685 self.set_data(n, g.clone());
686 n
687 })
688 .collect();
689 ids.extend(projs.iter().map(|(fp, pr)| self.project_node(fp, Some(pr))));
690 ids
691 }
692 Plan::User(username) => {
693 let Some(d) = self.users.borrow().get(&username).copied() else {
694 return Vec::new();
695 };
696 let Some(data) = self.user_data(d, &username) else {
697 return Vec::new();
698 };
699 let Some(id) = data.get("id").and_then(|v| v.as_i64()) else {
700 return Vec::new();
701 };
702 let mut projs: Vec<(String, Json)> = self
703 .call_paged(&format!("users/{id}/projects"))
704 .into_iter()
705 .filter_map(|pr| {
706 Some((pr.get("path_with_namespace")?.as_str()?.to_string(), pr))
707 })
708 .collect();
709 projs.sort_by(|a, b| a.0.cmp(&b.0));
710 projs
711 .iter()
712 .map(|(fp, pr)| self.project_node(fp, Some(pr)))
713 .collect()
714 }
715 Plan::Project(path) => Sec::ALL
716 .iter()
717 .map(|&sec| {
718 self.push_node(
719 Kind::Section {
720 proj: path.clone(),
721 sec,
722 },
723 Some(sec.name().to_string()),
724 Some(node),
725 None,
726 )
727 })
728 .collect(),
729 Plan::Section(proj, sec) => self.section_children(node, &proj, sec),
730 Plan::Pipeline(proj, id) => self
731 .call_paged(&format!("projects/{}/pipelines/{id}/jobs", enc(&proj)))
732 .iter()
733 .filter_map(|j| {
734 let name = j.get("name")?.as_str()?.to_string();
735 Some(self.push_node(Kind::Job, Some(name), Some(node), Some(j.clone())))
736 })
737 .collect(),
738 Plan::Dir(proj, path) => self.tree_children(node, &proj, &path),
739 Plan::Leaf => Vec::new(),
740 };
741 *self.nodes.borrow()[node.0 as usize].children.borrow_mut() = Some(ids.clone());
742 ids
743 }
744
745 fn children_named(&self, node: NodeId, name: &str) -> Vec<NodeId> {
749 enum Plan {
750 Root,
751 Group(String),
752 User(String),
753 Section(String, Sec),
754 Other,
755 }
756 let plan = match &self.nodes.borrow()[node.0 as usize].kind {
757 Kind::Root => Plan::Root,
758 Kind::Group { path } => Plan::Group(path.clone()),
759 Kind::User { username } => Plan::User(username.clone()),
760 Kind::Section {
761 proj,
762 sec: sec @ (Sec::Issues | Sec::Mrs | Sec::Pipelines),
763 } => Plan::Section(proj.clone(), *sec),
764 _ => Plan::Other,
765 };
766 match plan {
767 Plan::Root => {
768 if let Some(&n) = self.groups.borrow().get(name) {
769 return vec![n];
770 }
771 if let Some(&n) = self.users.borrow().get(name) {
772 return vec![n];
773 }
774 self.fetch_group(name)
775 .or_else(|| self.fetch_user(name))
776 .into_iter()
777 .collect()
778 }
779 Plan::Group(path) => {
780 let child = format!("{path}/{name}");
781 if let Some(&n) = self.projects.borrow().get(&child) {
782 return vec![n];
783 }
784 if let Some(&n) = self.groups.borrow().get(&child) {
785 return vec![n];
786 }
787 self.fetch_project(&child)
788 .or_else(|| self.fetch_group(&child))
789 .into_iter()
790 .collect()
791 }
792 Plan::User(username) => {
793 let child = format!("{username}/{name}");
794 if let Some(&n) = self.projects.borrow().get(&child) {
795 return vec![n];
796 }
797 self.fetch_project(&child).into_iter().collect()
798 }
799 Plan::Section(proj, sec) => {
800 let Ok(num) = name.parse::<i64>() else {
801 return Vec::new();
802 };
803 let (tag, api) = match sec {
804 Sec::Mrs => ('m', "merge_requests"),
805 Sec::Pipelines => ('p', "pipelines"),
806 _ => ('i', "issues"),
807 };
808 if sec == Sec::Pipelines {
809 let Ok(d) = self.call(&format!("projects/{}/pipelines/{num}", enc(&proj)))
810 else {
811 return Vec::new();
812 };
813 return vec![self.push_node(
814 Kind::Pipeline {
815 proj: proj.clone(),
816 id: num,
817 },
818 Some(num.to_string()),
819 Some(node),
820 Some(d),
821 )];
822 }
823 let key = format!("{proj}!{tag}!{num}");
824 if let Some(&n) = self.items.borrow().get(&key) {
825 return vec![n];
826 }
827 let Ok(d) = self.call(&format!("projects/{}/{api}/{num}", enc(&proj))) else {
828 return Vec::new();
829 };
830 vec![self.item_node(key, num.to_string(), Some(node), d)]
831 }
832 Plan::Other => self
833 .children(node)
834 .into_iter()
835 .filter(|&c| self.name(c).as_deref() == Some(name))
836 .collect(),
837 }
838 }
839
840 fn name(&self, node: NodeId) -> Option<String> {
841 self.nodes.borrow()[node.0 as usize].name.clone()
842 }
843
844 fn parent(&self, node: NodeId) -> Option<NodeId> {
845 self.nodes.borrow()[node.0 as usize].parent
846 }
847
848 fn traits(&self, node: NodeId) -> Vec<String> {
851 enum Shape {
852 Fixed(&'static str),
853 Group,
854 Project,
855 Item,
856 Status(&'static str),
857 Entry(&'static str),
858 }
859 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
860 Kind::Root => return Vec::new(),
861 Kind::Section { .. } => Shape::Fixed("section"),
862 Kind::User { .. } => Shape::Fixed("user"),
863 Kind::Group { .. } => Shape::Group,
864 Kind::Project { .. } => Shape::Project,
865 Kind::Item => Shape::Item,
866 Kind::Pipeline { .. } => Shape::Status("pipeline"),
867 Kind::Job => Shape::Status("job"),
868 Kind::Dir { .. } => Shape::Entry("dir"),
869 Kind::File { .. } => Shape::Entry("file"),
870 };
871 let data = self.data(node);
872 let d = data.as_deref();
873 let s = |k: &str| {
874 d.and_then(|d| d.get(k))
875 .and_then(|v| v.as_str())
876 .map(str::to_string)
877 };
878 let flag = |k: &str| d.and_then(|d| d.get(k)).and_then(|v| v.as_bool()) == Some(true);
879 match shape {
880 Shape::Fixed(t) => vec![t.to_string()],
881 Shape::Group => {
882 let mut ts = vec!["group".to_string()];
883 ts.extend(s("visibility"));
884 ts
885 }
886 Shape::Project => {
887 let mut ts = vec!["project".to_string(), "repo".to_string()];
891 ts.extend(s("visibility"));
892 if flag("archived") {
893 ts.push("archived".to_string());
894 }
895 if d.is_some_and(|d| d.get("forked_from_project").is_some_and(|v| !v.is_null())) {
896 ts.push("fork".to_string());
897 }
898 if let Some(topics) = d.and_then(|d| d.get("topics")).and_then(|v| v.as_array()) {
899 ts.extend(topics.iter().filter_map(|t| t.as_str().map(str::to_string)));
900 }
901 ts
902 }
903 Shape::Item => {
904 let mut ts = Vec::new();
905 let kind = if d.is_some_and(|d| d.get("tag_name").is_some()) {
906 "release"
907 } else if d.is_some_and(|d| d.get("source_branch").is_some()) {
908 "mr"
909 } else {
910 "issue"
911 };
912 ts.push(kind.to_string());
913 ts.extend(s("state"));
914 if flag("draft") {
915 ts.push("draft".to_string());
916 }
917 if flag("confidential") {
918 ts.push("confidential".to_string());
919 }
920 if flag("upcoming_release") {
921 ts.push("upcoming".to_string());
922 }
923 if let Some(labels) = d.and_then(|d| d.get("labels")).and_then(|v| v.as_array()) {
924 ts.extend(labels.iter().filter_map(|l| l.as_str().map(str::to_string)));
925 }
926 ts
927 }
928 Shape::Status(kind) => {
929 let mut ts = vec![kind.to_string()];
930 ts.extend(s("status"));
931 ts
932 }
933 Shape::Entry(t) => {
934 if d.and_then(|d| d.get("type")).and_then(|v| v.as_str()) == Some("commit") {
936 return vec!["submodule".to_string()];
937 }
938 vec![t.to_string()]
939 }
940 }
941 }
942
943 fn property(&self, node: NodeId, name: &str) -> Option<Value> {
946 enum Shape {
947 Group(String),
948 Project(String),
949 User(String),
950 Data,
951 }
952 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
953 Kind::Group { path } => Shape::Group(path.clone()),
954 Kind::Project { path } => Shape::Project(path.clone()),
955 Kind::User { username } => Shape::User(username.clone()),
956 Kind::Item | Kind::Pipeline { .. } | Kind::Job | Kind::File { .. } => Shape::Data,
957 _ => return None,
958 };
959 let at = |d: &Json, ptr: &str| d.pointer(ptr).and_then(json_scalar);
960 match shape {
961 Shape::Group(path) => {
962 let d = self.group_data(node, &path)?;
963 let key = match name {
964 "name" => "/name",
965 "path" => "/path",
966 "full-path" => "/full_path",
967 "description" => "/description",
968 "visibility" => "/visibility",
969 "created" => "/created_at",
970 _ => return None,
971 };
972 at(&d, key)
973 }
974 Shape::Project(path) => {
975 let full = matches!(name, "parent" | "stars" | "forks" | "open-issues");
976 let d = self.project_data(node, &path, full)?;
977 match name {
978 "topics" => {
979 let ts = d.get("topics")?.as_array()?;
980 Some(Value::list(
981 ts.iter()
982 .filter_map(|t| Some(Value::Str(t.as_str()?.to_string())))
983 .collect(),
984 ))
985 }
986 "parent" => at(&d, "/forked_from_project/path_with_namespace"),
987 _ => {
988 let key = match name {
989 "name" => "/name",
990 "path" => "/path",
991 "full-path" => "/path_with_namespace",
992 "description" => "/description",
993 "stars" => "/star_count",
994 "forks" => "/forks_count",
995 "open-issues" => "/open_issues_count",
996 "default-branch" => "/default_branch",
997 "visibility" => "/visibility",
998 "created" => "/created_at",
999 "activity" => "/last_activity_at",
1000 _ => return None,
1001 };
1002 at(&d, key)
1003 }
1004 }
1005 }
1006 Shape::User(username) => {
1007 let d = self.user_data(node, &username)?;
1008 let key = match name {
1009 "username" => "/username",
1010 "name" => "/name",
1011 "state" => "/state",
1012 _ => return None,
1013 };
1014 at(&d, key)
1015 }
1016 Shape::Data => {
1017 let d = self.data(node)?;
1018 match name {
1019 "labels" => {
1020 let ls = d.get("labels")?.as_array()?;
1021 Some(Value::list(
1022 ls.iter()
1023 .filter_map(|l| Some(Value::Str(l.as_str()?.to_string())))
1024 .collect(),
1025 ))
1026 }
1027 "author" => at(&d, "/author/username"),
1028 "milestone" => at(&d, "/milestone/title"),
1029 _ => {
1030 let key = match name {
1031 "iid" => "/iid",
1032 "title" => "/title",
1033 "state" => "/state",
1034 "comments" => "/user_notes_count",
1035 "upvotes" => "/upvotes",
1036 "weight" => "/weight",
1037 "created" => "/created_at",
1038 "updated" => "/updated_at",
1039 "closed" => "/closed_at",
1040 "merged" => "/merged_at",
1041 "source-branch" => "/source_branch",
1042 "target-branch" => "/target_branch",
1043 "sha" => "/sha",
1044 "tag" => "/tag_name",
1045 "name" => "/name",
1046 "released" => "/released_at",
1047 "status" => "/status",
1048 "ref" => "/ref",
1049 "source" => "/source",
1050 "duration" => "/duration",
1051 "stage" => "/stage",
1052 "size" => "/size",
1053 _ => return None,
1054 };
1055 at(&d, key)
1056 }
1057 }
1058 }
1059 }
1060 }
1061
1062 fn default_value(&self, node: NodeId) -> Option<Value> {
1065 let (proj, path) = match &self.nodes.borrow()[node.0 as usize].kind {
1066 Kind::File { proj, path } => (proj.clone(), path.clone()),
1067 Kind::Item => {
1068 let d = self.data(node)?;
1069 let body = d.get("description")?.as_str()?;
1070 return Some(Value::Str(body.to_string()));
1071 }
1072 _ => return None,
1073 };
1074 self.file_content(&proj, &path).map(Value::Str)
1075 }
1076
1077 fn metadata(&self, node: NodeId, key: &str) -> Option<Value> {
1078 match key {
1079 "path" => Some(Value::Str(self.locator(node))),
1080 "url" => {
1081 let d = self.data(node)?;
1082 Some(Value::Str(d.get("web_url")?.as_str()?.to_string()))
1083 }
1084 _ => None,
1085 }
1086 }
1087
1088 fn resolve(&self, node: NodeId, property: &str, _hint: Option<&str>) -> Option<NodeId> {
1090 let proj = match &self.nodes.borrow()[node.0 as usize].kind {
1091 Kind::Project { path } => Some(path.clone()),
1092 Kind::Item => None,
1093 _ => return None,
1094 };
1095 match (proj, property) {
1096 (Some(path), "parent") => {
1097 let d = self.project_data(node, &path, true)?;
1098 let pkey = d
1099 .pointer("/forked_from_project/path_with_namespace")?
1100 .as_str()?
1101 .to_string();
1102 Some(self.project_node(&pkey, d.get("forked_from_project")))
1103 }
1104 (None, "author") => {
1105 let d = self.data(node)?;
1106 self.author_of(&d)
1107 }
1108 _ => None,
1109 }
1110 }
1111
1112 fn links(&self, node: NodeId) -> Vec<(String, NodeId)> {
1116 enum Shape {
1117 Group(String),
1118 Project(String),
1119 Item,
1120 }
1121 let shape = match &self.nodes.borrow()[node.0 as usize].kind {
1122 Kind::Group { path } => Shape::Group(path.clone()),
1123 Kind::Project { path } => Shape::Project(path.clone()),
1124 Kind::Item => Shape::Item,
1125 _ => return Vec::new(),
1126 };
1127 let mut out = Vec::new();
1128 match shape {
1129 Shape::Group(path) => out.extend(self.member_links("groups", &path)),
1130 Shape::Project(path) => {
1131 if let Some(n) = self.resolve(node, "parent", None) {
1132 out.push(("parent".to_string(), n));
1133 }
1134 out.extend(self.member_links("projects", &path));
1135 }
1136 Shape::Item => {
1137 let Some(d) = self.data(node) else {
1138 return out;
1139 };
1140 if let Some(n) = self.author_of(&d) {
1141 out.push(("author".to_string(), n));
1142 }
1143 for key in ["assignees", "reviewers"] {
1144 let label = key.trim_end_matches('s');
1145 if let Some(us) = d.get(key).and_then(|v| v.as_array()) {
1146 for u in us {
1147 if let Some(username) = u.get("username").and_then(|v| v.as_str()) {
1148 out.push((label.to_string(), self.user_node(username, Some(u))));
1149 }
1150 }
1151 }
1152 }
1153 }
1154 }
1155 out
1156 }
1157
1158 fn backlinks(&self, node: NodeId) -> Vec<(String, NodeId)> {
1160 let path = match &self.nodes.borrow()[node.0 as usize].kind {
1161 Kind::Project { path } => path.clone(),
1162 _ => return Vec::new(),
1163 };
1164 self.call_paged(&format!("projects/{}/forks", enc(&path)))
1165 .iter()
1166 .filter_map(|f| {
1167 let fp = f.get("path_with_namespace")?.as_str()?;
1168 Some(("parent".to_string(), self.project_node(fp, Some(f))))
1169 })
1170 .collect()
1171 }
1172
1173 fn link_property(
1176 &self,
1177 source: NodeId,
1178 label: &str,
1179 target: NodeId,
1180 name: &str,
1181 ) -> Option<Value> {
1182 if label != "member" || !matches!(name, "access" | "role") {
1183 return None;
1184 }
1185 let scope = match &self.nodes.borrow()[source.0 as usize].kind {
1186 Kind::Group { path } | Kind::Project { path } => path.clone(),
1187 _ => return None,
1188 };
1189 let username = match &self.nodes.borrow()[target.0 as usize].kind {
1190 Kind::User { username } => username.clone(),
1191 _ => return None,
1192 };
1193 let level = *self.access.borrow().get(&format!("{scope}@{username}"))?;
1194 Some(match name {
1195 "access" => Value::Int(level),
1196 _ => Value::Str(role_name(level).to_string()),
1197 })
1198 }
1199}
1200
1201#[cfg(test)]
1202mod tests {
1203 use super::*;
1204
1205 #[test]
1206 fn paths_encode() {
1207 assert_eq!(
1208 enc("tesslab/instruments/gauge"),
1209 "tesslab%2Finstruments%2Fgauge"
1210 );
1211 }
1212
1213 #[test]
1214 fn roles_map() {
1215 assert_eq!(role_name(50), "owner");
1216 assert_eq!(role_name(40), "maintainer");
1217 assert_eq!(role_name(30), "developer");
1218 assert_eq!(role_name(20), "reporter");
1219 assert_eq!(role_name(10), "guest");
1220 assert_eq!(role_name(5), "minimal");
1221 }
1222
1223 #[test]
1224 fn timestamps_become_instants() {
1225 assert!(matches!(
1226 str_value("2026-07-20T12:00:00.000Z"),
1227 Value::Instant { .. }
1228 ));
1229 assert!(matches!(str_value("v1.0"), Value::Str(_)));
1230 }
1231
1232 #[test]
1233 fn target_needs_the_scheme() {
1234 assert!(matches!(
1235 GitlabAdapter::connect("gl:tesslab"),
1236 Err(GitlabError::Target(_))
1237 ));
1238 }
1239}