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