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
269 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 Deployment,
12 MAX_DIGEST_REPOS,
13 type ProjectDeploys,
14 type Repo,
15 type RepoPath,
16 type User,
17 type WorkflowRun,
18 notifyClient,
19} from "@g1t/contracts";
20
21import { type CodePull, type CodeWork, G1T_ACTOR, type RepoRef, type Span, type Spend, deployActorKey, spanMonths, spendIn } from "./home";
22import { monthSpan } from "./usage";
23import { actions, billing, chat, deployments, events, identity, repos, work } from "./services.server";
24
25/** Pull requests read per project and list: `LIST_PAGE` in services/work. */
26const PULL_PAGE = 100;
27/** The most projects whose pull requests are read. */
28const MAX_PROJECTS = 50;
29/** The most projects whose builds are read for the span. */
30const MAX_DEPLOY_PROJECTS = 20;
31/** The most projects whose workflow runs are read for Running now: those pushed to most in the span. */
32const MAX_WORKFLOW_PROJECTS = 10;
33/** Runs read per project: enough to find the ones not finished. */
34const WORKFLOW_RUNS = 20;
35/** The most member keys named in one lookup. */
36const MAX_NAMES = 200;
37const DAY = 24 * 60 * 60 * 1000;
38
39const warn = (what: string) => (error: unknown) => {
40 console.warn(`home: ${what} failed`, error);
41 return null;
42};
43
44/**
45 * When the viewer was last on this workspace's Home, from notify, where it
46 * is kept with them (the same on every device). Null when there is no
47 * visit on record; undefined when notify did not answer.
48 */
49export async function loadLastVisit(viewer: User, slug: string): Promise<number | null | undefined> {
50 if (!env.NOTIFY) return undefined;
51 const visit = await notifyClient(env.NOTIFY).lastVisit(viewer, slug).catch(warn("last visit"));
52 if (!visit) return undefined;
53 return visit.seen_at ? Date.parse(visit.seen_at) : null;
54}
55
56/** Marks the viewer's visit at `at`, the time the page they looked at loaded. */
57export async function markVisit(viewer: User, slug: string, at: number): Promise<boolean> {
58 if (!env.NOTIFY) return false;
59 const marked = await notifyClient(env.NOTIFY).markVisit(viewer, slug, new Date(at).toISOString()).catch(warn("mark visit"));
60 return marked != null;
61}
62
63/**
64 * The workspace's own repositories the viewer may read (no pull request
65 * working copies), as every Code section reads them. Null when repos did
66 * not answer.
67 */
68export async function loadRepos(viewer: User, slug: string): Promise<Repo[] | null> {
69 const list = await repos.list(viewer, { namespace: slug }).catch(warn("repos"));
70 return list ? list.filter((repo) => !repo.forkOf && repo.namespace.toLowerCase() === slug) : null;
71}
72
73/** The repositories by id, as the digest's lines and links need them. */
74export function repoRefs(list: Repo[] | null): Record<string, RepoRef> {
75 const refs: Record<string, RepoRef> = {};
76 for (const repo of list ?? []) refs[repo.id] = { namespace: repo.namespace, name: repo.name, defaultBranch: repo.defaultBranch };
77 return refs;
78}
79
80/**
81 * The workspace's projects' pull requests, open and recently merged or
82 * closed, any author's. `complete` when every list reaches back to
83 * `reach`. Null when Code did not answer.
84 */
85export async function loadCodeWork(viewer: User, slug: string, reach: number, owned: Repo[] | null): Promise<CodeWork | null> {
86 if (!owned) return null;
87 const chosen = owned.slice(0, MAX_PROJECTS);
88 const byId = new Map<string, Repo>(chosen.map((repo) => [repo.id, repo]));
89 const batch = chosen.length > 0 ? await work.pullsForRepos([...byId.keys()], viewer, PULL_PAGE).catch(warn("pull requests")) : [];
90 if (!batch) return null;
91 const pulls: CodePull[] = [];
92 let complete = owned.length <= MAX_PROJECTS;
93 for (const entry of batch) {
94 const repo = byId.get(entry.repoId);
95 if (!repo) continue;
96 const path = { namespace: repo.namespace, name: repo.name };
97 for (const pull of [...entry.open, ...entry.closed]) {
98 pulls.push({
99 repo: path,
100 number: pull.number,
101 title: pull.title,
102 status: pull.status,
103 mergedAt: pull.mergedAt,
104 mergedBy: pull.mergedBy,
105 createdAt: pull.createdAt,
106 updatedAt: pull.updatedAt,
107 author: { username: pull.author.username, kind: pull.author.kind },
108 });
109 }
110 // A full page that does not reach back far enough may leave some out.
111 for (const list of [entry.open, entry.closed]) {
112 if (list.length >= PULL_PAGE) {
113 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)));
114 if (oldest > reach) complete = false;
115 }
116 }
117 }
118 return { pulls, complete };
119}
120
121/**
122 * What happened in the span, counted by who did it: the events service's
123 * digest of the workspace's repositories (the first MAX_DIGEST_REPOS of
124 * them; none without Code access) and of its artifacts. Null when events
125 * did not answer.
126 */
127export async function loadActivity(slug: string, span: Pick<Span, "from" | "now">, owned: Repo[] | null): Promise<ActivityDigest | null> {
128 const digest = await events
129 .activityDigest({
130 repo_ids: (owned ?? []).slice(0, MAX_DIGEST_REPOS).map((repo) => repo.id),
131 workspace: slug,
132 from: new Date(span.from).toISOString(),
133 until: new Date(span.now + 1).toISOString(),
134 })
135 .catch(warn("activity digest"));
136 if (!digest) return null;
137 // More repositories than the digest reads: it says so.
138 return owned && owned.length > MAX_DIGEST_REPOS ? { ...digest, complete: false } : digest;
139}
140
141/** What was said in the span where the viewer can read, counted. Null when chat did not answer. */
142export async function loadChatActivity(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<ChatActivity | null> {
143 const activity = await chat
144 .activity(slug, viewer, { from: new Date(span.from).toISOString(), until: new Date(span.now + 1).toISOString() })
145 .catch(warn("chat activity"));
146 return activity?.ok ? activity.value : null;
147}
148
149/** Every member key the digest and chat name. */
150export function actorKeys(activity: ActivityDigest | null, chatActivity: ChatActivity | null, deploys?: { projects: { deployments: { createdBy: string }[] }[] } | null): string[] {
151 const keys = new Set<string>();
152 for (const project of deploys?.projects ?? []) for (const deploy of project.deployments) keys.add(deployActorKey(deploy.createdBy) ?? "");
153 for (const repo of activity?.repos ?? []) {
154 for (const by of [...repo.pushes.by, ...repo.pushes.default_branch.by]) keys.add(by.actor);
155 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]) {
156 for (const entry of list) keys.add(entry.actor);
157 }
158 for (const release of repo.releases) keys.add(release.actor);
159 for (const pkg of repo.packages) keys.add(pkg.actor);
160 }
161 for (const entry of activity?.folios?.created ?? []) keys.add(entry.actor);
162 for (const folio of activity?.folios?.edited ?? []) for (const author of folio.authors) keys.add(author);
163 for (const author of chatActivity?.authors ?? []) keys.add(author.key);
164 keys.delete("");
165 return [...keys];
166}
167
168/**
169 * The names behind member keys: people's usernames from identity (one
170 * lookup for them all), g1t as itself. A key identity does not know, and
171 * an agent of older rows, is left out, and shows as "someone".
172 */
173export async function loadNames(keys: string[]): Promise<Record<string, string>> {
174 const names: Record<string, string> = {};
175 const userIds: string[] = [];
176 for (const key of keys.slice(0, MAX_NAMES)) {
177 if (key === G1T_ACTOR || key === "user:g1t" || key.startsWith("user:wsp_")) names[key] = "g1t";
178 else if (key.startsWith("agent:") || key.startsWith("user:agt_")) continue;
179 else if (key.startsWith("user:")) userIds.push(key.slice("user:".length));
180 }
181 if (userIds.length > 0) {
182 const found = await identity.usernames(userIds).catch(warn("usernames"));
183 for (const [id, username] of Object.entries(found ?? {})) names[`user:${id}`] = username;
184 }
185 return names;
186}
187
188/**
189 * The newest workflow runs of the projects pushed to most in the span (at
190 * most MAX_WORKFLOW_PROJECTS), for the ones still going. A project whose
191 * runs could not be read is left out; `complete` says whether every one was.
192 */
193export async function loadWorkflowRuns(
194 viewer: User,
195 activity: ActivityDigest | null,
196 owned: Repo[] | null,
197): Promise<{ projects: { repo: RepoPath; runs: WorkflowRun[] }[]; complete: boolean }> {
198 const byId = new Map((owned ?? []).map((repo) => [repo.id, repo]));
199 const active = [...(activity?.repos ?? [])]
200 .filter((repo) => repo.pushes.count > 0 && byId.has(repo.repo_id))
201 .sort((a, b) => b.pushes.count - a.pushes.count || a.repo_id.localeCompare(b.repo_id))
202 .slice(0, MAX_WORKFLOW_PROJECTS);
203 const lists = await Promise.all(
204 active.map((digest) => {
205 const repo = byId.get(digest.repo_id)!;
206 const path = { namespace: repo.namespace, name: repo.name };
207 return actions
208 .runs(path, viewer, { limit: WORKFLOW_RUNS })
209 .then((result) => (result.ok ? { repo: path, runs: result.value } : null))
210 .catch(warn(`workflow runs of ${repo.name}`));
211 }),
212 );
213 const projects = lists.filter((entry) => entry != null);
214 return { projects, complete: projects.length === active.length };
215}
216
217/**
218 * Every project's builds at a glance, and the builds of the projects that
219 * deploy, for the span: the projects whose newest build is older than the
220 * span are not read again.
221 */
222export async function loadDeploys(
223 viewer: User,
224 slug: string,
225 from: number,
226): Promise<{ overview: ProjectDeploys[]; projects: { slug: string; deployments: Deployment[] }[]; complete: boolean } | null> {
227 const overview = await deployments.overview(slug, viewer).catch(warn("deploys overview"));
228 if (!overview?.ok) return null;
229 const recent = overview.value.filter((project) => project.latest && Date.parse(project.latest.finishedAt ?? project.latest.createdAt) >= from);
230 const read = recent.slice(0, MAX_DEPLOY_PROJECTS);
231 const lists = await Promise.all(
232 read.map((project) =>
233 deployments
234 .list({ workspace: slug, slug: project.slug }, viewer)
235 .then((result) => (result.ok ? { slug: project.slug, deployments: result.value.deployments } : null))
236 .catch(warn(`deploys of ${project.slug}`)),
237 ),
238 );
239 const projects = lists.filter((entry) => entry != null);
240 return { overview: overview.value, projects, complete: projects.length === recent.length };
241}
242
243/**
244 * The span's spend from the statements of the months it touches, and the
245 * month so far from the usage report Usage reads (`usage.ts` `monthSpan`,
246 * at price), so Home's figure is Usage's. Null
247 * when billing did not answer; the month alone null when only it did not.
248 */
249export async function loadSpend(viewer: User, slug: string, span: Pick<Span, "from" | "now">): Promise<Spend | null> {
250 const months = spanMonths(span);
251 const [statements, month] = await Promise.all([
252 Promise.all(months.map((month) => billing.statement(slug, viewer, month, "day").catch(warn(`statement ${month}`)))),
253 billing
254 .usageReport(slug, viewer, monthSpan(new Date(span.now)))
255 .then((result) => (result.ok ? result.value.totals.priceMicros : null))
256 .catch(warn("month usage")),
257 ]);
258 if (statements.some((statement) => !statement?.ok)) return null;
259 return spendIn(
260 statements.map((statement) => (statement as Extract<typeof statement, { ok: true }>).value),
261 span,
262 month,
263 );
264}
265
266/** How far back Code is read: 7 days before the span, for the comparison. */
267export function reachFor(span: Pick<Span, "from">): number {
268 return span.from - 7 * DAY;
269}