| 1 | /** |
| 2 | * The daily signal's reads: the work service's `workspaceSignals` for the |
| 3 | * workspace's repositories, a hundred at a time; the agents' usage for the |
| 4 | * weeks and for the quarter; and, for the Workspace overview, its teams. |
| 5 | * Each comes back on its own, so one that fails leaves only what needs it |
| 6 | * saying so. The shaping is in ./signals.ts. |
| 7 | */ |
| 8 | import type { Repo, RepoPath, Usage, User, WorkspaceSignals } from "@g1t/contracts"; |
| 9 | |
| 10 | import { billing, identity, work } from "./services.server"; |
| 11 | import { type Group, type WeekTotals, mergeSignals, signalsRange, weekStarts, weekTotals, weeklySpend } from "./signals"; |
| 12 | |
| 13 | /** Repositories asked of the work service at once: its limit. */ |
| 14 | const BATCH = 100; |
| 15 | /** Teams the rhythm reads the projects of. */ |
| 16 | const MAX_TEAMS = 8; |
| 17 | |
| 18 | export type SignalData = { |
| 19 | slug: string; |
| 20 | now: number; |
| 21 | range: ReturnType<typeof signalsRange>; |
| 22 | starts: string[]; |
| 23 | repos: Record<string, RepoPath>; |
| 24 | signals: WorkspaceSignals; |
| 25 | /** Every batch answered: otherwise the figures leave some projects out. */ |
| 26 | complete: boolean; |
| 27 | weeks: WeekTotals[]; |
| 28 | /** Usage per week; null when billing did not answer. */ |
| 29 | spend: number[] | null; |
| 30 | quarterUsage: Usage | null; |
| 31 | }; |
| 32 | |
| 33 | async function usageSince(viewer: User, slug: string, day: string): Promise<Usage | null> { |
| 34 | const result = await billing.usage(slug, viewer, `${day}T00:00:00.000Z`).catch(() => null); |
| 35 | return result?.ok ? result.value : null; |
| 36 | } |
| 37 | |
| 38 | /** |
| 39 | * What the workspace's work says, for a page drawn at `now`. Null when |
| 40 | * the work service answered none of the batches: the page then says so. |
| 41 | */ |
| 42 | export async function loadSignalData(viewer: User, slug: string, now: number, owned: Repo[] | null): Promise<SignalData | null> { |
| 43 | if (!owned) return null; |
| 44 | const range = signalsRange(now); |
| 45 | const starts = weekStarts(range.until, range.weeks); |
| 46 | const ids = owned.map((repo) => repo.id); |
| 47 | const repos: Record<string, RepoPath> = {}; |
| 48 | for (const repo of owned) repos[repo.id] = { namespace: repo.namespace, name: repo.name }; |
| 49 | const [batches, weekly, quarterUsage] = await Promise.all([ |
| 50 | Promise.allSettled( |
| 51 | Array.from({ length: Math.ceil(ids.length / BATCH) }, (_, i) => |
| 52 | work.workspaceSignals( |
| 53 | { workspace: slug, repoIds: ids.slice(i * BATCH, (i + 1) * BATCH), until: range.until, weeks: range.weeks, days: range.days, since: range.since }, |
| 54 | viewer, |
| 55 | ), |
| 56 | ), |
| 57 | ), |
| 58 | usageSince(viewer, slug, starts[0]!), |
| 59 | usageSince(viewer, slug, range.quarter), |
| 60 | ]); |
| 61 | const answered = batches.flatMap((batch) => (batch.status === "fulfilled" ? [batch.value] : [])); |
| 62 | for (const batch of batches) if (batch.status === "rejected") console.warn("g1t: workspace signals could not be read:", batch.reason); |
| 63 | if (ids.length > 0 && answered.length === 0) return null; |
| 64 | const signals = mergeSignals(answered); |
| 65 | return { |
| 66 | slug, |
| 67 | now, |
| 68 | range, |
| 69 | starts, |
| 70 | repos, |
| 71 | signals, |
| 72 | complete: answered.length === batches.length, |
| 73 | weeks: weekTotals(signals.weeks, starts), |
| 74 | spend: weeklySpend(weekly, starts), |
| 75 | quarterUsage, |
| 76 | }; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * What the rhythm's rows are: the workspace's teams by the projects they |
| 81 | * work on, with how many people each has; else, with no teams, the |
| 82 | * projects themselves. Null when identity did not answer. |
| 83 | */ |
| 84 | export async function loadGroups(viewer: User, slug: string, owned: Repo[]): Promise<{ groups: Group[]; by: "team" | "project" } | null> { |
| 85 | const projects = (): { groups: Group[]; by: "project" } => ({ |
| 86 | by: "project", |
| 87 | groups: owned.map((repo) => ({ key: repo.id, label: repo.name, note: repo.namespace, to: `/${repo.namespace}/${repo.name}`, repoIds: [repo.id] })), |
| 88 | }); |
| 89 | const listed = await identity.listTeams(viewer, slug).catch(() => null); |
| 90 | if (!listed) return null; |
| 91 | if (!listed.ok || listed.value.length === 0) return projects(); |
| 92 | const teams = listed.value.slice(0, MAX_TEAMS); |
| 93 | const [repos, memberships] = await Promise.all([ |
| 94 | Promise.all(teams.map((team) => identity.teamRepos(viewer, slug, team.slug).catch(() => null))), |
| 95 | identity.teamMemberships(viewer, slug).catch(() => null), |
| 96 | ]); |
| 97 | const people = new Map<string, number>(); |
| 98 | if (memberships?.ok) for (const member of memberships.value) for (const team of member.teams) people.set(team.slug, (people.get(team.slug) ?? 0) + 1); |
| 99 | const groups = teams.flatMap((team, i) => { |
| 100 | const found = repos[i]; |
| 101 | if (!found?.ok || found.value.length === 0) return []; |
| 102 | const count = people.get(team.slug); |
| 103 | return [ |
| 104 | { |
| 105 | key: team.slug, |
| 106 | label: team.name, |
| 107 | note: count == null ? "" : `${count} ${count === 1 ? "person" : "people"}`, |
| 108 | to: `/${slug}/-/teams/${team.slug}`, |
| 109 | repoIds: found.value.map((repo) => repo.repo_id), |
| 110 | }, |
| 111 | ]; |
| 112 | }); |
| 113 | return groups.length > 0 ? { by: "team", groups } : projects(); |
| 114 | } |