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.

473 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 ?? [])
146 .filter((repo) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase())
147 .slice(0, MAX_PROJECTS);
148 const [batch, logs, pushes] = await Promise.all([
149 work.pullsForRepos(chosen.map((repo) => repo.id), viewer, PULL_PAGE).catch(() => []),
150 Promise.all(chosen.map((repo) => eventLog.list({ repoId: repo.id, types: [...FEED_EVENT_TYPES], limit: EVENTS_PER_PROJECT }).catch(() => null))),
151 // People's pushes straight to the default branch, which no pull
152 // request counts: the commits each brought, for the week.
153 Promise.all(chosen.map((repo) => directCommits(repo, viewer).catch(() => []))),
154 ]);
155 const byId = new Map(batch.map((entry) => [entry.repoId, entry]));
156 return Promise.all(
157 chosen.map(async (repo, index) => {
158 const found = byId.get(repo.id);
159 if (found) return { repo, pulls: found.open.slice(0, 60), closed: found.closed, events: logs[index], direct: pushes[index] ?? [] };
160 // A fork, which the batch leaves out: asked on its own.
161 const path = { namespace: repo.namespace, name: repo.name };
162 const [pulls, closed] = await Promise.all([
163 work.listPulls(path, viewer, "open").catch(() => null),
164 work.listPulls(path, viewer, "closed").catch(() => null),
165 ]);
166 return {
167 repo,
168 pulls: pulls?.ok ? pulls.value.slice(0, 60) : null,
169 closed: closed?.ok ? closed.value : null,
170 events: logs[index],
171 direct: pushes[index] ?? [],
172 };
173 }),
174 );
175 });
176
177 const [repos, perRepo, active, profile, overview, projectList, invitations, unread] = await Promise.all([
178 reposP,
179 soft("projects", perRepoP),
180 soft("pulls", work.listActivePulls(viewer)),
181 soft("profile", identity.profile(username)),
182 slug ? soft("deploys", deployments.overview(slug, viewer)) : null,
183 slug ? soft("projectList", projectsApi.list(slug, viewer)) : null,
184 // Repositories someone has invited the viewer to.
185 soft("invitations", identity.myRepoInvitations(viewer)),
186 // What in their inbox is unread: a failure or a warning goes on the Needs you card.
187 soft("inbox", inbox.list(viewer, { unread: true, limit: INBOX_READ })),
188 ]);
189
190 const repoList = repos ?? [];
191 // Each pull request is shown under its repository. Most are in the
192 // viewer's own, already listed; the rest are looked up once each.
193 const known = new Map<string, Repo>(repoList.map((repo) => [repo.id, repo]));
194 const missing = [...new Set((active ?? []).map(({ pull }) => pull.repoId))].filter((id) => !known.has(id));
195 for (const repo of await readableRepos(missing, viewer)) known.set(repo.id, repo);
196 const pathOf = (repo: Repo): RepoPath => ({ namespace: repo.namespace, name: repo.name });
197 const inWorkspace = (repo: RepoPath) => slug != null && repo.namespace.toLowerCase() === slug.toLowerCase();
198
199 const activeList = (active ?? []).flatMap((item) => {
200 const repo = known.get(item.pull.repoId);
201 return repo ? [{ pull: item.pull, lifecycle: item.lifecycle, repo }] : [];
202 });
203 const overviewList = okOr(overview ?? null);
204 const projectsOk = okOr(projectList ?? null);
205
206 const lower = username.toLowerCase();
207 const openPulls = (perRepo ?? []).flatMap(({ repo, pulls }) => (pulls ?? []).map((pull) => ({ pull, repo })));
208 // Never your own.
209 const reviewRequested = openPulls.filter(
210 ({ pull }) =>
211 pull.status === "open" &&
212 workOwner(pull).username.toLowerCase() !== lower &&
213 pull.reviewers.some((name) => name.toLowerCase() === lower),
214 );
215
216 // --- Needs you ------------------------------------------------------------
217 const needs: Need[] = [];
218 const extras = new Map<string, Extra>();
219 /** What every pull request's row shares: where it is, who opened it, what is known. */
220 const pullExtra = (pull: Pull, repo: Repo): Extra => {
221 const base = `/${repo.namespace}/${repo.name}`;
222 return {
223 repo: pathOf(repo),
224 ref: `#${pull.number}`,
225 by: who(workOwner(pull).username),
226 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files }),
227 open: pull.item != null ? itemPath(repo.namespace, pull.item) : `${base}/pull/${pull.number}/files`,
228 };
229 };
230 const pullAction = (repo: Repo, pull: Pull, quick: Omit<QuickAction, "to">): QuickAction => ({
231 ...quick,
232 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
233 });
234
235 for (const invitation of invitations ?? []) {
236 if (invitation.status !== "pending") continue;
237 const key = `invitation:${invitation.id}`;
238 needs.push({
239 key,
240 kind: "invitation",
241 title: `${invitation.invited_by ?? "Someone"} invited you to ${invitation.repo}`,
242 detail: `With the ${REPO_ROLE_LABELS[invitation.role]} role. The invitation expires ${new Date(invitation.expires_at).toISOString().slice(0, 10)}.`,
243 to: `/${invitation.repo}/invitations`,
244 action: "Respond",
245 at: Date.parse(invitation.created_at),
246 where: invitation.repo,
247 });
248 const [namespace, name] = invitation.repo.split("/");
249 extras.set(key, {
250 repo: namespace && name ? { namespace, name } : null,
251 by: who(invitation.invited_by),
252 facts: [
253 { label: "Role", value: REPO_ROLE_LABELS[invitation.role], tone: null },
254 { label: "Expires", value: new Date(invitation.expires_at).toISOString().slice(0, 10), tone: null },
255 ],
256 });
257 }
258 for (const entry of overviewList ?? []) {
259 const latest = entry.latest;
260 if (latest?.kind === "production" && latest.status === "failed" && slug) {
261 const key = `deploy:${entry.slug}`;
262 const to = `/${slug}/${entry.slug}/deployments/${latest.id}`;
263 needs.push({
264 key,
265 kind: "deploy",
266 title: `Production build of ${entry.slug} failed`,
267 detail: latest.error ?? "The last build of the default branch failed. Production still serves the build before it.",
268 to,
269 action: "See the build",
270 at: Date.parse(latest.finishedAt ?? latest.createdAt),
271 where: `${slug}/${entry.slug}`,
272 });
273 const facts: Fact[] = [
274 { label: "Commit", value: latest.commit.slice(0, 7), tone: null },
275 {
276 label: "Production",
277 value: entry.production ? "Serving the build before" : "Not live yet",
278 tone: entry.production ? "good" : "warn",
279 },
280 ];
281 extras.set(key, {
282 repo: { namespace: slug, name: entry.slug },
283 by: who(latest.createdBy),
284 facts,
285 link: { label: "Deployment settings", to: `/${slug}/${entry.slug}/settings/deployments` },
286 });
287 }
288 }
289 for (const { pull, lifecycle, repo } of activeList) {
290 if (pull.status !== "open") continue;
291 // In this workspace, when the page is for one.
292 if (slug && !inWorkspace(repo)) continue;
293 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
294 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
295 // Your pull request, where its lifecycle says it stands (work keeps it
296 // fresh as checks, reviews and pushes arrive): someone has to step in,
297 // or everything is met and it lands when you merge it.
298 if (lifecycle?.stage === "needs_you") {
299 const conflict = /conflict/i.test(lifecycle.detail);
300 const key = `pull:${pull.id}`;
301 needs.push({ key, kind: conflict ? "conflict" : "stalled", title: pull.title, detail: lifecycle.detail, to, action: conflict ? "Resolve" : "Decide", at: Date.parse(pull.updatedAt), where });
302 extras.set(key, { ...pullExtra(pull, repo), quick: null });
303 continue;
304 }
305 if (lifecycle?.stage === "ready") {
306 const key = `ready:${pull.id}`;
307 needs.push({ key, kind: "ready", title: pull.title, detail: "Ready to land when you merge it.", to, action: "Merge", at: Date.parse(pull.updatedAt), where });
308 // Its lifecycle says the required checks passed, which a list's merge-queue status does not.
309 const [, ...rest] = pullExtra(pull, repo).facts ?? [];
310 extras.set(key, { ...pullExtra(pull, repo), facts: [{ label: "Required checks", value: "Passing", tone: "good" }, ...rest], quick: null });
311 continue;
312 }
313 // Taken out of the merge queue: its change failed combined with what was ahead.
314 if (pull.checkStatus !== "failed") continue;
315 const key = `pull:${pull.id}`;
316 needs.push({
317 key,
318 kind: "checks",
319 title: pull.title,
320 detail: "It failed in the merge queue. Push a fix, then merge it again.",
321 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
322 action: "See checks",
323 at: Date.parse(pull.updatedAt),
324 where: `${repo.namespace}/${repo.name}#${pull.number}`,
325 });
326 extras.set(key, { ...pullExtra(pull, repo), quick: null });
327 }
328 for (const { pull, repo } of reviewRequested) {
329 const key = `review:${pull.id}`;
330 needs.push({
331 key,
332 kind: "review",
333 title: pull.title,
334 detail: `${workOwner(pull).username} asked for your review.`,
335 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
336 action: "Review",
337 at: Date.parse(pull.updatedAt),
338 where: `${repo.namespace}/${repo.name}#${pull.number}`,
339 });
340 extras.set(key, {
341 ...pullExtra(pull, repo),
342 quick: pullAction(repo, pull, { label: "Approve the change", fields: { action: "comment", verdict: "approve", body: "" }, done: "Approved" }),
343 });
344 }
345 for (const job of (await stuckJobsP) ?? []) {
346 const key = `runner:${job.id}`;
347 const minutes = Math.max(10, Math.round((now - Date.parse(job.queuedAt)) / 60_000));
348 const [namespace, name] = job.repo.split("/");
349 needs.push({
350 key,
351 kind: "runner",
352 title: `${job.name} is waiting for a self-hosted runner`,
353 detail: `No runner with labels ${job.labels} is online. Start one, or change the job's runs-on.`,
354 to: `/${job.repo}/actions/runs/${job.runId}`,
355 action: "Look",
356 at: Date.parse(job.queuedAt),
357 where: job.repo,
358 });
359 extras.set(key, {
360 repo: namespace && name ? { namespace, name } : undefined,
361 facts: [
362 { label: "Labels", value: job.labels, tone: null },
363 { label: "Waiting", value: waitedFor(minutes), tone: "warn" },
364 ],
365 // Runners are the owners' to see to.
366 ...(namespace && roleIn(viewer, namespace) === "owner" ? { link: { label: "Runners", to: `/${namespace}/-/runners` } } : {}),
367 });
368 }
369
370 const needRows: NeedRow[] = rankNeeds(needs).map((need) => {
371 const extra = extras.get(need.key) ?? {};
372 const reason = reasonFor(need);
373 return {
374 key: need.key,
375 reason,
376 repo: extra.repo ?? null,
377 ref: extra.ref ?? null,
378 title: need.title,
379 ask: need.detail,
380 by: extra.by ?? null,
381 at: need.at,
382 to: need.to,
383 open: extra.open ?? need.to,
384 facts: extra.facts ?? [],
385 why: whyFor(reason, need),
386 quick: extra.quick ?? null,
387 link: extra.link ?? null,
388 };
389 });
390
391 // --- Landed ---------------------------------------------------------------
392 const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) =>
393 (closed ?? []).flatMap((pull) =>
394 pull.status === "merged" && pull.mergedAt
395 ? [{ repo: pathOf(repo), number: pull.number, title: pull.title, mergedBy: pull.mergedBy, mergedAt: pull.mergedAt, files: pull.files }]
396 : [],
397 ),
398 );
399 const twoWeeksAgo = now - 14 * TIME.DAY;
400 const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
401 // Commits pushed straight to the default branch are changes too, on the day they landed.
402 const direct = (perRepo ?? []).flatMap(({ direct }) => direct.map((commit) => ({ mergedAt: commit.at })));
403 const week = weekOf([...merged, ...direct], now, tz, complete);
404 const landed = landedToday(merged, now, tz);
405
406 // --- Activity -------------------------------------------------------------
407 const items: ActivityItem[] = [];
408 const titles: Record<string, string> = {};
409 const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
410 for (const { repo, pulls, closed, events } of perRepo ?? []) {
411 for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title;
412 for (const event of events ?? []) {
413 if (event.type === "work_item.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title;
414 const item = eventItem(event, pathOf(repo));
415 if (item) items.push(item);
416 }
417 }
418 // The log names people by account id: their usernames, in one lookup.
419 const ids = actorIds(items.map((item) => item.actor));
420 const names = ids.length > 0 ? await soft("usernames", identity.usernames(ids)) : {};
421 const groups = groupActivity(items.map((item) => ({ ...item, actor: nameActor(item.actor, names) }))).slice(0, 40);
422 // Only the titles the feed names travel to the page.
423 const shownTitles: Record<string, string> = {};
424 for (const group of groups) {
425 for (const part of group.parts) {
426 for (const number of part.numbers) {
427 const key = titleKey(group.repo, number);
428 if (titles[key]) shownTitles[key] = titles[key];
429 }
430 }
431 }
432
433 // --- The strip ------------------------------------------------------------
434 const month = dayKey(now, tz).slice(0, 7);
435 const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null);
436 const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null;
437 const openCount = perRepo != null ? openPulls.filter(({ pull }) => pull.status === "open" || pull.status === "draft").length : null;
438
439 times.total = Date.now() - started;
440 const serverTiming = Object.entries(times)
441 .map(([name, ms]) => `${name};dur=${ms}`)
442 .join(", ");
443 return {
444 timing: serverTiming,
445 value: {
446 signedIn: true as const,
447 viewer,
448 name: profile?.name?.trim() || username,
449 date: dateLine(now, tz),
450 summary: summaryLine({ total: week.total, needs: needRows.length }),
451 workspace: slug,
452 repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf),
453 needs: needRows,
454 landed,
455 perRepoLoaded: perRepo != null,
456 stats: {
457 projects: projectCount,
458 projectsThisMonth,
459 openPulls: openCount,
460 },
461 week,
462 groups,
463 titles: shownTitles,
464 inboxNeeds: needsYou(unread?.items ?? [], INBOX_SHOWN),
465 },
466 };
467}
468
469/** What Code's Overview takes. */
470export type Loaded = Awaited<ReturnType<typeof loadMissionControl>>["value"];
471
472/** The cookie that holds the viewer's time zone. */
473export { TZ_COOKIE };