Skip to main content

comindly_core/
team.rs

1//! Team settings, stored with the index (`<index>/_team`, a versioned LanceDB table) so everyone
2//! using it runs the same LLM, embedding model and LLM steps. No secrets: each member's API key
3//! comes from their own credentials file or environment.
4
5use 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
15/// Parse a `teams` filter: comma-separated names, `none` for unlabelled.
16pub 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/// The stored settings with the table version they came from and who saved them.
25#[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
37/// The team settings of the index at `root` (empty when none were saved).
38pub 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
48/// Save `settings` as `by` if nobody saved since `expected` (the version they were loaded at).
49pub 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        // Several owners: the most open decides.
159        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}