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.

200 linesCodeBlameRaw
1/**
2 * Teams: groups of a workspace's members, given roles on repositories
3 * together, mentioned together and asked to review together. Kept by the
4 * identity service; mirrors `crates/contracts/src/teams.rs`.
5 */
6import type { RepoRole } from "./access";
7import type { User } from "./identity";
8import type { Result } from "./result";
9
10export type TeamVisibility = "visible" | "secret";
11
12/**
13 * Who may create a workspace's teams: any member (the default) or its
14 * owners only. Owners change it in the workspace's settings.
15 */
16export type TeamCreation = "members" | "owners";
17
18/** Whether someone with `role` in a workspace may create its teams. */
19export function mayCreateTeams(setting: TeamCreation | null | undefined, role: "owner" | "member" | null | undefined): boolean {
20 if (!role) return false;
21 return (setting ?? "members") === "members" || role === "owner";
22}
23export type TeamRole = "member" | "maintainer";
24export type ReviewAlgorithm = "round_robin" | "load_balance";
25
26export const TEAM_VISIBILITY_LABELS: Record<TeamVisibility, string> = {
27 visible: "Visible",
28 secret: "Secret",
29};
30
31export const TEAM_VISIBILITY_SUMMARIES: Record<TeamVisibility, string> = {
32 visible: "Every member of the workspace can see it and mention it.",
33 secret: "Only its own people and the workspace's owners can see it. Secret teams cannot be nested.",
34};
35
36export const REVIEW_ALGORITHM_LABELS: Record<ReviewAlgorithm, string> = {
37 round_robin: "Round robin",
38 load_balance: "Load balance",
39};
40
41export const REVIEW_ALGORITHM_SUMMARIES: Record<ReviewAlgorithm, string> = {
42 round_robin: "Whoever this team asked least recently goes first.",
43 load_balance: "Whoever has the fewest pull requests waiting on their review goes first.",
44};
45
46/** The most people review assignment picks for one request. */
47export const MAX_ASSIGNED = 10;
48
49export type ReviewAssignment = {
50 enabled: boolean;
51 algorithm: ReviewAlgorithm;
52 count: number;
53 skip_busy: boolean;
54 busy_at: number;
55 include_child_teams: boolean;
56 excluded: string[];
57 notify_team: boolean;
58};
59
60export const DEFAULT_REVIEW_ASSIGNMENT: ReviewAssignment = {
61 enabled: false,
62 algorithm: "round_robin",
63 count: 1,
64 skip_busy: false,
65 busy_at: 5,
66 include_child_teams: false,
67 excluded: [],
68 notify_team: false,
69};
70
71export type TeamRef = { slug: string; name: string };
72
73/**
74 * Who leads a team: a person on it. `agent` is a lead from g1t's retired
75 * hosted agents, kept only so old data parses; identity never answers with
76 * one and no team can be given one.
77 */
78export type TeamLead = { kind: "user"; username: string; name: string | null; avatar: string | null } | { kind: "agent"; agent_id: string };
79
80/** A team's chat channel: its id, and its name (without `#`) when it was chosen. */
81export type TeamChannel = { id: string; name: string };
82
83/** How `update_team` takes a lead: `@username`; `""` for none. */
84export function leadInput(lead: { username: string } | null): string {
85 return lead ? `@${lead.username}` : "";
86}
87
88export type Team = {
89 id: string;
90 workspace: string;
91 slug: string;
92 name: string;
93 description: string | null;
94 visibility: TeamVisibility;
95 parent: TeamRef | null;
96 notify: boolean;
97 review_assignment: ReviewAssignment;
98 members_count: number;
99 repos_count: number;
100 child_teams_count: number;
101 lead: TeamLead | null;
102 channel: TeamChannel | null;
103 viewer_role: TeamRole | null;
104 can_manage: boolean;
105 created_at: string;
106 updated_at: string;
107};
108
109export type TeamMember = {
110 username: string;
111 name: string | null;
112 avatar: string | null;
113 role: TeamRole;
114 /** The child team they are in, when listed through one. */
115 via: string | null;
116};
117
118export type TeamRepo = {
119 /** `workspace/name`. */
120 repo: string;
121 repo_id: string;
122 role: RepoRole;
123 /** The parent team it comes from, when inherited. */
124 inherited_from: string | null;
125};
126
127/** A team with a role on a repository, as its Access settings list it. */
128export type RepoTeam = {
129 slug: string;
130 name: string;
131 role: RepoRole;
132 members_count: number;
133 visibility: TeamVisibility;
134};
135
136export type NewTeam = {
137 name: string;
138 slug?: string | null;
139 description?: string | null;
140 visibility?: TeamVisibility | null;
141 parent?: string | null;
142 notify?: boolean | null;
143 members?: string[];
144};
145
146/** What changes; the rest stays. `parent: ""` takes the team out from under its parent. */
147export type TeamChanges = {
148 name?: string;
149 slug?: string;
150 description?: string;
151 visibility?: TeamVisibility;
152 parent?: string;
153 notify?: boolean;
154 review_assignment?: ReviewAssignment;
155 /** `@username`, someone on the team (see `leadInput`); `""` for no lead. */
156 lead?: string;
157 /** The chat channel's id; `""` for none. Given with `channel_name`. */
158 channel_id?: string;
159 channel_name?: string;
160};
161
162/** `@acme/backend`, the way a team is mentioned. */
163export function teamHandle(team: { workspace: string; slug: string }): string {
164 return `@${team.workspace}/${team.slug}`;
165}
166
167/** A team's slug from its name, as identity makes it. Null when nothing is left. */
168export function teamSlug(name: string): string | null {
169 let slug = "";
170 for (const c of name.trim()) {
171 if (/[A-Za-z0-9]/.test(c)) slug += c.toLowerCase();
172 else if (slug && !slug.endsWith("-")) slug += "-";
173 }
174 slug = slug.replace(/-+$/, "").slice(0, 60).replace(/-+$/, "");
175 return slug ? slug : null;
176}
177
178/** One person's teams in a workspace, for the Members page. */
179export type MemberTeams = { username: string; teams: TeamRef[] };
180
181/** Identity's team methods. */
182export interface TeamsClient {
183 listTeams(viewer: User | null, workspace: string, query?: string | null): Promise<Result<Team[]>>;
184 getTeam(viewer: User | null, workspace: string, team: string): Promise<Result<Team>>;
185 createTeam(actor: User, workspace: string, team: NewTeam): Promise<Result<Team>>;
186 /** Who may create the workspace's teams. Owners only. */
187 setTeamCreation(actor: User, slug: string, setting: TeamCreation): Promise<Result<TeamCreation>>;
188 updateTeam(actor: User, workspace: string, team: string, changes: TeamChanges): Promise<Result<Team>>;
189 deleteTeam(actor: User, workspace: string, team: string): Promise<Result<boolean>>;
190 teamMembers(viewer: User | null, workspace: string, team: string, includeChildTeams?: boolean): Promise<Result<TeamMember[]>>;
191 setTeamMember(actor: User, workspace: string, team: string, username: string, role: TeamRole): Promise<Result<TeamMember>>;
192 removeTeamMember(actor: User, workspace: string, team: string, username: string): Promise<Result<boolean>>;
193 childTeams(viewer: User | null, workspace: string, team: string): Promise<Result<Team[]>>;
194 teamRepos(viewer: User | null, workspace: string, team: string): Promise<Result<TeamRepo[]>>;
195 setTeamRepo(actor: User, workspace: string, team: string, owner: string, name: string, role: RepoRole): Promise<Result<TeamRepo>>;
196 removeTeamRepo(actor: User, workspace: string, team: string, owner: string, name: string): Promise<Result<boolean>>;
197 userTeams(viewer: User | null, workspace: string, username: string): Promise<Result<Team[]>>;
198 /** Each member's teams the viewer can see. Members only. */
199 teamMemberships(viewer: User | null, workspace: string): Promise<Result<MemberTeams[]>>;
200}