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