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, 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::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 | /// A person's place in a team. | |
| 80 | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] | |
| 81 | #[serde(rename_all = "snake_case")] | |
| 82 | pub enum TeamRole { | |
| 83 | Member, | |
| 84 | /// Manages the team's people and settings. | |
| 85 | Maintainer, | |
| 86 | } | |
| 87 | ||
| 88 | impl TeamRole { | |
| 89 | pub fn as_str(self) -> &'static str { | |
| 90 | match self { | |
| 91 | TeamRole::Member => "member", | |
| 92 | TeamRole::Maintainer => "maintainer", | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | pub fn parse(text: &str) -> Option<TeamRole> { | |
| 97 | match text.trim().to_ascii_lowercase().as_str() { | |
| 98 | "member" => Some(TeamRole::Member), | |
| 99 | "maintainer" => Some(TeamRole::Maintainer), | |
| 100 | _ => None, | |
| 101 | } | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | /// How review assignment picks people. | |
| 106 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 107 | #[serde(rename_all = "snake_case")] | |
| 108 | pub enum ReviewAlgorithm { | |
| 109 | /// Whoever was asked least recently by this team goes first. | |
| 110 | #[default] | |
| 111 | RoundRobin, | |
| 112 | /// Whoever has the fewest pull requests waiting on their review goes | |
| 113 | /// first. | |
| 114 | LoadBalance, | |
| 115 | } | |
| 116 | ||
| 117 | impl ReviewAlgorithm { | |
| 118 | pub fn as_str(self) -> &'static str { | |
| 119 | match self { | |
| 120 | ReviewAlgorithm::RoundRobin => "round_robin", | |
| 121 | ReviewAlgorithm::LoadBalance => "load_balance", | |
| 122 | } | |
| 123 | } | |
| 124 | ||
| 125 | pub fn parse(text: &str) -> Option<ReviewAlgorithm> { | |
| 126 | match text.trim().to_ascii_lowercase().as_str() { | |
| 127 | "round_robin" => Some(ReviewAlgorithm::RoundRobin), | |
| 128 | "load_balance" => Some(ReviewAlgorithm::LoadBalance), | |
| 129 | _ => None, | |
| 130 | } | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | /// What happens when a team is asked to review a pull request. | |
| 135 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 136 | pub struct ReviewAssignment { | |
| 137 | /// Off: everyone in the team is asked. On: `count` people are picked. | |
| 138 | pub enabled: bool, | |
| 139 | pub algorithm: ReviewAlgorithm, | |
| 140 | /// How many people to pick, 1 to [`MAX_ASSIGNED`]. People from the team | |
| 141 | /// already asked count towards it. | |
| 142 | pub count: u32, | |
| 143 | /// Leave out anyone with `busy_at` or more open pull requests waiting | |
| 144 | /// on their review. | |
| 145 | pub skip_busy: bool, | |
| 146 | pub busy_at: u32, | |
| 147 | /// Also pick from the people of its child teams. | |
| 148 | pub include_child_teams: bool, | |
| 149 | /// Usernames never picked. | |
| 150 | #[serde(default)] | |
| 151 | pub excluded: Vec<String>, | |
| 152 | /// Also tell the rest of the team when people are picked. | |
| 153 | pub notify_team: bool, | |
| 154 | } | |
| 155 | ||
| 156 | impl Default for ReviewAssignment { | |
| 157 | fn default() -> Self { | |
| 158 | ReviewAssignment { | |
| 159 | enabled: false, | |
| 160 | algorithm: ReviewAlgorithm::RoundRobin, | |
| 161 | count: 1, | |
| 162 | skip_busy: false, | |
| 163 | busy_at: 5, | |
| 164 | include_child_teams: false, | |
| 165 | excluded: Vec::new(), | |
| 166 | notify_team: false, | |
| 167 | } | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | impl ReviewAssignment { | |
| 172 | /// The same, with every number within bounds and the usernames | |
| 173 | /// lowercased, each once. | |
| 174 | pub fn bounded(mut self) -> Self { | |
| 175 | self.count = self.count.clamp(1, MAX_ASSIGNED); | |
| 176 | self.busy_at = self.busy_at.clamp(1, 100); | |
| 177 | let mut excluded: Vec<String> = Vec::new(); | |
| 178 | for name in self.excluded { | |
| 179 | let name = name.trim().trim_start_matches('@').to_lowercase(); | |
| 180 | if !name.is_empty() && !excluded.contains(&name) { | |
| 181 | excluded.push(name); | |
| 182 | } | |
| 183 | } | |
| 184 | excluded.truncate(100); | |
| 185 | self.excluded = excluded; | |
| 186 | self | |
| 187 | } | |
| 188 | } | |
| 189 | ||
| 190 | /// A team as another names it: its parent, or a child. | |
| 191 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 192 | pub struct TeamRef { | |
| 193 | pub slug: String, | |
| 194 | pub name: String, | |
| 195 | } | |
| 196 | ||
| 197 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 198 | pub struct Team { | |
| 199 | pub id: String, | |
| 200 | /// Its workspace's slug. | |
| 201 | pub workspace: String, | |
| 202 | /// Its name in URLs and mentions: `@<workspace>/<slug>`. | |
| 203 | pub slug: String, | |
| 204 | pub name: String, | |
| 205 | pub description: Option<String>, | |
| 206 | pub visibility: TeamVisibility, | |
| 207 | pub parent: Option<TeamRef>, | |
| 208 | /// Whether its people are notified when it is mentioned. | |
| 209 | pub notify: bool, | |
| 210 | pub review_assignment: ReviewAssignment, | |
| 211 | /// Its own people, not counting child teams'. | |
| 212 | pub members_count: u32, | |
| 213 | /// Repositories it has a role on itself, not counting inherited ones. | |
| 214 | pub repos_count: u32, | |
| 215 | pub child_teams_count: u32, | |
| 216 | /// The viewer's place in it, if any. | |
| 217 | pub viewer_role: Option<TeamRole>, | |
| 218 | /// Whether the viewer may change it: an owner of the workspace, or one | |
| 219 | /// of its maintainers. | |
| 220 | pub can_manage: bool, | |
| 221 | /// RFC 3339. | |
| 222 | pub created_at: String, | |
| 223 | pub updated_at: String, | |
| 224 | } | |
| 225 | ||
| 226 | impl Team { | |
| 227 | /// How it is mentioned: `@acme/backend`. | |
| 228 | pub fn handle(&self) -> String { | |
| 229 | format!("@{}/{}", self.workspace, self.slug) | |
| 230 | } | |
| 231 | } | |
| 232 | ||
| 233 | /// One person in a team. | |
| 234 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 235 | pub struct TeamMember { | |
| 236 | pub username: String, | |
| 237 | pub name: Option<String>, | |
| 238 | pub avatar: Option<String>, | |
| 239 | pub role: TeamRole, | |
| 240 | /// The child team they are in, when listed through one; null for the | |
| 241 | /// team's own people. | |
| 242 | pub via: Option<String>, | |
| 243 | } | |
| 244 | ||
| 245 | /// A repository a team has a role on. | |
| 246 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 247 | pub struct TeamRepo { | |
| 248 | /// `workspace/name`. | |
| 249 | pub repo: String, | |
| 250 | pub repo_id: String, | |
| 251 | pub role: RepoRole, | |
| 252 | /// The parent team it comes from, by slug, when the team inherits it. | |
| 253 | pub inherited_from: Option<String>, | |
| 254 | } | |
| 255 | ||
| 256 | /// A team with a role on a repository, as its Access settings list it. | |
| 257 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 258 | pub struct RepoTeam { | |
| 259 | pub slug: String, | |
| 260 | pub name: String, | |
| 261 | pub role: RepoRole, | |
| 262 | pub members_count: u32, | |
| 263 | pub visibility: TeamVisibility, | |
| 264 | } | |
| 265 | ||
| 266 | /// A team as services need it to notify or ask its people: everyone in it, | |
| 267 | /// and its settings. Never shown as is. | |
| 268 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 269 | pub struct ResolvedTeam { | |
| 270 | pub id: String, | |
| 271 | pub workspace: String, | |
| 272 | pub slug: String, | |
| 273 | pub name: String, | |
| 274 | pub visibility: TeamVisibility, | |
| 275 | pub notify: bool, | |
| 276 | pub review_assignment: ReviewAssignment, | |
| 277 | /// Its own people. | |
| 278 | pub members: Vec<TeamPerson>, | |
| 279 | /// The people of its child teams (and theirs) who are not its own. | |
| 280 | pub child_members: Vec<TeamPerson>, | |
| 281 | /// Its role on the repository asked about, its own or inherited. | |
| 282 | pub repo_role: Option<RepoRole>, | |
| 283 | /// Whether the person asked about (`asker`) may see it, and so mention | |
| 284 | /// it or ask it to review: a member of its workspace, and for a secret | |
| 285 | /// team, in it or an owner. | |
| 286 | #[serde(default)] | |
| 287 | pub asker_sees: bool, | |
| 288 | } | |
| 289 | ||
| 290 | impl ResolvedTeam { | |
| 291 | /// Everyone a mention or a request reaches: its own people, then its | |
| 292 | /// child teams'. | |
| 293 | pub fn everyone(&self) -> impl Iterator<Item = &TeamPerson> { | |
| 294 | self.members.iter().chain(self.child_members.iter()) | |
| 295 | } | |
| 296 | ||
| 297 | pub fn handle(&self) -> String { | |
| 298 | format!("@{}/{}", self.workspace, self.slug) | |
| 299 | } | |
| 300 | } | |
| 301 | ||
| 302 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 303 | pub struct TeamPerson { | |
| 304 | pub id: String, | |
| 305 | pub username: String, | |
| 306 | } | |
| 307 | ||
| 308 | /// A team's slug from its name: lowercase letters, digits and single | |
| 309 | /// hyphens, as mentions spell it. `None` when nothing is left. | |
| 310 | pub fn slug_of(name: &str) -> Option<String> { | |
| 311 | let mut slug = String::new(); | |
| 312 | for c in name.trim().chars() { | |
| 313 | if c.is_ascii_alphanumeric() { | |
| 314 | slug.push(c.to_ascii_lowercase()); | |
| 315 | } else if !slug.is_empty() && !slug.ends_with('-') { | |
| 316 | slug.push('-'); | |
| 317 | } | |
| 318 | } | |
| 319 | let slug = slug.trim_end_matches('-').chars().take(60).collect::<String>(); | |
| 320 | let slug = slug.trim_end_matches('-').to_owned(); | |
| 321 | is_valid_slug(&slug).then_some(slug) | |
| 322 | } | |
| 323 | ||
| 324 | /// Whether `slug` is a team slug: 1 to 60 lowercase letters, digits and | |
| 325 | /// single hyphens, not starting or ending with one. | |
| 326 | pub fn is_valid_slug(slug: &str) -> bool { | |
| 327 | !slug.is_empty() | |
| 328 | && slug.len() <= 60 | |
| 329 | && slug.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') | |
| 330 | && !slug.starts_with('-') | |
| 331 | && !slug.ends_with('-') | |
| 332 | && !slug.contains("--") | |
| 333 | } | |
| 334 | ||
| 335 | /// `@workspace/team` as written, split; `None` if it is not that shape. | |
| 336 | pub fn parse_handle(text: &str) -> Option<(String, String)> { | |
| 337 | let text = text.trim().strip_prefix('@')?; | |
| 338 | let (workspace, slug) = text.split_once('/')?; | |
| 339 | let workspace = workspace.to_lowercase(); | |
| 340 | let slug = slug.to_lowercase(); | |
| 341 | (crate::is_valid_namespace(&workspace) && is_valid_slug(&slug)).then_some((workspace, slug)) | |
| 342 | } | |
| 343 | ||
| 344 | // --- Identity methods --------------------------------------------------------- | |
| 345 | ||
| 346 | /// `list_teams`: the teams of a workspace the viewer can see, theirs first, | |
| 347 | /// then by name. Members only. `query` narrows by name or slug. Returns | |
| 348 | /// `Outcome<Vec<Team>>`. | |
| 349 | #[derive(Debug, Serialize, Deserialize)] | |
| 350 | pub struct ListTeamsArgs { | |
| 351 | pub viewer: crate::Viewer, | |
| 352 | pub workspace: String, | |
| 353 | #[serde(default)] | |
| 354 | pub query: Option<String>, | |
| 355 | } | |
| 356 | ||
| 357 | /// `get_team`, `child_teams`, `team_repos`: one team, its child teams, or | |
| 358 | /// the repositories it has a role on (its own and inherited), as the | |
| 359 | /// viewer may see them. `team_members` takes `include_child_teams`. | |
| 360 | #[derive(Debug, Serialize, Deserialize)] | |
| 361 | pub struct TeamArgs { | |
| 362 | pub viewer: crate::Viewer, | |
| 363 | pub workspace: String, | |
| 364 | pub team: String, | |
| 365 | /// `team_members` only: also list the people of child teams. | |
| 366 | #[serde(default)] | |
| 367 | pub include_child_teams: bool, | |
| 368 | } | |
| 369 | ||
| 370 | /// `create_team`. Any member of the workspace may create a team, and | |
| 371 | /// becomes its maintainer; a team with a parent needs an owner, or a | |
| 372 | /// maintainer of the parent. Returns `Outcome<Team>`. | |
| 373 | #[derive(Debug, Serialize, Deserialize)] | |
| 374 | pub struct CreateTeamArgs { | |
| 375 | pub actor: User, | |
| 376 | pub workspace: String, | |
| 377 | pub name: String, | |
| 378 | /// Defaults to one made from the name. | |
| 379 | #[serde(default)] | |
| 380 | pub slug: Option<String>, | |
| 381 | #[serde(default)] | |
| 382 | pub description: Option<String>, | |
| 383 | #[serde(default)] | |
| 384 | pub visibility: Option<TeamVisibility>, | |
| 385 | /// The parent's slug. | |
| 386 | #[serde(default)] | |
| 387 | pub parent: Option<String>, | |
| 388 | #[serde(default)] | |
| 389 | pub notify: Option<bool>, | |
| 390 | /// People to add as members, by username, besides the creator. | |
| 391 | #[serde(default)] | |
| 392 | pub members: Vec<String>, | |
| 393 | #[serde(default)] | |
| 394 | pub surface: Option<crate::audit::Surface>, | |
| 395 | } | |
| 396 | ||
| 397 | /// `update_team`: what is given changes; the rest stays. `parent` set to an | |
| 398 | /// empty string takes the team out from under its parent. Owners and the | |
| 399 | /// team's maintainers. Returns `Outcome<Team>`. | |
| 400 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 401 | pub struct UpdateTeamArgs { | |
| 402 | pub actor: User, | |
| 403 | pub workspace: String, | |
| 404 | pub team: String, | |
| 405 | #[serde(default)] | |
| 406 | pub name: Option<String>, | |
| 407 | #[serde(default)] | |
| 408 | pub slug: Option<String>, | |
| 409 | #[serde(default)] | |
| 410 | pub description: Option<String>, | |
| 411 | #[serde(default)] | |
| 412 | pub visibility: Option<TeamVisibility>, | |
| 413 | #[serde(default)] | |
| 414 | pub parent: Option<String>, | |
| 415 | #[serde(default)] | |
| 416 | pub notify: Option<bool>, | |
| 417 | #[serde(default)] | |
| 418 | pub review_assignment: Option<ReviewAssignment>, | |
| 419 | #[serde(default)] | |
| 420 | pub surface: Option<crate::audit::Surface>, | |
| 421 | } | |
| 422 | ||
| 423 | /// `delete_team`: its child teams move up to its parent, and the roles it | |
| 424 | /// gave go with it. Owners and the team's maintainers. Returns | |
| 425 | /// `Outcome<bool>`. | |
| 426 | #[derive(Debug, Serialize, Deserialize)] | |
| 427 | pub struct DeleteTeamArgs { | |
| 428 | pub actor: User, | |
| 429 | pub workspace: String, | |
| 430 | pub team: String, | |
| 431 | #[serde(default)] | |
| 432 | pub surface: Option<crate::audit::Surface>, | |
| 433 | } | |
| 434 | ||
| 435 | /// `set_team_member`: adds a member of the workspace to a team, or changes | |
| 436 | /// their role in it. Owners and the team's maintainers. Returns | |
| 437 | /// `Outcome<TeamMember>`. | |
| 438 | #[derive(Debug, Serialize, Deserialize)] | |
| 439 | pub struct SetTeamMemberArgs { | |
| 440 | pub actor: User, | |
| 441 | pub workspace: String, | |
| 442 | pub team: String, | |
| 443 | pub username: String, | |
| 444 | pub role: TeamRole, | |
| 445 | #[serde(default)] | |
| 446 | pub surface: Option<crate::audit::Surface>, | |
| 447 | } | |
| 448 | ||
| 449 | /// `remove_team_member`: owners and the team's maintainers; anyone may | |
| 450 | /// leave a team themselves. Returns `Outcome<bool>`. | |
| 451 | #[derive(Debug, Serialize, Deserialize)] | |
| 452 | pub struct RemoveTeamMemberArgs { | |
| 453 | pub actor: User, | |
| 454 | pub workspace: String, | |
| 455 | pub team: String, | |
| 456 | pub username: String, | |
| 457 | #[serde(default)] | |
| 458 | pub surface: Option<crate::audit::Surface>, | |
| 459 | } | |
| 460 | ||
| 461 | /// `set_team_repo`: gives a team a role on a repository of its workspace, | |
| 462 | /// or changes it. Needs Admin on the repository. Returns | |
| 463 | /// `Outcome<TeamRepo>`. | |
| 464 | #[derive(Debug, Serialize, Deserialize)] | |
| 465 | pub struct SetTeamRepoArgs { | |
| 466 | pub actor: User, | |
| 467 | pub workspace: String, | |
| 468 | pub team: String, | |
| 469 | pub repo: RepoPath, | |
| 470 | pub role: RepoRole, | |
| 471 | #[serde(default)] | |
| 472 | pub surface: Option<crate::audit::Surface>, | |
| 473 | } | |
| 474 | ||
| 475 | /// `remove_team_repo`: takes a team's role on a repository away. Admin on | |
| 476 | /// the repository, an owner, or one of the team's maintainers. Returns | |
| 477 | /// `Outcome<bool>`. | |
| 478 | #[derive(Debug, Serialize, Deserialize)] | |
| 479 | pub struct RemoveTeamRepoArgs { | |
| 480 | pub actor: User, | |
| 481 | pub workspace: String, | |
| 482 | pub team: String, | |
| 483 | pub repo: RepoPath, | |
| 484 | #[serde(default)] | |
| 485 | pub surface: Option<crate::audit::Surface>, | |
| 486 | } | |
| 487 | ||
| 488 | /// `user_teams`: the teams `username` is in within a workspace, as the | |
| 489 | /// viewer may see them. Members only. Returns `Outcome<Vec<Team>>`. | |
| 490 | #[derive(Debug, Serialize, Deserialize)] | |
| 491 | pub struct UserTeamsArgs { | |
| 492 | pub viewer: crate::Viewer, | |
| 493 | pub workspace: String, | |
| 494 | pub username: String, | |
| 495 | } | |
| 496 | ||
| 497 | /// `team_memberships`: for each member of a workspace, the teams they are | |
| 498 | /// in that the viewer can see, for the Members page. Members only. | |
| 499 | /// Returns `Outcome<Vec<MemberTeams>>`. | |
| 500 | #[derive(Debug, Serialize, Deserialize)] | |
| 501 | pub struct TeamMembershipsArgs { | |
| 502 | pub viewer: crate::Viewer, | |
| 503 | pub workspace: String, | |
| 504 | } | |
| 505 | ||
| 506 | /// One person's teams in a workspace. | |
| 507 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 508 | pub struct MemberTeams { | |
| 509 | pub username: String, | |
| 510 | pub teams: Vec<TeamRef>, | |
| 511 | } | |
| 512 | ||
| 513 | /// `resolve_teams`: for services. Each team named `workspace/slug` (or | |
| 514 | /// `@workspace/slug`) that exists, with everyone in it and, given | |
| 515 | /// `repo_id`, its role on that repository. Missing teams are left out. | |
| 516 | /// Returns `Vec<ResolvedTeam>`. | |
| 517 | #[derive(Debug, Default, Serialize, Deserialize)] | |
| 518 | pub struct ResolveTeamsArgs { | |
| 519 | pub teams: Vec<String>, | |
| 520 | #[serde(default)] | |
| 521 | pub repo_id: Option<String>, | |
| 522 | /// A user id, for `asker_sees`. | |
| 523 | #[serde(default)] | |
| 524 | pub asker: Option<String>, | |
| 525 | } | |
| 526 | ||
| 527 | /// One owner a CODEOWNERS file names, as identity resolved it. | |
| 528 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 529 | pub struct ResolvedOwner { | |
| 530 | pub owner: crate::codeowners::Owner, | |
| 531 | pub check: crate::codeowners::OwnerCheck, | |
| 532 | /// Who may answer for it, by username: the person, the account a | |
| 533 | /// confirmed address belongs to, or everyone in a team and its child | |
| 534 | /// teams. Empty when it did not resolve. | |
| 535 | pub members: Vec<String>, | |
| 536 | /// For a team: the team, as `workspace/slug`, after `@org/team` was | |
| 537 | /// mapped to the repository's workspace. | |
| 538 | #[serde(default)] | |
| 539 | pub team: Option<String>, | |
| 540 | } | |
| 541 | ||
| 542 | /// `resolve_owners`: for services. Resolves the owners a CODEOWNERS file | |
| 543 | /// names on a repository: accounts, teams of the repository's workspace | |
| 544 | /// (`@org/team` with an `org` that is not a g1t workspace means the | |
| 545 | /// repository's own workspace), and confirmed email addresses, checking | |
| 546 | /// each has the Write role or higher on it. `g1t` resolves to itself. | |
| 547 | /// Returns `Vec<ResolvedOwner>`, in the order asked. | |
| 548 | #[derive(Debug, Serialize, Deserialize)] | |
| 549 | pub struct ResolveOwnersArgs { | |
| 550 | pub repo_id: String, | |
| 551 | /// The repository's workspace, by slug. | |
| 552 | pub workspace: String, | |
| 553 | pub owners: Vec<crate::codeowners::Owner>, | |
| 554 | } | |
| 555 | ||
| 556 | #[cfg(test)] | |
| 557 | mod tests { | |
| 558 | use super::*; | |
| 559 | ||
| 560 | #[test] | |
| 561 | fn slugs_come_from_names() { | |
| 562 | assert_eq!(slug_of("Backend").as_deref(), Some("backend")); | |
| 563 | assert_eq!(slug_of(" Web & Mobile ").as_deref(), Some("web-mobile")); | |
| 564 | assert_eq!(slug_of("SRE / On-call").as_deref(), Some("sre-on-call")); | |
| 565 | assert_eq!(slug_of("!!!"), None); | |
| 566 | assert_eq!(slug_of(&"a".repeat(80)).map(|slug| slug.len()), Some(60)); | |
| 567 | assert!(is_valid_slug("platform-2")); | |
| 568 | assert!(!is_valid_slug("Platform") && !is_valid_slug("-a") && !is_valid_slug("a--b") && !is_valid_slug("")); | |
| 569 | } | |
| 570 | ||
| 571 | #[test] | |
| 572 | fn handles_name_a_workspace_and_a_team() { | |
| 573 | assert_eq!(parse_handle("@acme/backend"), Some(("acme".into(), "backend".into()))); | |
| 574 | assert_eq!(parse_handle("@Acme/Backend"), Some(("acme".into(), "backend".into()))); | |
| 575 | assert_eq!(parse_handle("acme/backend"), None); | |
| 576 | assert_eq!(parse_handle("@acme"), None); | |
| 577 | assert_eq!(parse_handle("@acme/a/b"), None); | |
| 578 | } | |
| 579 | ||
| 580 | #[test] | |
| 581 | fn words_read_back() { | |
| 582 | for visibility in [TeamVisibility::Visible, TeamVisibility::Secret] { | |
| 583 | assert_eq!(TeamVisibility::parse(visibility.as_str()), Some(visibility)); | |
| 584 | assert_eq!(serde_json::to_value(visibility).unwrap(), visibility.as_str()); | |
| 585 | } | |
| 586 | for role in [TeamRole::Member, TeamRole::Maintainer] { | |
| 587 | assert_eq!(TeamRole::parse(role.as_str()), Some(role)); | |
| 588 | assert_eq!(serde_json::to_value(role).unwrap(), role.as_str()); | |
| 589 | } | |
| 590 | for algorithm in [ReviewAlgorithm::RoundRobin, ReviewAlgorithm::LoadBalance] { | |
| 591 | assert_eq!(ReviewAlgorithm::parse(algorithm.as_str()), Some(algorithm)); | |
| 592 | assert_eq!(serde_json::to_value(algorithm).unwrap(), algorithm.as_str()); | |
| 593 | } | |
| 594 | assert!(TeamRole::Member < TeamRole::Maintainer); | |
| 595 | } | |
| 596 | ||
| 597 | #[test] | |
| 598 | fn review_assignment_is_kept_within_bounds() { | |
| 599 | let wild = ReviewAssignment { | |
| 600 | count: 0, | |
| 601 | busy_at: 0, | |
| 602 | excluded: vec!["@Ana".into(), "ana".into(), " ".into(), "bo".into()], | |
| 603 | ..ReviewAssignment::default() | |
| 604 | } | |
| 605 | .bounded(); | |
| 606 | assert_eq!(wild.count, 1); | |
| 607 | assert_eq!(wild.busy_at, 1); | |
| 608 | assert_eq!(wild.excluded, vec!["ana", "bo"]); | |
| 609 | assert_eq!(ReviewAssignment { count: 50, ..ReviewAssignment::default() }.bounded().count, MAX_ASSIGNED); | |
| 610 | } | |
| 611 | } |