| 1 | //! Teams: groups of a workspace's members, given roles on repositories |
| 2 | //! together, mentioned together and asked to review together. |
| 3 | //! |
| 4 | //! **Who is in one.** A team has **maintainers**, who manage its people |
| 5 | //! and settings, and **members**. Only members of the workspace can be in |
| 6 | //! its teams; leaving the workspace takes a person out of all of them. |
| 7 | //! Owners of the workspace manage every team, whether or not they are in |
| 8 | //! it. |
| 9 | //! |
| 10 | //! **Visibility.** A **visible** team is seen by every member of the |
| 11 | //! workspace. A **secret** team is seen only by its own people and the |
| 12 | //! workspace's owners. Secret teams cannot be nested. |
| 13 | //! |
| 14 | //! **Nesting.** A team can have a parent. A child team inherits its |
| 15 | //! parent's roles on repositories (and its parent's parent's), and a |
| 16 | //! mention or review request for the parent reaches the child teams' |
| 17 | //! people too. A team's own people never get anything from its children. |
| 18 | //! |
| 19 | //! **Repository access.** A team is given a [`RepoRole`] on a repository |
| 20 | //! as a person is: a row in `repo_grants` whose principal is the team. |
| 21 | //! Identity resolves it into the same [`RepoGrant`](crate::access::RepoGrant)s |
| 22 | //! on every person in the team and in its child teams, so `access::can` |
| 23 | //! decides with it as with any other grant: the highest role wins. |
| 24 | //! |
| 25 | //! **Review requests.** A pull request can ask a team to review it. With |
| 26 | //! [`ReviewAssignment`] off, everyone in the team is asked. With it on, |
| 27 | //! g1t picks `count` people from it (never the pull request's author) and |
| 28 | //! asks them; the team stays shown as requested beside them. |
| 29 | //! |
| 30 | //! Every method is served by identity at `POST /rpc/<method>`. Changing a |
| 31 | //! team is for people, signed in or with a personal access token; never |
| 32 | //! an agent's or a workspace's token. |
| 33 | |
| 34 | use serde::{Deserialize, Serialize}; |
| 35 | |
| 36 | use crate::{Role, User}; |
| 37 | use crate::access::RepoRole; |
| 38 | use crate::repos::RepoPath; |
| 39 | |
| 40 | /// The most teams one workspace can have. |
| 41 | pub const MAX_TEAMS: u32 = 500; |
| 42 | /// The longest team name. |
| 43 | pub const MAX_NAME_LENGTH: usize = 80; |
| 44 | /// The longest description. |
| 45 | pub const MAX_DESCRIPTION_LENGTH: usize = 280; |
| 46 | /// How deep teams can nest: a team, its child, and so on. |
| 47 | pub const MAX_DEPTH: usize = 8; |
| 48 | /// The most people review assignment picks for one request. |
| 49 | pub const MAX_ASSIGNED: u32 = 10; |
| 50 | |
| 51 | /// Who can see a team. |
| 52 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 53 | #[serde(rename_all = "snake_case")] |
| 54 | pub enum TeamVisibility { |
| 55 | /// Every member of the workspace. |
| 56 | #[default] |
| 57 | Visible, |
| 58 | /// The team's own people and the workspace's owners. |
| 59 | Secret, |
| 60 | } |
| 61 | |
| 62 | impl TeamVisibility { |
| 63 | pub fn as_str(self) -> &'static str { |
| 64 | match self { |
| 65 | TeamVisibility::Visible => "visible", |
| 66 | TeamVisibility::Secret => "secret", |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | pub fn parse(text: &str) -> Option<TeamVisibility> { |
| 71 | match text.trim().to_ascii_lowercase().as_str() { |
| 72 | "visible" | "closed" => Some(TeamVisibility::Visible), |
| 73 | "secret" => Some(TeamVisibility::Secret), |
| 74 | _ => None, |
| 75 | } |
| 76 | } |
| 77 | } |
| 78 | |
| 79 | /// Who may create a workspace's teams: a workspace setting, changed by |
| 80 | /// its owners (`set_team_creation`). Stored in `workspaces.team_creation`, |
| 81 | /// NULL for the default. |
| 82 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 83 | #[serde(rename_all = "snake_case")] |
| 84 | pub enum TeamCreation { |
| 85 | /// Any member with a confirmed email address. |
| 86 | #[default] |
| 87 | Members, |
| 88 | /// The workspace's owners only. |
| 89 | Owners, |
| 90 | } |
| 91 | |
| 92 | impl TeamCreation { |
| 93 | pub const ALL: [TeamCreation; 2] = [TeamCreation::Members, TeamCreation::Owners]; |
| 94 | |
| 95 | pub fn as_str(self) -> &'static str { |
| 96 | match self { |
| 97 | TeamCreation::Members => "members", |
| 98 | TeamCreation::Owners => "owners", |
| 99 | } |
| 100 | } |
| 101 | |
| 102 | pub fn parse(text: &str) -> Option<TeamCreation> { |
| 103 | match text.trim().to_ascii_lowercase().as_str() { |
| 104 | "members" | "member" | "any" => Some(TeamCreation::Members), |
| 105 | "owners" | "owner" => Some(TeamCreation::Owners), |
| 106 | _ => None, |
| 107 | } |
| 108 | } |
| 109 | |
| 110 | /// Whether someone with `role` in the workspace may create a team. |
| 111 | pub fn allows(self, role: Role) -> bool { |
| 112 | match self { |
| 113 | TeamCreation::Members => true, |
| 114 | TeamCreation::Owners => role == Role::Owner, |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | /// `set_team_creation`: who may create the workspace's teams. Owners |
| 120 | /// only, as a person. Returns `Outcome<TeamCreation>`. |
| 121 | #[derive(Debug, Serialize, Deserialize)] |
| 122 | pub struct SetTeamCreationArgs { |
| 123 | pub actor: User, |
| 124 | pub slug: String, |
| 125 | pub team_creation: TeamCreation, |
| 126 | #[serde(default)] |
| 127 | pub surface: Option<crate::audit::Surface>, |
| 128 | } |
| 129 | |
| 130 | /// A person's place in a team. |
| 131 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] |
| 132 | #[serde(rename_all = "snake_case")] |
| 133 | pub enum TeamRole { |
| 134 | Member, |
| 135 | /// Manages the team's people and settings. |
| 136 | Maintainer, |
| 137 | } |
| 138 | |
| 139 | impl TeamRole { |
| 140 | pub fn as_str(self) -> &'static str { |
| 141 | match self { |
| 142 | TeamRole::Member => "member", |
| 143 | TeamRole::Maintainer => "maintainer", |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | pub fn parse(text: &str) -> Option<TeamRole> { |
| 148 | match text.trim().to_ascii_lowercase().as_str() { |
| 149 | "member" => Some(TeamRole::Member), |
| 150 | "maintainer" => Some(TeamRole::Maintainer), |
| 151 | _ => None, |
| 152 | } |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /// How review assignment picks people. |
| 157 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 158 | #[serde(rename_all = "snake_case")] |
| 159 | pub enum ReviewAlgorithm { |
| 160 | /// Whoever was asked least recently by this team goes first. |
| 161 | #[default] |
| 162 | RoundRobin, |
| 163 | /// Whoever has the fewest pull requests waiting on their review goes |
| 164 | /// first. |
| 165 | LoadBalance, |
| 166 | } |
| 167 | |
| 168 | impl ReviewAlgorithm { |
| 169 | pub fn as_str(self) -> &'static str { |
| 170 | match self { |
| 171 | ReviewAlgorithm::RoundRobin => "round_robin", |
| 172 | ReviewAlgorithm::LoadBalance => "load_balance", |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | pub fn parse(text: &str) -> Option<ReviewAlgorithm> { |
| 177 | match text.trim().to_ascii_lowercase().as_str() { |
| 178 | "round_robin" => Some(ReviewAlgorithm::RoundRobin), |
| 179 | "load_balance" => Some(ReviewAlgorithm::LoadBalance), |
| 180 | _ => None, |
| 181 | } |
| 182 | } |
| 183 | } |
| 184 | |
| 185 | /// What happens when a team is asked to review a pull request. |
| 186 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 187 | pub struct ReviewAssignment { |
| 188 | /// Off: everyone in the team is asked. On: `count` people are picked. |
| 189 | pub enabled: bool, |
| 190 | pub algorithm: ReviewAlgorithm, |
| 191 | /// How many people to pick, 1 to [`MAX_ASSIGNED`]. People from the team |
| 192 | /// already asked count towards it. |
| 193 | pub count: u32, |
| 194 | /// Leave out anyone with `busy_at` or more open pull requests waiting |
| 195 | /// on their review. |
| 196 | pub skip_busy: bool, |
| 197 | pub busy_at: u32, |
| 198 | /// Also pick from the people of its child teams. |
| 199 | pub include_child_teams: bool, |
| 200 | /// Usernames never picked. |
| 201 | #[serde(default)] |
| 202 | pub excluded: Vec<String>, |
| 203 | /// Also tell the rest of the team when people are picked. |
| 204 | pub notify_team: bool, |
| 205 | } |
| 206 | |
| 207 | impl Default for ReviewAssignment { |
| 208 | fn default() -> Self { |
| 209 | ReviewAssignment { |
| 210 | enabled: false, |
| 211 | algorithm: ReviewAlgorithm::RoundRobin, |
| 212 | count: 1, |
| 213 | skip_busy: false, |
| 214 | busy_at: 5, |
| 215 | include_child_teams: false, |
| 216 | excluded: Vec::new(), |
| 217 | notify_team: false, |
| 218 | } |
| 219 | } |
| 220 | } |
| 221 | |
| 222 | impl ReviewAssignment { |
| 223 | /// The same, with every number within bounds and the usernames |
| 224 | /// lowercased, each once. |
| 225 | pub fn bounded(mut self) -> Self { |
| 226 | self.count = self.count.clamp(1, MAX_ASSIGNED); |
| 227 | self.busy_at = self.busy_at.clamp(1, 100); |
| 228 | let mut excluded: Vec<String> = Vec::new(); |
| 229 | for name in self.excluded { |
| 230 | let name = name.trim().trim_start_matches('@').to_lowercase(); |
| 231 | if !name.is_empty() && !excluded.contains(&name) { |
| 232 | excluded.push(name); |
| 233 | } |
| 234 | } |
| 235 | excluded.truncate(100); |
| 236 | self.excluded = excluded; |
| 237 | self |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | /// Who leads a team: a person on it, or an agent on it. |
| 242 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 243 | #[serde(tag = "kind", rename_all = "snake_case")] |
| 244 | pub enum TeamLead { |
| 245 | User { |
| 246 | username: String, |
| 247 | #[serde(default)] |
| 248 | name: Option<String>, |
| 249 | #[serde(default)] |
| 250 | avatar: Option<String>, |
| 251 | }, |
| 252 | /// An agent, by its id in the agents service. |
| 253 | Agent { agent_id: String }, |
| 254 | } |
| 255 | |
| 256 | /// A lead as written to `update_team`: `@username` (or a bare username) |
| 257 | /// for a person, `agent:<id>` for an agent. |
| 258 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 259 | pub enum LeadInput { |
| 260 | User(String), |
| 261 | Agent(String), |
| 262 | } |
| 263 | |
| 264 | /// `lead` as `update_team` takes it; `None` when it is not one. An empty |
| 265 | /// string means no lead, and is not parsed here. |
| 266 | pub fn parse_lead(text: &str) -> Option<LeadInput> { |
| 267 | let text = text.trim(); |
| 268 | if let Some(id) = text.strip_prefix("agent:") { |
| 269 | let id = id.trim(); |
| 270 | let ok = !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-'); |
| 271 | return ok.then(|| LeadInput::Agent(id.to_owned())); |
| 272 | } |
| 273 | let name = text.trim_start_matches('@').to_lowercase(); |
| 274 | crate::is_valid_namespace(&name).then_some(LeadInput::User(name)) |
| 275 | } |
| 276 | |
| 277 | /// A team's chat channel. |
| 278 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 279 | pub struct TeamChannel { |
| 280 | /// Its id in the chat service. |
| 281 | pub id: String, |
| 282 | /// Its name, without `#`, as it was when chosen. |
| 283 | pub name: String, |
| 284 | } |
| 285 | |
| 286 | /// One agent on a team. |
| 287 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 288 | pub struct TeamAgent { |
| 289 | pub agent_id: String, |
| 290 | /// Who added it, by username; null when its account is gone. |
| 291 | pub added_by: Option<String>, |
| 292 | /// RFC 3339. |
| 293 | pub created_at: String, |
| 294 | } |
| 295 | |
| 296 | /// A team as another names it: its parent, or a child. |
| 297 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 298 | pub struct TeamRef { |
| 299 | pub slug: String, |
| 300 | pub name: String, |
| 301 | } |
| 302 | |
| 303 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 304 | pub struct Team { |
| 305 | pub id: String, |
| 306 | /// Its workspace's slug. |
| 307 | pub workspace: String, |
| 308 | /// Its name in URLs and mentions: `@<workspace>/<slug>`. |
| 309 | pub slug: String, |
| 310 | pub name: String, |
| 311 | pub description: Option<String>, |
| 312 | pub visibility: TeamVisibility, |
| 313 | pub parent: Option<TeamRef>, |
| 314 | /// Whether its people are notified when it is mentioned. |
| 315 | pub notify: bool, |
| 316 | pub review_assignment: ReviewAssignment, |
| 317 | /// Its own people, not counting child teams'. |
| 318 | pub members_count: u32, |
| 319 | /// Repositories it has a role on itself, not counting inherited ones. |
| 320 | pub repos_count: u32, |
| 321 | pub child_teams_count: u32, |
| 322 | /// Agents added to it. Agents whose home team it is are on it too. |
| 323 | #[serde(default)] |
| 324 | pub agents_count: u32, |
| 325 | /// Who leads it, if anyone. |
| 326 | #[serde(default)] |
| 327 | pub lead: Option<TeamLead>, |
| 328 | /// Its chat channel, if it has one. |
| 329 | #[serde(default)] |
| 330 | pub channel: Option<TeamChannel>, |
| 331 | /// What its agents may spend together in a calendar month, in |
| 332 | /// millionths of a dollar; null for no team budget. |
| 333 | #[serde(default)] |
| 334 | pub budget_micros: Option<i64>, |
| 335 | /// The viewer's place in it, if any. |
| 336 | pub viewer_role: Option<TeamRole>, |
| 337 | /// Whether the viewer may change it: an owner of the workspace, or one |
| 338 | /// of its maintainers. |
| 339 | pub can_manage: bool, |
| 340 | /// RFC 3339. |
| 341 | pub created_at: String, |
| 342 | pub updated_at: String, |
| 343 | } |
| 344 | |
| 345 | impl Team { |
| 346 | /// How it is mentioned: `@acme/backend`. |
| 347 | pub fn handle(&self) -> String { |
| 348 | format!("@{}/{}", self.workspace, self.slug) |
| 349 | } |
| 350 | } |
| 351 | |
| 352 | /// One person in a team. |
| 353 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 354 | pub struct TeamMember { |
| 355 | pub username: String, |
| 356 | pub name: Option<String>, |
| 357 | pub avatar: Option<String>, |
| 358 | pub role: TeamRole, |
| 359 | /// The child team they are in, when listed through one; null for the |
| 360 | /// team's own people. |
| 361 | pub via: Option<String>, |
| 362 | } |
| 363 | |
| 364 | /// A repository a team has a role on. |
| 365 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 366 | pub struct TeamRepo { |
| 367 | /// `workspace/name`. |
| 368 | pub repo: String, |
| 369 | pub repo_id: String, |
| 370 | pub role: RepoRole, |
| 371 | /// The parent team it comes from, by slug, when the team inherits it. |
| 372 | pub inherited_from: Option<String>, |
| 373 | } |
| 374 | |
| 375 | /// A team with a role on a repository, as its Access settings list it. |
| 376 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 377 | pub struct RepoTeam { |
| 378 | pub slug: String, |
| 379 | pub name: String, |
| 380 | pub role: RepoRole, |
| 381 | pub members_count: u32, |
| 382 | pub visibility: TeamVisibility, |
| 383 | } |
| 384 | |
| 385 | /// A team as services need it to notify or ask its people: everyone in it, |
| 386 | /// and its settings. Never shown as is. |
| 387 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 388 | pub struct ResolvedTeam { |
| 389 | pub id: String, |
| 390 | pub workspace: String, |
| 391 | pub slug: String, |
| 392 | pub name: String, |
| 393 | pub visibility: TeamVisibility, |
| 394 | pub notify: bool, |
| 395 | pub review_assignment: ReviewAssignment, |
| 396 | /// Its own people. |
| 397 | pub members: Vec<TeamPerson>, |
| 398 | /// The people of its child teams (and theirs) who are not its own. |
| 399 | pub child_members: Vec<TeamPerson>, |
| 400 | /// Its role on the repository asked about, its own or inherited. |
| 401 | pub repo_role: Option<RepoRole>, |
| 402 | /// Whether the person asked about (`asker`) may see it, and so mention |
| 403 | /// it or ask it to review: a member of its workspace, and for a secret |
| 404 | /// team, in it or an owner. |
| 405 | #[serde(default)] |
| 406 | pub asker_sees: bool, |
| 407 | } |
| 408 | |
| 409 | impl ResolvedTeam { |
| 410 | /// Everyone a mention or a request reaches: its own people, then its |
| 411 | /// child teams'. |
| 412 | pub fn everyone(&self) -> impl Iterator<Item = &TeamPerson> { |
| 413 | self.members.iter().chain(self.child_members.iter()) |
| 414 | } |
| 415 | |
| 416 | pub fn handle(&self) -> String { |
| 417 | format!("@{}/{}", self.workspace, self.slug) |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 422 | pub struct TeamPerson { |
| 423 | pub id: String, |
| 424 | pub username: String, |
| 425 | } |
| 426 | |
| 427 | /// A team's slug from its name: lowercase letters, digits and single |
| 428 | /// hyphens, as mentions spell it. `None` when nothing is left. |
| 429 | pub fn slug_of(name: &str) -> Option<String> { |
| 430 | let mut slug = String::new(); |
| 431 | for c in name.trim().chars() { |
| 432 | if c.is_ascii_alphanumeric() { |
| 433 | slug.push(c.to_ascii_lowercase()); |
| 434 | } else if !slug.is_empty() && !slug.ends_with('-') { |
| 435 | slug.push('-'); |
| 436 | } |
| 437 | } |
| 438 | let slug = slug.trim_end_matches('-').chars().take(60).collect::<String>(); |
| 439 | let slug = slug.trim_end_matches('-').to_owned(); |
| 440 | is_valid_slug(&slug).then_some(slug) |
| 441 | } |
| 442 | |
| 443 | /// Whether `slug` is a team slug: 1 to 60 lowercase letters, digits and |
| 444 | /// single hyphens, not starting or ending with one. |
| 445 | pub fn is_valid_slug(slug: &str) -> bool { |
| 446 | !slug.is_empty() |
| 447 | && slug.len() <= 60 |
| 448 | && slug.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') |
| 449 | && !slug.starts_with('-') |
| 450 | && !slug.ends_with('-') |
| 451 | && !slug.contains("--") |
| 452 | } |
| 453 | |
| 454 | /// `@workspace/team` as written, split; `None` if it is not that shape. |
| 455 | pub fn parse_handle(text: &str) -> Option<(String, String)> { |
| 456 | let text = text.trim().strip_prefix('@')?; |
| 457 | let (workspace, slug) = text.split_once('/')?; |
| 458 | let workspace = workspace.to_lowercase(); |
| 459 | let slug = slug.to_lowercase(); |
| 460 | (crate::is_valid_namespace(&workspace) && is_valid_slug(&slug)).then_some((workspace, slug)) |
| 461 | } |
| 462 | |
| 463 | // --- Identity methods --------------------------------------------------------- |
| 464 | |
| 465 | /// `list_teams`: the teams of a workspace the viewer can see, theirs first, |
| 466 | /// then by name. Members only. `query` narrows by name or slug. Returns |
| 467 | /// `Outcome<Vec<Team>>`. |
| 468 | #[derive(Debug, Serialize, Deserialize)] |
| 469 | pub struct ListTeamsArgs { |
| 470 | pub viewer: crate::Viewer, |
| 471 | pub workspace: String, |
| 472 | #[serde(default)] |
| 473 | pub query: Option<String>, |
| 474 | } |
| 475 | |
| 476 | /// `get_team`, `child_teams`, `team_repos`: one team, its child teams, or |
| 477 | /// the repositories it has a role on (its own and inherited), as the |
| 478 | /// viewer may see them. `team_members` takes `include_child_teams`. |
| 479 | #[derive(Debug, Serialize, Deserialize)] |
| 480 | pub struct TeamArgs { |
| 481 | pub viewer: crate::Viewer, |
| 482 | pub workspace: String, |
| 483 | pub team: String, |
| 484 | /// `team_members` only: also list the people of child teams. |
| 485 | #[serde(default)] |
| 486 | pub include_child_teams: bool, |
| 487 | } |
| 488 | |
| 489 | /// `create_team`. Members may create a team, unless the workspace's |
| 490 | /// [`TeamCreation`] says owners only, and become its maintainer; a team |
| 491 | /// with a parent needs an owner, or a maintainer of the parent. Returns |
| 492 | /// `Outcome<Team>`. |
| 493 | #[derive(Debug, Serialize, Deserialize)] |
| 494 | pub struct CreateTeamArgs { |
| 495 | pub actor: User, |
| 496 | pub workspace: String, |
| 497 | pub name: String, |
| 498 | /// Defaults to one made from the name. |
| 499 | #[serde(default)] |
| 500 | pub slug: Option<String>, |
| 501 | #[serde(default)] |
| 502 | pub description: Option<String>, |
| 503 | #[serde(default)] |
| 504 | pub visibility: Option<TeamVisibility>, |
| 505 | /// The parent's slug. |
| 506 | #[serde(default)] |
| 507 | pub parent: Option<String>, |
| 508 | #[serde(default)] |
| 509 | pub notify: Option<bool>, |
| 510 | /// People to add as members, by username, besides the creator. |
| 511 | #[serde(default)] |
| 512 | pub members: Vec<String>, |
| 513 | #[serde(default)] |
| 514 | pub surface: Option<crate::audit::Surface>, |
| 515 | } |
| 516 | |
| 517 | /// `update_team`: what is given changes; the rest stays. `parent` set to an |
| 518 | /// empty string takes the team out from under its parent. Owners and the |
| 519 | /// team's maintainers. Returns `Outcome<Team>`. |
| 520 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 521 | pub struct UpdateTeamArgs { |
| 522 | pub actor: User, |
| 523 | pub workspace: String, |
| 524 | pub team: String, |
| 525 | #[serde(default)] |
| 526 | pub name: Option<String>, |
| 527 | #[serde(default)] |
| 528 | pub slug: Option<String>, |
| 529 | #[serde(default)] |
| 530 | pub description: Option<String>, |
| 531 | #[serde(default)] |
| 532 | pub visibility: Option<TeamVisibility>, |
| 533 | #[serde(default)] |
| 534 | pub parent: Option<String>, |
| 535 | #[serde(default)] |
| 536 | pub notify: Option<bool>, |
| 537 | #[serde(default)] |
| 538 | pub review_assignment: Option<ReviewAssignment>, |
| 539 | /// Who leads it: `@username` or `agent:<id>`, someone on the team; an |
| 540 | /// empty string for no lead. |
| 541 | #[serde(default)] |
| 542 | pub lead: Option<String>, |
| 543 | /// Its chat channel's id in the chat service; an empty string for none. |
| 544 | #[serde(default)] |
| 545 | pub channel_id: Option<String>, |
| 546 | /// That channel's name, without `#`; given with `channel_id`. |
| 547 | #[serde(default)] |
| 548 | pub channel_name: Option<String>, |
| 549 | /// What its agents may spend together in a month, in millionths of a |
| 550 | /// dollar; 0 for no team budget. |
| 551 | #[serde(default)] |
| 552 | pub budget_micros: Option<i64>, |
| 553 | #[serde(default)] |
| 554 | pub surface: Option<crate::audit::Surface>, |
| 555 | } |
| 556 | |
| 557 | /// `set_team_agent`: adds one of the workspace's agents to a team. The |
| 558 | /// caller checks the agent is the workspace's (identity knows agents only |
| 559 | /// by id). Owners and the team's maintainers. Returns `Outcome<TeamAgent>`. |
| 560 | #[derive(Debug, Serialize, Deserialize)] |
| 561 | pub struct SetTeamAgentArgs { |
| 562 | pub actor: User, |
| 563 | pub workspace: String, |
| 564 | pub team: String, |
| 565 | pub agent_id: String, |
| 566 | #[serde(default)] |
| 567 | pub surface: Option<crate::audit::Surface>, |
| 568 | } |
| 569 | |
| 570 | /// `remove_team_agent`: takes an agent off a team; if it led the team, |
| 571 | /// the team has no lead after. Owners and the team's maintainers. |
| 572 | /// Returns `Outcome<bool>`. |
| 573 | #[derive(Debug, Serialize, Deserialize)] |
| 574 | pub struct RemoveTeamAgentArgs { |
| 575 | pub actor: User, |
| 576 | pub workspace: String, |
| 577 | pub team: String, |
| 578 | pub agent_id: String, |
| 579 | #[serde(default)] |
| 580 | pub surface: Option<crate::audit::Surface>, |
| 581 | } |
| 582 | |
| 583 | /// `agent_teams`: for the agents service. The visible teams an agent is |
| 584 | /// on in a workspace (added to, or its home team), each with everyone on |
| 585 | /// it, for what the agent is told every turn and its team budgets. |
| 586 | /// Returns `Vec<AgentTeam>`. |
| 587 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 588 | pub struct AgentTeamsArgs { |
| 589 | /// The workspace's slug. |
| 590 | pub workspace: String, |
| 591 | pub agent_id: String, |
| 592 | /// The team its profile names, by slug. |
| 593 | #[serde(default)] |
| 594 | pub home_team: Option<String>, |
| 595 | } |
| 596 | |
| 597 | /// A person on a team, as an agent on it is told of them. |
| 598 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 599 | pub struct RosterPerson { |
| 600 | pub user_id: String, |
| 601 | pub username: String, |
| 602 | pub name: Option<String>, |
| 603 | pub title: Option<String>, |
| 604 | pub timezone: Option<String>, |
| 605 | pub owns: Vec<String>, |
| 606 | /// Who they report to, by username. |
| 607 | pub manager: Option<String>, |
| 608 | pub maintainer: bool, |
| 609 | } |
| 610 | |
| 611 | /// A team an agent is on, as it is told of it. |
| 612 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 613 | pub struct AgentTeam { |
| 614 | pub slug: String, |
| 615 | pub name: String, |
| 616 | pub description: Option<String>, |
| 617 | pub lead: Option<TeamLead>, |
| 618 | pub channel: Option<TeamChannel>, |
| 619 | pub budget_micros: Option<i64>, |
| 620 | pub people: Vec<RosterPerson>, |
| 621 | /// The agents added to it, by id. |
| 622 | pub agent_ids: Vec<String>, |
| 623 | } |
| 624 | |
| 625 | /// `delete_team`: its child teams move up to its parent, and the roles it |
| 626 | /// gave go with it. Owners and the team's maintainers. Returns |
| 627 | /// `Outcome<bool>`. |
| 628 | #[derive(Debug, Serialize, Deserialize)] |
| 629 | pub struct DeleteTeamArgs { |
| 630 | pub actor: User, |
| 631 | pub workspace: String, |
| 632 | pub team: String, |
| 633 | #[serde(default)] |
| 634 | pub surface: Option<crate::audit::Surface>, |
| 635 | } |
| 636 | |
| 637 | /// `set_team_member`: adds a member of the workspace to a team, or changes |
| 638 | /// their role in it. Owners and the team's maintainers. Returns |
| 639 | /// `Outcome<TeamMember>`. |
| 640 | #[derive(Debug, Serialize, Deserialize)] |
| 641 | pub struct SetTeamMemberArgs { |
| 642 | pub actor: User, |
| 643 | pub workspace: String, |
| 644 | pub team: String, |
| 645 | pub username: String, |
| 646 | pub role: TeamRole, |
| 647 | #[serde(default)] |
| 648 | pub surface: Option<crate::audit::Surface>, |
| 649 | } |
| 650 | |
| 651 | /// `remove_team_member`: owners and the team's maintainers; anyone may |
| 652 | /// leave a team themselves. Returns `Outcome<bool>`. |
| 653 | #[derive(Debug, Serialize, Deserialize)] |
| 654 | pub struct RemoveTeamMemberArgs { |
| 655 | pub actor: User, |
| 656 | pub workspace: String, |
| 657 | pub team: String, |
| 658 | pub username: String, |
| 659 | #[serde(default)] |
| 660 | pub surface: Option<crate::audit::Surface>, |
| 661 | } |
| 662 | |
| 663 | /// `set_team_repo`: gives a team a role on a repository of its workspace, |
| 664 | /// or changes it. Needs Admin on the repository. Returns |
| 665 | /// `Outcome<TeamRepo>`. |
| 666 | #[derive(Debug, Serialize, Deserialize)] |
| 667 | pub struct SetTeamRepoArgs { |
| 668 | pub actor: User, |
| 669 | pub workspace: String, |
| 670 | pub team: String, |
| 671 | pub repo: RepoPath, |
| 672 | pub role: RepoRole, |
| 673 | #[serde(default)] |
| 674 | pub surface: Option<crate::audit::Surface>, |
| 675 | } |
| 676 | |
| 677 | /// `remove_team_repo`: takes a team's role on a repository away. Admin on |
| 678 | /// the repository, an owner, or one of the team's maintainers. Returns |
| 679 | /// `Outcome<bool>`. |
| 680 | #[derive(Debug, Serialize, Deserialize)] |
| 681 | pub struct RemoveTeamRepoArgs { |
| 682 | pub actor: User, |
| 683 | pub workspace: String, |
| 684 | pub team: String, |
| 685 | pub repo: RepoPath, |
| 686 | #[serde(default)] |
| 687 | pub surface: Option<crate::audit::Surface>, |
| 688 | } |
| 689 | |
| 690 | /// `user_teams`: the teams `username` is in within a workspace, as the |
| 691 | /// viewer may see them. Members only. Returns `Outcome<Vec<Team>>`. |
| 692 | #[derive(Debug, Serialize, Deserialize)] |
| 693 | pub struct UserTeamsArgs { |
| 694 | pub viewer: crate::Viewer, |
| 695 | pub workspace: String, |
| 696 | pub username: String, |
| 697 | } |
| 698 | |
| 699 | /// `team_memberships`: for each member of a workspace, the teams they are |
| 700 | /// in that the viewer can see, for the Members page. Members only. |
| 701 | /// Returns `Outcome<Vec<MemberTeams>>`. |
| 702 | #[derive(Debug, Serialize, Deserialize)] |
| 703 | pub struct TeamMembershipsArgs { |
| 704 | pub viewer: crate::Viewer, |
| 705 | pub workspace: String, |
| 706 | } |
| 707 | |
| 708 | /// One person's teams in a workspace. |
| 709 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 710 | pub struct MemberTeams { |
| 711 | pub username: String, |
| 712 | pub teams: Vec<TeamRef>, |
| 713 | } |
| 714 | |
| 715 | /// `resolve_teams`: for services. Each team named `workspace/slug` (or |
| 716 | /// `@workspace/slug`) that exists, with everyone in it and, given |
| 717 | /// `repo_id`, its role on that repository. Missing teams are left out. |
| 718 | /// Returns `Vec<ResolvedTeam>`. |
| 719 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 720 | pub struct ResolveTeamsArgs { |
| 721 | pub teams: Vec<String>, |
| 722 | #[serde(default)] |
| 723 | pub repo_id: Option<String>, |
| 724 | /// A user id, for `asker_sees`. |
| 725 | #[serde(default)] |
| 726 | pub asker: Option<String>, |
| 727 | } |
| 728 | |
| 729 | /// One owner a CODEOWNERS file names, as identity resolved it. |
| 730 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 731 | pub struct ResolvedOwner { |
| 732 | pub owner: crate::codeowners::Owner, |
| 733 | pub check: crate::codeowners::OwnerCheck, |
| 734 | /// Who may answer for it, by username: the person, the account a |
| 735 | /// confirmed address belongs to, or everyone in a team and its child |
| 736 | /// teams. Empty when it did not resolve. |
| 737 | pub members: Vec<String>, |
| 738 | /// For a team: the team, as `workspace/slug`, after `@org/team` was |
| 739 | /// mapped to the repository's workspace. |
| 740 | #[serde(default)] |
| 741 | pub team: Option<String>, |
| 742 | } |
| 743 | |
| 744 | /// `resolve_owners`: for services. Resolves the owners a CODEOWNERS file |
| 745 | /// names on a repository: accounts, teams of the repository's workspace |
| 746 | /// (`@org/team` with an `org` that is not a g1t workspace means the |
| 747 | /// repository's own workspace), and confirmed email addresses, checking |
| 748 | /// each has the Write role or higher on it. `g1t` resolves to itself. |
| 749 | /// Returns `Vec<ResolvedOwner>`, in the order asked. |
| 750 | #[derive(Debug, Serialize, Deserialize)] |
| 751 | pub struct ResolveOwnersArgs { |
| 752 | pub repo_id: String, |
| 753 | /// The repository's workspace, by slug. |
| 754 | pub workspace: String, |
| 755 | pub owners: Vec<crate::codeowners::Owner>, |
| 756 | } |
| 757 | |
| 758 | #[cfg(test)] |
| 759 | mod tests { |
| 760 | use super::*; |
| 761 | |
| 762 | #[test] |
| 763 | fn who_may_create_teams() { |
| 764 | assert_eq!(TeamCreation::default(), TeamCreation::Members); |
| 765 | assert!(TeamCreation::Members.allows(Role::Member) && TeamCreation::Members.allows(Role::Owner)); |
| 766 | assert!(!TeamCreation::Owners.allows(Role::Member) && TeamCreation::Owners.allows(Role::Owner)); |
| 767 | for setting in TeamCreation::ALL { |
| 768 | assert_eq!(TeamCreation::parse(setting.as_str()), Some(setting)); |
| 769 | assert_eq!(serde_json::to_value(setting).unwrap(), setting.as_str()); |
| 770 | } |
| 771 | assert_eq!(TeamCreation::parse(" Owners "), Some(TeamCreation::Owners)); |
| 772 | assert_eq!(TeamCreation::parse("maintainers"), None); |
| 773 | } |
| 774 | |
| 775 | #[test] |
| 776 | fn slugs_come_from_names() { |
| 777 | assert_eq!(slug_of("Backend").as_deref(), Some("backend")); |
| 778 | assert_eq!(slug_of(" Web & Mobile ").as_deref(), Some("web-mobile")); |
| 779 | assert_eq!(slug_of("SRE / On-call").as_deref(), Some("sre-on-call")); |
| 780 | assert_eq!(slug_of("!!!"), None); |
| 781 | assert_eq!(slug_of(&"a".repeat(80)).map(|slug| slug.len()), Some(60)); |
| 782 | assert!(is_valid_slug("platform-2")); |
| 783 | assert!(!is_valid_slug("Platform") && !is_valid_slug("-a") && !is_valid_slug("a--b") && !is_valid_slug("")); |
| 784 | } |
| 785 | |
| 786 | #[test] |
| 787 | fn leads_are_people_or_agents() { |
| 788 | assert_eq!(parse_lead("@Priya"), Some(LeadInput::User("priya".into()))); |
| 789 | assert_eq!(parse_lead("priya"), Some(LeadInput::User("priya".into()))); |
| 790 | assert_eq!(parse_lead("agent:agt_123"), Some(LeadInput::Agent("agt_123".into()))); |
| 791 | assert_eq!(parse_lead("agent:"), None); |
| 792 | assert_eq!(parse_lead("agent:a b"), None); |
| 793 | assert_eq!(parse_lead("not a name"), None); |
| 794 | let lead = TeamLead::Agent { agent_id: "agt_1".into() }; |
| 795 | assert_eq!(serde_json::to_value(&lead).unwrap(), serde_json::json!({ "kind": "agent", "agent_id": "agt_1" })); |
| 796 | let lead: TeamLead = serde_json::from_value(serde_json::json!({ "kind": "user", "username": "ana" })).unwrap(); |
| 797 | assert_eq!(lead, TeamLead::User { username: "ana".into(), name: None, avatar: None }); |
| 798 | } |
| 799 | |
| 800 | #[test] |
| 801 | fn handles_name_a_workspace_and_a_team() { |
| 802 | assert_eq!(parse_handle("@acme/backend"), Some(("acme".into(), "backend".into()))); |
| 803 | assert_eq!(parse_handle("@Acme/Backend"), Some(("acme".into(), "backend".into()))); |
| 804 | assert_eq!(parse_handle("acme/backend"), None); |
| 805 | assert_eq!(parse_handle("@acme"), None); |
| 806 | assert_eq!(parse_handle("@acme/a/b"), None); |
| 807 | } |
| 808 | |
| 809 | #[test] |
| 810 | fn words_read_back() { |
| 811 | for visibility in [TeamVisibility::Visible, TeamVisibility::Secret] { |
| 812 | assert_eq!(TeamVisibility::parse(visibility.as_str()), Some(visibility)); |
| 813 | assert_eq!(serde_json::to_value(visibility).unwrap(), visibility.as_str()); |
| 814 | } |
| 815 | for role in [TeamRole::Member, TeamRole::Maintainer] { |
| 816 | assert_eq!(TeamRole::parse(role.as_str()), Some(role)); |
| 817 | assert_eq!(serde_json::to_value(role).unwrap(), role.as_str()); |
| 818 | } |
| 819 | for algorithm in [ReviewAlgorithm::RoundRobin, ReviewAlgorithm::LoadBalance] { |
| 820 | assert_eq!(ReviewAlgorithm::parse(algorithm.as_str()), Some(algorithm)); |
| 821 | assert_eq!(serde_json::to_value(algorithm).unwrap(), algorithm.as_str()); |
| 822 | } |
| 823 | assert!(TeamRole::Member < TeamRole::Maintainer); |
| 824 | } |
| 825 | |
| 826 | #[test] |
| 827 | fn review_assignment_is_kept_within_bounds() { |
| 828 | let wild = ReviewAssignment { |
| 829 | count: 0, |
| 830 | busy_at: 0, |
| 831 | excluded: vec!["@Ana".into(), "ana".into(), " ".into(), "bo".into()], |
| 832 | ..ReviewAssignment::default() |
| 833 | } |
| 834 | .bounded(); |
| 835 | assert_eq!(wild.count, 1); |
| 836 | assert_eq!(wild.busy_at, 1); |
| 837 | assert_eq!(wild.excluded, vec!["ana", "bo"]); |
| 838 | assert_eq!(ReviewAssignment { count: 50, ..ReviewAssignment::default() }.bounded().count, MAX_ASSIGNED); |
| 839 | } |
| 840 | } |