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