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.

g1t.sh
390 linesCodeBlameRaw
1/**
2 * Home's reads: each section's data from its own service, every call on
3 * its own so one that fails leaves only its section saying so. The shaping
4 * is in ./home.ts.
5 */
6import { env } from "cloudflare:workers";
7
8import {
9 type ActivityDigest,
10 type ChatActivity,
11 type Commit,
12 type Deployment,
13 type G1tEvent,
14 type InboxItem,
15 MAX_DIGEST_REPOS,
16 type ProjectDeploys,
17 type PullSessions,
18 type Repo,
19 type RepoPath,
20 type ShippedRepo,
21 type User,
22 type WorkflowRun,
23 notifyClient,
24} from "@g1t/contracts";
25
26import {
27 type CodePull,
28 type CodeWork,
29 type DirectPush,
30 G1T_ACTOR,
31 type LandedChange,
32 type RepoRef,
33 type Span,
34 type Spend,
35 deployActorKey,
36 shippedAsks,
37 spanMonths,
38 spendIn,
39} from "./home";
40import { monthSpan } from "./usage";
41import { actions, billing, chat, deployments, events, identity, inbox, repos, work } from "./services.server";
42
43/** Pull requests read per project and list: `LIST_PAGE` in services/work. */
44const PULL_PAGE = 100;
45/** The most projects whose pull requests are read. */
46const MAX_PROJECTS = 50;
47/** The most projects whose builds are read for the span. */
48const MAX_DEPLOY_PROJECTS = 20;
49/** The most projects whose workflow runs are read for Running now: those pushed to most in the span. */
50const MAX_WORKFLOW_PROJECTS = 10;
51/** Runs read per project: enough to find the ones not finished. */
52const WORKFLOW_RUNS = 20;
53/** The most member keys named in one lookup. */
54const MAX_NAMES = 200;
55const DAY = 24 * 60 * 60 * 1000;
56
57const warn = (what: string) => (error: unknown) => {
58 console.warn(`home: ${what} failed`, error);
59 return null;
60};
61
62/**
63 * When the viewer was last on this workspace's Home, from notify, where it
64 * is kept with them (the same on every device). Null when there is no
65 * visit on record; undefined when notify did not answer.
66 */
67export async function loadLastVisit(viewer: User, slug: string): Promise<number | null | undefined> {
68 if (!env.NOTIFY) return undefined;
69 const visit = await notifyClient(env.NOTIFY).lastVisit(viewer, slug).catch(warn("last visit"));
70 if (!visit) return undefined;
71 return visit.seen_at ? Date.parse(visit.seen_at) : null;
72}
73
74/** Marks the viewer's visit at `at`, the time the page they looked at loaded. */
75export async function markVisit(viewer: User, slug: string, at: number): Promise<boolean> {
76 if (!env.NOTIFY) return false;
77 const marked = await notifyClient(env.NOTIFY).markVisit(viewer, slug, new Date(at).toISOString()).catch(warn("mark visit"));
78 return marked != null;
79}
80
81/**
82 * The workspace's own repositories the viewer may read (no pull request
83 * working copies), as every Code section reads them. Null when repos did
84 * not answer.
85 */
86export async function loadRepos(viewer: User, slug: string): Promise<Repo[] | null> {
87 const list = await repos.list(viewer, { namespace: slug }).catch(warn("repos"));
88 return list ? list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug) : null;
89}
90
91/** The repositories by id, as the digest's lines and links need them. */
92export function repoRefs(list: Repo[] | null): Record<string, RepoRef> {
93 const refs: Record<string, RepoRef> = {};
94 for (const repo of list ?? []) refs[repo.id] = { namespace: repo.namespace, name: repo.name, defaultBranch: repo.defaultBranch };
95 return refs;
96}
97
98/**
99 * The workspace's projects' pull requests, open and recently merged or
100 * closed, any author's. `complete` when every list reaches back to
101 * `reach`. Null when Code did not answer.
102 */
103export async function loadCodeWork(viewer: User, slug: string, reach: number, owned: Repo[] | null): Promise<CodeWork | null> {
104 if (!owned) return null;
105 const chosen = owned.slice(0, MAX_PROJECTS);
106 const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo]));
107 const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : [];
108 if (!batch) return null;
109 const pulls: CodePull[] = [];
110 let complete = owned.length <= MAX_PROJECTS;
111 for (const entry of batch) {
112 const repo = byId.get(entry.repoId);
113 if (!repo) continue;
114 const path = { namespace: repo.namespace, name: repo.name };
115 for (const pull of [...entry.open, ...entry.closed]) {
116 pulls.push({
117 repo: path,
118 repoId: repo.id,
119 mergeCommit: pull.mergeCommit ?? null,
120 number: pull.number,
121 title: pull.title,
122 status: pull.status,
123 mergedAt: pull.mergedAt,
124 mergedBy: pull.mergedBy,
125 createdAt: pull.createdAt,
126 updatedAt: pull.updatedAt,
127 author: { username: pull.author.username, kind: pull.author.kind },
128 });
129 }
130 // A full page that does not reach back far enough may leave some out.
131 for (const list of [entry.open, entry.closed]) {
132 if (list.length >= PULL_PAGE) {
133 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)));
134 if (oldest > reach) complete = false;
135 }
136 }
137 }
138 return { pulls, complete };
139}
140
141/**
142 * What happened in the span, counted by who did it: the events service's
143 * digest of the workspace's repositories (the first MAX_DIGEST_REPOS of
144 * them; none without Code access) and of its artifacts. Null when events
145 * did not answer.
146 */
147export async function loadActivity(slug: string, span: Pick<Span, "from" | "now">, owned: Repo[] | null): Promise<ActivityDigest | null> {
148 const digest = await events
149 .activityDigest({
150 repo_ids: (owned ?? []).slice(0, MAX_DIGEST_REPOS).map((repo) => repo.id),
151 workspace: slug,
152 from: new Date(span.from).toISOString(),
153 until: new Date(span.now + 1).toISOString(),
154 })
155 .catch(warn("activity digest"));
156 if (!digest) return null;
157 // More repositories than the digest reads: it says so.
158 return owned && owned.length > MAX_DIGEST_REPOS ? { ...digest, complete: false } : digest;
159}
160
161/** What was said in the span where the viewer can read, counted. Null when chat did not answer. */
162export async function loadChatActivity(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<ChatActivity | null> {
163 const activity = await chat
164 .activity(slug, viewer, { from: new Date(span.from).toISOString(), until: new Date(span.now + 1).toISOString() })
165 .catch(warn("chat activity"));
166 return activity?.ok ? activity.value : null;
167}
168
169/** Every member key the digest and chat name. */
170export function actorKeys(activity: ActivityDigest | null, chatActivity: ChatActivity | null, deploys?: { projects: { deployments: { createdBy: string }[] }[] } | null): string[] {
171 const keys = new Set<string>();
172 for (const project of deploys?.projects ?? []) for (const deploy of project.deployments) keys.add(deployActorKey(deploy.createdBy) ?? "");
173 for (const repo of activity?.repos ?? []) {
174 for (const by of [...repo.pushes.by, ...repo.pushes.default_branch.by]) keys.add(by.actor);
175 for (const list of [repo.pulls.opened, repo.pulls.merged, repo.pulls.closed, repo.issues.opened, repo.issues.closed, repo.reviews, repo.comments, repo.deployments.succeeded, repo.deployments.failed]) {
176 for (const entry of list) keys.add(entry.actor);
177 }
178 for (const release of repo.releases) keys.add(release.actor);
179 for (const pkg of repo.packages) keys.add(pkg.actor);
180 }
181 for (const entry of activity?.folios?.created ?? []) keys.add(entry.actor);
182 for (const folio of activity?.folios?.edited ?? []) for (const author of folio.authors) keys.add(author);
183 for (const author of chatActivity?.authors ?? []) keys.add(author.key);
184 keys.delete("");
185 return [...keys];
186}
187
188/**
189 * The names behind member keys: people's usernames from identity (one
190 * lookup for them all), g1t as itself. A key identity does not know, and
191 * an agent of older rows, is left out, and shows as "someone".
192 */
193export async function loadNames(keys: string[]): Promise<Record<string, string>> {
194 const names: Record<string, string> = {};
195 const userIds: string[] = [];
196 for (const key of keys.slice(0, MAX_NAMES)) {
197 if (key === G1T_ACTOR || key === "user:g1t" || key.startsWith("user:wsp_")) names[key] = "g1t";
198 else if (key.startsWith("agent:") || key.startsWith("user:agt_")) continue;
199 else if (key.startsWith("user:")) userIds.push(key.slice("user:".length));
200 }
201 if (userIds.length > 0) {
202 const found = await identity.usernames(userIds).catch(warn("usernames"));
203 for (const [id, username] of Object.entries(found ?? {})) names[`user:${id}`] = username;
204 }
205 return names;
206}
207
208/**
209 * The newest workflow runs of the projects pushed to most in the span (at
210 * most MAX_WORKFLOW_PROJECTS), for the ones still going. A project whose
211 * runs could not be read is left out; `complete` says whether every one was.
212 */
213export async function loadWorkflowRuns(
214 viewer: User,
215 activity: ActivityDigest | null,
216 owned: Repo[] | null,
217): Promise<{ projects: { repo: RepoPath; runs: WorkflowRun[] }[]; complete: boolean }> {
218 const byId = new Map((owned ?? []).map((repo) => [repo.id, repo]));
219 const active = [...(activity?.repos ?? [])]
220 .filter((repo) => repo.pushes.count > 0 && byId.has(repo.repo_id))
221 .sort((a, b) => b.pushes.count - a.pushes.count || a.repo_id.localeCompare(b.repo_id))
222 .slice(0, MAX_WORKFLOW_PROJECTS);
223 const lists = await Promise.all(
224 active.map((digest) => {
225 const repo = byId.get(digest.repo_id)!;
226 const path = { namespace: repo.namespace, name: repo.name };
227 return actions
228 .runs(path, viewer, { limit: WORKFLOW_RUNS })
229 .then((result) => (result.ok ? { repo: path, runs: result.value } : null))
230 .catch(warn(`workflow runs of ${repo.name}`));
231 }),
232 );
233 const projects = lists.filter((entry) => entry != null);
234 return { projects, complete: projects.length === active.length };
235}
236
237/**
238 * Every project's builds at a glance, and the builds of the projects that
239 * deploy, for the span: the projects whose newest build is older than the
240 * span are not read again.
241 */
242export async function loadDeploys(
243 viewer: User,
244 slug: string,
245 from: number,
246): Promise<{ overview: ProjectDeploys[]; projects: { slug: string; deployments: Deployment[] }[]; complete: boolean } | null> {
247 const overview = await deployments.overview(slug, viewer).catch(warn("deploys overview"));
248 if (!overview?.ok) return null;
249 const recent = overview.value.filter((project) => project.latest && Date.parse(project.latest.finishedAt ?? project.latest.createdAt) >= from);
250 const read = recent.slice(0, MAX_DEPLOY_PROJECTS);
251 const lists = await Promise.all(
252 read.map((project) =>
253 deployments
254 .list({ workspace: slug, slug: project.slug }, viewer)
255 .then((result) => (result.ok ? { slug: project.slug, deployments: result.value.deployments } : null))
256 .catch(warn(`deploys of ${project.slug}`)),
257 ),
258 );
259 const projects = lists.filter((entry) => entry != null);
260 return { overview: overview.value, projects, complete: projects.length === recent.length };
261}
262
263/**
264 * The span's spend from the statements of the months it touches, and the
265 * month so far from the usage report Usage reads (`usage.ts` `monthSpan`,
266 * at price), so Home's figure is Usage's. Null
267 * when billing did not answer; the month alone null when only it did not.
268 */
269export async function loadSpend(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<Spend | null> {
270 const months = spanMonths(span);
271 const [statements, month] = await Promise.all([
272 Promise.all(months.map((month) => billing.statement(slug, viewer, month, "day").catch(warn(`statement ${month}`)))),
273 billing
274 .usageReport(slug, viewer, monthSpan(new Date(span.now)))
275 .then((result) => (result.ok ? result.value.totals.priceMicros : null))
276 .catch(warn("month usage")),
277 ]);
278 if (statements.some((statement) => !statement?.ok)) return null;
279 return spendIn(
280 statements.map((statement) => (statement as Extract<typeof statement, { ok: true }>).value),
281 span,
282 month,
283 );
284}
285
286/** The most projects whose pushes to the default branch are listed one by one, pushes read per project, and commits read back. */
287const MAX_PUSH_PROJECTS = 10;
288const PUSHES_READ = 100;
289const PUSH_HISTORY = 200;
290
291/**
292 * Each person's push to a default branch in the span, for the projects
293 * the digest says were pushed to (those pushed to most, at most
294 * MAX_PUSH_PROJECTS), with each project's default branch read back from
295 * its newest push to place each push's commits. `by` is the pusher's
296 * member key, named by the caller. Null when events did not answer.
297 */
298export async function loadDirectPushes(
299 viewer: User,
300 activity: ActivityDigest | null,
301 owned: Repo[] | null,
302 span: Pick<Span, "from" | "now">,
303): Promise<{ pushes: (Omit<DirectPush, "by"> & { actorKey: string })[]; history: Record<string, Commit[]>; complete: boolean } | null> {
304 if (!activity || !owned) return null;
305 const byId = new Map(owned.map((repo) => [repo.id, repo]));
306 const pushedTo = activity.repos
307 .filter((digest) => digest.pushes.default_branch.count > 0 && byId.has(digest.repo_id))
308 .sort((a, b) => b.pushes.default_branch.count - a.pushes.default_branch.count || a.repo_id.localeCompare(b.repo_id));
309 const read = pushedTo.slice(0, MAX_PUSH_PROJECTS);
310 const lists = await Promise.all(
311 read.map(async (digest) => {
312 const repo = byId.get(digest.repo_id)!;
313 const log = await events.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ }).catch(warn(`pushes of ${repo.name}`));
314 if (!log) return null;
315 const pushes = log.filter(
316 (event): event is G1tEvent<"git.push"> =>
317 event.type === "git.push" && (event as G1tEvent<"git.push">).data.defaultBranch && Date.parse(event.time) >= span.from && Date.parse(event.time) <= span.now,
318 );
319 if (pushes.length === 0) return { repo, pushes, history: [] as Commit[] };
320 const path = { namespace: repo.namespace, name: repo.name };
321 const history = await repos.log(path, viewer, pushes[0]!.data.after, PUSH_HISTORY).catch(warn(`history of ${repo.name}`));
322 return { repo, pushes, history: history?.ok ? history.value : [] };
323 }),
324 );
325 const pushes: (Omit<DirectPush, "by"> & { actorKey: string })[] = [];
326 const history: Record<string, Commit[]> = {};
327 for (const entry of lists) {
328 if (!entry) continue;
329 history[entry.repo.id] = entry.history;
330 const ref: RepoRef = { namespace: entry.repo.namespace, name: entry.repo.name, defaultBranch: entry.repo.defaultBranch };
331 for (const event of entry.pushes) {
332 pushes.push({
333 repoId: entry.repo.id,
334 repo: ref,
335 push: { id: event.id, time: event.time, actor: event.actor ?? null, data: event.data },
336 actorKey: event.actor ? deployActorKey(event.actor) ?? "" : "",
337 });
338 }
339 }
340 return { pushes, history, complete: lists.every((entry) => entry != null) && pushedTo.length <= MAX_PUSH_PROJECTS };
341}
342
343/**
344 * Whether each merge and push that landed went live, from deployments'
345 * record of what each production deployment shipped. Null when
346 * deployments did not answer.
347 */
348export async function loadShipped(viewer: User, slug: string, rows: LandedChange[]): Promise<ShippedRepo[] | null> {
349 const asks = shippedAsks(rows).slice(0, 20);
350 if (asks.length === 0) return [];
351 const answer = await deployments.shipped(slug, viewer, asks).catch(warn("shipped"));
352 return answer?.ok ? answer.value : null;
353}
354
355/** The harness sessions tied to each merged pull request, per project; null when work did not answer for any. */
356export async function loadPullSessions(viewer: User, pulls: CodePull[]): Promise<{ repo: RepoPath; pulls: PullSessions[] }[] | null> {
357 const byRepo = new Map<string, { repo: RepoPath; numbers: number[] }>();
358 for (const pull of pulls) {
359 if (pull.status !== "merged") continue;
360 const key = `${pull.repo.namespace}/${pull.repo.name}`.toLowerCase();
361 const entry = byRepo.get(key) ?? { repo: pull.repo, numbers: [] };
362 entry.numbers.push(pull.number);
363 byRepo.set(key, entry);
364 }
365 if (byRepo.size === 0) return [];
366 const lists = await Promise.all(
367 [...byRepo.values()].slice(0, MAX_PUSH_PROJECTS).map(({ repo, numbers }) =>
368 work
369 .pullSessions(repo, viewer, numbers.slice(0, 100))
370 .then((result) => (result.ok ? { repo, pulls: result.value } : null))
371 .catch(warn(`sessions of ${repo.name}`)),
372 ),
373 );
374 const found = lists.filter((entry) => entry != null);
375 return found.length === 0 && lists.length > 0 ? null : found;
376}
377
378/** Notifications read for what settled by itself in the span. */
379const SETTLED_READ = 100;
380
381/** The viewer's notifications done since the span began, newest activity first; null when the inbox did not answer. */
382export async function loadSettled(viewer: User, span: Pick<Span, "from">): Promise<InboxItem[] | null> {
383 const page = await inbox.list(viewer, { view: "done", limit: SETTLED_READ }).catch(warn("settled notifications"));
384 return page ? page.items.filter((item) => item.doneAt != null && Date.parse(item.doneAt) >= span.from) : null;
385}
386
387/** How far back Code is read: 7 days before the span, for the comparison. */
388export function reachFor(span: Pick<Span, "from">): number {
389 return span.from - 7 * DAY;
390}