| 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 | /// A team as another names it: its parent, or a child. |
| 242 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 243 | pub struct TeamRef { |
| 244 | pub slug: String, |
| 245 | pub name: String, |
| 246 | } |
| 247 | |
| 248 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 249 | pub struct Team { |
| 250 | pub id: String, |
| 251 | /// Its workspace's slug. |
| 252 | pub workspace: String, |
| 253 | /// Its name in URLs and mentions: `@<workspace>/<slug>`. |
| 254 | pub slug: String, |
| 255 | pub name: String, |
| 256 | pub description: Option<String>, |
| 257 | pub visibility: TeamVisibility, |
| 258 | pub parent: Option<TeamRef>, |
| 259 | /// Whether its people are notified when it is mentioned. |
| 260 | pub notify: bool, |
| 261 | pub review_assignment: ReviewAssignment, |
| 262 | /// Its own people, not counting child teams'. |
| 263 | pub members_count: u32, |
| 264 | /// Repositories it has a role on itself, not counting inherited ones. |
| 265 | pub repos_count: u32, |
| 266 | pub child_teams_count: u32, |
| 267 | /// The viewer's place in it, if any. |
| 268 | pub viewer_role: Option<TeamRole>, |
| 269 | /// Whether the viewer may change it: an owner of the workspace, or one |
| 270 | /// of its maintainers. |
| 271 | pub can_manage: bool, |
| 272 | /// RFC 3339. |
| 273 | pub created_at: String, |
| 274 | pub updated_at: String, |
| 275 | } |
| 276 | |
| 277 | impl Team { |
| 278 | /// How it is mentioned: `@acme/backend`. |
| 279 | pub fn handle(&self) -> String { |
| 280 | format!("@{}/{}", self.workspace, self.slug) |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /// One person in a team. |
| 285 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 286 | pub struct TeamMember { |
| 287 | pub username: String, |
| 288 | pub name: Option<String>, |
| 289 | pub avatar: Option<String>, |
| 290 | pub role: TeamRole, |
| 291 | /// The child team they are in, when listed through one; null for the |
| 292 | /// team's own people. |
| 293 | pub via: Option<String>, |
| 294 | } |
| 295 | |
| 296 | /// A repository a team has a role on. |
| 297 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 298 | pub struct TeamRepo { |
| 299 | /// `workspace/name`. |
| 300 | pub repo: String, |
| 301 | pub repo_id: String, |
| 302 | pub role: RepoRole, |
| 303 | /// The parent team it comes from, by slug, when the team inherits it. |
| 304 | pub inherited_from: Option<String>, |
| 305 | } |
| 306 | |
| 307 | /// A team with a role on a repository, as its Access settings list it. |
| 308 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 309 | pub struct RepoTeam { |
| 310 | pub slug: String, |
| 311 | pub name: String, |
| 312 | pub role: RepoRole, |
| 313 | pub members_count: u32, |
| 314 | pub visibility: TeamVisibility, |
| 315 | } |
| 316 | |
| 317 | /// A team as services need it to notify or ask its people: everyone in it, |
| 318 | /// and its settings. Never shown as is. |
| 319 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 320 | pub struct ResolvedTeam { |
| 321 | pub id: String, |
| 322 | pub workspace: String, |
| 323 | pub slug: String, |
| 324 | pub name: String, |
| 325 | pub visibility: TeamVisibility, |
| 326 | pub notify: bool, |
| 327 | pub review_assignment: ReviewAssignment, |
| 328 | /// Its own people. |
| 329 | pub members: Vec<TeamPerson>, |
| 330 | /// The people of its child teams (and theirs) who are not its own. |
| 331 | pub child_members: Vec<TeamPerson>, |
| 332 | /// Its role on the repository asked about, its own or inherited. |
| 333 | pub repo_role: Option<RepoRole>, |
| 334 | /// Whether the person asked about (`asker`) may see it, and so mention |
| 335 | /// it or ask it to review: a member of its workspace, and for a secret |
| 336 | /// team, in it or an owner. |
| 337 | #[serde(default)] |
| 338 | pub asker_sees: bool, |
| 339 | } |
| 340 | |
| 341 | impl ResolvedTeam { |
| 342 | /// Everyone a mention or a request reaches: its own people, then its |
| 343 | /// child teams'. |
| 344 | pub fn everyone(&self) -> impl Iterator<Item = &TeamPerson> { |
| 345 | self.members.iter().chain(self.child_members.iter()) |
| 346 | } |
| 347 | |
| 348 | pub fn handle(&self) -> String { |
| 349 | format!("@{}/{}", self.workspace, self.slug) |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] |
| 354 | pub struct TeamPerson { |
| 355 | pub id: String, |
| 356 | pub username: String, |
| 357 | } |
| 358 | |
| 359 | /// A team's slug from its name: lowercase letters, digits and single |
| 360 | /// hyphens, as mentions spell it. `None` when nothing is left. |
| 361 | pub fn slug_of(name: &str) -> Option<String> { |
| 362 | let mut slug = String::new(); |
| 363 | for c in name.trim().chars() { |
| 364 | if c.is_ascii_alphanumeric() { |
| 365 | slug.push(c.to_ascii_lowercase()); |
| 366 | } else if !slug.is_empty() && !slug.ends_with('-') { |
| 367 | slug.push('-'); |
| 368 | } |
| 369 | } |
| 370 | let slug = slug.trim_end_matches('-').chars().take(60).collect::<String>(); |
| 371 | let slug = slug.trim_end_matches('-').to_owned(); |
| 372 | is_valid_slug(&slug).then_some(slug) |
| 373 | } |
| 374 | |
| 375 | /// Whether `slug` is a team slug: 1 to 60 lowercase letters, digits and |
| 376 | /// single hyphens, not starting or ending with one. |
| 377 | pub fn is_valid_slug(slug: &str) -> bool { |
| 378 | !slug.is_empty() |
| 379 | && slug.len() <= 60 |
| 380 | && slug.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') |
| 381 | && !slug.starts_with('-') |
| 382 | && !slug.ends_with('-') |
| 383 | && !slug.contains("--") |
| 384 | } |
| 385 | |
| 386 | /// `@workspace/team` as written, split; `None` if it is not that shape. |
| 387 | pub fn parse_handle(text: &str) -> Option<(String, String)> { |
| 388 | let text = text.trim().strip_prefix('@')?; |
| 389 | let (workspace, slug) = text.split_once('/')?; |
| 390 | let workspace = workspace.to_lowercase(); |
| 391 | let slug = slug.to_lowercase(); |
| 392 | (crate::is_valid_namespace(&workspace) && is_valid_slug(&slug)).then_some((workspace, slug)) |
| 393 | } |
| 394 | |
| 395 | // --- Identity methods --------------------------------------------------------- |
| 396 | |
| 397 | /// `list_teams`: the teams of a workspace the viewer can see, theirs first, |
| 398 | /// then by name. Members only. `query` narrows by name or slug. Returns |
| 399 | /// `Outcome<Vec<Team>>`. |
| 400 | #[derive(Debug, Serialize, Deserialize)] |
| 401 | pub struct ListTeamsArgs { |
| 402 | pub viewer: crate::Viewer, |
| 403 | pub workspace: String, |
| 404 | #[serde(default)] |
| 405 | pub query: Option<String>, |
| 406 | } |
| 407 | |
| 408 | /// `get_team`, `child_teams`, `team_repos`: one team, its child teams, or |
| 409 | /// the repositories it has a role on (its own and inherited), as the |
| 410 | /// viewer may see them. `team_members` takes `include_child_teams`. |
| 411 | #[derive(Debug, Serialize, Deserialize)] |
| 412 | pub struct TeamArgs { |
| 413 | pub viewer: crate::Viewer, |
| 414 | pub workspace: String, |
| 415 | pub team: String, |
| 416 | /// `team_members` only: also list the people of child teams. |
| 417 | #[serde(default)] |
| 418 | pub include_child_teams: bool, |
| 419 | } |
| 420 | |
| 421 | /// `create_team`. Members may create a team, unless the workspace's |
| 422 | /// [`TeamCreation`] says owners only, and become its maintainer; a team |
| 423 | /// with a parent needs an owner, or a maintainer of the parent. Returns |
| 424 | /// `Outcome<Team>`. |
| 425 | #[derive(Debug, Serialize, Deserialize)] |
| 426 | pub struct CreateTeamArgs { |
| 427 | pub actor: User, |
| 428 | pub workspace: String, |
| 429 | pub name: String, |
| 430 | /// Defaults to one made from the name. |
| 431 | #[serde(default)] |
| 432 | pub slug: Option<String>, |
| 433 | #[serde(default)] |
| 434 | pub description: Option<String>, |
| 435 | #[serde(default)] |
| 436 | pub visibility: Option<TeamVisibility>, |
| 437 | /// The parent's slug. |
| 438 | #[serde(default)] |
| 439 | pub parent: Option<String>, |
| 440 | #[serde(default)] |
| 441 | pub notify: Option<bool>, |
| 442 | /// People to add as members, by username, besides the creator. |
| 443 | #[serde(default)] |
| 444 | pub members: Vec<String>, |
| 445 | #[serde(default)] |
| 446 | pub surface: Option<crate::audit::Surface>, |
| 447 | } |
| 448 | |
| 449 | /// `update_team`: what is given changes; the rest stays. `parent` set to an |
| 450 | /// empty string takes the team out from under its parent. Owners and the |
| 451 | /// team's maintainers. Returns `Outcome<Team>`. |
| 452 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 453 | pub struct UpdateTeamArgs { |
| 454 | pub actor: User, |
| 455 | pub workspace: String, |
| 456 | pub team: String, |
| 457 | #[serde(default)] |
| 458 | pub name: Option<String>, |
| 459 | #[serde(default)] |
| 460 | pub slug: Option<String>, |
| 461 | #[serde(default)] |
| 462 | pub description: Option<String>, |
| 463 | #[serde(default)] |
| 464 | pub visibility: Option<TeamVisibility>, |
| 465 | #[serde(default)] |
| 466 | pub parent: Option<String>, |
| 467 | #[serde(default)] |
| 468 | pub notify: Option<bool>, |
| 469 | #[serde(default)] |
| 470 | pub review_assignment: Option<ReviewAssignment>, |
| 471 | #[serde(default)] |
| 472 | pub surface: Option<crate::audit::Surface>, |
| 473 | } |
| 474 | |
| 475 | /// `delete_team`: its child teams move up to its parent, and the roles it |
| 476 | /// gave go with it. Owners and the team's maintainers. Returns |
| 477 | /// `Outcome<bool>`. |
| 478 | #[derive(Debug, Serialize, Deserialize)] |
| 479 | pub struct DeleteTeamArgs { |
| 480 | pub actor: User, |
| 481 | pub workspace: String, |
| 482 | pub team: String, |
| 483 | #[serde(default)] |
| 484 | pub surface: Option<crate::audit::Surface>, |
| 485 | } |
| 486 | |
| 487 | /// `set_team_member`: adds a member of the workspace to a team, or changes |
| 488 | /// their role in it. Owners and the team's maintainers. Returns |
| 489 | /// `Outcome<TeamMember>`. |
| 490 | #[derive(Debug, Serialize, Deserialize)] |
| 491 | pub struct SetTeamMemberArgs { |
| 492 | pub actor: User, |
| 493 | pub workspace: String, |
| 494 | pub team: String, |
| 495 | pub username: String, |
| 496 | pub role: TeamRole, |
| 497 | #[serde(default)] |
| 498 | pub surface: Option<crate::audit::Surface>, |
| 499 | } |
| 500 | |
| 501 | /// `remove_team_member`: owners and the team's maintainers; anyone may |
| 502 | /// leave a team themselves. Returns `Outcome<bool>`. |
| 503 | #[derive(Debug, Serialize, Deserialize)] |
| 504 | pub struct RemoveTeamMemberArgs { |
| 505 | pub actor: User, |
| 506 | pub workspace: String, |
| 507 | pub team: String, |
| 508 | pub username: String, |
| 509 | #[serde(default)] |
| 510 | pub surface: Option<crate::audit::Surface>, |
| 511 | } |
| 512 | |
| 513 | /// `set_team_repo`: gives a team a role on a repository of its workspace, |
| 514 | /// or changes it. Needs Admin on the repository. Returns |
| 515 | /// `Outcome<TeamRepo>`. |
| 516 | #[derive(Debug, Serialize, Deserialize)] |
| 517 | pub struct SetTeamRepoArgs { |
| 518 | pub actor: User, |
| 519 | pub workspace: String, |
| 520 | pub team: String, |
| 521 | pub repo: RepoPath, |
| 522 | pub role: RepoRole, |
| 523 | #[serde(default)] |
| 524 | pub surface: Option<crate::audit::Surface>, |
| 525 | } |
| 526 | |
| 527 | /// `remove_team_repo`: takes a team's role on a repository away. Admin on |
| 528 | /// the repository, an owner, or one of the team's maintainers. Returns |
| 529 | /// `Outcome<bool>`. |
| 530 | #[derive(Debug, Serialize, Deserialize)] |
| 531 | pub struct RemoveTeamRepoArgs { |
| 532 | pub actor: User, |
| 533 | pub workspace: String, |
| 534 | pub team: String, |
| 535 | pub repo: RepoPath, |
| 536 | #[serde(default)] |
| 537 | pub surface: Option<crate::audit::Surface>, |
| 538 | } |
| 539 | |
| 540 | /// `user_teams`: the teams `username` is in within a workspace, as the |
| 541 | /// viewer may see them. Members only. Returns `Outcome<Vec<Team>>`. |
| 542 | #[derive(Debug, Serialize, Deserialize)] |
| 543 | pub struct UserTeamsArgs { |
| 544 | pub viewer: crate::Viewer, |
| 545 | pub workspace: String, |
| 546 | pub username: String, |
| 547 | } |
| 548 | |
| 549 | /// `team_memberships`: for each member of a workspace, the teams they are |
| 550 | /// in that the viewer can see, for the Members page. Members only. |
| 551 | /// Returns `Outcome<Vec<MemberTeams>>`. |
| 552 | #[derive(Debug, Serialize, Deserialize)] |
| 553 | pub struct TeamMembershipsArgs { |
| 554 | pub viewer: crate::Viewer, |
| 555 | pub workspace: String, |
| 556 | } |
| 557 | |
| 558 | /// One person's teams in a workspace. |
| 559 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 560 | pub struct MemberTeams { |
| 561 | pub username: String, |
| 562 | pub teams: Vec<TeamRef>, |
| 563 | } |
| 564 | |
| 565 | /// `resolve_teams`: for services. Each team named `workspace/slug` (or |
| 566 | /// `@workspace/slug`) that exists, with everyone in it and, given |
| 567 | /// `repo_id`, its role on that repository. Missing teams are left out. |
| 568 | /// Returns `Vec<ResolvedTeam>`. |
| 569 | #[derive(Debug, Default, Serialize, Deserialize)] |
| 570 | pub struct ResolveTeamsArgs { |
| 571 | pub teams: Vec<String>, |
| 572 | #[serde(default)] |
| 573 | pub repo_id: Option<String>, |
| 574 | /// A user id, for `asker_sees`. |
| 575 | #[serde(default)] |
| 576 | pub asker: Option<String>, |
| 577 | } |
| 578 | |
| 579 | /// One owner a CODEOWNERS file names, as identity resolved it. |
| 580 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 581 | pub struct ResolvedOwner { |
| 582 | pub owner: crate::codeowners::Owner, |
| 583 | pub check: crate::codeowners::OwnerCheck, |
| 584 | /// Who may answer for it, by username: the person, the account a |
| 585 | /// confirmed address belongs to, or everyone in a team and its child |
| 586 | /// teams. Empty when it did not resolve. |
| 587 | pub members: Vec<String>, |
| 588 | /// For a team: the team, as `workspace/slug`, after `@org/team` was |
| 589 | /// mapped to the repository's workspace. |
| 590 | #[serde(default)] |
| 591 | pub team: Option<String>, |
| 592 | } |
| 593 | |
| 594 | /// `resolve_owners`: for services. Resolves the owners a CODEOWNERS file |
| 595 | /// names on a repository: accounts, teams of the repository's workspace |
| 596 | /// (`@org/team` with an `org` that is not a g1t workspace means the |
| 597 | /// repository's own workspace), and confirmed email addresses, checking |
| 598 | /// each has the Write role or higher on it. `g1t` resolves to itself. |
| 599 | /// Returns `Vec<ResolvedOwner>`, in the order asked. |
| 600 | #[derive(Debug, Serialize, Deserialize)] |
| 601 | pub struct ResolveOwnersArgs { |
| 602 | pub repo_id: String, |
| 603 | /// The repository's workspace, by slug. |
| 604 | pub workspace: String, |
| 605 | pub owners: Vec<crate::codeowners::Owner>, |
| 606 | } |
| 607 | |
| 608 | #[cfg(test)] |
| 609 | mod tests { |
| 610 | use super::*; |
| 611 | |
| 612 | #[test] |
| 613 | fn who_may_create_teams() { |
| 614 | assert_eq!(TeamCreation::default(), TeamCreation::Members); |
| 615 | assert!(TeamCreation::Members.allows(Role::Member) && TeamCreation::Members.allows(Role::Owner)); |
| 616 | assert!(!TeamCreation::Owners.allows(Role::Member) && TeamCreation::Owners.allows(Role::Owner)); |
| 617 | for setting in TeamCreation::ALL { |
| 618 | assert_eq!(TeamCreation::parse(setting.as_str()), Some(setting)); |
| 619 | assert_eq!(serde_json::to_value(setting).unwrap(), setting.as_str()); |
| 620 | } |
| 621 | assert_eq!(TeamCreation::parse(" Owners "), Some(TeamCreation::Owners)); |
| 622 | assert_eq!(TeamCreation::parse("maintainers"), None); |
| 623 | } |
| 624 | |
| 625 | #[test] |
| 626 | fn slugs_come_from_names() { |
| 627 | assert_eq!(slug_of("Backend").as_deref(), Some("backend")); |
| 628 | assert_eq!(slug_of(" Web & Mobile ").as_deref(), Some("web-mobile")); |
| 629 | assert_eq!(slug_of("SRE / On-call").as_deref(), Some("sre-on-call")); |
| 630 | assert_eq!(slug_of("!!!"), None); |
| 631 | assert_eq!(slug_of(&"a".repeat(80)).map(|slug| slug.len()), Some(60)); |
| 632 | assert!(is_valid_slug("platform-2")); |
| 633 | assert!(!is_valid_slug("Platform") && !is_valid_slug("-a") && !is_valid_slug("a--b") && !is_valid_slug("")); |
| 634 | } |
| 635 | |
| 636 | #[test] |
| 637 | fn handles_name_a_workspace_and_a_team() { |
| 638 | assert_eq!(parse_handle("@acme/backend"), Some(("acme".into(), "backend".into()))); |
| 639 | assert_eq!(parse_handle("@Acme/Backend"), Some(("acme".into(), "backend".into()))); |
| 640 | assert_eq!(parse_handle("acme/backend"), None); |
| 641 | assert_eq!(parse_handle("@acme"), None); |
| 642 | assert_eq!(parse_handle("@acme/a/b"), None); |
| 643 | } |
| 644 | |
| 645 | #[test] |
| 646 | fn words_read_back() { |
| 647 | for visibility in [TeamVisibility::Visible, TeamVisibility::Secret] { |
| 648 | assert_eq!(TeamVisibility::parse(visibility.as_str()), Some(visibility)); |
| 649 | assert_eq!(serde_json::to_value(visibility).unwrap(), visibility.as_str()); |
| 650 | } |
| 651 | for role in [TeamRole::Member, TeamRole::Maintainer] { |
| 652 | assert_eq!(TeamRole::parse(role.as_str()), Some(role)); |
| 653 | assert_eq!(serde_json::to_value(role).unwrap(), role.as_str()); |
| 654 | } |
| 655 | for algorithm in [ReviewAlgorithm::RoundRobin, ReviewAlgorithm::LoadBalance] { |
| 656 | assert_eq!(ReviewAlgorithm::parse(algorithm.as_str()), Some(algorithm)); |
| 657 | assert_eq!(serde_json::to_value(algorithm).unwrap(), algorithm.as_str()); |
| 658 | } |
| 659 | assert!(TeamRole::Member < TeamRole::Maintainer); |
| 660 | } |
| 661 | |
| 662 | #[test] |
| 663 | fn review_assignment_is_kept_within_bounds() { |
| 664 | let wild = ReviewAssignment { |
| 665 | count: 0, |
| 666 | busy_at: 0, |
| 667 | excluded: vec!["@Ana".into(), "ana".into(), " ".into(), "bo".into()], |
| 668 | ..ReviewAssignment::default() |
| 669 | } |
| 670 | .bounded(); |
| 671 | assert_eq!(wild.count, 1); |
| 672 | assert_eq!(wild.busy_at, 1); |
| 673 | assert_eq!(wild.excluded, vec!["ana", "bo"]); |
| 674 | assert_eq!(ReviewAssignment { count: 50, ..ReviewAssignment::default() }.bounded().count, MAX_ASSIGNED); |
| 675 | } |
| 676 | } |