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.

114 linesCodeBlameRaw
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 */
8import type { Repo, RepoPath, Usage, User, WorkspaceSignals } from "@g1t/contracts";
9
10import { billing, identity, work } from "./services.server";
11import { type Group, type WeekTotals, mergeSignals, signalsRange, weekStarts, weekTotals, weeklySpend } from "./signals";
12
13/** Repositories asked of the work service at once: its limit. */
14const BATCH = 100;
15/** Teams the rhythm reads the projects of. */
16const MAX_TEAMS = 8;
17
18export 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
33async 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 */
42export 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 */
84export 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}