Skip to content
2,007 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! Teams: groups of a workspace's members (see `g1t_contracts::teams`).
2//!
3//! Kept beside the workspaces they belong to: `teams` and `team_members`,
4//! and a team's roles on repositories as rows of `repo_grants` whose
5//! principal is the team. Nothing here decides access on a request:
6//! [`Identity::grants_of`] (access.rs) folds a person's teams' roles into
7//! the grants every resolved user carries.
8//!
9//! **Who may do what.**
10//!
11//! | | Who |
12//! | --- | --- |
13//! | See a visible team | Every member of the workspace |
14//! | See a secret team | Its own people and the workspace's owners |
Merge branch 'worktree-agent-ad7c6d88d93adc817'15//! | Create a team | Any member, or owners only when the workspace says so (`TeamCreation`); they become its maintainer. Under a parent: an owner, or a maintainer of the parent |
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar16//! | Change a team, its people and settings, or delete it | Owners, and the team's maintainers |
17//! | Move a team under another | Owners, or maintainers of both |
18//! | Give a team a role on a repository | Admin on the repository |
19//! | Take a team's role away | Admin on the repository, owners, and the team's maintainers |
20//! | Leave a team | Anyone in it |
21//!
22//! Changes are for people only, never an agent's or a workspace's token,
23//! and need a confirmed email address. Each is published as a `team.*`
24//! event and recorded in the workspace's audit log.
25
26use std::collections::{HashMap, HashSet};
27
28use g1t_contracts::access::{RepoRef, RepoRole, granted};
29use g1t_contracts::audit::Surface;
30use g1t_contracts::codeowners::{Owner, OwnerCheck};
31use g1t_contracts::events::TeamChanged;
32use g1t_contracts::identity::AGENT_NAME;
33use g1t_contracts::teams::*;
34use g1t_contracts::time::rfc3339;
35use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, new_id};
36use g1t_kit::now_ms;
37use serde::Deserialize;
38use worker::Result;
39use worker::wasm_bindgen::JsValue;
40
41use crate::Identity;
42use crate::access::Named;
43
44const PEOPLE_ONLY: &str = "Only a person can change a team, signed in as themselves; never an agent's or a workspace's token.";
45const CONFIRM_FIRST: &str = "Confirm your email address before changing a team.";
46const NOT_FOUND: &str = "Team not found.";
Merge branch 'worktree-agent-ad7c6d88d93adc817'47const OWNERS_CREATE: &str = "Only owners can create teams in this workspace. Ask an owner to create one, or to let members create them in the workspace's settings.";
48
49/// A [`TeamCreation`] as the audit log says it.
50fn creation_words(setting: TeamCreation) -> &'static str {
51 match setting {
52 TeamCreation::Members => "any member",
53 TeamCreation::Owners => "owners only",
54 }
55}
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar56const NO_SUCH_USER: &str = "There is no account with that username.";
57const LIST_LIMIT: u32 = 500;
58
59fn opt(value: Option<&str>) -> JsValue {
60 value.map_or(JsValue::NULL, JsValue::from)
61}
62
63/// A team's row, with what lists show of it and the viewer's place in it.
64#[derive(Clone, Debug, Default, Deserialize)]
65pub(crate) struct TeamRow {
66 pub id: String,
67 pub workspace_id: String,
68 pub workspace: String,
69 pub slug: String,
70 pub name: String,
71 #[serde(default)]
72 pub description: Option<String>,
73 pub visibility: String,
74 #[serde(default)]
75 pub parent_id: Option<String>,
76 #[serde(default)]
77 pub parent_slug: Option<String>,
78 #[serde(default)]
79 pub parent_name: Option<String>,
80 pub notify: u8,
81 #[serde(default)]
82 pub review_assignment: Option<String>,
83 pub members_count: u32,
84 pub repos_count: u32,
85 pub child_teams_count: u32,
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.86 #[serde(default)]
87 pub agents_count: u32,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar88 /// The viewer's role in it, if they are in it.
89 #[serde(default)]
90 pub viewer_role: Option<String>,
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.91 /// Its lead: `user` or `agent`, and the id; for a person, who they are.
92 #[serde(default)]
93 pub lead_kind: Option<String>,
94 #[serde(default)]
95 pub lead_id: Option<String>,
96 #[serde(default)]
97 pub lead_username: Option<String>,
98 #[serde(default)]
99 pub lead_name: Option<String>,
100 #[serde(default)]
101 pub lead_avatar: Option<String>,
102 #[serde(default)]
103 pub channel_id: Option<String>,
104 #[serde(default)]
105 pub channel_name: Option<String>,
106 /// D1 hands numbers over as doubles.
107 #[serde(default)]
108 pub budget_micros: Option<f64>,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar109 pub created_at: String,
110 pub updated_at: String,
111}
112
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.113/// A team's lead from its columns: a person still on record, or an agent.
114pub(crate) fn lead_of(
115 kind: Option<&str>,
116 id: Option<&str>,
117 username: Option<&str>,
118 name: Option<&str>,
119 avatar: Option<&str>,
120) -> Option<TeamLead> {
121 match (kind?, id?) {
122 ("agent", id) => Some(TeamLead::Agent { agent_id: id.to_owned() }),
123 ("user", _) => Some(TeamLead::User {
124 username: username?.to_owned(),
125 name: name.map(str::to_owned),
126 avatar: avatar.map(str::to_owned),
127 }),
128 _ => None,
129 }
130}
131
132/// A channel from its columns, when it has both.
133pub(crate) fn channel_of(id: Option<&str>, name: Option<&str>) -> Option<TeamChannel> {
134 match (id, name) {
135 (Some(id), Some(name)) if !id.is_empty() => Some(TeamChannel {
136 id: id.to_owned(),
137 name: name.to_owned(),
138 }),
139 _ => None,
140 }
141}
142
143/// A stored budget: a whole positive number of micros, or none.
144pub(crate) fn budget_of(micros: Option<f64>) -> Option<i64> {
145 micros.filter(|m| m.is_finite() && *m > 0.0).map(|m| m as i64)
146}
147
148/// The most a team budget may be: a million dollars a month.
149pub const MAX_TEAM_BUDGET_MICROS: i64 = 1_000_000 * 1_000_000;
150
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar151impl TeamRow {
152 pub fn visibility(&self) -> TeamVisibility {
153 TeamVisibility::parse(&self.visibility).unwrap_or_default()
154 }
155
156 pub fn review(&self) -> ReviewAssignment {
157 self.review_assignment
158 .as_deref()
159 .and_then(|json| serde_json::from_str::<ReviewAssignment>(json).ok())
160 .unwrap_or_default()
161 }
162
163 fn viewer_role(&self) -> Option<TeamRole> {
164 self.viewer_role.as_deref().and_then(TeamRole::parse)
165 }
166
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.167 pub(crate) fn lead(&self) -> Option<TeamLead> {
168 lead_of(
169 self.lead_kind.as_deref(),
170 self.lead_id.as_deref(),
171 self.lead_username.as_deref(),
172 self.lead_name.as_deref(),
173 self.lead_avatar.as_deref(),
174 )
175 }
176
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar177 fn shown(&self, owner: bool) -> Team {
178 let viewer_role = self.viewer_role();
179 Team {
180 id: self.id.clone(),
181 workspace: self.workspace.clone(),
182 slug: self.slug.clone(),
183 name: self.name.clone(),
184 description: self.description.clone(),
185 visibility: self.visibility(),
186 parent: match (&self.parent_slug, &self.parent_name) {
187 (Some(slug), Some(name)) => Some(TeamRef {
188 slug: slug.clone(),
189 name: name.clone(),
190 }),
191 _ => None,
192 },
193 notify: self.notify != 0,
194 review_assignment: self.review(),
195 members_count: self.members_count,
196 repos_count: self.repos_count,
197 child_teams_count: self.child_teams_count,
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.198 agents_count: self.agents_count,
199 lead: self.lead(),
200 channel: channel_of(self.channel_id.as_deref(), self.channel_name.as_deref()),
201 budget_micros: budget_of(self.budget_micros),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar202 viewer_role,
203 can_manage: may_manage(owner, viewer_role),
204 created_at: self.created_at.clone(),
205 updated_at: self.updated_at.clone(),
206 }
207 }
208
209 fn event(&self) -> TeamChanged {
210 TeamChanged {
211 workspace: self.workspace.clone(),
212 team_id: self.id.clone(),
213 team: self.slug.clone(),
214 name: self.name.clone(),
215 visibility: Some(self.visibility()),
216 parent: self.parent_slug.clone(),
217 ..TeamChanged::default()
218 }
219 }
220}
221
222/// Every column of [`TeamRow`]; binds the viewer's id as `?1`.
223const TEAM_COLUMNS: &str = "t.id, t.workspace_id, w.slug AS workspace, t.slug, t.name, t.description, t.visibility,
224 t.parent_id, p.slug AS parent_slug, p.name AS parent_name, t.notify, t.review_assignment,
225 (SELECT count(*) FROM team_members tm WHERE tm.team_id = t.id) AS members_count,
226 (SELECT count(*) FROM repo_grants g WHERE g.principal_kind = 'team' AND g.principal_id = t.id) AS repos_count,
227 (SELECT count(*) FROM teams c WHERE c.parent_id = t.id) AS child_teams_count,
228 (SELECT role FROM team_members me WHERE me.team_id = t.id AND me.user_id = ?1) AS viewer_role,
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.229 (SELECT count(*) FROM team_agents ta WHERE ta.team_id = t.id) AS agents_count,
230 t.lead_kind, t.lead_id, lu.username AS lead_username, lu.display_name AS lead_name, lu.avatar AS lead_avatar,
231 t.channel_id, t.channel_name, t.budget_micros,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar232 t.created_at, t.updated_at
233 FROM teams t
234 JOIN workspaces w ON w.id = t.workspace_id AND w.deleted_at IS NULL
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.235 LEFT JOIN teams p ON p.id = t.parent_id
236 LEFT JOIN users lu ON t.lead_kind = 'user' AND lu.id = t.lead_id AND lu.deleted_at IS NULL";
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar237
238// --- The rules, apart from the database ---------------------------------------
239
240/// Whether someone sees a team: everyone in the workspace sees a visible
241/// one; a secret one, its own people and the owners.
242pub fn may_see(visibility: TeamVisibility, owner: bool, in_team: bool) -> bool {
243 match visibility {
244 TeamVisibility::Visible => true,
245 TeamVisibility::Secret => owner || in_team,
246 }
247}
248
249/// Whether someone may change a team: owners, and its maintainers.
250pub fn may_manage(owner: bool, role: Option<TeamRole>) -> bool {
251 owner || role == Some(TeamRole::Maintainer)
252}
253
254/// Why a team cannot go under `parent`, or `None` if it can. `chain` is
255/// the parent and its ancestors, nearest first; `below` how many levels
256/// of teams sit under the team (0 for none).
257pub fn nesting_problem(
258 team_id: &str,
259 team_visibility: TeamVisibility,
260 parent_id: &str,
261 parent_visibility: TeamVisibility,
262 chain: &[String],
263 below: usize,
264) -> Option<&'static str> {
265 if team_id == parent_id || chain.iter().any(|id| id == team_id) {
266 return Some("A team cannot go under itself or one of its own child teams.");
267 }
268 if team_visibility == TeamVisibility::Secret || parent_visibility == TeamVisibility::Secret {
269 return Some("Secret teams cannot be nested. Make both teams visible first.");
270 }
271 // The parent's chain, this team, and what is under it.
272 if chain.len() + 1 + below > MAX_DEPTH {
273 return Some("Teams can nest at most 8 levels deep.");
274 }
275 None
276}
277
Fine-grained personal tokens, workspace token rules and approvals in identity278/// The workspace role the viewer has, counting g1t acting in it, and a
279/// workspace's own token an owner gave Admin, as owners. Any other
280/// workspace token is a member.
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.281pub(crate) fn role_of(viewer: &User, workspace: &str) -> Option<Role> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar282 if matches!(viewer.kind, PrincipalKind::Workspace | PrincipalKind::System) && viewer.is_member(workspace) {
Fine-grained personal tokens, workspace token rules and approvals in identity283 if viewer.kind == PrincipalKind::Workspace && viewer.token.as_deref().is_some_and(|token| !token.admin) {
284 return Some(Role::Member);
285 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar286 return Some(Role::Owner);
287 }
288 viewer.role_in(workspace)
289}
290
291/// One person, as a team lists them.
292#[derive(Deserialize)]
293struct MemberRow {
294 id: String,
295 username: String,
296 #[serde(default)]
297 name: Option<String>,
298 #[serde(default)]
299 avatar: Option<String>,
300 role: String,
301 /// The team they are in directly.
302 team_id: String,
303 team_slug: String,
304}
305
306#[derive(Deserialize)]
307struct IdRow {
308 id: String,
309}
310
311#[derive(Deserialize)]
312struct RoleRow {
313 role: String,
314}
315
316impl Identity {
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.317 pub(crate) async fn team_rows(&self, filter: &str, binds: &[JsValue]) -> Result<Vec<TeamRow>> {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar318 self.db
319 .prepare(format!("SELECT {TEAM_COLUMNS} {filter} LIMIT {LIST_LIMIT}"))
320 .bind(binds)?
321 .all()
322 .await?
323 .results::<TeamRow>()
324 }
325
326 /// One team of a workspace by slug, with the viewer's place in it.
327 pub(crate) async fn team_row(&self, workspace: &str, slug: &str, viewer_id: Option<&str>) -> Result<Option<TeamRow>> {
328 Ok(self
329 .team_rows(
330 "WHERE w.slug = ?2 AND t.slug = ?3",
331 &[opt(viewer_id), workspace.to_lowercase().into(), slug.trim().trim_start_matches('@').to_lowercase().into()],
332 )
333 .await?
334 .into_iter()
335 .next())
336 }
337
338 /// The team, if `viewer` may see it: a member of its workspace, and
339 /// for a secret team, in it or an owner. Missing otherwise.
340 async fn seen_team(&self, viewer: &Viewer, workspace: &str, slug: &str) -> Result<Outcome<(TeamRow, bool)>> {
341 let workspace = workspace.trim().to_lowercase();
342 let Some(viewer) = viewer.as_ref() else {
343 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
344 };
345 let Some(role) = role_of(viewer, &workspace) else {
346 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
347 };
348 let owner = role == Role::Owner;
349 let Some(row) = self.team_row(&workspace, slug, Some(&viewer.id)).await? else {
350 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
351 };
352 if !may_see(row.visibility(), owner, row.viewer_role.is_some()) {
353 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
354 }
355 Ok(Outcome::Ok((row, owner)))
356 }
357
358 /// The team, if `actor` may change it.
359 async fn managed_team(&self, actor: &User, workspace: &str, slug: &str) -> Result<Outcome<(TeamRow, bool)>> {
360 if !crate::security::is_person(actor) {
361 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
362 }
363 let (row, owner) = match self.seen_team(&Some(actor.clone()), workspace, slug).await? {
364 Outcome::Ok(found) => found,
365 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
366 };
367 if !may_manage(owner, row.viewer_role()) {
368 return Ok(Outcome::fail(
369 FailureCode::Forbidden,
370 format!("Only owners of {} and maintainers of {} can change it.", row.workspace, row.name),
371 ));
372 }
373 if !actor.verified {
374 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
375 }
376 Ok(Outcome::Ok((row, owner)))
377 }
378
379 pub async fn list_teams(&self, a: ListTeamsArgs) -> Result<Outcome<Vec<Team>>> {
380 let workspace = a.workspace.trim().to_lowercase();
381 let Some(role) = a.viewer.as_ref().and_then(|viewer| role_of(viewer, &workspace)) else {
382 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
383 };
384 let owner = role == Role::Owner;
385 let viewer_id = a.viewer.as_ref().map(|viewer| viewer.id.as_str());
386 let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty());
387 let rows = match query {
388 Some(query) => {
389 let like = format!("%{}%", query.to_lowercase().replace(['%', '_'], ""));
390 self.team_rows(
391 "WHERE w.slug = ?2 AND (lower(t.name) LIKE ?3 OR t.slug LIKE ?3)
392 ORDER BY viewer_role IS NULL, lower(t.name)",
393 &[opt(viewer_id), workspace.as_str().into(), like.into()],
394 )
395 .await?
396 }
397 None => {
398 self.team_rows(
399 "WHERE w.slug = ?2 ORDER BY viewer_role IS NULL, lower(t.name)",
400 &[opt(viewer_id), workspace.as_str().into()],
401 )
402 .await?
403 }
404 };
405 Ok(Outcome::Ok(
406 rows.iter()
407 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
408 .map(|row| row.shown(owner))
409 .collect(),
410 ))
411 }
412
413 pub async fn get_team(&self, a: TeamArgs) -> Result<Outcome<Team>> {
414 Ok(match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
415 Outcome::Ok((row, owner)) => Outcome::Ok(row.shown(owner)),
416 Outcome::Fail(failure) => Outcome::Fail(failure),
417 })
418 }
419
420 pub async fn child_teams(&self, a: TeamArgs) -> Result<Outcome<Vec<Team>>> {
421 let (row, owner) = match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
422 Outcome::Ok(found) => found,
423 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
424 };
425 let viewer_id = a.viewer.as_ref().map(|viewer| viewer.id.as_str());
426 let rows = self
427 .team_rows("WHERE t.parent_id = ?2 ORDER BY lower(t.name)", &[opt(viewer_id), row.id.as_str().into()])
428 .await?;
429 Ok(Outcome::Ok(
430 rows.iter()
431 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
432 .map(|row| row.shown(owner))
433 .collect(),
434 ))
435 }
436
437 pub async fn user_teams(&self, a: UserTeamsArgs) -> Result<Outcome<Vec<Team>>> {
438 let workspace = a.workspace.trim().to_lowercase();
439 let Some(role) = a.viewer.as_ref().and_then(|viewer| role_of(viewer, &workspace)) else {
440 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
441 };
442 let owner = role == Role::Owner;
443 let viewer_id = a.viewer.as_ref().map(|viewer| viewer.id.as_str());
444 let rows = self
445 .team_rows(
446 "WHERE w.slug = ?2 AND t.id IN (
447 SELECT tm.team_id FROM team_members tm JOIN users u ON u.id = tm.user_id WHERE u.username = ?3
448 ) ORDER BY lower(t.name)",
449 &[opt(viewer_id), workspace.as_str().into(), a.username.trim().trim_start_matches('@').to_lowercase().into()],
450 )
451 .await?;
452 Ok(Outcome::Ok(
453 rows.iter()
454 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
455 .map(|row| row.shown(owner))
456 .collect(),
457 ))
458 }
459
460 /// The parent and its ancestors, nearest first, by id.
461 async fn chain_of(&self, team_id: &str) -> Result<Vec<String>> {
462 Ok(self
463 .db
464 .prepare(
465 "WITH RECURSIVE up(id, depth) AS (
466 SELECT ?1, 0
467 UNION ALL
468 SELECT t.parent_id, up.depth + 1 FROM teams t JOIN up ON t.id = up.id
469 WHERE t.parent_id IS NOT NULL AND up.depth < 20
470 )
471 SELECT id FROM up ORDER BY depth",
472 )
473 .bind(&[team_id.into()])?
474 .all()
475 .await?
476 .results::<IdRow>()?
477 .into_iter()
478 .map(|row| row.id)
479 .collect())
480 }
481
482 /// How many levels of teams sit under a team.
483 async fn depth_below(&self, team_id: &str) -> Result<usize> {
484 #[derive(Deserialize)]
485 struct Depth {
486 depth: Option<u32>,
487 }
488 let row = self
489 .db
490 .prepare(
491 "WITH RECURSIVE down(id, depth) AS (
492 SELECT ?1, 0
493 UNION ALL
494 SELECT t.id, down.depth + 1 FROM teams t JOIN down ON t.parent_id = down.id WHERE down.depth < 20
495 )
496 SELECT max(depth) AS depth FROM down",
497 )
498 .bind(&[team_id.into()])?
499 .first::<Depth>(None)
500 .await?;
501 Ok(row.and_then(|row| row.depth).unwrap_or(0) as usize)
502 }
503
504 /// Whether `parent` can take `row` (or a new team, with `row` None)
505 /// under it, as `actor`: the parent exists, the actor may manage it,
506 /// and the nesting is allowed. Returns the parent.
507 async fn parent_for(
508 &self,
509 actor: &User,
510 owner: bool,
511 workspace: &str,
512 parent: &str,
513 team: Option<(&str, TeamVisibility)>,
514 visibility: TeamVisibility,
515 ) -> Result<Outcome<TeamRow>> {
516 let Some(parent) = self.team_row(workspace, parent, Some(&actor.id)).await? else {
517 return Ok(Outcome::fail(FailureCode::NotFound, "There is no team with that slug to go under."));
518 };
519 if !may_see(parent.visibility(), owner, parent.viewer_role.is_some()) {
520 return Ok(Outcome::fail(FailureCode::NotFound, "There is no team with that slug to go under."));
521 }
522 if !may_manage(owner, parent.viewer_role()) {
523 return Ok(Outcome::fail(
524 FailureCode::Forbidden,
525 format!("Only owners and maintainers of {} can put a team under it.", parent.name),
526 ));
527 }
528 let chain = self.chain_of(&parent.id).await?;
529 let (team_id, below) = match team {
530 Some((id, _)) => (id.to_owned(), self.depth_below(id).await?),
531 None => (String::new(), 0),
532 };
533 let team_visibility = team.map_or(visibility, |(_, _)| visibility);
534 if let Some(why) = nesting_problem(&team_id, team_visibility, &parent.id, parent.visibility(), &chain[..], below) {
535 return Ok(Outcome::fail(FailureCode::Invalid, why));
536 }
537 Ok(Outcome::Ok(parent))
538 }
539
Merge branch 'worktree-agent-ad7c6d88d93adc817'540 /// Who may create a workspace's teams; the default when there is no
541 /// such workspace.
542 pub(crate) async fn team_creation_of(&self, slug: &str) -> Result<TeamCreation> {
543 #[derive(Deserialize)]
544 struct Row {
545 #[serde(default)]
546 team_creation: Option<String>,
547 }
548 let row = self
549 .db
550 .prepare("SELECT team_creation FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
551 .bind(&[slug.into()])?
552 .first::<Row>(None)
553 .await?;
554 Ok(row
555 .and_then(|row| row.team_creation)
556 .as_deref()
557 .and_then(TeamCreation::parse)
558 .unwrap_or_default())
559 }
560
561 /// `set_team_creation`: who may create the workspace's teams. Owners
562 /// only, as a person with a confirmed email address. Teams already made
563 /// stay as they are.
564 pub async fn set_team_creation(&self, a: SetTeamCreationArgs) -> Result<Outcome<TeamCreation>> {
565 let slug = a.slug.trim().to_lowercase();
566 if !crate::security::is_person(&a.actor) || a.actor.role_in(&slug) != Some(Role::Owner) {
567 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can change who may create teams."));
568 }
569 if !a.actor.verified {
570 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before changing the workspace's settings."));
571 }
572 let Some(workspace_id) = self.workspace_id_of(&slug).await? else {
573 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
574 };
575 let previous = self.team_creation_of(&slug).await?;
576 if previous == a.team_creation {
577 return Ok(Outcome::Ok(previous));
578 }
579 let stored = match a.team_creation {
580 TeamCreation::Members => JsValue::NULL,
581 other => other.as_str().into(),
582 };
583 self.db
584 .prepare("UPDATE workspaces SET team_creation = ? WHERE id = ?")
585 .bind(&[stored, workspace_id.as_str().into()])?
586 .run()
587 .await?;
588 self.audit_workspace(
589 &a.actor,
590 "workspace.team_creation_changed",
591 &slug,
592 a.surface.unwrap_or(Surface::Web),
593 format!(
594 "Changed who may create teams from {} to {}",
595 creation_words(previous),
596 creation_words(a.team_creation)
597 ),
598 )
599 .await;
600 // Members' resolved users carry the setting, for the site's menus.
601 self.announce_workspace(&workspace_id, &slug, Some(&a.actor.id)).await;
602 Ok(Outcome::Ok(a.team_creation))
603 }
604
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar605 pub async fn create_team(&self, a: CreateTeamArgs) -> Result<Outcome<Team>> {
606 let workspace = a.workspace.trim().to_lowercase();
607 if !crate::security::is_person(&a.actor) {
608 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
609 }
610 let Some(role) = a.actor.role_in(&workspace) else {
611 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members of a workspace can create its teams."));
612 };
613 if !a.actor.verified {
614 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
615 }
Merge branch 'worktree-agent-ad7c6d88d93adc817'616 // A workspace can keep creating teams to its owners.
617 if !self.team_creation_of(&workspace).await?.allows(role) {
618 return Ok(Outcome::fail(FailureCode::Forbidden, OWNERS_CREATE));
619 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar620 let owner = role == Role::Owner;
621 let name: String = a.name.trim().chars().take(MAX_NAME_LENGTH).collect();
622 if name.is_empty() {
623 return Ok(Outcome::fail(FailureCode::Invalid, "Give the team a name."));
624 }
625 let slug = match a.slug.as_deref().map(str::trim).filter(|slug| !slug.is_empty()) {
626 Some(slug) if is_valid_slug(&slug.to_lowercase()) => slug.to_lowercase(),
627 Some(_) => {
628 return Ok(Outcome::fail(
629 FailureCode::Invalid,
630 "Team slugs use lowercase letters, digits and single hyphens, up to 60 characters.",
631 ));
632 }
633 None => match slug_of(&name) {
634 Some(slug) => slug,
635 None => return Ok(Outcome::fail(FailureCode::Invalid, "Use letters or digits in the team's name.")),
636 },
637 };
638 let Some(workspace_id) = self.workspace_id_of(&workspace).await? else {
639 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
640 };
641 if self.team_row(&workspace, &slug, None).await?.is_some() {
642 return Ok(Outcome::fail(FailureCode::Conflict, format!("{workspace} already has a team called @{workspace}/{slug}.")));
643 }
644 #[derive(Deserialize)]
645 struct Count {
646 n: u32,
647 }
648 let count = self
649 .db
650 .prepare("SELECT count(*) AS n FROM teams WHERE workspace_id = ?")
651 .bind(&[workspace_id.as_str().into()])?
652 .first::<Count>(None)
653 .await?
654 .map_or(0, |row| row.n);
655 if count >= MAX_TEAMS {
656 return Ok(Outcome::fail(FailureCode::Conflict, format!("A workspace can have at most {MAX_TEAMS} teams.")));
657 }
658 let visibility = a.visibility.unwrap_or_default();
659 let parent = match a.parent.as_deref().map(str::trim).filter(|parent| !parent.is_empty()) {
660 Some(parent) => match self.parent_for(&a.actor, owner, &workspace, parent, None, visibility).await? {
661 Outcome::Ok(parent) => Some(parent),
662 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
663 },
664 None => None,
665 };
666 // The people to add first: members of the workspace only.
667 let mut people: Vec<(String, String)> = Vec::new();
668 for username in &a.members {
669 let Some((id, name)) = self.person_by_username(username).await? else {
670 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no account named {}.", username.trim())));
671 };
672 if !self.is_member_of(&workspace_id, &id).await? {
673 return Ok(Outcome::fail(
674 FailureCode::Invalid,
675 format!("{name} is not a member of {workspace}. Add them to the workspace first."),
676 ));
677 }
678 if id != a.actor.id && !people.iter().any(|(had, _)| *had == id) {
679 people.push((id, name));
680 }
681 }
682 let now = now_ms();
683 let id = new_id("team", now);
684 let at = rfc3339(now);
685 let description = a
686 .description
687 .as_deref()
688 .map(|text| text.trim().chars().take(MAX_DESCRIPTION_LENGTH).collect::<String>())
689 .filter(|text| !text.is_empty());
690 let mut statements = vec![
691 self.db
692 .prepare(
693 "INSERT INTO teams (id, workspace_id, slug, name, description, visibility, parent_id, notify, review_assignment, created_by, created_at, updated_at)
694 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)",
695 )
696 .bind(&[
697 id.as_str().into(),
698 workspace_id.as_str().into(),
699 slug.as_str().into(),
700 name.as_str().into(),
701 opt(description.as_deref()),
702 visibility.as_str().into(),
703 opt(parent.as_ref().map(|parent| parent.id.as_str())),
704 u8::from(a.notify.unwrap_or(true)).into(),
705 serde_json::to_string(&ReviewAssignment::default())?.into(),
706 a.actor.id.as_str().into(),
707 at.as_str().into(),
708 ])?,
709 // Whoever creates it maintains it.
710 self.db
711 .prepare("INSERT INTO team_members (team_id, user_id, role, created_at) VALUES (?, ?, 'maintainer', ?)")
712 .bind(&[id.as_str().into(), a.actor.id.as_str().into(), at.as_str().into()])?,
713 ];
714 for (user_id, _) in &people {
715 statements.push(
716 self.db
717 .prepare("INSERT OR IGNORE INTO team_members (team_id, user_id, role, created_at) VALUES (?, ?, 'member', ?)")
718 .bind(&[id.as_str().into(), user_id.as_str().into(), at.as_str().into()])?,
719 );
720 }
721 self.db.batch(statements).await?;
722 let Some(row) = self.team_row(&workspace, &slug, Some(&a.actor.id)).await? else {
723 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
724 };
725 let surface = a.surface.unwrap_or(Surface::Web);
726 self.team_event("team.created", &a.actor, row.event(), surface, format!("Created the team {}", row.name))
727 .await;
728 for (username, role) in std::iter::once((a.actor.username.clone(), TeamRole::Maintainer))
729 .chain(people.into_iter().map(|(_, name)| (name, TeamRole::Member)))
730 {
731 self.team_event(
732 "team.member_added",
733 &a.actor,
734 TeamChanged {
735 username: Some(username.clone()),
736 role: Some(role),
737 ..row.event()
738 },
739 surface,
740 format!("Added {username} to {} as a {}", row.name, role.as_str()),
741 )
742 .await;
743 }
744 Ok(Outcome::Ok(row.shown(owner)))
745 }
746
747 pub async fn update_team(&self, a: UpdateTeamArgs) -> Result<Outcome<Team>> {
748 let (row, owner) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
749 Outcome::Ok(found) => found,
750 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
751 };
752 let mut changes: Vec<&'static str> = Vec::new();
753 let mut sets: Vec<String> = Vec::new();
754 let mut binds: Vec<JsValue> = Vec::new();
755 let mut set = |column: &str, value: JsValue, binds: &mut Vec<JsValue>| {
756 binds.push(value);
757 sets.push(format!("{column} = ?{}", binds.len()));
758 };
759 if let Some(name) = &a.name {
760 let name: String = name.trim().chars().take(MAX_NAME_LENGTH).collect();
761 if name.is_empty() {
762 return Ok(Outcome::fail(FailureCode::Invalid, "Give the team a name."));
763 }
764 if name != row.name {
765 set("name", name.into(), &mut binds);
766 changes.push("name");
767 }
768 }
769 let mut slug = row.slug.clone();
770 if let Some(wanted) = a.slug.as_deref().map(|slug| slug.trim().to_lowercase()).filter(|slug| *slug != row.slug) {
771 if !is_valid_slug(&wanted) {
772 return Ok(Outcome::fail(
773 FailureCode::Invalid,
774 "Team slugs use lowercase letters, digits and single hyphens, up to 60 characters.",
775 ));
776 }
777 if self.team_row(&row.workspace, &wanted, None).await?.is_some() {
778 return Ok(Outcome::fail(
779 FailureCode::Conflict,
780 format!("{0} already has a team called @{0}/{wanted}.", row.workspace),
781 ));
782 }
783 set("slug", wanted.as_str().into(), &mut binds);
784 changes.push("slug");
785 slug = wanted;
786 }
787 if let Some(description) = &a.description {
788 let description: String = description.trim().chars().take(MAX_DESCRIPTION_LENGTH).collect();
789 if Some(&description) != row.description.as_ref() {
790 set(
791 "description",
792 if description.is_empty() { JsValue::NULL } else { description.into() },
793 &mut binds,
794 );
795 changes.push("description");
796 }
797 }
798 let visibility = a.visibility.unwrap_or(row.visibility());
799 let parent_slug = a.parent.as_deref().map(str::trim);
800 let parent_changes = parent_slug.is_some_and(|parent| Some(parent.to_lowercase()) != row.parent_slug);
801 if visibility != row.visibility() {
802 if visibility == TeamVisibility::Secret {
803 let has_parent = if parent_changes { parent_slug.is_some_and(|parent| !parent.is_empty()) } else { row.parent_id.is_some() };
804 if has_parent || row.child_teams_count > 0 {
805 return Ok(Outcome::fail(
806 FailureCode::Invalid,
807 "Secret teams cannot be nested. Take it out from under its parent and move its child teams first.",
808 ));
809 }
810 }
811 set("visibility", visibility.as_str().into(), &mut binds);
812 changes.push("visibility");
813 }
814 if parent_changes {
815 match parent_slug.filter(|parent| !parent.is_empty()) {
816 Some(parent) => {
817 let parent = match self
818 .parent_for(&a.actor, owner, &row.workspace, parent, Some((&row.id, visibility)), visibility)
819 .await?
820 {
821 Outcome::Ok(parent) => parent,
822 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
823 };
824 set("parent_id", parent.id.as_str().into(), &mut binds);
825 }
826 None => set("parent_id", JsValue::NULL, &mut binds),
827 }
828 changes.push("parent");
829 }
830 if let Some(notify) = a.notify
831 && notify != (row.notify != 0)
832 {
833 set("notify", u8::from(notify).into(), &mut binds);
834 changes.push("notify");
835 }
836 if let Some(review) = a.review_assignment {
837 let review = review.bounded();
838 if review != row.review() {
839 set("review_assignment", serde_json::to_string(&review)?.into(), &mut binds);
840 changes.push("review_assignment");
841 }
842 }
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.843 if let Some(lead) = a.lead.as_deref().map(str::trim) {
844 let (kind, id): (JsValue, JsValue) = if lead.is_empty() {
845 (JsValue::NULL, JsValue::NULL)
846 } else {
847 match parse_lead(lead) {
848 Some(LeadInput::User(username)) => {
849 let Some((user_id, username)) = self.person_by_username(&username).await? else {
850 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
851 };
852 if self.team_role_of(&row.id, &user_id).await?.is_none() {
853 return Ok(Outcome::fail(
854 FailureCode::Invalid,
855 format!("{username} is not on {}. Add them to the team first.", row.name),
856 ));
857 }
858 ("user".into(), user_id.into())
859 }
860 Some(LeadInput::Agent(agent_id)) => {
861 if !self.has_team_agent(&row.id, &agent_id).await? {
862 return Ok(Outcome::fail(
863 FailureCode::Invalid,
864 format!("That agent is not on {}. Add it to the team first.", row.name),
865 ));
866 }
867 ("agent".into(), agent_id.into())
868 }
869 None => return Ok(Outcome::fail(FailureCode::Invalid, "Name the lead as @username, or agent:<id> for an agent.")),
870 }
871 };
872 let same = kind.as_string() == row.lead_kind && id.as_string() == row.lead_id;
873 if !same {
874 set("lead_kind", kind, &mut binds);
875 set("lead_id", id, &mut binds);
876 changes.push("lead");
877 }
878 }
879 if let Some(channel_id) = a.channel_id.as_deref().map(str::trim) {
880 let name = a
881 .channel_name
882 .as_deref()
883 .map(|name| name.trim().trim_start_matches('#').chars().take(80).collect::<String>())
884 .unwrap_or_default();
885 if !channel_id.is_empty() && name.is_empty() {
886 return Ok(Outcome::fail(FailureCode::Invalid, "Give the channel's name with its id."));
887 }
888 if Some(channel_id) != row.channel_id.as_deref() || (!channel_id.is_empty() && Some(name.as_str()) != row.channel_name.as_deref()) {
889 if channel_id.is_empty() {
890 set("channel_id", JsValue::NULL, &mut binds);
891 set("channel_name", JsValue::NULL, &mut binds);
892 } else {
893 set("channel_id", channel_id.into(), &mut binds);
894 set("channel_name", name.into(), &mut binds);
895 }
896 changes.push("channel");
897 }
898 }
899 if let Some(budget) = a.budget_micros {
900 if budget > MAX_TEAM_BUDGET_MICROS {
901 return Ok(Outcome::fail(FailureCode::Invalid, "A team budget can be at most $1,000,000 a month."));
902 }
903 let budget = (budget > 0).then_some(budget);
904 if budget != budget_of(row.budget_micros) {
905 set("budget_micros", budget.map_or(JsValue::NULL, |m| JsValue::from_f64(m as f64)), &mut binds);
906 changes.push("budget");
907 }
908 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar909 if !changes.is_empty() {
910 set("updated_at", rfc3339(now_ms()).into(), &mut binds);
911 binds.push(row.id.as_str().into());
912 self.db
913 .prepare(format!("UPDATE teams SET {} WHERE id = ?{}", sets.join(", "), binds.len()))
914 .bind(&binds)?
915 .run()
916 .await?;
917 }
918 let Some(updated) = self.team_row(&row.workspace, &slug, Some(&a.actor.id)).await? else {
919 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
920 };
921 if !changes.is_empty() {
922 self.team_event(
923 "team.edited",
924 &a.actor,
925 TeamChanged {
926 changes: changes.iter().map(|change| (*change).to_owned()).collect(),
927 ..updated.event()
928 },
929 a.surface.unwrap_or(Surface::Web),
930 format!("Changed the team {}: {}", updated.name, changes.join(", ").replace('_', " ")),
931 )
932 .await;
933 }
934 Ok(Outcome::Ok(updated.shown(owner)))
935 }
936
937 pub async fn delete_team(&self, a: DeleteTeamArgs) -> Result<Outcome<bool>> {
938 let (row, _) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
939 Outcome::Ok(found) => found,
940 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
941 };
942 let id = JsValue::from(row.id.as_str());
943 self.db
944 .batch(vec![
945 // Its child teams move up to its parent.
946 self.db
947 .prepare("UPDATE teams SET parent_id = ?1 WHERE parent_id = ?2")
948 .bind(&[opt(row.parent_id.as_deref()), id.clone()])?,
949 self.db.prepare("DELETE FROM team_members WHERE team_id = ?").bind(std::slice::from_ref(&id))?,
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.950 self.db.prepare("DELETE FROM team_agents WHERE team_id = ?").bind(std::slice::from_ref(&id))?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar951 self.db
952 .prepare("DELETE FROM repo_grants WHERE principal_kind = 'team' AND principal_id = ?")
953 .bind(std::slice::from_ref(&id))?,
954 self.db.prepare("DELETE FROM teams WHERE id = ?").bind(&[id])?,
955 ])
956 .await?;
957 self.team_event(
958 "team.deleted",
959 &a.actor,
960 row.event(),
961 a.surface.unwrap_or(Surface::Web),
962 format!("Deleted the team {}", row.name),
963 )
964 .await;
965 Ok(Outcome::Ok(true))
966 }
967
968 /// The people of a team, its own first; with `children`, also the
969 /// people of its child teams who are not its own, each with the child
970 /// team they are in.
971 async fn member_rows(&self, team_id: &str, children: bool) -> Result<Vec<MemberRow>> {
972 let reach = if children {
973 "WITH RECURSIVE down(id) AS (SELECT ?1 UNION SELECT t.id FROM teams t JOIN down ON t.parent_id = down.id)"
974 } else {
975 "WITH down(id) AS (SELECT ?1)"
976 };
977 self.db
978 .prepare(format!(
979 "{reach}
980 SELECT u.id, u.username, u.display_name AS name, u.avatar, tm.role, tm.team_id, t.slug AS team_slug
981 FROM down JOIN team_members tm ON tm.team_id = down.id
982 JOIN teams t ON t.id = tm.team_id
983 JOIN users u ON u.id = tm.user_id
984 ORDER BY tm.team_id != ?1, u.username, t.slug LIMIT 2000"
985 ))
986 .bind(&[team_id.into()])?
987 .all()
988 .await?
989 .results::<MemberRow>()
990 }
991
992 pub async fn team_members(&self, a: TeamArgs) -> Result<Outcome<Vec<TeamMember>>> {
993 let (row, _) = match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
994 Outcome::Ok(found) => found,
995 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
996 };
997 let mut seen: HashSet<String> = HashSet::new();
998 let mut members: Vec<TeamMember> = Vec::new();
999 for person in self.member_rows(&row.id, a.include_child_teams).await? {
1000 if !seen.insert(person.id.clone()) {
1001 continue;
1002 }
1003 let own = person.team_id == row.id;
1004 members.push(TeamMember {
1005 username: person.username,
1006 name: person.name,
1007 avatar: person.avatar,
1008 role: if own { TeamRole::parse(&person.role).unwrap_or(TeamRole::Member) } else { TeamRole::Member },
1009 via: (!own).then_some(person.team_slug),
1010 });
1011 }
1012 // Maintainers first, then by name; child teams' people after.
1013 members.sort_by(|a, b| {
1014 a.via
1015 .is_some()
1016 .cmp(&b.via.is_some())
1017 .then_with(|| b.role.cmp(&a.role))
1018 .then_with(|| a.username.cmp(&b.username))
1019 });
1020 Ok(Outcome::Ok(members))
1021 }
1022
1023 async fn team_role_of(&self, team_id: &str, user_id: &str) -> Result<Option<TeamRole>> {
1024 Ok(self
1025 .db
1026 .prepare("SELECT role FROM team_members WHERE team_id = ? AND user_id = ?")
1027 .bind(&[team_id.into(), user_id.into()])?
1028 .first::<RoleRow>(None)
1029 .await?
1030 .and_then(|row| TeamRole::parse(&row.role)))
1031 }
1032
1033 pub async fn set_team_member(&self, a: SetTeamMemberArgs) -> Result<Outcome<TeamMember>> {
1034 let (row, _) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
1035 Outcome::Ok(found) => found,
1036 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1037 };
1038 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
1039 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
1040 };
1041 if !self.is_member_of(&row.workspace_id, &user_id).await? {
1042 return Ok(Outcome::fail(
1043 FailureCode::Invalid,
1044 format!("{username} is not a member of {}. Add them to the workspace first.", row.workspace),
1045 ));
1046 }
1047 let previous = self.team_role_of(&row.id, &user_id).await?;
1048 let now = rfc3339(now_ms());
1049 self.db
1050 .prepare(
1051 "INSERT INTO team_members (team_id, user_id, role, created_at) VALUES (?1, ?2, ?3, ?4)
1052 ON CONFLICT (team_id, user_id) DO UPDATE SET role = excluded.role",
1053 )
1054 .bind(&[row.id.as_str().into(), user_id.as_str().into(), a.role.as_str().into(), now.as_str().into()])?
1055 .run()
1056 .await?;
1057 let surface = a.surface.unwrap_or(Surface::Web);
1058 match previous {
1059 None => {
1060 self.team_event(
1061 "team.member_added",
1062 &a.actor,
1063 TeamChanged {
1064 username: Some(username.clone()),
1065 role: Some(a.role),
1066 ..row.event()
1067 },
1068 surface,
1069 format!("Added {username} to {} as a {}", row.name, a.role.as_str()),
1070 )
1071 .await;
1072 }
1073 Some(previous) if previous != a.role => {
1074 self.team_event(
1075 "team.member_role_changed",
1076 &a.actor,
1077 TeamChanged {
1078 username: Some(username.clone()),
1079 role: Some(a.role),
1080 previous_role: Some(previous),
1081 ..row.event()
1082 },
1083 surface,
1084 format!("Made {username} a {} of {}", a.role.as_str(), row.name),
1085 )
1086 .await;
1087 }
1088 Some(_) => {}
1089 }
1090 #[derive(Deserialize)]
1091 struct Person {
1092 #[serde(default)]
1093 name: Option<String>,
1094 #[serde(default)]
1095 avatar: Option<String>,
1096 }
1097 let person = self
1098 .db
1099 .prepare("SELECT display_name AS name, avatar FROM users WHERE id = ?")
1100 .bind(&[user_id.as_str().into()])?
1101 .first::<Person>(None)
1102 .await?;
1103 Ok(Outcome::Ok(TeamMember {
1104 username,
1105 name: person.as_ref().and_then(|person| person.name.clone()),
1106 avatar: person.and_then(|person| person.avatar),
1107 role: a.role,
1108 via: None,
1109 }))
1110 }
1111
1112 pub async fn remove_team_member(&self, a: RemoveTeamMemberArgs) -> Result<Outcome<bool>> {
1113 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
1114 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_USER));
1115 };
1116 // Anyone may leave a team; otherwise, owners and maintainers.
1117 let leaving = crate::security::is_person(&a.actor) && a.actor.id == user_id;
1118 let row = if leaving {
1119 match self.seen_team(&Some(a.actor.clone()), &a.workspace, &a.team).await? {
1120 Outcome::Ok((row, _)) => row,
1121 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1122 }
1123 } else {
1124 match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
1125 Outcome::Ok((row, _)) => row,
1126 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1127 }
1128 };
1129 let Some(previous) = self.team_role_of(&row.id, &user_id).await? else {
1130 return Ok(Outcome::fail(FailureCode::NotFound, format!("{username} is not in {}.", row.name)));
1131 };
1132 self.db
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.1133 .batch(vec![
1134 self.db
1135 .prepare("DELETE FROM team_members WHERE team_id = ? AND user_id = ?")
1136 .bind(&[row.id.as_str().into(), user_id.as_str().into()])?,
1137 // Someone off the team no longer leads it.
1138 self.db
1139 .prepare("UPDATE teams SET lead_kind = NULL, lead_id = NULL WHERE id = ? AND lead_kind = 'user' AND lead_id = ?")
1140 .bind(&[row.id.as_str().into(), user_id.as_str().into()])?,
1141 ])
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1142 .await?;
1143 self.team_event(
1144 "team.member_removed",
1145 &a.actor,
1146 TeamChanged {
1147 username: Some(username.clone()),
1148 previous_role: Some(previous),
1149 ..row.event()
1150 },
1151 a.surface.unwrap_or(Surface::Web),
1152 format!("Removed {username} from {}", row.name),
1153 )
1154 .await;
1155 Ok(Outcome::Ok(true))
1156 }
1157
1158 pub async fn team_repos(&self, a: TeamArgs) -> Result<Outcome<Vec<TeamRepo>>> {
1159 let (row, owner) = match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
1160 Outcome::Ok(found) => found,
1161 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1162 };
1163 #[derive(Deserialize)]
1164 struct Row {
1165 repo_id: String,
1166 repo_name: String,
1167 workspace: String,
1168 role: String,
1169 depth: u32,
1170 team: String,
1171 }
1172 let rows = self
1173 .db
1174 .prepare(format!(
1175 "WITH RECURSIVE up(id, depth) AS (
1176 SELECT ?1, 0
1177 UNION ALL
1178 SELECT t.parent_id, up.depth + 1 FROM teams t JOIN up ON t.id = up.id
1179 WHERE t.parent_id IS NOT NULL AND up.depth < 20
1180 )
1181 SELECT g.repo_id, g.repo_name, w.slug AS workspace, g.role, up.depth, t.slug AS team
1182 FROM up JOIN repo_grants g ON g.principal_kind = 'team' AND g.principal_id = up.id
1183 JOIN teams t ON t.id = up.id
1184 JOIN workspaces w ON w.id = g.workspace_id AND w.deleted_at IS NULL
1185 ORDER BY g.repo_name, up.depth LIMIT {LIST_LIMIT}"
1186 ))
1187 .bind(&[row.id.as_str().into()])?
1188 .all()
1189 .await?
1190 .results::<Row>()?;
1191 let found = rows.into_iter().filter_map(|row| {
1192 Some((row.repo_id, format!("{}/{}", row.workspace, row.repo_name), RepoRole::parse(&row.role)?, row.depth, row.team))
1193 });
1194 let mut repos = fold_team_repos(found);
1195 // Only what the viewer can see: owners everything; others what
1196 // they have a role on.
1197 if !owner && let Some(viewer) = a.viewer.as_ref() {
1198 repos.retain(|repo| {
1199 granted(
1200 viewer,
1201 RepoRef {
1202 id: &repo.repo_id,
1203 namespace: &row.workspace,
1204 private: true,
1205 },
1206 )
1207 .is_some()
1208 });
1209 }
1210 Ok(Outcome::Ok(repos))
1211 }
1212
1213 /// The repository at `path`, in the team's workspace, if `actor` may
1214 /// manage who has access to it.
1215 async fn team_repo_target(&self, actor: &User, row: &TeamRow, path: &g1t_contracts::repos::RepoPath) -> Result<Outcome<crate::access::Target>> {
1216 let target = match self.manageable(actor, path).await? {
1217 Outcome::Ok(target) => target,
1218 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1219 };
1220 if target.workspace_id != row.workspace_id {
1221 return Ok(Outcome::fail(
1222 FailureCode::Invalid,
1223 format!("A team has roles only on its own workspace's repositories, and {}/{} is not in {}.", target.repo.namespace, target.repo.name, row.workspace),
1224 ));
1225 }
1226 Ok(Outcome::Ok(target))
1227 }
1228
1229 async fn team_grant(&self, repo_id: &str, team_id: &str) -> Result<Option<RepoRole>> {
1230 Ok(self
1231 .db
1232 .prepare("SELECT role FROM repo_grants WHERE repo_id = ? AND principal_kind = 'team' AND principal_id = ?")
1233 .bind(&[repo_id.into(), team_id.into()])?
1234 .first::<RoleRow>(None)
1235 .await?
1236 .and_then(|row| RepoRole::parse(&row.role)))
1237 }
1238
1239 pub async fn set_team_repo(&self, a: SetTeamRepoArgs) -> Result<Outcome<TeamRepo>> {
1240 if !crate::security::is_person(&a.actor) {
1241 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1242 }
1243 let Some(row) = (match self.seen_team(&Some(a.actor.clone()), &a.workspace, &a.team).await? {
1244 Outcome::Ok((row, _)) => Some(row),
1245 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1246 }) else {
1247 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
1248 };
1249 let target = match self.team_repo_target(&a.actor, &row, &a.repo).await? {
1250 Outcome::Ok(target) => target,
1251 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1252 };
1253 let repo = &target.repo;
1254 let previous = self.team_grant(&repo.id, &row.id).await?;
1255 let now = rfc3339(now_ms());
1256 self.db
1257 .prepare(
1258 "INSERT INTO repo_grants
1259 (repo_id, principal_kind, principal_id, workspace_id, repo_name, role, granted_by, created_at, updated_at)
1260 VALUES (?1, 'team', ?2, ?3, ?4, ?5, ?6, ?7, ?7)
1261 ON CONFLICT (repo_id, principal_kind, principal_id)
1262 DO UPDATE SET role = excluded.role, updated_at = excluded.updated_at",
1263 )
1264 .bind(&[
1265 repo.id.as_str().into(),
1266 row.id.as_str().into(),
1267 target.workspace_id.as_str().into(),
1268 repo.name.to_lowercase().into(),
1269 a.role.as_str().into(),
1270 a.actor.id.as_str().into(),
1271 now.as_str().into(),
1272 ])?
1273 .run()
1274 .await?;
1275 if previous != Some(a.role) {
1276 let kind = if previous.is_none() { "team.repo_added" } else { "team.repo_role_changed" };
1277 let message = match previous {
1278 None => format!("Gave the team {} the {} role", row.name, a.role.label()),
1279 Some(previous) => format!("Changed the team {}'s role from {} to {}", row.name, previous.label(), a.role.label()),
1280 };
1281 self.team_repo_event(kind, &a.actor, &row, repo.into(), Some(a.role), previous, a.surface.unwrap_or(Surface::Web), message)
1282 .await;
1283 }
1284 Ok(Outcome::Ok(TeamRepo {
1285 repo: format!("{}/{}", repo.namespace, repo.name),
1286 repo_id: repo.id.clone(),
1287 role: a.role,
1288 inherited_from: None,
1289 }))
1290 }
1291
1292 pub async fn remove_team_repo(&self, a: RemoveTeamRepoArgs) -> Result<Outcome<bool>> {
1293 if !crate::security::is_person(&a.actor) {
1294 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1295 }
1296 let (row, owner) = match self.seen_team(&Some(a.actor.clone()), &a.workspace, &a.team).await? {
1297 Outcome::Ok(found) => found,
1298 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1299 };
1300 // An owner or maintainer may take a role from their team; anyone
1301 // else needs Admin on the repository.
1302 let repo = if may_manage(owner, row.viewer_role()) {
1303 if !a.actor.verified {
1304 return Ok(Outcome::fail(FailureCode::Forbidden, CONFIRM_FIRST));
1305 }
1306 match self.repo_for(&a.repo, &Some(a.actor.clone())).await? {
1307 Some(repo) => repo,
1308 None => return Ok(Outcome::fail(FailureCode::NotFound, "Repository not found.")),
1309 }
1310 } else {
1311 match self.team_repo_target(&a.actor, &row, &a.repo).await? {
1312 Outcome::Ok(target) => target.repo,
1313 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1314 }
1315 };
1316 let Some(previous) = self.team_grant(&repo.id, &row.id).await? else {
1317 return Ok(Outcome::fail(
1318 FailureCode::NotFound,
1319 format!("{} has no role of its own on {}/{}.", row.name, repo.namespace, repo.name),
1320 ));
1321 };
1322 self.db
1323 .prepare("DELETE FROM repo_grants WHERE repo_id = ? AND principal_kind = 'team' AND principal_id = ?")
1324 .bind(&[repo.id.as_str().into(), row.id.as_str().into()])?
1325 .run()
1326 .await?;
1327 self.team_repo_event(
1328 "team.repo_removed",
1329 &a.actor,
1330 &row,
1331 (&repo).into(),
1332 None,
1333 Some(previous),
1334 a.surface.unwrap_or(Surface::Web),
1335 format!("Removed the team {}'s {} role", row.name, previous.label()),
1336 )
1337 .await;
1338 Ok(Outcome::Ok(true))
1339 }
1340
1341 pub async fn team_memberships(&self, a: TeamMembershipsArgs) -> Result<Outcome<Vec<MemberTeams>>> {
1342 let workspace = a.workspace.trim().to_lowercase();
1343 let Some(viewer) = a.viewer.as_ref() else {
1344 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
1345 };
1346 let Some(role) = role_of(viewer, &workspace) else {
1347 return Ok(Outcome::fail(FailureCode::Forbidden, "Only members can see a workspace's teams."));
1348 };
1349 #[derive(Deserialize)]
1350 struct Row {
1351 username: String,
1352 slug: String,
1353 name: String,
1354 visibility: String,
1355 /// Whether the viewer is in the team.
1356 mine: u8,
1357 }
1358 let rows = self
1359 .db
1360 .prepare(
1361 "SELECT u.username, t.slug, t.name, t.visibility,
1362 EXISTS (SELECT 1 FROM team_members me WHERE me.team_id = t.id AND me.user_id = ?2) AS mine
1363 FROM teams t
1364 JOIN workspaces w ON w.id = t.workspace_id AND w.deleted_at IS NULL
1365 JOIN team_members tm ON tm.team_id = t.id
1366 JOIN users u ON u.id = tm.user_id
1367 WHERE w.slug = ?1
1368 ORDER BY u.username, lower(t.name) LIMIT 5000",
1369 )
1370 .bind(&[workspace.as_str().into(), viewer.id.as_str().into()])?
1371 .all()
1372 .await?
1373 .results::<Row>()?;
1374 let owner = role == Role::Owner;
1375 let mut people: Vec<MemberTeams> = Vec::new();
1376 for row in rows {
1377 let visibility = TeamVisibility::parse(&row.visibility).unwrap_or_default();
1378 if !may_see(visibility, owner, row.mine != 0) {
1379 continue;
1380 }
1381 let team = TeamRef {
1382 slug: row.slug,
1383 name: row.name,
1384 };
1385 match people.last_mut().filter(|person| person.username == row.username) {
1386 Some(person) => person.teams.push(team),
1387 None => people.push(MemberTeams {
1388 username: row.username,
1389 teams: vec![team],
1390 }),
1391 }
1392 }
1393 Ok(Outcome::Ok(people))
1394 }
1395
1396 // --- For other services ---
1397
1398 /// Everyone in a team: its own people, and its child teams' people who
1399 /// are not its own.
1400 async fn people_of(&self, team_id: &str) -> Result<(Vec<TeamPerson>, Vec<TeamPerson>)> {
1401 let mut own = Vec::new();
1402 let mut children = Vec::new();
1403 let mut seen: HashSet<String> = HashSet::new();
1404 for row in self.member_rows(team_id, true).await? {
1405 if !seen.insert(row.id.clone()) {
1406 continue;
1407 }
1408 let person = TeamPerson {
1409 id: row.id,
1410 username: row.username,
1411 };
1412 if row.team_id == team_id {
1413 own.push(person);
1414 } else {
1415 children.push(person);
1416 }
1417 }
1418 Ok((own, children))
1419 }
1420
1421 /// A team's role on a repository: its own, or one inherited from a
1422 /// parent, the highest.
1423 async fn team_role_on(&self, team_id: &str, repo_id: &str) -> Result<Option<RepoRole>> {
1424 Ok(self
1425 .db
1426 .prepare(
1427 "WITH RECURSIVE up(id, depth) AS (
1428 SELECT ?1, 0
1429 UNION ALL
1430 SELECT t.parent_id, up.depth + 1 FROM teams t JOIN up ON t.id = up.id
1431 WHERE t.parent_id IS NOT NULL AND up.depth < 20
1432 )
1433 SELECT g.role FROM up JOIN repo_grants g ON g.principal_kind = 'team' AND g.principal_id = up.id
1434 WHERE g.repo_id = ?2",
1435 )
1436 .bind(&[team_id.into(), repo_id.into()])?
1437 .all()
1438 .await?
1439 .results::<RoleRow>()?
1440 .into_iter()
1441 .filter_map(|row| RepoRole::parse(&row.role))
1442 .max())
1443 }
1444
1445 /// Whether `user_id` may see a team: a member of its workspace, and for
1446 /// a secret team, in it or an owner.
1447 async fn sees(&self, row: &TeamRow, team: &ResolvedTeam, user_id: &str) -> Result<bool> {
1448 let role = self
1449 .db
1450 .prepare("SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
1451 .bind(&[row.workspace_id.as_str().into(), user_id.into()])?
1452 .first::<RoleRow>(None)
1453 .await?;
1454 let Some(role) = role else {
1455 return Ok(false);
1456 };
1457 let in_team = team.everyone().any(|person| person.id == user_id);
1458 Ok(may_see(row.visibility(), role.role == "owner", in_team))
1459 }
1460
1461 async fn resolved(&self, row: &TeamRow, repo_id: Option<&str>) -> Result<ResolvedTeam> {
1462 let (members, child_members) = self.people_of(&row.id).await?;
1463 let repo_role = match repo_id {
1464 Some(repo_id) => self.team_role_on(&row.id, repo_id).await?,
1465 None => None,
1466 };
1467 Ok(ResolvedTeam {
1468 id: row.id.clone(),
1469 workspace: row.workspace.clone(),
1470 slug: row.slug.clone(),
1471 name: row.name.clone(),
1472 visibility: row.visibility(),
1473 notify: row.notify != 0,
1474 review_assignment: row.review(),
1475 members,
1476 child_members,
1477 repo_role,
1478 asker_sees: false,
1479 })
1480 }
1481
1482 pub async fn resolve_teams(&self, a: ResolveTeamsArgs) -> Result<Vec<ResolvedTeam>> {
1483 let mut found: Vec<ResolvedTeam> = Vec::new();
1484 for name in a.teams.iter().take(50) {
1485 let name = name.trim().trim_start_matches('@');
1486 let Some((workspace, slug)) = name.split_once('/') else {
1487 continue;
1488 };
1489 let Some(row) = self.team_row(workspace, slug, None).await? else {
1490 continue;
1491 };
1492 if found.iter().any(|team| team.id == row.id) {
1493 continue;
1494 }
1495 let mut team = self.resolved(&row, a.repo_id.as_deref()).await?;
1496 if let Some(asker) = a.asker.as_deref() {
1497 team.asker_sees = self.sees(&row, &team, asker).await?;
1498 }
1499 found.push(team);
1500 }
1501 Ok(found)
1502 }
1503
1504 /// Someone's effective role on a repository of a workspace: owner,
1505 /// base permission, a direct grant or a team's.
1506 async fn person_role(&self, repo_id: &str, workspace_id: &str, user_id: &str) -> Result<Option<RepoRole>> {
1507 #[derive(Deserialize)]
1508 struct Row {
1509 #[serde(default)]
1510 workspace_role: Option<String>,
1511 #[serde(default)]
1512 direct: Option<String>,
1513 }
1514 let row = self
1515 .db
1516 .prepare(
1517 "SELECT m.role AS workspace_role, g.role AS direct FROM users u
1518 LEFT JOIN workspace_members m ON m.user_id = u.id AND m.workspace_id = ?2
1519 LEFT JOIN repo_grants g ON g.principal_kind = 'user' AND g.principal_id = u.id AND g.repo_id = ?1
1520 WHERE u.id = ?3",
1521 )
1522 .bind(&[repo_id.into(), workspace_id.into(), user_id.into()])?
1523 .first::<Row>(None)
1524 .await?;
1525 let Some(row) = row else {
1526 return Ok(None);
1527 };
1528 let base = self.base_of(workspace_id).await?;
1529 let teams = self.team_roles_on(repo_id, Some(user_id)).await?;
1530 let workspace_role = row.workspace_role.as_deref();
1531 Ok(crate::access::effective(
1532 workspace_role == Some("owner"),
1533 workspace_role.and(base.role()),
1534 row.direct.as_deref().and_then(RepoRole::parse),
1535 teams.get(user_id).map(|(role, _)| *role),
1536 )
1537 .map(|(role, _)| role))
1538 }
1539
1540 pub async fn resolve_owners(&self, a: ResolveOwnersArgs) -> Result<Vec<ResolvedOwner>> {
1541 let workspace = a.workspace.trim().to_lowercase();
1542 let Some(workspace_id) = self.workspace_id_of(&workspace).await? else {
1543 return Ok(Vec::new());
1544 };
1545 let mut cache: HashMap<Owner, ResolvedOwner> = HashMap::new();
1546 let mut out = Vec::new();
1547 for owner in a.owners.iter().take(2000) {
1548 if let Some(done) = cache.get(owner) {
1549 out.push(done.clone());
1550 continue;
1551 }
1552 let resolved = self.resolve_owner(&a.repo_id, &workspace, &workspace_id, owner).await?;
1553 cache.insert(owner.clone(), resolved.clone());
1554 out.push(resolved);
1555 }
1556 Ok(out)
1557 }
1558
1559 async fn resolve_owner(&self, repo_id: &str, workspace: &str, workspace_id: &str, owner: &Owner) -> Result<ResolvedOwner> {
1560 let answer = |check: OwnerCheck, members: Vec<String>, team: Option<String>| ResolvedOwner {
1561 owner: owner.clone(),
1562 check,
1563 members,
1564 team,
1565 };
1566 let person = |id: Option<(String, String)>| async move {
1567 let Some((id, username)) = id else {
1568 return Ok::<_, worker::Error>(None);
1569 };
1570 let writes = self
1571 .person_role(repo_id, workspace_id, &id)
1572 .await?
1573 .is_some_and(|role| role >= RepoRole::Write);
1574 Ok(Some((username, writes)))
1575 };
1576 Ok(match owner {
1577 Owner::User { username } if username.eq_ignore_ascii_case(AGENT_NAME) => {
1578 answer(OwnerCheck::Ok, vec![AGENT_NAME.to_owned()], None)
1579 }
1580 Owner::User { username } => match person(self.person_by_username(username).await?).await? {
1581 None => answer(OwnerCheck::UnknownUser, Vec::new(), None),
1582 Some((username, true)) => answer(OwnerCheck::Ok, vec![username], None),
1583 Some((username, false)) => answer(OwnerCheck::NoWriteAccess, vec![username], None),
1584 },
1585 Owner::Email { email } => {
1586 let id = match self.user_with_verified_email(email).await? {
1587 Some(id) => self
1588 .find_public_user("SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?", &id)
1589 .await?
1590 .map(|user| (user.id, user.username)),
1591 None => None,
1592 };
1593 match person(id).await? {
1594 None => answer(OwnerCheck::UnknownEmail, Vec::new(), None),
1595 Some((username, true)) => answer(OwnerCheck::Ok, vec![username], None),
1596 Some((username, false)) => answer(OwnerCheck::NoWriteAccess, vec![username], None),
1597 }
1598 }
1599 Owner::Team { workspace: org, slug } => {
1600 // A team of another g1t workspace never has a role here;
1601 // an `org` that is not a g1t workspace means this one.
1602 let home = if org.eq_ignore_ascii_case(workspace) || self.workspace_id_of(org).await?.is_none() {
1603 workspace.to_owned()
1604 } else {
1605 org.to_lowercase()
1606 };
1607 match self.team_row(&home, slug, None).await? {
1608 None => answer(OwnerCheck::UnknownTeam, Vec::new(), None),
1609 Some(row) => {
1610 let team = self.resolved(&row, Some(repo_id)).await?;
1611 let members = team.everyone().map(|person| person.username.clone()).collect();
1612 let check = if home == workspace && team.repo_role.is_some_and(|role| role >= RepoRole::Write) {
1613 OwnerCheck::Ok
1614 } else {
1615 OwnerCheck::TeamNoAccess
1616 };
1617 answer(check, members, Some(format!("{}/{}", row.workspace, row.slug)))
1618 }
1619 }
1620 }
1621 })
1622 }
1623
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.1624 // --- Agents on teams ---
1625
1626 pub(crate) async fn has_team_agent(&self, team_id: &str, agent_id: &str) -> Result<bool> {
1627 Ok(self
1628 .db
1629 .prepare("SELECT agent_id AS id FROM team_agents WHERE team_id = ? AND agent_id = ?")
1630 .bind(&[team_id.into(), agent_id.into()])?
1631 .first::<IdRow>(None)
1632 .await?
1633 .is_some())
1634 }
1635
1636 /// The agents added to a team, as the viewer may see the team.
1637 pub async fn team_agents(&self, a: TeamArgs) -> Result<Outcome<Vec<TeamAgent>>> {
1638 let (row, _) = match self.seen_team(&a.viewer, &a.workspace, &a.team).await? {
1639 Outcome::Ok(found) => found,
1640 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1641 };
1642 #[derive(Deserialize)]
1643 struct AgentRow {
1644 agent_id: String,
1645 #[serde(default)]
1646 added_by: Option<String>,
1647 created_at: String,
1648 }
1649 let rows = self
1650 .db
1651 .prepare(
1652 "SELECT ta.agent_id, u.username AS added_by, ta.created_at FROM team_agents ta
1653 LEFT JOIN users u ON u.id = ta.added_by AND u.deleted_at IS NULL
1654 WHERE ta.team_id = ? ORDER BY ta.created_at LIMIT 500",
1655 )
1656 .bind(&[row.id.as_str().into()])?
1657 .all()
1658 .await?
1659 .results::<AgentRow>()?;
1660 Ok(Outcome::Ok(
1661 rows.into_iter()
1662 .map(|row| TeamAgent {
1663 agent_id: row.agent_id,
1664 added_by: row.added_by,
1665 created_at: row.created_at,
1666 })
1667 .collect(),
1668 ))
1669 }
1670
1671 pub async fn set_team_agent(&self, a: SetTeamAgentArgs) -> Result<Outcome<TeamAgent>> {
1672 let (row, _) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
1673 Outcome::Ok(found) => found,
1674 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1675 };
1676 let agent_id = a.agent_id.trim();
1677 if !matches!(parse_lead(&format!("agent:{agent_id}")), Some(LeadInput::Agent(_))) {
1678 return Ok(Outcome::fail(FailureCode::Invalid, "That is not an agent's id."));
1679 }
1680 let now = rfc3339(now_ms());
1681 let added = !self.has_team_agent(&row.id, agent_id).await?;
1682 self.db
1683 .prepare(
1684 "INSERT INTO team_agents (team_id, agent_id, added_by, created_at) VALUES (?1, ?2, ?3, ?4)
1685 ON CONFLICT (team_id, agent_id) DO NOTHING",
1686 )
1687 .bind(&[row.id.as_str().into(), agent_id.into(), a.actor.id.as_str().into(), now.as_str().into()])?
1688 .run()
1689 .await?;
1690 if added {
1691 self.team_event(
1692 "team.edited",
1693 &a.actor,
1694 TeamChanged {
1695 changes: vec!["agents".to_owned()],
1696 ..row.event()
1697 },
1698 a.surface.unwrap_or(Surface::Web),
1699 format!("Added an agent ({agent_id}) to {}", row.name),
1700 )
1701 .await;
1702 }
1703 Ok(Outcome::Ok(TeamAgent {
1704 agent_id: agent_id.to_owned(),
1705 added_by: Some(a.actor.username.clone()),
1706 created_at: now,
1707 }))
1708 }
1709
1710 pub async fn remove_team_agent(&self, a: RemoveTeamAgentArgs) -> Result<Outcome<bool>> {
1711 let (row, _) = match self.managed_team(&a.actor, &a.workspace, &a.team).await? {
1712 Outcome::Ok(found) => found,
1713 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1714 };
1715 let agent_id = a.agent_id.trim();
1716 if !self.has_team_agent(&row.id, agent_id).await? {
1717 return Ok(Outcome::fail(FailureCode::NotFound, format!("That agent was not added to {}.", row.name)));
1718 }
1719 self.db
1720 .batch(vec![
1721 self.db
1722 .prepare("DELETE FROM team_agents WHERE team_id = ? AND agent_id = ?")
1723 .bind(&[row.id.as_str().into(), agent_id.into()])?,
1724 self.db
1725 .prepare("UPDATE teams SET lead_kind = NULL, lead_id = NULL WHERE id = ? AND lead_kind = 'agent' AND lead_id = ?")
1726 .bind(&[row.id.as_str().into(), agent_id.into()])?,
1727 ])
1728 .await?;
1729 self.team_event(
1730 "team.edited",
1731 &a.actor,
1732 TeamChanged {
1733 changes: vec!["agents".to_owned()],
1734 ..row.event()
1735 },
1736 a.surface.unwrap_or(Surface::Web),
1737 format!("Took an agent ({agent_id}) off {}", row.name),
1738 )
1739 .await;
1740 Ok(Outcome::Ok(true))
1741 }
1742
1743 /// For the agents service: the visible teams an agent is on, with
1744 /// everyone on each, for what it is told every turn.
1745 pub async fn agent_teams(&self, a: AgentTeamsArgs) -> Result<Vec<AgentTeam>> {
1746 let workspace = a.workspace.trim().to_lowercase();
1747 let home = a.home_team.as_deref().map(|slug| slug.trim().to_lowercase()).unwrap_or_default();
1748 let rows = self
1749 .team_rows(
1750 "WHERE w.slug = ?2 AND t.visibility = 'visible'
1751 AND (t.id IN (SELECT team_id FROM team_agents WHERE agent_id = ?3) OR t.slug = ?4)
1752 ORDER BY lower(t.name)",
1753 &[JsValue::NULL, workspace.as_str().into(), a.agent_id.trim().into(), home.as_str().into()],
1754 )
1755 .await?;
1756 let rows: Vec<TeamRow> = rows.into_iter().take(20).collect();
1757 if rows.is_empty() {
1758 return Ok(Vec::new());
1759 }
1760 let ids = serde_json::to_string(&rows.iter().map(|row| row.id.as_str()).collect::<Vec<_>>())?;
1761 #[derive(Deserialize)]
1762 struct PersonRow {
1763 team_id: String,
1764 user_id: String,
1765 username: String,
1766 #[serde(default)]
1767 name: Option<String>,
1768 #[serde(default)]
1769 timezone: Option<String>,
1770 role: String,
1771 #[serde(default)]
1772 title: Option<String>,
1773 #[serde(default)]
1774 owns: Option<String>,
1775 #[serde(default)]
1776 manager: Option<String>,
1777 }
1778 #[derive(Deserialize)]
1779 struct AgentRow {
1780 team_id: String,
1781 agent_id: String,
1782 }
1783 // One trip for both.
1784 let mut found = self
1785 .db
1786 .batch(vec![
1787 self.db
1788 .prepare(
1789 "SELECT tm.team_id, u.id AS user_id, u.username, u.display_name AS name, u.timezone, tm.role,
1790 wm.title, wm.owns, mu.username AS manager
1791 FROM team_members tm
1792 JOIN teams t ON t.id = tm.team_id
1793 JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
1794 LEFT JOIN workspace_members wm ON wm.workspace_id = t.workspace_id AND wm.user_id = u.id
1795 LEFT JOIN users mu ON mu.id = wm.manager_id AND mu.deleted_at IS NULL
1796 WHERE tm.team_id IN (SELECT value FROM json_each(?1))
1797 ORDER BY tm.role DESC, u.username LIMIT 2000",
1798 )
1799 .bind(&[ids.as_str().into()])?,
1800 self.db
1801 .prepare("SELECT team_id, agent_id FROM team_agents WHERE team_id IN (SELECT value FROM json_each(?1)) ORDER BY created_at LIMIT 2000")
1802 .bind(&[ids.as_str().into()])?,
1803 ])
1804 .await?
1805 .into_iter();
1806 let people = match found.next() {
1807 Some(result) => result.results::<PersonRow>()?,
1808 None => Vec::new(),
1809 };
1810 let agents = match found.next() {
1811 Some(result) => result.results::<AgentRow>()?,
1812 None => Vec::new(),
1813 };
1814 Ok(rows
1815 .iter()
1816 .map(|row| AgentTeam {
1817 slug: row.slug.clone(),
1818 name: row.name.clone(),
1819 description: row.description.clone(),
1820 lead: row.lead(),
1821 channel: channel_of(row.channel_id.as_deref(), row.channel_name.as_deref()),
1822 budget_micros: budget_of(row.budget_micros),
1823 people: people
1824 .iter()
1825 .filter(|person| person.team_id == row.id)
1826 .map(|person| RosterPerson {
1827 user_id: person.user_id.clone(),
1828 username: person.username.clone(),
1829 name: person.name.clone(),
1830 title: person.title.clone(),
1831 timezone: person.timezone.clone(),
1832 owns: crate::people::owns_from(person.owns.as_deref()),
1833 manager: person.manager.clone(),
1834 maintainer: person.role == TeamRole::Maintainer.as_str(),
1835 })
1836 .collect(),
1837 agent_ids: agents.iter().filter(|agent| agent.team_id == row.id).map(|agent| agent.agent_id.clone()).collect(),
1838 })
1839 .collect())
1840 }
1841
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1842 // --- Telling others ---
1843
1844 /// Publishes a change to a team and records it in the workspace's
1845 /// audit log.
1846 async fn team_event(&self, kind: &'static str, actor: &User, data: TeamChanged, surface: Surface, message: String) {
1847 let workspace = data.workspace.clone();
1848 self.announce(kind, Some(&actor.id), data).await;
1849 self.audit_workspace(actor, kind, &workspace, surface, message).await;
1850 }
1851
1852 #[allow(clippy::too_many_arguments)]
1853 async fn team_repo_event(
1854 &self,
1855 kind: &'static str,
1856 actor: &User,
1857 row: &TeamRow,
1858 repo: Named<'_>,
1859 role: Option<RepoRole>,
1860 previous: Option<RepoRole>,
1861 surface: Surface,
1862 message: String,
1863 ) {
1864 let data = TeamChanged {
1865 repo_id: Some(repo.id.to_owned()),
1866 repo: Some(format!("{}/{}", repo.namespace, repo.name)),
1867 repo_role: role,
1868 previous_repo_role: previous,
1869 ..row.event()
1870 };
1871 self.publish_repo(kind, repo.id, &actor.id, data).await;
1872 self.audit(actor, kind, repo, surface, message).await;
1873 }
1874}
1875
1876/// A team's repositories from its own grants and its ancestors' (`depth`
1877/// 0 for its own): each repository once, at the highest role, named by
1878/// the ancestor it comes from when that is higher than its own.
1879pub fn fold_team_repos(rows: impl IntoIterator<Item = (String, String, RepoRole, u32, String)>) -> Vec<TeamRepo> {
1880 let mut repos: Vec<(TeamRepo, u32)> = Vec::new();
1881 for (repo_id, repo, role, depth, team) in rows {
1882 let inherited_from = (depth > 0).then_some(team);
1883 match repos.iter_mut().find(|(had, _)| had.repo_id == repo_id) {
1884 Some((had, had_depth)) => {
1885 if role > had.role || (role == had.role && depth < *had_depth) {
1886 had.role = role;
1887 had.inherited_from = inherited_from;
1888 *had_depth = depth;
1889 }
1890 }
1891 None => repos.push((
1892 TeamRepo {
1893 repo,
1894 repo_id,
1895 role,
1896 inherited_from,
1897 },
1898 depth,
1899 )),
1900 }
1901 }
1902 let mut repos: Vec<TeamRepo> = repos.into_iter().map(|(repo, _)| repo).collect();
1903 repos.sort_by(|a, b| a.repo.cmp(&b.repo));
1904 repos
1905}
1906
1907#[cfg(test)]
1908mod tests {
1909 use super::*;
1910
1911 #[test]
1912 fn secret_teams_are_seen_by_their_people_and_owners() {
1913 assert!(may_see(TeamVisibility::Visible, false, false));
1914 assert!(!may_see(TeamVisibility::Secret, false, false));
1915 assert!(may_see(TeamVisibility::Secret, false, true));
1916 assert!(may_see(TeamVisibility::Secret, true, false));
1917 }
1918
1919 #[test]
1920 fn owners_and_maintainers_manage_a_team() {
1921 assert!(may_manage(true, None));
1922 assert!(may_manage(false, Some(TeamRole::Maintainer)));
1923 assert!(!may_manage(false, Some(TeamRole::Member)));
1924 assert!(!may_manage(false, None));
1925 }
1926
1927 #[test]
1928 fn nesting_refuses_cycles_secret_teams_and_depth() {
1929 use TeamVisibility::*;
1930 let chain = |ids: &[&str]| ids.iter().map(|id| (*id).to_owned()).collect::<Vec<_>>();
1931 assert_eq!(nesting_problem("t1", Visible, "t2", Visible, &chain(&["t2"]), 0), None);
1932 // Under itself, or under its own child.
1933 assert!(nesting_problem("t1", Visible, "t1", Visible, &chain(&["t1"]), 0).is_some());
1934 assert!(nesting_problem("t1", Visible, "t3", Visible, &chain(&["t3", "t2", "t1"]), 2).is_some());
1935 // Secret on either side.
1936 assert!(nesting_problem("t1", Secret, "t2", Visible, &chain(&["t2"]), 0).is_some());
1937 assert!(nesting_problem("t1", Visible, "t2", Secret, &chain(&["t2"]), 0).is_some());
1938 // Eight levels at most: a chain of 6, the team, and one below.
1939 let deep = chain(&["a", "b", "c", "d", "e", "f"]);
1940 assert_eq!(nesting_problem("t1", Visible, "a", Visible, &deep, 1), None);
1941 assert!(nesting_problem("t1", Visible, "a", Visible, &deep, 2).is_some());
1942 // A new team has no id yet and nothing below.
1943 assert_eq!(nesting_problem("", Visible, "a", Visible, &chain(&["a"]), 0), None);
1944 }
1945
1946 #[test]
1947 fn a_teams_repositories_are_its_own_and_its_parents_at_the_highest_role() {
1948 let repos = fold_team_repos([
1949 ("rep_1".to_owned(), "acme/api".to_owned(), RepoRole::Write, 0, "backend".to_owned()),
1950 ("rep_1".to_owned(), "acme/api".to_owned(), RepoRole::Read, 1, "engineering".to_owned()),
1951 ("rep_2".to_owned(), "acme/web".to_owned(), RepoRole::Triage, 0, "backend".to_owned()),
1952 ("rep_2".to_owned(), "acme/web".to_owned(), RepoRole::Maintain, 2, "everyone".to_owned()),
1953 ("rep_3".to_owned(), "acme/docs".to_owned(), RepoRole::Read, 1, "engineering".to_owned()),
1954 ("rep_4".to_owned(), "acme/cli".to_owned(), RepoRole::Write, 1, "engineering".to_owned()),
1955 ("rep_4".to_owned(), "acme/cli".to_owned(), RepoRole::Write, 0, "backend".to_owned()),
1956 ]);
1957 let shown: Vec<(&str, RepoRole, Option<&str>)> =
1958 repos.iter().map(|repo| (repo.repo.as_str(), repo.role, repo.inherited_from.as_deref())).collect();
1959 assert_eq!(
1960 shown,
1961 vec![
1962 ("acme/api", RepoRole::Write, None),
1963 // Its own role is a tie with the parent's: its own.
1964 ("acme/cli", RepoRole::Write, None),
1965 ("acme/docs", RepoRole::Read, Some("engineering")),
1966 ("acme/web", RepoRole::Maintain, Some("everyone")),
1967 ]
1968 );
1969 }
1970
1971 #[test]
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.1972 fn a_teams_lead_channel_and_budget_read_from_their_columns() {
1973 assert_eq!(
1974 lead_of(Some("agent"), Some("agt_1"), None, None, None),
1975 Some(TeamLead::Agent { agent_id: "agt_1".into() })
1976 );
1977 assert_eq!(
1978 lead_of(Some("user"), Some("usr_1"), Some("priya"), Some("Priya Shah"), None),
1979 Some(TeamLead::User { username: "priya".into(), name: Some("Priya Shah".into()), avatar: None })
1980 );
1981 // A person whose account is gone leads nothing.
1982 assert_eq!(lead_of(Some("user"), Some("usr_1"), None, None, None), None);
1983 assert_eq!(lead_of(None, None, None, None, None), None);
1984 assert_eq!(channel_of(Some("chn_1"), Some("sales")).map(|c| c.name), Some("sales".into()));
1985 assert_eq!(channel_of(Some(""), Some("sales")), None);
1986 assert_eq!(budget_of(Some(150_000_000.0)), Some(150_000_000));
1987 assert_eq!(budget_of(Some(0.0)), None);
1988 assert_eq!(budget_of(None), None);
1989 }
1990
1991 #[test]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1992 fn a_workspace_token_sees_teams_as_an_owner() {
1993 let token = User {
1994 id: "wsp_1".into(),
1995 username: "acme".into(),
1996 kind: PrincipalKind::Workspace,
1997 workspaces: vec![g1t_contracts::Membership::member("acme")],
1998 ..User::default()
1999 };
Fine-grained personal tokens, workspace token rules and approvals in identity2000 assert_eq!(role_of(&token, "acme"), Some(Role::Owner), "the workspace itself, with no token: a service");
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2001 assert_eq!(role_of(&token, "globex"), None);
Fine-grained personal tokens, workspace token rules and approvals in identity2002 let mut writer = User { token: Some(Box::new(g1t_contracts::scopes::TokenAccess::full())), ..token.clone() };
2003 assert_eq!(role_of(&writer, "acme"), Some(Role::Member), "a workspace token is a member unless given Admin");
2004 writer.token.as_mut().unwrap().admin = true;
2005 assert_eq!(role_of(&writer, "acme"), Some(Role::Owner));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar2006 }
2007}

This file's history is long; its oldest lines are credited to the oldest commit read.