1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3
4#[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 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub embed: Option<bool>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub max_enrich: Option<usize>,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub max_flows: Option<usize>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub llm_concurrency: Option<usize>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub llm_price_in: Option<f64>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub llm_price_out: Option<f64>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub format: Option<String>,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub scip: Option<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub auto_index: Option<bool>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub auto_index_llm: Option<bool>,
58 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub auto_index_minutes: Option<usize>,
61 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub repos: Vec<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub teams: Option<Vec<TeamDef>>,
67}
68
69#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub struct TeamDef {
72 pub name: String,
73 #[serde(default)]
74 pub repos: Vec<String>,
75 #[serde(default)]
77 pub members: Vec<String>,
78 #[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 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 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 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 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 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 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 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 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 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 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 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#[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
279pub const UNASSIGNED: &str = "none";