Skip to content

g1t/crates/contracts/src/teams.rs

611 lines21,339 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
36use crate::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/// A person's place in a team.
80#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
81#[serde(rename_all = "snake_case")]
82pub enum TeamRole {
83 Member,
84 /// Manages the team's people and settings.
85 Maintainer,
86}
87
88impl 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")]
108pub 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
117impl 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)]
136pub 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
156impl 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
171impl 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)]
192pub struct TeamRef {
193 pub slug: String,
194 pub name: String,
195}
196
197#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
198pub 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
226impl 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)]
235pub 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)]
247pub 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)]
258pub 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)]
269pub 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
290impl 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)]
303pub 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.
310pub 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.
326pub 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.
336pub 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)]
350pub 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)]
361pub 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)]
374pub 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)]
401pub 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)]
427pub 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)]
439pub 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)]
452pub 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)]
465pub 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)]
479pub 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)]
491pub 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)]
501pub 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)]
508pub 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)]
518pub 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)]
529pub 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)]
549pub 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)]
557mod 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}