Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1 | //! Teams: groups of a workspace's members, given roles on repositories |
| 2 | //! together, mentioned together and asked to review together. | |
| 3 | //! | |
| 4 | //! **Who is in one.** A team has **maintainers**, who manage its people | |
| 5 | //! and settings, and **members**. Only members of the workspace can be in | |
| 6 | //! its teams; leaving the workspace takes a person out of all of them. | |
| 7 | //! Owners of the workspace manage every team, whether or not they are in | |
| 8 | //! it. | |
| 9 | //! | |
| 10 | //! **Visibility.** A **visible** team is seen by every member of the | |
| 11 | //! workspace. A **secret** team is seen only by its own people and the | |
| 12 | //! workspace's owners. Secret teams cannot be nested. | |
| 13 | //! | |
| 14 | //! **Nesting.** A team can have a parent. A child team inherits its | |
| 15 | //! parent's roles on repositories (and its parent's parent's), and a | |
| 16 | //! mention or review request for the parent reaches the child teams' | |
| 17 | //! people too. A team's own people never get anything from its children. | |
| 18 | //! | |
| 19 | //! **Repository access.** A team is given a [`RepoRole`] on a repository | |
| 20 | //! as a person is: a row in `repo_grants` whose principal is the team. | |
| 21 | //! Identity resolves it into the same [`RepoGrant`](crate::access::RepoGrant)s | |
| 22 | //! on every person in the team and in its child teams, so `access::can` | |
| 23 | //! decides with it as with any other grant: the highest role wins. | |
| 24 | //! | |
| 25 | //! **Review requests.** A pull request can ask a team to review it. With | |
| 26 | //! [`ReviewAssignment`] off, everyone in the team is asked. With it on, | |
| 27 | //! g1t picks `count` people from it (never the pull request's author) and | |
| 28 | //! asks them; the team stays shown as requested beside them. | |
| 29 | //! | |
| 30 | //! Every method is served by identity at `POST /rpc/<method>`. Changing a | |
| 31 | //! team is for people, signed in or with a personal access token; never | |
| 32 | //! an agent's or a workspace's token. | |
| 33 | ||
| 34 | use serde::{Deserialize, Serialize}; | |
| 35 | ||
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 36 | use crate::{Role, User}; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 37 | use crate::access::RepoRole; |
| 38 | use crate::repos::RepoPath; | |
| 39 | ||
| 40 | /// The most teams one workspace can have. | |
| 41 | pub const MAX_TEAMS: u32 = 500; | |
| 42 | /// The longest team name. | |
| 43 | pub const MAX_NAME_LENGTH: usize = 80; | |
| 44 | /// The longest description. | |
| 45 | pub const MAX_DESCRIPTION_LENGTH: usize = 280; | |
| 46 | /// How deep teams can nest: a team, its child, and so on. | |
| 47 | pub const MAX_DEPTH: usize = 8; | |
| 48 | /// The most people review assignment picks for one request. | |
| 49 | pub const MAX_ASSIGNED: u32 = 10; | |
| 50 | ||
| 51 | /// Who can see a team. | |
| 52 | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 53 | #[serde(rename_all = "snake_case")] | |
| 54 | pub enum TeamVisibility { | |
| 55 | /// Every member of the workspace. | |
| 56 | #[default] | |
| 57 | Visible, | |
| 58 | /// The team's own people and the workspace's owners. | |
| 59 | Secret, | |
| 60 | } | |
| 61 | ||
| 62 | impl TeamVisibility { | |
| 63 | pub fn as_str(self) -> &'static str { | |
| 64 | match self { | |
| 65 | TeamVisibility::Visible => "visible", | |
| 66 | TeamVisibility::Secret => "secret", | |
| 67 | } | |
| 68 | } | |
| 69 | ||
| 70 | pub fn parse(text: &str) -> Option<TeamVisibility> { | |
| 71 | match text.trim().to_ascii_lowercase().as_str() { | |
| 72 | "visible" | "closed" => Some(TeamVisibility::Visible), | |
| 73 | "secret" => Some(TeamVisibility::Secret), | |
| 74 | _ => None, | |
| 75 | } | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 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")] | |
| 84 | pub enum TeamCreation { | |
| 85 | /// Any member with a confirmed email address. | |
| 86 | #[default] | |
| 87 | Members, | |
| 88 | /// The workspace's owners only. | |
| 89 | Owners, | |
| 90 | } | |
| 91 | ||
| 92 | impl 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)] | |
| 122 | pub 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 bar | 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")] | |
| 133 | pub enum TeamRole { | |
| 134 | Member, | |
| 135 | /// Manages the team's people and settings. | |
| 136 | Maintainer, | |
| 137 | } | |
| 138 | ||
| 139 | impl 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")] | |
| 159 | pub 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 | ||
| 168 | impl 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)] | |
| 187 | pub 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 | ||
| 207 | impl 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 | ||
| 222 | impl 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 | ||
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 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")] | |
| 244 | pub 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)] | |
| 259 | pub 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. | |
| 266 | pub 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)] | |
| 279 | pub 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)] | |
| 288 | pub 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 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 296 | /// A team as another names it: its parent, or a child. |
| 297 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 298 | pub struct TeamRef { | |
| 299 | pub slug: String, | |
| 300 | pub name: String, | |
| 301 | } | |
| 302 | ||
| 303 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 304 | pub 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, | |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 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>, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | ||
| 345 | impl 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)] | |
| 354 | pub 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)] | |
| 366 | pub 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)] | |
| 377 | pub 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)] | |
| 388 | pub 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 | ||
| 409 | impl 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)] | |
| 422 | pub 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. | |
| 429 | pub 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. | |
| 445 | pub 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. | |
| 455 | pub 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)] | |
| 469 | pub 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)] | |
| 480 | pub 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 | ||
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 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>`. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 493 | #[derive(Debug, Serialize, Deserialize)] |
| 494 | pub 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)] | |
| 521 | pub 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>, | |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 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>, | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 553 | #[serde(default)] |
| 554 | pub surface: Option<crate::audit::Surface>, | |
| 555 | } | |
| 556 | ||
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 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)] | |
| 561 | pub 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)] | |
| 574 | pub 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)] | |
| 588 | pub 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)] | |
| 599 | pub 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)] | |
| 613 | pub 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 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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)] | |
| 629 | pub 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)] | |
| 641 | pub 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)] | |
| 654 | pub 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)] | |
| 667 | pub 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)] | |
| 681 | pub 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)] | |
| 693 | pub 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)] | |
| 703 | pub 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)] | |
| 710 | pub 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)] | |
| 720 | pub 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)] | |
| 731 | pub 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)] | |
| 751 | pub 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)] | |
| 759 | mod tests { | |
| 760 | use super::*; | |
| 761 | ||
| 762 | #[test] | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 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] | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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] | |
| People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how. | 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] | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.