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 bar | 1 | //! 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 bar | 16 | //! | 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 | ||
| 26 | use std::collections::{HashMap, HashSet}; | |
| 27 | ||
| 28 | use g1t_contracts::access::{RepoRef, RepoRole, granted}; | |
| 29 | use g1t_contracts::audit::Surface; | |
| 30 | use g1t_contracts::codeowners::{Owner, OwnerCheck}; | |
| 31 | use g1t_contracts::events::TeamChanged; | |
| 32 | use g1t_contracts::identity::AGENT_NAME; | |
| 33 | use g1t_contracts::teams::*; | |
| 34 | use g1t_contracts::time::rfc3339; | |
| 35 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, new_id}; | |
| 36 | use g1t_kit::now_ms; | |
| 37 | use serde::Deserialize; | |
| 38 | use worker::Result; | |
| 39 | use worker::wasm_bindgen::JsValue; | |
| 40 | ||
| 41 | use crate::Identity; | |
| 42 | use crate::access::Named; | |
| 43 | ||
| 44 | const PEOPLE_ONLY: &str = "Only a person can change a team, signed in as themselves; never an agent's or a workspace's token."; | |
| 45 | const CONFIRM_FIRST: &str = "Confirm your email address before changing a team."; | |
| 46 | const NOT_FOUND: &str = "Team not found."; | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 47 | const 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. | |
| 50 | fn 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 bar | 56 | const NO_SUCH_USER: &str = "There is no account with that username."; |
| 57 | const LIST_LIMIT: u32 = 500; | |
| 58 | ||
| 59 | fn 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)] | |
| 65 | pub(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 bar | 88 | /// 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 bar | 109 | 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. |
| 114 | pub(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. | |
| 133 | pub(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. | |
| 144 | pub(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. | |
| 149 | pub 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 bar | 151 | impl 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 bar | 177 | 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 bar | 202 | 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`. | |
| 223 | const 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 bar | 232 | 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 bar | 237 | |
| 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. | |
| 242 | pub 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. | |
| 250 | pub 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). | |
| 257 | pub 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 identity | 278 | /// 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. | 281 | pub(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 bar | 282 | if matches!(viewer.kind, PrincipalKind::Workspace | PrincipalKind::System) && viewer.is_member(workspace) { |
| Fine-grained personal tokens, workspace token rules and approvals in identity | 283 | 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 bar | 286 | return Some(Role::Owner); |
| 287 | } | |
| 288 | viewer.role_in(workspace) | |
| 289 | } | |
| 290 | ||
| 291 | /// One person, as a team lists them. | |
| 292 | #[derive(Deserialize)] | |
| 293 | struct 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)] | |
| 307 | struct IdRow { | |
| 308 | id: String, | |
| 309 | } | |
| 310 | ||
| 311 | #[derive(Deserialize)] | |
| 312 | struct RoleRow { | |
| 313 | role: String, | |
| 314 | } | |
| 315 | ||
| 316 | impl 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 bar | 318 | 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 bar | 605 | 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 bar | 620 | 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 bar | 909 | 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 bar | 951 | 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 bar | 1142 | .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 bar | 1842 | // --- 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. | |
| 1879 | pub 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)] | |
| 1908 | mod 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 bar | 1992 | 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 identity | 2000 | 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 bar | 2001 | assert_eq!(role_of(&token, "globex"), None); |
| Fine-grained personal tokens, workspace token rules and approvals in identity | 2002 | 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 bar | 2006 | } |
| 2007 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.