1use anyhow::Result;
6
7const TABLE: &str = "settings";
8
9pub use comindly_api::team::{TeamDef, TeamSettings};
10
11pub use comindly_api::team::Incoming;
12
13pub use comindly_api::team::UNASSIGNED;
14
15pub fn parse_selection(raw: Option<&str>) -> Vec<String> {
17 raw.unwrap_or_default()
18 .split(',')
19 .map(|s| s.trim().to_string())
20 .filter(|s| !s.is_empty())
21 .collect()
22}
23
24#[derive(Debug, Clone, Default)]
26pub struct Stored {
27 pub settings: TeamSettings,
28 pub version: Option<u64>,
29 pub changed_by: Option<String>,
30 pub changed_at: Option<String>,
31}
32
33fn uri(root: &str) -> String {
34 format!("{}/_team", root.trim_end_matches('/'))
35}
36
37pub async fn load(root: &str) -> Result<Stored> {
39 let s = crate::docstore::load::<TeamSettings>(&uri(root), TABLE).await?;
40 Ok(Stored {
41 settings: s.doc,
42 version: s.version,
43 changed_by: s.changed_by,
44 changed_at: s.changed_at,
45 })
46}
47
48pub async fn save_as(
50 root: &str,
51 settings: &TeamSettings,
52 expected: Option<u64>,
53 by: &str,
54) -> Result<u64> {
55 crate::docstore::save(&uri(root), TABLE, settings, expected, by)
56 .await
57 .map_err(|e| match format!("{e}").contains("changed meanwhile") {
58 true => anyhow::anyhow!(
59 "the team settings were changed by someone else; reload and try again"
60 ),
61 false => e,
62 })
63}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[tokio::test]
70 async fn save_load_and_refuse_a_stale_write() {
71 let dir = std::env::temp_dir().join(format!("comindly-team-{}", std::process::id()));
72 let root = dir.to_str().unwrap();
73 let first = TeamSettings {
74 llm_model: Some("m1".into()),
75 ..Default::default()
76 };
77 let v1 = save_as(root, &first, None, "t").await.unwrap();
78 assert_eq!(load(root).await.unwrap().settings, first);
79 let second = TeamSettings {
80 llm_model: Some("m2".into()),
81 ..Default::default()
82 };
83 save_as(root, &second, Some(v1), "t").await.unwrap();
84 assert!(
85 save_as(root, &first, Some(v1), "t").await.is_err(),
86 "stale version"
87 );
88 assert_eq!(
89 load(root).await.unwrap().settings.llm_model.as_deref(),
90 Some("m2")
91 );
92 std::fs::remove_dir_all(&dir).ok();
93 }
94
95 fn two_teams() -> TeamSettings {
96 let mut t = TeamSettings::default();
97 t.label("catalog", &["Search".into()]);
98 t.label("shared-lib", &["Search".into(), "Backend".into()]);
99 t.label("billing", &["Backend".into()]);
100 t
101 }
102
103 #[test]
104 fn labels_select_repositories() {
105 let t = two_teams();
106 assert_eq!(t.teams_of("shared-lib"), ["Search", "Backend"]);
107 let search = vec!["search".to_string()];
108 assert!(t.in_view("catalog", &search) && t.in_view("shared-lib", &search));
109 assert!(!t.in_view("billing", &search));
110 assert!(t.in_view("billing", &[]), "no selection shows everything");
111 let none = vec![UNASSIGNED.to_string()];
112 assert!(t.in_view("docs", &none) && !t.in_view("catalog", &none));
113 }
114
115 #[test]
116 fn relabel_and_unlabel() {
117 let mut t = two_teams();
118 t.label("shared-lib", &["Backend".into()]);
119 assert_eq!(t.teams_of("shared-lib"), ["Backend"]);
120 t.unlabel("billing");
121 assert!(t.teams_of("billing").is_empty());
122 assert_eq!(t.team_list().len(), 2, "an emptied team stays");
123 }
124
125 #[test]
126 fn tidy_refuses_duplicates_and_the_reserved_name() {
127 let mut t = two_teams();
128 t.teams.as_mut().unwrap().push(TeamDef {
129 name: " search ".into(),
130 ..Default::default()
131 });
132 assert!(t.tidy_teams().is_err());
133 let mut t = TeamSettings {
134 teams: Some(vec![TeamDef {
135 name: "None".into(),
136 ..Default::default()
137 }]),
138 ..Default::default()
139 };
140 assert!(t.tidy_teams().is_err());
141 }
142
143 #[test]
144 fn closed_teams_refuse_notes_from_outside() {
145 let mut t = TeamSettings::default();
146 t.label("api", &["Backend".into()]);
147 t.label("pay", &["Payments".into(), "Backend".into()]);
148 let search = ["Search".to_string()];
149 assert_eq!(
150 t.outside(&search, &["api"]),
151 Some(vec!["Backend".to_string()])
152 );
153 assert_eq!(t.outside(&["backend".into()], &["api"]), None);
154 assert_eq!(t.outside(&[], &["api"]), None);
155 assert_eq!(t.outside(&search, &["unlabelled"]), None);
156 t.teams.as_mut().unwrap()[0].incoming = Incoming::Closed;
157 assert!(t.accepts(&search, &["api"]).is_err());
158 assert!(t.accepts(&search, &["pay"]).is_ok());
160 assert!(t.accepts(&["Payments".into()], &["api"]).is_err());
161 }
162
163 #[test]
164 fn backfill_only_once() {
165 let org = TeamSettings {
166 name: Some("Search".into()),
167 ..Default::default()
168 };
169 let done = org
170 .backfill(&["b".into(), "a".into(), "a".into()], &["Ann@X.io".into()])
171 .unwrap();
172 assert_eq!(done.team_list()[0].name, "Search");
173 assert_eq!(done.team_list()[0].repos, ["a", "b"]);
174 assert_eq!(done.teams_of_member("ann@x.io"), ["Search"]);
175 assert!(
176 done.backfill(&["c".into()], &[]).is_none(),
177 "already set up"
178 );
179 let emptied = TeamSettings {
180 teams: Some(vec![]),
181 ..org.clone()
182 };
183 assert!(
184 emptied.backfill(&["a".into()], &[]).is_none(),
185 "deliberately none"
186 );
187 assert!(
188 TeamSettings::default()
189 .backfill(&["a".into()], &[])
190 .is_none(),
191 "no org name"
192 );
193 }
194
195 #[test]
196 fn members_pick_existing_teams() {
197 let mut t = two_teams();
198 t.set_member("Ann@x.io", &["search".into(), "Backend".into()])
199 .unwrap();
200 assert_eq!(t.teams_of_member("ann@x.io"), ["Search", "Backend"]);
201 t.set_member("ann@x.io", &["Backend".into()]).unwrap();
202 assert_eq!(t.teams_of_member("ann@x.io"), ["Backend"]);
203 assert!(t.set_member("ann@x.io", &["Nope".into()]).is_err());
204 }
205}