Skip to content

g1t/crates/contracts/src/teams.rs

676 lines23,697 bytesCodeBlame

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 bar1//! 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
Merge branch 'worktree-agent-ad7c6d88d93adc817'36use crate::{Role, User};
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar37use 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
Merge branch 'worktree-agent-ad7c6d88d93adc817'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
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar130/// 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/// A team as another names it: its parent, or a child.
242#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
243pub struct TeamRef {
244 pub slug: String,
245 pub name: String,
246}
247
248#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
249pub 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
277impl 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)]
286pub 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)]
298pub 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)]
309pub 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)]
320pub 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
341impl 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)]
354pub 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.
361pub 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.
377pub 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.
387pub 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)]
401pub 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)]
412pub 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
Merge branch 'worktree-agent-ad7c6d88d93adc817'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>`.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar425#[derive(Debug, Serialize, Deserialize)]
426pub 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)]
453pub 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)]
479pub 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)]
491pub 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)]
504pub 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)]
517pub 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)]
531pub 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)]
543pub 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)]
553pub 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)]
560pub 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)]
570pub 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)]
581pub 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)]
601pub 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)]
609mod tests {
610 use super::*;
611
612 #[test]
Merge branch 'worktree-agent-ad7c6d88d93adc817'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]
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar626 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}

This file's history is long; its oldest lines are credited to the oldest commit read.