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
472 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, lifecycle: item.lifecycle, 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}/files`,
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, lifecycle, repo } of activeList) {
289 if (pull.status !== "open") continue;
290 // In this workspace, when the page is for one.
291 if (slug && !inWorkspace(repo)) continue;
292 const where = `${repo.namespace}/${repo.name}#${pull.number}`;
293 const to = `/${repo.namespace}/${repo.name}/pull/${pull.number}`;
294 // Your pull request, where its lifecycle says it stands (work keeps it
295 // fresh as checks, reviews and pushes arrive): someone has to step in,
296 // or everything is met and it lands when you merge it.
297 if (lifecycle?.stage === "needs_you") {
298 const conflict = /conflict/i.test(lifecycle.detail);
299 const key = `pull:${pull.id}`;
300 needs.push({ key, kind: conflict ? "conflict" : "stalled", title: pull.title, detail: lifecycle.detail, to, action: conflict ? "Resolve" : "Decide", at: Date.parse(pull.updatedAt), where });
301 extras.set(key, { ...pullExtra(pull, repo), quick: null });
302 continue;
303 }
304 if (lifecycle?.stage === "ready") {
305 const key = `ready:${pull.id}`;
306 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 });
307 // Its lifecycle says the required checks passed, which a list's merge-queue status does not.
308 const [, ...rest] = pullExtra(pull, repo).facts ?? [];
309 extras.set(key, { ...pullExtra(pull, repo), facts: [{ label: "Required checks", value: "Passing", tone: "good" }, ...rest], quick: null });
310 continue;
311 }
312 // Taken out of the merge queue: its change failed combined with what was ahead.
313 if (pull.checkStatus !== "failed") continue;
314 const key = `pull:${pull.id}`;
315 needs.push({
316 key,
317 kind: "checks",
318 title: pull.title,
319 detail: "It failed in the merge queue. Push a fix, then merge it again.",
320 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
321 action: "See checks",
322 at: Date.parse(pull.updatedAt),
323 where: `${repo.namespace}/${repo.name}#${pull.number}`,
324 });
325 extras.set(key, { ...pullExtra(pull, repo), quick: null });
326 }
327 for (const { pull, repo } of reviewRequested) {
328 const key = `review:${pull.id}`;
329 needs.push({
330 key,
331 kind: "review",
332 title: pull.title,
333 detail: `${workOwner(pull).username} asked for your review.`,
334 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
335 action: "Review",
336 at: Date.parse(pull.updatedAt),
337 where: `${repo.namespace}/${repo.name}#${pull.number}`,
338 });
339 extras.set(key, {
340 ...pullExtra(pull, repo),
341 quick: pullAction(repo, pull, { label: "Approve the change", fields: { action: "comment", verdict: "approve", body: "" }, done: "Approved" }),
342 });
343 }
344 for (const job of (await stuckJobsP) ?? []) {
345 const key = `runner:${job.id}`;
346 const minutes = Math.max(10, Math.round((now - Date.parse(job.queuedAt)) / 60_000));
347 const [namespace, name] = job.repo.split("/");
348 needs.push({
349 key,
350 kind: "runner",
351 title: `${job.name} is waiting for a self-hosted runner`,
352 detail: `No runner with labels ${job.labels} is online. Start one, or change the job's runs-on.`,
353 to: `/${job.repo}/actions/runs/${job.runId}`,
354 action: "Look",
355 at: Date.parse(job.queuedAt),
356 where: job.repo,
357 });
358 extras.set(key, {
359 repo: namespace && name ? { namespace, name } : undefined,
360 facts: [
361 { label: "Labels", value: job.labels, tone: null },
362 { label: "Waiting", value: waitedFor(minutes), tone: "warn" },
363 ],
364 // Runners are the owners' to see to.
365 ...(namespace && roleIn(viewer, namespace) === "owner" ? { link: { label: "Runners", to: `/${namespace}/-/runners` } } : {}),
366 });
367 }
368
369 const needRows: NeedRow[] = rankNeeds(needs).map((need) => {
370 const extra = extras.get(need.key) ?? {};
371 const reason = reasonFor(need);
372 return {
373 key: need.key,
374 reason,
375 repo: extra.repo ?? null,
376 ref: extra.ref ?? null,
377 title: need.title,
378 ask: need.detail,
379 by: extra.by ?? null,
380 at: need.at,
381 to: need.to,
382 open: extra.open ?? need.to,
383 facts: extra.facts ?? [],
384 why: whyFor(reason, need),
385 quick: extra.quick ?? null,
386 link: extra.link ?? null,
387 };
388 });
389
390 // --- Landed ---------------------------------------------------------------
391 const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) =>
392 (closed ?? []).flatMap((pull) =>
393 pull.status === "merged" && pull.mergedAt
394 ? [{ repo: pathOf(repo), number: pull.number, title: pull.title, mergedBy: pull.mergedBy, mergedAt: pull.mergedAt, files: pull.files }]
395 : [],
396 ),
397 );
398 const twoWeeksAgo = now - 14 * TIME.DAY;
399 const complete = perRepo != null && perRepo.every(({ closed }) => closed != null && reachesBack(closed, twoWeeksAgo, PULL_PAGE));
400 // Commits pushed straight to the default branch are changes too, on the day they landed.
401 const direct = (perRepo ?? []).flatMap(({ direct }) => direct.map((commit) => ({ mergedAt: commit.at })));
402 const week = weekOf([...merged, ...direct], now, tz, complete);
403 const landed = landedToday(merged, now, tz);
404
405 // --- Activity -------------------------------------------------------------
406 const items: ActivityItem[] = [];
407 const titles: Record<string, string> = {};
408 const titleKey = (repo: RepoPath, number: number) => `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
409 for (const { repo, pulls, closed, events } of perRepo ?? []) {
410 for (const pull of [...(pulls ?? []), ...(closed ?? [])]) titles[titleKey(repo, pull.number)] = pull.title;
411 for (const event of events ?? []) {
412 if (event.type === "issue.opened") titles[titleKey(repo, event.data.number)] ??= event.data.title;
413 const item = eventItem(event, pathOf(repo));
414 if (item) items.push(item);
415 }
416 }
417 // The log names people by account id: their usernames, in one lookup.
418 const ids = actorIds(items.map((item) => item.actor));
419 const names = ids.length > 0 ? await soft("usernames", identity.usernames(ids)) : {};
420 const groups = groupActivity(items.map((item) => ({ ...item, actor: nameActor(item.actor, names) }))).slice(0, 40);
421 // Only the titles the feed names travel to the page.
422 const shownTitles: Record<string, string> = {};
423 for (const group of groups) {
424 for (const part of group.parts) {
425 for (const number of part.numbers) {
426 const key = titleKey(group.repo, number);
427 if (titles[key]) shownTitles[key] = titles[key];
428 }
429 }
430 }
431
432 // --- The strip ------------------------------------------------------------
433 const month = dayKey(now, tz).slice(0, 7);
434 const projectCount = projectsOk?.length ?? (perRepo != null ? perRepo.length : null);
435 const projectsThisMonth = projectsOk ? projectsOk.filter((project) => dayKey(Date.parse(project.createdAt), tz).slice(0, 7) === month).length : null;
436 const openCount = perRepo != null ? openPulls.filter(({ pull }) => pull.status === "open" || pull.status === "draft").length : null;
437
438 times.total = Date.now() - started;
439 const serverTiming = Object.entries(times)
440 .map(([name, ms]) => `${name};dur=${ms}`)
441 .join(", ");
442 return {
443 timing: serverTiming,
444 value: {
445 signedIn: true as const,
446 viewer,
447 name: profile?.name?.trim() || username,
448 date: dateLine(now, tz),
449 summary: summaryLine({ total: week.total, needs: needRows.length }),
450 workspace: slug,
451 repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf),
452 needs: needRows,
453 landed,
454 perRepoLoaded: perRepo != null,
455 stats: {
456 projects: projectCount,
457 projectsThisMonth,
458 openPulls: openCount,
459 },
460 week,
461 groups,
462 titles: shownTitles,
463 inboxNeeds: needsYou(unread?.items ?? [], INBOX_SHOWN),
464 },
465 };
466}
467
468/** What Code's Overview takes. */
469export type Loaded = Awaited<ReturnType<typeof loadMissionControl>>["value"];
470
471/** The cookie that holds the viewer's time zone. */
472export { TZ_COOKIE };