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 | /** |
| 2 | * People on the site: the directory (people and agents in one search), | |
| 3 | * profiles, teams of any mix, and the org chart. Types only from the | |
| 4 | * contracts, so this file runs under `node --test`. | |
| 5 | */ | |
| 6 | import type { DirectoryPerson, DirectoryTeam, PeopleDirectory, TeamLead, WorkspaceAgent } from "@g1t/contracts"; | |
| 7 | ||
| 8 | /** An agent as People's pages need it. */ | |
| 9 | export type PeopleAgent = Pick< | |
| 10 | WorkspaceAgent, | |
| 11 | "id" | "handle" | "display_name" | "avatar" | "avatar_seed" | "title" | "role" | "team" | "department" | "status" | "responsibilities" | "builtin" | "spent_month_micros" | |
| 12 | >; | |
| 13 | ||
| 14 | /** Just what People's pages read of an agent, so loaders send no more. */ | |
| 15 | export function peopleAgent(agent: PeopleAgent): PeopleAgent { | |
| 16 | const { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros } = agent; | |
| 17 | return { id, handle, display_name, avatar, avatar_seed, title, role, team, department, status, responsibilities, builtin, spent_month_micros }; | |
| 18 | } | |
| 19 | ||
| 20 | /** Where a person's profile is. */ | |
| 21 | export function personPath(workspace: string, username: string): string { | |
| 22 | return `/${workspace}/-/people/${username}`; | |
| 23 | } | |
| 24 | ||
| 25 | /** Where an agent's People profile is. */ | |
| 26 | export function agentPath(workspace: string, handle: string): string { | |
| 27 | return `/${workspace}/-/people/agents/${handle}`; | |
| 28 | } | |
| 29 | ||
| 30 | /** The agents on a team, by id: those added to it, then those whose home team it is. */ | |
| 31 | export function agentIdsOn(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly Pick<PeopleAgent, "id" | "team">[]): string[] { | |
| 32 | const ids = [...team.agent_ids]; | |
| 33 | for (const agent of agents) if (agent.team === team.slug && !ids.includes(agent.id)) ids.push(agent.id); | |
| 34 | return ids; | |
| 35 | } | |
| 36 | ||
| 37 | /** The agents on a team, in order, as found among `agents`. */ | |
| 38 | export function agentsOn<A extends Pick<PeopleAgent, "id" | "team">>(team: Pick<DirectoryTeam, "slug" | "agent_ids">, agents: readonly A[]): A[] { | |
| 39 | const byId = new Map(agents.map((agent) => [agent.id, agent])); | |
| 40 | return agentIdsOn(team, agents) | |
| 41 | .map((id) => byId.get(id)) | |
| 42 | .filter((agent): agent is A => !!agent); | |
| 43 | } | |
| 44 | ||
| 45 | /** The teams a person is on. */ | |
| 46 | export function teamsOfPerson<T extends Pick<DirectoryTeam, "people">>(teams: readonly T[], username: string): T[] { | |
| 47 | return teams.filter((team) => team.people.some((person) => person.username === username)); | |
| 48 | } | |
| 49 | ||
| 50 | /** The teams an agent is on: added to, or its home team. */ | |
| 51 | export function teamsOfAgent<T extends Pick<DirectoryTeam, "slug" | "agent_ids">>(teams: readonly T[], agent: Pick<PeopleAgent, "id" | "team">): T[] { | |
| 52 | return teams.filter((team) => team.agent_ids.includes(agent.id) || team.slug === agent.team); | |
| 53 | } | |
| 54 | ||
| 55 | /** Whether `lead` is this person or this agent. */ | |
| 56 | export function leads(lead: TeamLead | null, who: { username: string } | { id: string }): boolean { | |
| 57 | if (!lead) return false; | |
| 58 | if ("username" in who) return lead.kind === "user" && lead.username === who.username; | |
| 59 | return lead.kind === "agent" && lead.agent_id === who.id; | |
| 60 | } | |
| 61 | ||
| 62 | /** A person's name as pages show it. */ | |
| 63 | export function personName(person: Pick<DirectoryPerson, "username" | "name" | "display_username">): string { | |
| 64 | return person.name?.trim() || person.display_username || person.username; | |
| 65 | } | |
| 66 | ||
| 67 | /** Whether every word of `query` is somewhere in `fields`. */ | |
| 68 | export function matches(query: string, fields: readonly (string | null | undefined)[]): boolean { | |
| 69 | const words = query.toLowerCase().split(/\s+/).filter(Boolean); | |
| 70 | if (!words.length) return true; | |
| 71 | const text = fields.filter(Boolean).join(" ").toLowerCase(); | |
| 72 | return words.every((word) => text.includes(word)); | |
| 73 | } | |
| 74 | ||
| 75 | export type DirectoryKind = "everyone" | "people" | "agents"; | |
| 76 | ||
| 77 | export function directoryKind(value: string | null): DirectoryKind { | |
| 78 | return value === "people" || value === "agents" ? value : "everyone"; | |
| 79 | } | |
| 80 | ||
| 81 | /** One card in the directory: a person or an agent. */ | |
| 82 | export type DirectoryEntry = | |
| 83 | | { kind: "person"; person: DirectoryPerson; teams: DirectoryTeam[] } | |
| 84 | | { kind: "agent"; agent: PeopleAgent; teams: DirectoryTeam[] }; | |
| 85 | ||
| 86 | /** | |
| 87 | * Everyone matching `query`, people first by name, then agents (the | |
| 88 | * orchestrator first, then by name). People match on their name, title, | |
| 89 | * teams, what they own, where they are and their bio; agents on their | |
| 90 | * name, handle, title, role, teams and responsibilities. | |
| 91 | */ | |
| 92 | export function directoryEntries(directory: PeopleDirectory, agents: readonly PeopleAgent[], query: string, kind: DirectoryKind): DirectoryEntry[] { | |
| 93 | const out: DirectoryEntry[] = []; | |
| 94 | if (kind !== "agents") { | |
| 95 | for (const person of directory.people) { | |
| 96 | const teams = teamsOfPerson(directory.teams, person.username); | |
| 97 | const fields = [person.username, person.name, person.title, person.location, person.bio, ...person.owns, ...teams.map((team) => team.name), person.role === "owner" ? "owner" : null]; | |
| 98 | if (matches(query, fields)) out.push({ kind: "person", person, teams }); | |
| 99 | } | |
| 100 | } | |
| 101 | if (kind !== "people") { | |
| 102 | for (const agent of agents) { | |
| 103 | const teams = teamsOfAgent(directory.teams, agent); | |
| 104 | const fields = [agent.handle, agent.display_name, agent.title, agent.role, agent.department, ...agent.responsibilities, ...teams.map((team) => team.name), "agent"]; | |
| 105 | if (matches(query, fields)) out.push({ kind: "agent", agent, teams }); | |
| 106 | } | |
| 107 | } | |
| 108 | return out; | |
| 109 | } | |
| 110 | ||
| 111 | /** One person in the org chart, with who reports to them and the agents of the teams they lead. */ | |
| 112 | export type OrgNode = { person: DirectoryPerson; agents: PeopleAgent[]; leads: DirectoryTeam[]; reports: OrgNode[] }; | |
| 113 | ||
| 114 | export type OrgChart = { | |
| 115 | /** Everyone at the top: no manager, or one who is no longer here. */ | |
| 116 | roots: OrgNode[]; | |
| 117 | /** Teams no person leads (an agent does, or no one), with their agents. */ | |
| 118 | unled: { team: DirectoryTeam; agents: PeopleAgent[] }[]; | |
| 119 | }; | |
| 120 | ||
| 121 | /** | |
| 122 | * Reporting lines, with each team's agents beside the person who leads it. | |
| 123 | * A loop (which identity refuses) would leave its people out of `roots`; | |
| 124 | * they are put back at the top, so no one is ever missing. | |
| 125 | */ | |
| 126 | export function orgChart(directory: PeopleDirectory, agents: readonly PeopleAgent[]): OrgChart { | |
| 127 | const known = new Set(directory.people.map((person) => person.username)); | |
| 128 | const byManager = new Map<string, DirectoryPerson[]>(); | |
| 129 | for (const person of directory.people) { | |
| 130 | if (person.manager && known.has(person.manager) && person.manager !== person.username) { | |
| 131 | byManager.set(person.manager, [...(byManager.get(person.manager) ?? []), person]); | |
| 132 | } | |
| 133 | } | |
| 134 | const placed = new Set<string>(); | |
| 135 | const node = (person: DirectoryPerson): OrgNode => { | |
| 136 | placed.add(person.username); | |
| 137 | const led = directory.teams.filter((team) => leads(team.lead, { username: person.username })); | |
| 138 | const theirAgents: PeopleAgent[] = []; | |
| 139 | for (const team of led) for (const agent of agentsOn(team, agents)) if (!theirAgents.includes(agent)) theirAgents.push(agent); | |
| 140 | const reports = (byManager.get(person.username) ?? []).filter((report) => !placed.has(report.username)).map(node); | |
| 141 | return { person, agents: theirAgents, leads: led, reports }; | |
| 142 | }; | |
| 143 | const roots = directory.people.filter((person) => !person.manager || !known.has(person.manager)).map(node); | |
| 144 | for (const person of directory.people) if (!placed.has(person.username)) roots.push(node(person)); | |
| 145 | const unled = directory.teams | |
| 146 | .filter((team) => !team.lead || team.lead.kind === "agent") | |
| 147 | .map((team) => ({ team, agents: agentsOn(team, agents) })) | |
| 148 | .filter((entry) => entry.agents.length > 0); | |
| 149 | return { roots, unled }; | |
| 150 | } | |
| 151 | ||
| 152 | /** Everyone who reports to `username`, directly. */ | |
| 153 | export function reportsOf(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 154 | return people.filter((person) => person.manager === username && person.username !== username); | |
| 155 | } | |
| 156 | ||
| 157 | /** Who `username` could report to: anyone but themselves and the people under them. */ | |
| 158 | export function managerChoices(people: readonly DirectoryPerson[], username: string): DirectoryPerson[] { | |
| 159 | const below = new Set<string>([username]); | |
| 160 | let grew = true; | |
| 161 | while (grew) { | |
| 162 | grew = false; | |
| 163 | for (const person of people) { | |
| 164 | if (person.manager && below.has(person.manager) && !below.has(person.username)) { | |
| 165 | below.add(person.username); | |
| 166 | grew = true; | |
| 167 | } | |
| 168 | } | |
| 169 | } | |
| 170 | return people.filter((person) => !below.has(person.username)); | |
| 171 | } | |
| 172 | ||
| 173 | /** What someone owns, from a form's text: one per line or comma. */ | |
| 174 | export function ownsFromText(text: string): string[] { | |
| 175 | return text | |
| 176 | .split(/[\n,]+/) | |
| 177 | .map((phrase) => phrase.trim()) | |
| 178 | .filter(Boolean); | |
| 179 | } | |
| 180 | ||
| 181 | /** A monthly budget in dollars from a form, as micro-dollars; 0 for none; null when it isn't a number. */ | |
| 182 | export function budgetFromText(text: string): number | null { | |
| 183 | const value = text.trim().replace(/^\$/, "").replace(/,/g, ""); | |
| 184 | if (!value) return 0; | |
| 185 | const dollars = Number(value); | |
| 186 | if (!Number.isFinite(dollars) || dollars < 0) return null; | |
| 187 | return Math.round(dollars * 1_000_000); | |
| 188 | } | |
| 189 | ||
| 190 | /** The access to Code a member has, in words: their role, the base permission, and the teams that add to it. */ | |
| 191 | export function codeAccessWords( | |
| 192 | person: Pick<DirectoryPerson, "role">, | |
| 193 | base: PeopleDirectory["base_permission"], | |
| 194 | teams: readonly Pick<DirectoryTeam, "name" | "repos_count">[], | |
| 195 | ): { summary: string; through: string[] } { | |
| 196 | const summary = | |
| 197 | person.role === "owner" | |
| 198 | ? "Admin on every repository, as an owner" | |
| 199 | : base === "none" | |
| 200 | ? "Only repositories they're given a role on" | |
| 201 | : `${base[0]!.toUpperCase()}${base.slice(1)} on every repository, the workspace's base permission`; | |
| 202 | const through = teams | |
| 203 | .filter((team) => team.repos_count > 0) | |
| 204 | .map((team) => `Roles on ${team.repos_count} ${team.repos_count === 1 ? "repository" : "repositories"} through ${team.name}`); | |
| 205 | return { summary, through }; | |
| 206 | } | |
| 207 | ||
| 208 | /** The part of what an agent is told that is about one team: its heading and what follows, up to the next. */ | |
| 209 | export function teamBlock(text: string | null, teamName: string): string | null { | |
| 210 | if (!text) return null; | |
| 211 | const blocks = text.split(/\n(?=### )/); | |
| 212 | const found = blocks.find((block) => block.startsWith(`### ${teamName}\n`) || block.trim() === `### ${teamName}`); | |
| 213 | return found ? found.trim() : null; | |
| 214 | } |