Skip to content
237 linesCodeBlameRaw
1//! People: a workspace's directory of everyone in it, people and agents,
2//! and what each person's place is: a title, who they report to, and what
3//! they own. Kept by identity beside membership; agents come from the
4//! agents service, which identity names only by id.
5//!
6//! **Profiles.** A member's title and what they own are theirs to write,
7//! and the workspace's owners can write anyone's. Who someone reports to
8//! (their manager) is set by owners: always another member, never
9//! themselves, and never a loop.
10//!
11//! **Agents on teams.** A team can have agents as well as people: mixed,
12//! people only or agents only. An agent is on a team when it was added to
13//! it (`set_team_agent`) and on the team its own profile names, its home
14//! team. Agents are told who is on their visible teams every turn
15//! (`agent_teams`).
16//!
17//! Every method is served by identity at `POST /rpc/<method>`.
18
19use serde::{Deserialize, Serialize};
20
21use crate::teams::{TeamChannel, TeamLead, TeamRef, TeamRole, TeamVisibility};
22use crate::{Role, User};
23
24/// The longest title.
25pub const MAX_TITLE_LENGTH: usize = 80;
26/// The most things one person owns, as their profile lists them.
27pub const MAX_OWNS: usize = 8;
28/// The longest of them.
29pub const MAX_OWNS_LENGTH: usize = 60;
30/// How far up a reporting line is followed, looking for a loop.
31pub const MAX_CHAIN: usize = 64;
32
33/// One member, as the directory, their profile and the org chart show them.
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35pub struct DirectoryPerson {
36 pub user_id: String,
37 pub username: String,
38 /// The username as its owner wrote it, when that differs.
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub display_username: Option<String>,
41 pub name: Option<String>,
42 pub avatar: Option<String>,
43 /// From their public profile.
44 pub bio: Option<String>,
45 pub location: Option<String>,
46 pub pronouns: Option<String>,
47 /// An IANA name, such as `America/Denver`, for their local time.
48 pub timezone: Option<String>,
49 /// Owner or member.
50 pub role: Role,
51 /// Their title in this workspace.
52 pub title: Option<String>,
53 /// Who they report to, by username.
54 pub manager: Option<String>,
55 /// What they own: a few short phrases.
56 pub owns: Vec<String>,
57 /// When they joined the workspace (RFC 3339).
58 pub joined_at: String,
59}
60
61/// One person on a team, as the directory names them.
62#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
63pub struct TeamPersonRef {
64 pub username: String,
65 pub role: TeamRole,
66}
67
68/// A team as the directory and the org chart need it: who is on it, by
69/// username and agent id, and who leads it.
70#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
71pub struct DirectoryTeam {
72 pub slug: String,
73 pub name: String,
74 pub description: Option<String>,
75 pub visibility: TeamVisibility,
76 pub parent: Option<TeamRef>,
77 pub lead: Option<TeamLead>,
78 pub channel: Option<TeamChannel>,
79 pub budget_micros: Option<i64>,
80 /// Its own people, not its child teams'.
81 pub people: Vec<TeamPersonRef>,
82 /// The agents added to it, by id. Agents whose home team it is are
83 /// on it too; the agents service names those.
84 pub agent_ids: Vec<String>,
85 /// Repositories it has a role on itself.
86 pub repos_count: u32,
87}
88
89/// `people_directory`: everyone in a workspace and its teams, as the
90/// viewer may see them (secret teams only for their people and owners).
91/// Members only. Returns `Outcome<PeopleDirectory>`.
92#[derive(Debug, Serialize, Deserialize)]
93pub struct PeopleArgs {
94 pub viewer: crate::Viewer,
95 pub workspace: String,
96}
97
98#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
99pub struct PeopleDirectory {
100 pub people: Vec<DirectoryPerson>,
101 pub teams: Vec<DirectoryTeam>,
102 /// What members get on every repository: `none`, `read`, `write` or `admin`.
103 pub base_permission: String,
104 /// Whether the viewer owns the workspace: they can set anyone's
105 /// title, manager and what they own.
106 pub can_manage: bool,
107}
108
109/// `set_member_profile`: what is given changes; the rest stays. `manager`
110/// set to an empty string clears it. Returns `Outcome<DirectoryPerson>`.
111#[derive(Debug, Default, Serialize, Deserialize)]
112pub struct SetMemberProfileArgs {
113 pub actor: User,
114 pub workspace: String,
115 pub username: String,
116 #[serde(default)]
117 pub title: Option<String>,
118 /// A member's username, or an empty string for none. Owners only.
119 #[serde(default)]
120 pub manager: Option<String>,
121 #[serde(default)]
122 pub owns: Option<Vec<String>>,
123 #[serde(default)]
124 pub surface: Option<crate::audit::Surface>,
125}
126
127/// A title as stored: trimmed, at most [`MAX_TITLE_LENGTH`] characters,
128/// `None` when nothing is left.
129pub fn clean_title(title: &str) -> Option<String> {
130 let title: String = title.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TITLE_LENGTH).collect();
131 let title = title.trim().to_owned();
132 (!title.is_empty()).then_some(title)
133}
134
135/// What someone owns, as stored: each phrase trimmed and cut to
136/// [`MAX_OWNS_LENGTH`], blanks and repeats (ignoring case) left out, at
137/// most [`MAX_OWNS`].
138pub fn clean_owns(owns: &[String]) -> Vec<String> {
139 let mut out: Vec<String> = Vec::new();
140 for phrase in owns {
141 let phrase: String = phrase.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_OWNS_LENGTH).collect();
142 let phrase = phrase.trim().trim_end_matches(['.', ',', ';']).trim().to_owned();
143 if phrase.is_empty() || out.iter().any(|kept| kept.eq_ignore_ascii_case(&phrase)) {
144 continue;
145 }
146 out.push(phrase);
147 if out.len() == MAX_OWNS {
148 break;
149 }
150 }
151 out
152}
153
154/// What may change on someone's profile: their title and what they own
155/// by themselves or an owner; their manager by an owner.
156#[derive(Clone, Copy, Debug, PartialEq, Eq)]
157pub enum ProfileField {
158 Title,
159 Owns,
160 Manager,
161}
162
163/// Whether `actor` may change `field` on someone's profile.
164pub fn may_edit_profile(field: ProfileField, is_self: bool, owner: bool) -> bool {
165 match field {
166 ProfileField::Title | ProfileField::Owns => is_self || owner,
167 ProfileField::Manager => owner,
168 }
169}
170
171/// Whether making `manager` the manager of `person` would make a loop.
172/// `manager_of` gives each person's current manager.
173pub fn makes_loop(person: &str, manager: &str, manager_of: impl Fn(&str) -> Option<String>) -> bool {
174 if person == manager {
175 return true;
176 }
177 let mut at = manager.to_owned();
178 for _ in 0..MAX_CHAIN {
179 match manager_of(&at) {
180 Some(next) if next == person => return true,
181 Some(next) => at = next,
182 None => return false,
183 }
184 }
185 // A chain this long is treated as a loop rather than followed further.
186 true
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use std::collections::HashMap;
193
194 #[test]
195 fn titles_are_tidied() {
196 assert_eq!(clean_title(" Staff Engineer "), Some("Staff Engineer".to_owned()));
197 assert_eq!(clean_title(" "), None);
198 assert_eq!(clean_title(&"a".repeat(200)).map(|t| t.len()), Some(MAX_TITLE_LENGTH));
199 }
200
201 #[test]
202 fn what_someone_owns_is_kept_short_and_once() {
203 let owns = clean_owns(&[
204 " storefront ".into(),
205 "Storefront".into(),
206 "".into(),
207 "the release process.".into(),
208 "x".repeat(100),
209 ]);
210 assert_eq!(owns[0], "storefront");
211 assert_eq!(owns[1], "the release process");
212 assert_eq!(owns[2].len(), MAX_OWNS_LENGTH);
213 assert_eq!(owns.len(), 3);
214 let many: Vec<String> = (0..20).map(|i| format!("thing {i}")).collect();
215 assert_eq!(clean_owns(&many).len(), MAX_OWNS);
216 }
217
218 #[test]
219 fn people_write_their_own_and_owners_set_managers() {
220 assert!(may_edit_profile(ProfileField::Title, true, false));
221 assert!(may_edit_profile(ProfileField::Owns, false, true));
222 assert!(!may_edit_profile(ProfileField::Owns, false, false));
223 assert!(!may_edit_profile(ProfileField::Manager, true, false));
224 assert!(may_edit_profile(ProfileField::Manager, false, true));
225 }
226
227 #[test]
228 fn reporting_lines_never_loop() {
229 // ana -> priya -> chase
230 let managers: HashMap<&str, &str> = HashMap::from([("ana", "priya"), ("priya", "chase")]);
231 let of = |id: &str| managers.get(id).map(|m| (*m).to_owned());
232 assert!(makes_loop("chase", "ana", of));
233 assert!(makes_loop("priya", "priya", of));
234 assert!(!makes_loop("ana", "chase", of));
235 assert!(!makes_loop("dev", "priya", of));
236 }
237}