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