Skip to main content

comindly_api/
team.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4/// What everyone using an index shares; `None` leaves comindly's default.
5#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
6pub struct TeamSettings {
7    #[serde(default, skip_serializing_if = "Option::is_none")]
8    pub name: Option<String>,
9    #[serde(default, skip_serializing_if = "Option::is_none")]
10    pub llm_base_url: Option<String>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub llm_model: Option<String>,
13    /// The model for one LLM task in place of `llm_model`.
14    #[serde(default, skip_serializing_if = "Option::is_none")]
15    pub llm_model_summary: Option<String>,
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub llm_model_guide: Option<String>,
18    #[serde(default, skip_serializing_if = "Option::is_none")]
19    pub llm_model_flow: Option<String>,
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub embed_model: Option<String>,
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub enrich: Option<bool>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub flows: Option<bool>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub guide: Option<bool>,
28    /// Search embeddings when indexing.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub embed: Option<bool>,
31    /// Most symbols summarised by the LLM, most central first.
32    #[serde(default, skip_serializing_if = "Option::is_none")]
33    pub max_enrich: Option<usize>,
34    /// Most flows given a written walkthrough, furthest-reaching first.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub max_flows: Option<usize>,
37    /// LLM requests in flight at once.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub llm_concurrency: Option<usize>,
40    /// The LLM's price in USD per million input tokens, to show what indexing costs.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub llm_price_in: Option<f64>,
43    /// The LLM's price in USD per million output tokens.
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub llm_price_out: Option<f64>,
46    /// What MCP tools return as text: `md` (default) or `json`; the JSON is always attached too.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub format: Option<String>,
49    /// SCIP use when indexing: `auto` (default), `require` or `off`.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub scip: Option<String>,
52    /// Re-index when a listed repository's default branch moves on its remote.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub auto_index: Option<bool>,
55    /// Also run the LLM steps on automatic re-indexes, for new or changed code only.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub auto_index_llm: Option<bool>,
58    /// Minutes between checks whether a default branch moved (default 5).
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub auto_index_minutes: Option<usize>,
61    /// Clone URLs of the team's repositories, so everyone can get the same set.
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub repos: Vec<String>,
64    /// Teams labelling repositories; `None` until set up, so an upgrade can backfill once.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub teams: Option<Vec<TeamDef>>,
67}
68
69/// A team: a label on repositories (by indexed name); a repository may carry several.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct TeamDef {
72    pub name: String,
73    #[serde(default)]
74    pub repos: Vec<String>,
75    /// Members' emails, lowercase; what they see first and how their notes are marked.
76    #[serde(default)]
77    pub members: Vec<String>,
78    /// How notes from people outside the team on its code count.
79    #[serde(default)]
80    pub incoming: Incoming,
81}
82
83impl TeamSettings {
84    pub fn team_list(&self) -> &[TeamDef] {
85        self.teams.as_deref().unwrap_or_default()
86    }
87
88    /// Teams labelling `repo`, in the order they were defined.
89    pub fn teams_of(&self, repo: &str) -> Vec<String> {
90        self.team_list()
91            .iter()
92            .filter(|t| t.repos.iter().any(|r| r == repo))
93            .map(|t| t.name.clone())
94            .collect()
95    }
96
97    /// The teams owning `repos` when the writer is in none of them; `None` inside or unlabelled.
98    pub fn outside(&self, writer: &[String], repos: &[&str]) -> Option<Vec<String>> {
99        if writer.is_empty() {
100            return None;
101        }
102        let mut owners: Vec<String> = Vec::new();
103        for r in repos {
104            for t in self.teams_of(r) {
105                if !owners.contains(&t) {
106                    owners.push(t);
107                }
108            }
109        }
110        let inside = owners
111            .iter()
112            .any(|o| writer.iter().any(|w| w.eq_ignore_ascii_case(o)));
113        (!owners.is_empty() && !inside).then_some(owners)
114    }
115
116    /// Refuses a note from outside when every team owning `repos` is closed to them.
117    pub fn accepts(&self, writer: &[String], repos: &[&str]) -> Result<()> {
118        if let Some(owners) = self.outside(writer, repos) {
119            let (takes, their) = if owners.len() == 1 {
120                ("takes", "its")
121            } else {
122                ("take", "their")
123            };
124            anyhow::ensure!(
125                self.incoming(&owners) != Incoming::Closed,
126                "{} {takes} notes on this code only from {their} members; ask one of them to record it",
127                owners.join(" and ")
128            );
129        }
130        Ok(())
131    }
132
133    /// The most open policy among `owners` for notes from outside.
134    pub fn incoming(&self, owners: &[String]) -> Incoming {
135        self.team_list()
136            .iter()
137            .filter(|t| owners.iter().any(|o| o.eq_ignore_ascii_case(&t.name)))
138            .map(|t| t.incoming)
139            .min()
140            .unwrap_or_default()
141    }
142
143    /// Teams `email` belongs to.
144    pub fn teams_of_member(&self, email: &str) -> Vec<String> {
145        let email = email.trim().to_lowercase();
146        self.team_list()
147            .iter()
148            .filter(|t| t.members.contains(&email))
149            .map(|t| t.name.clone())
150            .collect()
151    }
152
153    /// Put `email` in exactly the existing teams named in `teams`.
154    pub fn set_member(&mut self, email: &str, teams: &[String]) -> Result<()> {
155        let email = email.trim().to_lowercase();
156        for n in teams {
157            anyhow::ensure!(
158                self.team_list()
159                    .iter()
160                    .any(|t| t.name.eq_ignore_ascii_case(n.trim())),
161                "there is no team called {n}"
162            );
163        }
164        for t in self.teams.iter_mut().flatten() {
165            let want = teams.iter().any(|n| n.trim().eq_ignore_ascii_case(&t.name));
166            t.members.retain(|m| *m != email);
167            if want {
168                t.members.push(email.clone());
169            }
170        }
171        Ok(())
172    }
173
174    /// Whether `repo` is in view for the `selected` teams (empty: everything; `none`: unlabelled).
175    pub fn in_view(&self, repo: &str, selected: &[String]) -> bool {
176        if selected.is_empty() {
177            return true;
178        }
179        let labels = self.teams_of(repo);
180        selected.iter().any(|s| match s.as_str() {
181            UNASSIGNED => labels.is_empty(),
182            name => labels.iter().any(|l| l.eq_ignore_ascii_case(name)),
183        })
184    }
185
186    /// Trim names, drop empty and duplicate teams and repeated repositories.
187    pub fn tidy_teams(&mut self) -> Result<()> {
188        let Some(teams) = self.teams.as_mut() else {
189            return Ok(());
190        };
191        let mut seen = std::collections::HashSet::new();
192        for t in teams.iter_mut() {
193            t.name = t.name.trim().to_string();
194            anyhow::ensure!(!t.name.is_empty(), "a team needs a name");
195            anyhow::ensure!(
196                !t.name.eq_ignore_ascii_case(UNASSIGNED),
197                "`{UNASSIGNED}` is reserved for repositories without a team"
198            );
199            anyhow::ensure!(
200                seen.insert(t.name.to_lowercase()),
201                "two teams are called {}",
202                t.name
203            );
204            let mut repos = std::collections::BTreeSet::new();
205            t.repos.retain(|r| repos.insert(r.clone()));
206            for m in t.members.iter_mut() {
207                *m = m.trim().to_lowercase();
208            }
209            let mut members = std::collections::BTreeSet::new();
210            t.members
211                .retain(|m| !m.is_empty() && members.insert(m.clone()));
212        }
213        Ok(())
214    }
215
216    /// Remove `repo` from every team.
217    pub fn unlabel(&mut self, repo: &str) {
218        for t in self.teams.iter_mut().flatten() {
219            t.repos.retain(|r| r != repo);
220        }
221    }
222
223    /// Set exactly `teams` on `repo`, creating teams that don't exist yet.
224    pub fn label(&mut self, repo: &str, teams: &[String]) {
225        let list = self.teams.get_or_insert_with(Vec::new);
226        for t in list.iter_mut() {
227            let want = teams.iter().any(|n| n.trim().eq_ignore_ascii_case(&t.name));
228            let has = t.repos.iter().any(|r| r == repo);
229            match (want, has) {
230                (true, false) => t.repos.push(repo.to_string()),
231                (false, true) => t.repos.retain(|r| r != repo),
232                _ => {}
233            }
234        }
235        for n in teams.iter().map(|n| n.trim()).filter(|n| !n.is_empty()) {
236            if !list.iter().any(|t| t.name.eq_ignore_ascii_case(n)) {
237                list.push(TeamDef {
238                    name: n.to_string(),
239                    repos: vec![repo.to_string()],
240                    ..Default::default()
241                });
242            }
243        }
244    }
245
246    /// A first team named after the org with these repos and members, when teams were never set up.
247    pub fn backfill(&self, repos: &[String], members: &[String]) -> Option<TeamSettings> {
248        let name = self.name.as_deref()?.trim();
249        if self.teams.is_some() || name.is_empty() {
250            return None;
251        }
252        let mut repos = repos.to_vec();
253        repos.sort();
254        repos.dedup();
255        let mut next = TeamSettings {
256            teams: Some(vec![TeamDef {
257                name: name.to_string(),
258                repos,
259                members: members.to_vec(),
260                ..Default::default()
261            }]),
262            ..self.clone()
263        };
264        next.tidy_teams().ok()?;
265        Some(next)
266    }
267}
268
269/// Ordered from most to least open.
270#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
271#[serde(rename_all = "lowercase")]
272pub enum Incoming {
273    Open,
274    #[default]
275    Review,
276    Closed,
277}
278
279/// The `none` filter value: repositories no team labels.
280pub const UNASSIGNED: &str = "none";