Skip to content

g1t/apps/web/app/lib/teams.ts

124 lines5,286 bytesCodeBlame
1/**
2 * Teams on the site: finding them, and reading their forms. Types only from
3 * the contracts, so this file runs under `node --test`.
4 */
5import type { NewTeam, ReviewAlgorithm, ReviewAssignment, Team, TeamChanges, TeamVisibility } from "@g1t/contracts";
6
7/** The most people review assignment picks; the same as the contracts' `MAX_ASSIGNED`. */
8const MAX_ASSIGNED = 10;
9
10/** Where a team's pages are. */
11export function teamPath(workspace: string, slug: string, tab?: "teams" | "repositories" | "settings"): string {
12 return `/${workspace}/-/teams/${slug}${tab ? `/${tab}` : ""}`;
13}
14
15/** The teams whose name, slug or description has every word of `query`, in their order. */
16export function filterTeams<T extends Pick<Team, "name" | "slug" | "description">>(teams: T[], query: string): T[] {
17 const words = query.toLowerCase().split(/\s+/).filter(Boolean);
18 if (words.length === 0) return teams;
19 return teams.filter((team) => {
20 const text = `${team.name} ${team.slug} ${team.description ?? ""}`.toLowerCase();
21 return words.every((word) => text.includes(word));
22 });
23}
24
25/** The viewer's teams, then the rest, keeping the order given. */
26export function splitTeams<T extends Pick<Team, "viewer_role">>(teams: T[]): { mine: T[]; others: T[] } {
27 return {
28 mine: teams.filter((team) => team.viewer_role != null),
29 others: teams.filter((team) => team.viewer_role == null),
30 };
31}
32
33/** The teams `team` could go under: visible, not itself, and not one of its own children. */
34export function parentChoices<T extends Pick<Team, "slug" | "visibility" | "parent">>(teams: T[], team: string | null): T[] {
35 if (!team) return teams.filter((other) => other.visibility === "visible");
36 // Everything under `team`, by following parents.
37 const below = new Set<string>([team]);
38 let grew = true;
39 while (grew) {
40 grew = false;
41 for (const other of teams) {
42 if (other.parent && below.has(other.parent.slug) && !below.has(other.slug)) {
43 below.add(other.slug);
44 grew = true;
45 }
46 }
47 }
48 return teams.filter((other) => other.visibility === "visible" && !below.has(other.slug));
49}
50
51const text = (form: FormData, key: string) => String(form.get(key) ?? "").trim();
52
53function visibilityOf(value: string): TeamVisibility | undefined {
54 return value === "secret" || value === "visible" ? value : undefined;
55}
56
57/** A new team from the form on the New team page. */
58export function newTeamFromForm(form: FormData): NewTeam {
59 const members = text(form, "members")
60 .split(/[\s,]+/)
61 .map((name) => name.replace(/^@/, ""))
62 .filter(Boolean);
63 return {
64 name: text(form, "name"),
65 slug: text(form, "slug") || null,
66 description: text(form, "description") || null,
67 visibility: visibilityOf(text(form, "visibility")) ?? "visible",
68 parent: text(form, "parent") || null,
69 members,
70 };
71}
72
73/** What the team's settings form changes: only the fields it has. */
74export function teamChangesFromForm(form: FormData): TeamChanges {
75 const changes: TeamChanges = {};
76 if (form.has("name")) changes.name = text(form, "name");
77 if (form.has("slug")) changes.slug = text(form, "slug");
78 if (form.has("description")) changes.description = text(form, "description");
79 const visibility = visibilityOf(text(form, "visibility"));
80 if (visibility) changes.visibility = visibility;
81 // "" takes it out from under its parent.
82 if (form.has("parent")) changes.parent = text(form, "parent");
83 if (form.has("notify-shown")) changes.notify = form.get("notify") != null;
84 return changes;
85}
86
87function count(value: string, fallback: number, low: number, high: number): number {
88 const number = Number.parseInt(value, 10);
89 if (!Number.isFinite(number)) return fallback;
90 return Math.min(high, Math.max(low, number));
91}
92
93/** Review assignment from its form, within bounds; `current` fills what the form leaves out. */
94export function reviewAssignmentFromForm(form: FormData, current: ReviewAssignment): ReviewAssignment {
95 const algorithm = text(form, "algorithm");
96 return {
97 enabled: form.get("enabled") != null,
98 algorithm: (algorithm === "round_robin" || algorithm === "load_balance" ? algorithm : current.algorithm) as ReviewAlgorithm,
99 count: count(text(form, "count"), current.count, 1, MAX_ASSIGNED),
100 skip_busy: form.get("skip_busy") != null,
101 busy_at: count(text(form, "busy_at"), current.busy_at, 1, 100),
102 include_child_teams: form.get("include_child_teams") != null,
103 excluded: [
104 ...new Set(
105 text(form, "excluded")
106 .split(/[\s,]+/)
107 .map((name) => name.replace(/^@/, "").toLowerCase())
108 .filter(Boolean),
109 ),
110 ],
111 notify_team: form.get("notify_team") != null,
112 };
113}
114
115/** "3 members · 2 repositories · 1 child team", leaving out what is none. */
116export function teamCounts(team: Pick<Team, "members_count" | "repos_count" | "child_teams_count">): string {
117 const part = (n: number, one: string, many: string) => (n === 0 ? null : `${n} ${n === 1 ? one : many}`);
118 const parts = [
119 part(team.members_count, "member", "members"),
120 part(team.repos_count, "repository", "repositories"),
121 part(team.child_teams_count, "child team", "child teams"),
122 ].filter(Boolean);
123 return parts.length ? parts.join(" · ") : "No members yet";
124}