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