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.

492 linesCodeBlameRaw
1/**
2 * Code's Overview data: what needs the viewer, what landed, and the week,
3 * across a workspace's projects. Code's Overview
4 * (routes/workspace/code-overview.tsx) shows all of it; Home
5 * (routes/workspace/home.tsx) takes what needs you from it.
6 */
7import { type G1tEvent, type Pull, type Repo, type RepoPath, type User, type Viewer, REPO_ROLE_LABELS, workOwner } from "@g1t/contracts";
8
9import { WORKSPACE_COOKIE, chosenWorkspace } from "./workspace-choice";
10import {
11 type ActivityItem,
12 type Need,
13 TIME,
14 actorIds,
15 eventItem,
16 FEED_EVENT_TYPES,
17 groupActivity,
18 nameActor,
19 rankNeeds,
20 readCookie,
21 waitedFor,
22} from "./mission";
23import { G1T_COMMIT_EMAILS, normalizeEmail } from "./commit-people";
24import {
25 type Fact,
26 type Merged,
27 type NeedRow,
28 type QuickAction,
29 dateLine,
30 dayKey,
31 historyCovers,
32 landedToday,
33 placePushes,
34 pullFacts,
35 reachesBack,
36 reasonFor,
37 summaryLine,
38 weekOf,
39 who,
40 whyFor,
41} from "./mission-control";
42import { needsYou } from "./inbox";
43import { deployments, events as eventLog, identity, inbox, projects as projectsApi, repos as reposApi, work } from "./services.server";
44import { itemPath } from "./refs";
45import { roleIn } from "./session.server";
46import { runners } from "./runners.server";
47import { readableRepos } from "./access.server";
48
49/** The viewer's time zone, which the page sets, so the date fits their day. */
50const TZ_COOKIE = "g1t_tz";
51/** The most projects whose pull requests and events are read for the page. */
52const MAX_PROJECTS = 10;
53const EVENTS_PER_PROJECT = 80;
54/** How many pull requests work lists at once (`LIST_PAGE` in services/work). */
55const PULL_PAGE = 100;
56/** Unread inbox items read for the Needs you card, and how many it shows. */
57const INBOX_READ = 50;
58const INBOX_SHOWN = 4;
59
60/** Where a need came from, so its row can say what is known about it. */
61type Extra = Partial<Pick<NeedRow, "repo" | "ref" | "by" | "facts" | "quick" | "link" | "open">>;
62
63/**
64 * Push events read per project, a page at a time, back two weeks. Pushes
65 * to every branch are in the log, so a fixed count lost a busy week's
66 * earlier days to other branches.
67 */
68const PUSH_PAGE = 200;
69const PUSH_PAGES = 5;
70/** The first page is smaller: most projects push far less in two weeks. */
71const FIRST_PUSH_PAGE = 50;
72/**
73 * Commits of the default branch read to place each push's commits: a
74 * short read first, which covers most projects' fortnight, and the longer
75 * one only when it does not reach the oldest push.
76 */
77const HISTORY_FIRST = 200;
78const HISTORY_READ = 1000;
79
80/**
81 * The commits people pushed straight to a project's default branch in the
82 * last two weeks, each with when its push landed. Merges and g1t's own
83 * commits are left out: pull requests count those.
84 */
85async function directCommits(repo: Repo, viewer: Viewer): Promise<{ hash: string; at: string }[]> {
86 const since = Date.now() - 14 * TIME.DAY;
87 const pushes: G1tEvent<"git.push">[] = [];
88 let before: string | undefined;
89 for (let page = 0; page < PUSH_PAGES; page++) {
90 const limit = page === 0 ? FIRST_PUSH_PAGE : PUSH_PAGE;
91 const batch = await eventLog.list({ repoId: repo.id, types: ["git.push"], limit, ...(before ? { before } : {}) });
92 for (const event of batch) {
93 if (event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since) pushes.push(event as G1tEvent<"git.push">);
94 }
95 const oldest = batch.at(-1);
96 if (batch.length < limit || !oldest || Date.parse(oldest.time) < since) break;
97 before = oldest.id;
98 }
99 if (pushes.length === 0) return [];
100 // A read of the branch from the newest push back; each push brought
101 // what lies between its `after` and its `before` in that history.
102 const path = { namespace: repo.namespace, name: repo.name };
103 const read = (limit: number) => reposApi.log(path, viewer, pushes[0].data.after, limit).catch(() => null);
104 let history = await read(HISTORY_FIRST);
105 if (history?.ok && !historyCovers(history.value, pushes, HISTORY_FIRST)) history = await read(HISTORY_READ);
106 if (!history?.ok) return [];
107 // g1t's own commits are told by their author address, never by name.
108 return placePushes(history.value, pushes, (commit) => G1T_COMMIT_EMAILS.has(normalizeEmail(commit.author.email)));
109}
110
111export async function loadMissionControl(viewer: User, request: Request, workspace: string | null) {
112 const now = Date.now();
113 const started = Date.now();
114 const times: Record<string, number> = {};
115 // Each call is timed, and one that fails leaves its section empty rather
116 // than taking the page down.
117 const soft = <T>(name: string, promise: Promise<T>): Promise<T | null> =>
118 promise.then(
119 (value) => ((times[name] = Date.now() - started), value),
120 (error) => {
121 console.warn(`overview: ${name} failed`, error);
122 times[name] = Date.now() - started;
123 return null;
124 },
125 );
126 const okOr = <T>(result: { ok: true; value: T } | { ok: false } | null): T | null => (result?.ok ? result.value : null);
127
128 const cookies = request.headers.get("cookie");
129 const tz = readCookie(cookies, TZ_COOKIE);
130 const memberships = viewer.workspaces ?? [];
131 // The workspace asked for, else the one you chose, as the sidebar shows it (lib/workspace-choice.ts).
132 const slug =
133 (workspace && roleIn(viewer, workspace) ? workspace.toLowerCase() : null) ??
134 chosenWorkspace(memberships, readCookie(cookies, WORKSPACE_COOKIE))?.slug ??
135 null;
136 const username = viewer.username;
137
138 const reposP = soft("repos", reposApi.list(viewer, { memberOnly: true }));
139 // Workflow jobs stuck waiting for a self-hosted runner that is not there.
140 const stuckJobsP = soft("stuck_jobs", runners.stuck(viewer));
141 // The chosen workspace's projects: their open and recently merged pull
142 // requests, all in one call to work, and their recent events, read as
143 // soon as the projects are known.
144 const perRepoP = reposP.then(async (repos) => {
145 const chosen = (repos ?? []).filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase()).slice(0, MAX_PROJECTS);
146 const [batch, logs, pushes] = await Promise.all([
147 work
148 .pullsForRepos(
149 chosen.map((repo) => repo.id),
150 viewer,
151 PULL_PAGE,
152 )
153 .catch(() => []),
154 Promise.all(chosen.map((repo) => eventLog.list({ repoId: repo.id, types: [...FEED_EVENT_TYPES], limit: EVENTS_PER_PROJECT }).catch(() => null))),
155 // People's pushes straight to the default branch, which no pull
156 // request counts: the commits each brought, for the week.
157 Promise.all(chosen.map((repo) => directCommits(repo, viewer).catch(() => []))),
158 ]);
159 const byId = new Map(batch.map((entry) => [entry.repoId, entry]));
160 return Promise.all(
161 chosen.map(async (repo, index) => {
162 const found = byId.get(repo.id);
163 if (found) return { repo, pulls: found.open.slice(0, 60), closed: found.closed, events: logs[index], direct: pushes[index] ?? [] };
164 // A fork, which the batch leaves out: asked on its own.
165 const path = { namespace: repo.namespace, name: repo.name };
166 const [pulls, closed] = await Promise.all([
167 work.listPulls(path, viewer, "open").catch(() => null),
168 work.listPulls(path, viewer, "closed").catch(() => null),
169 ]);
170 return {
171 repo,
172 pulls: pulls?.ok ? pulls.value.slice(0, 60) : null,
173 closed: closed?.ok ? closed.value : null,
174 events: logs[index],
175 direct: pushes[index] ?? [],
176 };
177 }),
178 );
179 });
180
181 const [repos, perRepo, active, profile, overview, projectList, invitations, unread] = await Promise.all([
182 reposP,
183 soft("projects", perRepoP),
184 soft("pulls", work.listActivePulls(viewer)),
185 soft("profile", identity.profile(username)),
186 slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
187 slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
188 // Repositories someone has invited the viewer to.
189 soft("invitations", identity.myRepoInvitations(viewer)),
190 // What in their inbox is unread: a failure or a warning goes on the Needs you card.
191 soft("inbox", inbox.list(viewer, { unread: true, limit: INBOX_READ })),
192 ]);
193
194 const repoList = repos ?? [];
195 // Each pull request is shown under its repository. Most are in the
196 // viewer's own, already listed; the rest are looked up once each.
197 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
198 const missing = [...new Set((active ?? []).map(({ pull }) => pull.repoId))].filter((id) => !known.has(id));
199 for (const repo of await readableRepos(missing, viewer)) known.set(repo.id, repo);
200 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
201 const inWorkspace = (repo: RepoPath) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase();
202
203 const activeList = (active ?? []).flatMap((item) => {
204 const repo = known.get(item.pull.repoId);
205 return repo ? [{ pull: item.pull, lifecycle: item.lifecycle, repo }] : [];
206 });
207 const overviewList = okOr(overview ?? null);
208 const projectsOk = okOr(projectList ?? null);
209
210 const lower = username.toLowerCase();
211 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
212 // Never your own.
213 const reviewRequested = openPulls.filter(
214 ({ pull }) => pull.status === "open" && workOwner(pull).username.toLowerCase() !== lower && pull.reviewers.some((name) => name.toLowerCase() === lower),
215 );
216
217 // --- Needs you ------------------------------------------------------------
218 const needs: Need[] = [];
219 const extras = new Map<string, Extra>();
220 /** What every pull request's row shares: where it is, who opened it, what is known. */
221 const pullExtra = (pull: Pull, repo: Repo): Extra => {
222 const base = `/${repo.namespace}/${repo.name}`;
223 return {
224 repo: pathOf(repo),
225 ref: `#${pull.number}`,
226 by: who(workOwner(pull).username),
227 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files }),
228 open: pull.item != null ? itemPath(repo.namespace, pull.item) : `${base}/pull/${pull.number}/files`,
229 };
230 };
231 const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({
232 ...quick,
233 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
234 });
235
236 for (const invitation of invitations ?? []) {
237 if (invitation.status !== "pending") continue;
238 const key = `invitation:${invitation.id}`;
239 needs.push({
240 key,
241 kind: "invitation",
242 title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`,
243 detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`,
244 to: `/${invitation.repo}/invitations`,
245 action: "Respond",
246 at: Date.parse(invitation.created_at),
247 where: invitation.repo,
248 });
249 const [namespace, name] = invitation.repo.split("/");
250 extras.set(key, {
251 repo: namespace && name ? { namespace, name } : null,
252 by: who(invitation.invited_by),
253 facts: [
254 { label: "Role", value: REPO_ROLE_LABELS[invitation.role], tone: null },
255 { label: "Expires", value: new Date(invitation.expires_at).toISOString().slice(0, 10), tone: null },
256 ],
257 });
258 }
259 for (const entry of overviewList ?? []) {
260 const latest = entry.latest;
261 if (latest?.kind === "production" && latest.status === "failed" && slug) {
262 const key = `deploy:${entry.slug}`;
263 const to = `/${slug}/${entry.slug}/deployments/${latest.id}`;
264 needs.push({
265 key,
266 kind: "deploy",
267 title: `Production build of ${entry.slug} failed`,
268 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
269 to,
270 action: "See the build",
271 at: Date.parse(latest.finishedAt ?? latest.createdAt),
272 where: `${slug}/${entry.slug}`,
273 });
274 const facts: Fact[] = [
275 { label: "Commit", value: latest.commit.slice(0, 7), tone: null },
276 {
277 label: "Production",
278 value: entry.production ? "Serving the build before" : "Not live yet",
279 tone: entry.production ? "good" : "warn",
280 },
281 ];
282 extras.set(key, {
283 repo: { namespace: slug, name: entry.slug },
284 by: who(latest.createdBy),
285 facts,
286 link: { label: "Deployment settings", to: `/${slug}/${entry.slug}/settings/deployments` },
287 });
288 }
289 }
290 for (const { pull, lifecycle, repo } of activeList) {
291 if (pull.status !== "open") continue;
292 // In this workspace, when the page is for one.
293 if (slug && !inWorkspace(repo)) continue;
294 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
295 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
296 // Your pull request, where its lifecycle says it stands (work keeps it
297 // fresh as checks, reviews and pushes arrive): someone has to step in,
298 // or everything is met and it lands when you merge it.
299 if (lifecycle?.stage === "needs_you") {
300 const conflict = /conflict/i.test(lifecycle.detail);
301 const key = `pull:${pull.id}`;
302 needs.push({
303 key,
304 kind: conflict ? "conflict" : "stalled",
305 title: pull.title,
306 detail: lifecycle.detail,
307 to,
308 action: conflict ? "Resolve" : "Decide",
309 at: Date.parse(pull.updatedAt),
310 where,
311 });
312 extras.set(key, { ...pullExtra(pull, repo), quick: null });
313 continue;
314 }
315 if (lifecycle?.stage === "ready") {
316 const key = `ready:${pull.id}`;
317 needs.push({
318 key,
319 kind: "ready",
320 title: pull.title,
321 detail: "Ready to land when you merge it.",
322 to,
323 action: "Merge",
324 at: Date.parse(pull.updatedAt),
325 where,
326 });
327 // Its lifecycle says the required checks passed, which a list's merge-queue status does not.
328 const [, ...rest] = pullExtra(pull, repo).facts ?? [];
329 extras.set(key, { ...pullExtra(pull, repo), facts: [{ label: "Required checks", value: "Passing", tone: "good" }, ...rest], quick: null });
330 continue;
331 }
332 // Taken out of the merge queue: its change failed combined with what was ahead.
333 if (pull.checkStatus !== "failed") continue;
334 const key = `pull:${pull.id}`;
335 needs.push({
336 key,
337 kind: "checks",
338 title: pull.title,
339 detail: "It failed in the merge queue. Push a fix, then merge it again.",
340 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
341 action: "See checks",
342 at: Date.parse(pull.updatedAt),
343 where: `${repo.namespace}/${repo.name}#${pull.number}`,
344 });
345 extras.set(key, { ...pullExtra(pull, repo), quick: null });
346 }
347 for (const { pull, repo } of reviewRequested) {
348 const key = `review:${pull.id}`;
349 needs.push({
350 key,
351 kind: "review",
352 title: pull.title,
353 detail: `${workOwner(pull).username} asked for your review.`,
354 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
355 action: "Review",
356 at: Date.parse(pull.updatedAt),
357 where: `${repo.namespace}/${repo.name}#${pull.number}`,
358 });
359 extras.set(key, {
360 ...pullExtra(pull, repo),
361 quick: pullAction(repo, pull, { label: "Approve the change", fields: { action: "comment", verdict: "approve", body: "" }, done: "Approved" }),
362 });
363 }
364 for (const job of (await stuckJobsP) ?? []) {
365 const key = `runner:${job.id}`;
366 const minutes = Math.max(10, Math.round((now - Date.parse(job.queuedAt)) / 60_000));
367 const [namespace, name] = job.repo.split("/");
368 needs.push({
369 key,
370 kind: "runner",
371 title: `${job.name} is waiting for a self-hosted runner`,
372 detail: `No runner with labels ${job.labels} is online. Start one, or change the job's runs-on.`,
373 to: `/${job.repo}/actions/runs/${job.runId}`,
374 action: "Look",
375 at: Date.parse(job.queuedAt),
376 where: job.repo,
377 });
378 extras.set(key, {
379 repo: namespace && name ? { namespace, name } : undefined,
380 facts: [
381 { label: "Labels", value: job.labels, tone: null },
382 { label: "Waiting", value: waitedFor(minutes), tone: "warn" },
383 ],
384 // Runners are the owners' to see to.
385 ...(namespace && roleIn(viewer, namespace) === "owner" ? { link: { label: "Runners", to: `/${namespace}/-/runners` } } : {}),
386 });
387 }
388
389 const needRows: NeedRow[] = rankNeeds(needs).map((need) => {
390 const extra = extras.get(need.key) ?? {};
391 const reason = reasonFor(need);
392 return {
393 key: need.key,
394 reason,
395 repo: extra.repo ?? null,
396 ref: extra.ref ?? null,
397 title: need.title,
398 ask: need.detail,
399 by: extra.by ?? null,
400 at: need.at,
401 to: need.to,
402 open: extra.open ?? need.to,
403 facts: extra.facts ?? [],
404 why: whyFor(reason, need),
405 quick: extra.quick ?? null,
406 link: extra.link ?? null,
407 };
408 });
409
410 // --- Landed ---------------------------------------------------------------
411 const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) =>
412 (closed ?? []).flatMap((pull) =>
413 pull.status === "merged" && pull.mergedAt
414 ? [{ repo: pathOf(repo), number: pull.number, title: pull.title, mergedBy: pull.mergedBy, mergedAt: pull.mergedAt, files: pull.files }]
415 : [],
416 ),
417 );
418 const twoWeeksAgo = now - 14 * TIME.DAY;
419 const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
420 // Commits pushed straight to the default branch are changes too, on the day they landed.
421 const direct = (perRepo ?? []).flatMap(({ direct }) => direct.map((commit) => ({ mergedAt: commit.at })));
422 const week = weekOf([...merged, ...direct], now, tz, complete);
423 const landed = landedToday(merged, now, tz);
424
425 // --- Activity -------------------------------------------------------------
426 const items: ActivityItem[] = [];
427 const titles: Record<string, string> = {};
428 const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
429 for (const { repo, pulls, closed, events } of perRepo ?? []) {
430 for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title;
431 for (const event of events ?? []) {
432 if (event.type === "work_item.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title;
433 const item = eventItem(event, pathOf(repo));
434 if (item) items.push(item);
435 }
436 }
437 // The log names people by account id: their usernames, in one lookup.
438 const ids = actorIds(items.map((item) => item.actor));
439 const names = ids.length > 0 ? await soft("usernames", identity.usernames(ids)) : {};
440 const groups = groupActivity(items.map((item) => ({ ...item, actor: nameActor(item.actor, names) }))).slice(0, 40);
441 // Only the titles the feed names travel to the page.
442 const shownTitles: Record<string, string> = {};
443 for (const group of groups) {
444 for (const part of group.parts) {
445 for (const number of part.numbers) {
446 const key = titleKey(group.repo, number);
447 if (titles[key]) shownTitles[key] = titles[key];
448 }
449 }
450 }
451
452 // --- The strip ------------------------------------------------------------
453 const month = dayKey(now, tz).slice(0, 7);
454 const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null);
455 const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null;
456 const openCount = perRepo != null ? openPulls.filter(({ pull }) => pull.status === "open" || pull.status === "draft").length : null;
457
458 times.total = Date.now() - started;
459 const serverTiming = Object.entries(times)
460 .map(([name, ms]) => `${name};dur=${ms}`)
461 .join(", ");
462 return {
463 timing: serverTiming,
464 value: {
465 signedIn: true as const,
466 viewer,
467 name: profile?.name?.trim() || username,
468 date: dateLine(now, tz),
469 summary: summaryLine({ total: week.total, needs: needRows.length }),
470 workspace: slug,
471 repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf),
472 needs: needRows,
473 landed,
474 perRepoLoaded: perRepo != null,
475 stats: {
476 projects: projectCount,
477 projectsThisMonth,
478 openPulls: openCount,
479 },
480 week,
481 groups,
482 titles: shownTitles,
483 inboxNeeds: needsYou(unread?.items ?? [], INBOX_SHOWN),
484 },
485 };
486}
487
488/** What Code's Overview takes. */
489export type Loaded = Awaited<ReturnType<typeof loadMissionControl>>["value"];
490
491/** The cookie that holds the viewer's time zone. */
492export { TZ_COOKIE };