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.

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