Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
350 linesCodeBlameRaw

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.

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.1//! A workspace's people: the directory of its members and teams, and each
2//! member's place in it (a title, who they report to, what they own). See
3//! `g1t_contracts::people`. Agents live in the agents service; teams name
4//! them by id.
5
6use std::collections::HashMap;
7
8use g1t_contracts::audit::Surface;
9use g1t_contracts::people::*;
10use g1t_contracts::teams::{TeamRef, TeamRole};
11use g1t_contracts::{FailureCode, Outcome, Role};
12use serde::Deserialize;
13use worker::Result;
14use worker::wasm_bindgen::JsValue;
15
16use crate::Identity;
17use crate::teams::{TeamRow, budget_of, channel_of, may_see, role_of};
18
19const MEMBERS_ONLY: &str = "Only members can see who is in a workspace.";
20const NO_SUCH_MEMBER: &str = "There is no member with that username.";
21
22/// What someone owns, from its stored JSON; nothing when it is missing or
23/// not a list of words.
24pub fn owns_from(json: Option<&str>) -> Vec<String> {
25 json.and_then(|json| serde_json::from_str::<Vec<String>>(json).ok())
26 .map(|owns| clean_owns(&owns))
27 .unwrap_or_default()
28}
29
30/// One member's row, as the directory reads it.
31#[derive(Deserialize)]
32struct PersonRow {
33 user_id: String,
34 username: String,
35 #[serde(default)]
36 display_username: Option<String>,
37 #[serde(default)]
38 name: Option<String>,
39 #[serde(default)]
40 avatar: Option<String>,
41 #[serde(default)]
42 bio: Option<String>,
43 #[serde(default)]
44 location: Option<String>,
45 #[serde(default)]
46 pronouns: Option<String>,
47 #[serde(default)]
48 timezone: Option<String>,
49 role: String,
50 #[serde(default)]
51 title: Option<String>,
52 #[serde(default)]
53 owns: Option<String>,
54 #[serde(default)]
55 manager: Option<String>,
56 joined_at: String,
57}
58
59impl PersonRow {
60 fn person(self) -> DirectoryPerson {
61 let blank = |value: Option<String>| value.filter(|value| !value.trim().is_empty());
62 DirectoryPerson {
63 user_id: self.user_id,
64 display_username: self.display_username.filter(|shown| *shown != self.username),
65 username: self.username,
66 name: blank(self.name),
67 avatar: self.avatar,
68 bio: blank(self.bio),
69 location: blank(self.location),
70 pronouns: blank(self.pronouns),
71 timezone: blank(self.timezone),
72 role: if self.role == "owner" { Role::Owner } else { Role::Member },
73 title: self.title.as_deref().and_then(clean_title),
74 manager: self.manager,
75 owns: owns_from(self.owns.as_deref()),
76 joined_at: self.joined_at,
77 }
78 }
79}
80
81/// Every member of a workspace (by slug, `?1`), with `filter` added.
82fn people_sql(filter: &str) -> String {
83 format!(
84 "SELECT u.id AS user_id, u.username, u.display_username, u.display_name AS name, u.avatar, u.bio, u.location,
85 u.pronouns, u.timezone, wm.role, wm.title, wm.owns, mu.username AS manager, wm.created_at AS joined_at
86 FROM workspace_members wm
87 JOIN users u ON u.id = wm.user_id AND u.deleted_at IS NULL
88 JOIN workspaces w ON w.id = wm.workspace_id AND w.deleted_at IS NULL
89 LEFT JOIN users mu ON mu.id = wm.manager_id AND mu.deleted_at IS NULL
90 WHERE w.slug = ?1 {filter}
91 ORDER BY lower(coalesce(u.display_name, u.username)) LIMIT 5000"
92 )
93}
94
95#[derive(Deserialize)]
96struct TeamPersonRow {
97 team_id: String,
98 username: String,
99 role: String,
100}
101
102#[derive(Deserialize)]
103struct TeamAgentRow {
104 team_id: String,
105 agent_id: String,
106}
107
108#[derive(Deserialize)]
109struct BaseRow {
110 #[serde(default)]
111 base_permission: Option<String>,
112}
113
114impl Identity {
115 pub async fn people_directory(&self, a: PeopleArgs) -> Result<Outcome<PeopleDirectory>> {
116 let workspace = a.workspace.trim().to_lowercase();
117 let Some(viewer) = a.viewer.as_ref() else {
118 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
119 };
120 let Some(role) = role_of(viewer, &workspace) else {
121 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
122 };
123 let owner = role == Role::Owner;
124 let slug = JsValue::from(workspace.as_str());
125 let mut found = self
126 .db
127 .batch(vec![
128 self.db.prepare(people_sql("")).bind(std::slice::from_ref(&slug))?,
129 self.db
130 .prepare(
131 "SELECT tm.team_id, u.username, tm.role FROM team_members tm
132 JOIN teams t ON t.id = tm.team_id
133 JOIN workspaces w ON w.id = t.workspace_id
134 JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
135 WHERE w.slug = ?1 ORDER BY tm.role DESC, u.username LIMIT 20000",
136 )
137 .bind(std::slice::from_ref(&slug))?,
138 self.db
139 .prepare(
140 "SELECT ta.team_id, ta.agent_id FROM team_agents ta
141 JOIN teams t ON t.id = ta.team_id
142 JOIN workspaces w ON w.id = t.workspace_id
143 WHERE w.slug = ?1 ORDER BY ta.created_at LIMIT 20000",
144 )
145 .bind(std::slice::from_ref(&slug))?,
146 self.db
147 .prepare("SELECT base_permission FROM workspaces WHERE slug = ?1 AND deleted_at IS NULL")
148 .bind(std::slice::from_ref(&slug))?,
149 ])
150 .await?
151 .into_iter();
152 let mut next = || found.next().ok_or_else(|| worker::Error::RustError("a statement of the batch did not answer".into()));
153 let people = next()?.results::<PersonRow>()?;
154 let team_people = next()?.results::<TeamPersonRow>()?;
155 let team_agents = next()?.results::<TeamAgentRow>()?;
156 let base = next()?.results::<BaseRow>()?;
157 let rows = self.workspace_team_rows(&workspace, &viewer.id).await?;
158 let teams = rows
159 .iter()
160 .filter(|row| may_see(row.visibility(), owner, row.viewer_role.is_some()))
161 .map(|row| DirectoryTeam {
162 slug: row.slug.clone(),
163 name: row.name.clone(),
164 description: row.description.clone(),
165 visibility: row.visibility(),
166 parent: match (&row.parent_slug, &row.parent_name) {
167 (Some(slug), Some(name)) => Some(TeamRef {
168 slug: slug.clone(),
169 name: name.clone(),
170 }),
171 _ => None,
172 },
173 lead: row.lead(),
174 channel: channel_of(row.channel_id.as_deref(), row.channel_name.as_deref()),
175 budget_micros: budget_of(row.budget_micros),
176 people: team_people
177 .iter()
178 .filter(|person| person.team_id == row.id)
179 .map(|person| TeamPersonRef {
180 username: person.username.clone(),
181 role: TeamRole::parse(&person.role).unwrap_or(TeamRole::Member),
182 })
183 .collect(),
184 agent_ids: team_agents.iter().filter(|agent| agent.team_id == row.id).map(|agent| agent.agent_id.clone()).collect(),
185 repos_count: row.repos_count,
186 })
187 .collect();
188 Ok(Outcome::Ok(PeopleDirectory {
189 people: people.into_iter().map(PersonRow::person).collect(),
190 teams,
191 base_permission: base
192 .into_iter()
193 .next()
194 .and_then(|row| row.base_permission)
195 .filter(|base| !base.trim().is_empty())
196 .unwrap_or_else(|| "write".to_owned()),
197 can_manage: owner,
198 }))
199 }
200
201 pub async fn set_member_profile(&self, a: SetMemberProfileArgs) -> Result<Outcome<DirectoryPerson>> {
202 let workspace = a.workspace.trim().to_lowercase();
203 if !crate::security::is_person(&a.actor) {
204 return Ok(Outcome::fail(
205 FailureCode::Forbidden,
206 "Only a person can change a profile, signed in as themselves.",
207 ));
208 }
209 let Some(role) = role_of(&a.actor, &workspace) else {
210 return Ok(Outcome::fail(FailureCode::Forbidden, MEMBERS_ONLY));
211 };
212 if !a.actor.verified {
213 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first."));
214 }
215 let owner = role == Role::Owner;
216 let Some(workspace_id) = self.workspace_id_of(&workspace).await? else {
217 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
218 };
219 let Some((user_id, username)) = self.person_by_username(&a.username).await? else {
220 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
221 };
222 if !self.is_member_of(&workspace_id, &user_id).await? {
223 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
224 }
225 let is_self = a.actor.id == user_id;
226 let mut sets: Vec<String> = Vec::new();
227 let mut binds: Vec<JsValue> = Vec::new();
228 let mut changed: Vec<&str> = Vec::new();
229 let refused = |what: &str| {
230 Ok(Outcome::fail(
231 FailureCode::Forbidden,
232 format!("Only owners of {workspace} can change {what} for someone else."),
233 ))
234 };
235 if let Some(title) = &a.title {
236 if !may_edit_profile(ProfileField::Title, is_self, owner) {
237 return refused("a title");
238 }
239 binds.push(clean_title(title).map_or(JsValue::NULL, JsValue::from));
240 sets.push(format!("title = ?{}", binds.len()));
241 changed.push("title");
242 }
243 if let Some(owns) = &a.owns {
244 if !may_edit_profile(ProfileField::Owns, is_self, owner) {
245 return refused("what someone owns");
246 }
247 let owns = clean_owns(owns);
248 binds.push(if owns.is_empty() { JsValue::NULL } else { serde_json::to_string(&owns)?.into() });
249 sets.push(format!("owns = ?{}", binds.len()));
250 changed.push("what they own");
251 }
252 if let Some(manager) = a.manager.as_deref().map(str::trim) {
253 if !may_edit_profile(ProfileField::Manager, is_self, owner) {
254 return Ok(Outcome::fail(FailureCode::Forbidden, format!("Only owners of {workspace} set who someone reports to.")));
255 }
256 if manager.is_empty() {
257 binds.push(JsValue::NULL);
258 } else {
259 let Some((manager_id, manager_name)) = self.person_by_username(manager).await? else {
260 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER));
261 };
262 if !self.is_member_of(&workspace_id, &manager_id).await? {
263 return Ok(Outcome::fail(
264 FailureCode::Invalid,
265 format!("{manager_name} is not a member of {workspace}."),
266 ));
267 }
268 let lines = self.reporting_lines(&workspace_id).await?;
269 if makes_loop(&user_id, &manager_id, |id| lines.get(id).cloned()) {
270 return Ok(Outcome::fail(
271 FailureCode::Invalid,
272 format!("{username} can't report to {manager_name}: {manager_name} already reports to {username}, directly or through others."),
273 ));
274 }
275 binds.push(manager_id.into());
276 }
277 sets.push(format!("manager_id = ?{}", binds.len()));
278 changed.push("manager");
279 }
280 if !sets.is_empty() {
281 binds.push(JsValue::from(workspace_id.clone()));
282 binds.push(JsValue::from(user_id.clone()));
283 self.db
284 .prepare(format!(
285 "UPDATE workspace_members SET {} WHERE workspace_id = ?{} AND user_id = ?{}",
286 sets.join(", "),
287 binds.len() - 1,
288 binds.len()
289 ))
290 .bind(&binds)?
291 .run()
292 .await?;
293 self.audit_workspace(
294 &a.actor,
295 "member.profile_edited",
296 &workspace,
297 a.surface.unwrap_or(Surface::Web),
298 format!("Changed {} for {username}", changed.join(", ")),
299 )
300 .await;
301 }
302 let person = self
303 .db
304 .prepare(people_sql("AND u.id = ?2"))
305 .bind(&[workspace.as_str().into(), user_id.as_str().into()])?
306 .first::<PersonRow>(None)
307 .await?;
308 Ok(match person {
309 Some(person) => Outcome::Ok(person.person()),
310 None => Outcome::fail(FailureCode::NotFound, NO_SUCH_MEMBER),
311 })
312 }
313
314 /// Each member's manager in a workspace, by user id.
315 async fn reporting_lines(&self, workspace_id: &str) -> Result<HashMap<String, String>> {
316 #[derive(Deserialize)]
317 struct Line {
318 user_id: String,
319 manager_id: String,
320 }
321 Ok(self
322 .db
323 .prepare("SELECT user_id, manager_id FROM workspace_members WHERE workspace_id = ? AND manager_id IS NOT NULL")
324 .bind(&[workspace_id.into()])?
325 .all()
326 .await?
327 .results::<Line>()?
328 .into_iter()
329 .map(|line| (line.user_id, line.manager_id))
330 .collect())
331 }
332
333 /// Every team of a workspace, as `viewer_id` stands in each.
334 async fn workspace_team_rows(&self, workspace: &str, viewer_id: &str) -> Result<Vec<TeamRow>> {
335 self.team_rows("WHERE w.slug = ?2 ORDER BY lower(t.name)", &[viewer_id.into(), workspace.into()])
336 .await
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn what_someone_owns_reads_back_from_storage() {
346 assert_eq!(owns_from(Some(r#"["storefront"," Storefront ","releases"]"#)), vec!["storefront", "releases"]);
347 assert!(owns_from(Some("not json")).is_empty());
348 assert!(owns_from(None).is_empty());
349 }
350}