Skip to content
407 linesCodeBlameRaw
1/**
2 * The shaping behind Code's Overview: why each thing needs the viewer,
3 * what is known about it, the tabs, the week of landed changes, and the
4 * one sentence under the title. Pure, so it is tested on its own; it
5 * imports only types.
6 */
7import type { CheckStatus, ChangedFile, RepoPath } from "@g1t/contracts";
8
9import type { Need } from "./mission";
10
11const DAY = 24 * 60 * 60 * 1000;
12
13/** Accounts that are g1t's own machinery: `isAgent` in ./mission, kept here so this module imports only types. Older rows still name them. */
14export function isAgent(name: string | null | undefined): boolean {
15 return name === "g1t" || (name ?? "").endsWith("-agent");
16}
17
18// --- Reasons ----------------------------------------------------------------
19
20/** Why something is waiting on a person, as the chip beside it says. */
21export type Reason = "blocking" | "invited" | "needs_review" | "checks_failing" | "stalled";
22
23export const REASON_LABEL: Record<Reason, string> = {
24 blocking: "Blocking",
25 invited: "Invitation",
26 needs_review: "Review requested",
27 checks_failing: "Checks failing",
28 stalled: "Stalled",
29};
30
31/** The reason a need is shown, from its kind. */
32export function reasonFor(need: Pick<Need, "kind">): Reason {
33 switch (need.kind) {
34 case "deploy":
35 case "conflict":
36 return "blocking";
37 case "invitation":
38 return "invited";
39 case "review":
40 case "ready":
41 return "needs_review";
42 case "checks":
43 return "checks_failing";
44 case "stalled":
45 case "stuck":
46 case "runner":
47 return "stalled";
48 }
49}
50
51/** Why only a person can move it: the callout beside what is known. */
52export function whyFor(reason: Reason, need: Pick<Need, "kind">): string {
53 if (need.kind === "deploy")
54 return "Production still serves the build before this one. Every push to the default branch builds again, so this fails until what broke is fixed.";
55 if (need.kind === "invitation") return "An invitation is to you. No one else can accept it.";
56 if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict.";
57 if (need.kind === "runner")
58 return "The job runs only on a self-hosted runner with those labels, and none is connected. It starts as soon as one is; nothing else picks it up.";
59 switch (reason) {
60 case "blocking":
61 return "Nothing moves until someone decides how it lands, or whether it should.";
62 case "checks_failing":
63 return "It was taken out of the merge queue because its checks failed with what was ahead of it. Push a fix, then merge it again.";
64 case "needs_review":
65 return "It waits for a verdict before it can land.";
66 case "invited":
67 return "It is addressed to you.";
68 case "stalled":
69 return "It has stopped moving and needs a look.";
70 }
71}
72
73/** Reasons where nothing moves until a person acts on it. */
74export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]);
75
76// --- What is known ----------------------------------------------------------
77
78export type FactTone = "good" | "warn" | "bad" | null;
79/** One thing known; a `wide` one takes the whole row and wraps. */
80export type Fact = { label: string; value: string; tone: FactTone; wide?: boolean };
81
82/** How the checks the default branch requires stand on a pull request, from what a list knows without opening it. */
83export function checksFact(checkStatus: CheckStatus | null): Fact {
84 const fact = (value: string, tone: FactTone): Fact => ({ label: "Required checks", value, tone });
85 if (checkStatus === "failed") return fact("Failed in the merge queue", "bad");
86 if (checkStatus === "passed") return fact("Passing", "good");
87 if (checkStatus === "queued" || checkStatus === "running") return fact("Running", null);
88 return fact("Not known yet", null);
89}
90
91/** Test files, by the names test runners look for. */
92export function isTestFile(path: string): boolean {
93 return (
94 /(^|\/)(__tests__|tests?|spec)\//i.test(path) ||
95 /[._-](test|spec)\.[a-z0-9]+$/i.test(path) ||
96 /_test\.(go|rs|py)$/i.test(path) ||
97 // A file named for what it is: test.js, tests.py, spec.rb.
98 /(^|\/)(tests?|specs?)\.[a-z0-9]+$/i.test(path)
99 );
100}
101
102const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
103
104/**
105 * What is known about a pull request without opening it: its checks, how
106 * much it changes and the tests it touches. Only what is known is listed.
107 */
108export function pullFacts(input: { checkStatus: CheckStatus | null; files: ChangedFile[] }): Fact[] {
109 const facts: Fact[] = [checksFact(input.checkStatus)];
110 if (input.files.length > 0) {
111 const added = input.files.reduce((sum, f) => sum + f.additions, 0);
112 const removed = input.files.reduce((sum, f) => sum + f.deletions, 0);
113 facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null });
114 facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null });
115 const tests = input.files.filter((f) => isTestFile(f.path)).length;
116 facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" });
117 }
118 return facts;
119}
120
121// --- Rows -------------------------------------------------------------------
122
123/** Someone on a row: a person, or g1t itself on older rows. */
124export type Who = { name: string; agent: boolean };
125
126export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null);
127
128/** A form on the page that acts without leaving it. */
129export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string };
130
131/** One thing that needs the viewer, with what is known about it. */
132export type NeedRow = {
133 key: string;
134 reason: Reason;
135 repo: RepoPath | null;
136 /** "#41", when it is an issue or pull request. */
137 ref: string | null;
138 title: string;
139 /** The ask, in one sentence. */
140 ask: string;
141 /** Who is waiting: the author, or the person who asked. */
142 by: Who | null;
143 at: number;
144 to: string;
145 open: string;
146 facts: Fact[];
147 why: string;
148 quick: QuickAction | null;
149 /** Somewhere else to act, when that is the next step. */
150 link: { label: string; to: string } | null;
151};
152
153const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase();
154
155// --- Landed -----------------------------------------------------------------
156
157/**
158 * The accounts g1t itself acts as on the event log: the account older rows
159 * name, and the policy that merges and pushes for it. An event's `actor`
160 * is an account id, so this is what tells g1t apart, whatever name a
161 * commit carries.
162 */
163export const G1T_ACCOUNT_IDS: ReadonlySet<string> = new Set(["usr_g1t_agent", "g1t_policy"]);
164
165/** A `git.push` from the log, as far as placing its commits needs it. */
166export type PushRecord = {
167 time: string;
168 /** The account that pushed; null when the log does not say. */
169 actor?: string | null;
170 data: { after: string; before?: string; causedByJob?: string };
171};
172
173/**
174 * Whether a person pushed: not g1t itself (by the account that signed in
175 * to push), and not a workflow job's own token.
176 */
177export function pushedByPerson(push: Pick<PushRecord, "actor" | "data">): boolean {
178 if (push.data.causedByJob) return false;
179 return !(push.actor && G1T_ACCOUNT_IDS.has(push.actor));
180}
181
182/**
183 * The commits a person's push to the default branch brought, from the
184 * history at its `after` (newest first) back to its `before`: their own,
185 * not merges (a pull request landing) and not g1t's (`byG1t`: a commit
186 * g1t wrote, told by its author address, which a person may fast-forward
187 * onto the branch). A push whose `before` is not in what was read gives
188 * what was read. Who pushed is the caller's to decide (`pushedByPerson`);
189 * a commit's author name says nothing about it.
190 */
191export function pushedCommits<C extends { hash: string; parents: string[] }>(
192 history: C[],
193 before: string | undefined,
194 byG1t: (commit: C) => boolean = () => false,
195): C[] {
196 const end = before ? history.findIndex((commit) => commit.hash === before) : -1;
197 const brought = end === -1 ? history : history.slice(0, end);
198 return brought.filter((commit) => commit.parents.length <= 1 && !byG1t(commit));
199}
200
201/**
202 * Each commit of `history` (newest first) a person's push in `pushes`
203 * (newest first) brought, at that push's time. A commit pushed twice
204 * (after a force push, say) counts once, at its first landing; a push
205 * whose `after` is no longer in the history (rewritten) brings nothing,
206 * and g1t's own pushes bring nothing here: pull requests count those.
207 */
208export function placePushes<C extends { hash: string; parents: string[] }>(
209 history: C[],
210 pushes: PushRecord[],
211 byG1t: (commit: C) => boolean = () => false,
212): { hash: string; at: string }[] {
213 const index = new Map(history.map((commit, i) => [commit.hash, i]));
214 // Each commit's first landing: the time, or null when g1t landed it.
215 const seen = new Map<string, string | null>();
216 for (const push of [...pushes].reverse()) {
217 const start = index.get(push.data.after);
218 if (start === undefined) continue;
219 const end = push.data.before ? index.get(push.data.before) : undefined;
220 const range = history.slice(start, end ?? history.length);
221 const at = pushedByPerson(push) ? push.time : null;
222 for (const commit of pushedCommits(range, undefined, byG1t)) {
223 if (!seen.has(commit.hash)) seen.set(commit.hash, at);
224 }
225 }
226 return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : []));
227}
228
229/**
230 * Whether a read of the default branch reaches back far enough to place
231 * every push, newest first: it holds the oldest push's `before`, or it is
232 * the whole branch. A shorter read than `asked` is the whole branch.
233 */
234export function historyCovers(history: { hash: string }[], pushes: PushRecord[], asked: number): boolean {
235 if (history.length < asked) return true;
236 const oldest = pushes.at(-1)?.data.before;
237 return oldest != null && history.some((commit) => commit.hash === oldest);
238}
239
240/** A merged pull request, as the week counts it. */
241export type Merged = {
242 repo: RepoPath;
243 number: number;
244 title: string;
245 mergedBy: string | null;
246 mergedAt: string;
247 files: ChangedFile[];
248};
249
250/**
251 * Whether a change landed without anyone pressing merge: g1t merged it, by
252 * auto-merge or from the merge queue.
253 */
254export function mergedAutomatically(change: Pick<Merged, "mergedBy">): boolean {
255 return change.mergedBy == null || isAgent(change.mergedBy);
256}
257
258/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
259export function dayKey(at: number, timeZone: string | null): string {
260 try {
261 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
262 } catch {
263 return new Date(at).toISOString().slice(0, 10);
264 }
265}
266
267/** One day's landed changes. */
268export type WeekDay = { key: string; label: string; count: number };
269
270export type Week = {
271 days: WeekDay[];
272 /** Changes landed in the last seven days. */
273 total: number;
274 /** The seven days before, or null when the lists read do not reach back that far. */
275 previous: number | null;
276};
277
278/**
279 * The last seven days in the viewer's zone, oldest first, each with how
280 * many changes landed, and the week before as one number. `complete` says
281 * whether what was read reaches back two weeks; when it does not, the week
282 * before is not guessed.
283 */
284export function weekOf(changes: Pick<Merged, "mergedAt">[], now: number, timeZone: string | null, complete = true): Week {
285 const days: WeekDay[] = [];
286 const index = new Map<string, number>();
287 for (let back = 6; back >= 0; back -= 1) {
288 const at = now - back * DAY;
289 const key = dayKey(at, timeZone);
290 if (index.has(key)) continue;
291 index.set(key, days.length);
292 let label: string;
293 try {
294 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
295 } catch {
296 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
297 }
298 days.push({ key, label, count: 0 });
299 }
300 const oldest = days[0]?.key ?? "";
301 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
302 let previous = 0;
303 for (const change of changes) {
304 const at = Date.parse(change.mergedAt);
305 if (!Number.isFinite(at) || at > now) continue;
306 const key = dayKey(at, timeZone);
307 const slot = index.get(key);
308 if (slot != null) days[slot].count += 1;
309 else if (key < oldest && key >= twoWeeks) previous += 1;
310 }
311 return { days, total: days.reduce((sum, day) => sum + day.count, 0), previous: complete ? previous : null };
312}
313
314/** The change from one number to another, as a share; null from nothing. */
315export function change(current: number, previous: number | null): number | null {
316 if (previous == null || previous === 0) return null;
317 return (current - previous) / previous;
318}
319
320/** "+18%", "−5%", "0%". */
321export function signedPercent(share: number): string {
322 const n = Math.round(share * 100);
323 return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
324}
325
326/**
327 * Whether the merged pull requests read for one repository reach back to
328 * `since`: the list is not full, or its oldest goes back that far.
329 */
330export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
331 if (list.length < page) return true;
332 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
333 return oldest <= since;
334}
335
336/** One change that landed, as the Landed tab lists it. */
337export type LandedRow = {
338 key: string;
339 repo: RepoPath;
340 ref: string;
341 title: string;
342 /** Who merged it; g1t for a change that merged automatically. */
343 by: Who | null;
344 automatic: boolean;
345 at: number;
346 to: string;
347 facts: Fact[];
348};
349
350/** What landed on the viewer's calendar day, newest first. */
351export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
352 const today = dayKey(now, timeZone);
353 return changes
354 .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
355 .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
356 .map((change) => ({
357 key: pathKey(change.repo, change.number),
358 repo: change.repo,
359 ref: `#${change.number}`,
360 title: change.title,
361 by: who(change.mergedBy ?? "g1t"),
362 automatic: mergedAutomatically(change),
363 at: Date.parse(change.mergedAt),
364 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
365 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"),
366 }));
367}
368
369// --- Tabs and sorting -------------------------------------------------------
370
371export type Tab = "needs" | "landed";
372export type Sort = "impact" | "newest";
373
374export const TABS: Tab[] = ["needs", "landed"];
375
376export function parseTab(value: string | null): Tab | null {
377 return value === "needs" || value === "landed" ? value : null;
378}
379
380export function parseSort(value: string | null): Sort {
381 return value === "newest" ? "newest" : "impact";
382}
383
384/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
385export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
386 return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
387}
388
389// --- The summary ------------------------------------------------------------
390
391/** The sentence under the title: the week, in one line. */
392export function summaryLine(input: { total: number; needs: number }): string {
393 const { total, needs } = input;
394 if (total > 0) return `${plural(total, "change")} landed in the last 7 days.`;
395 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
396 return "Nothing has landed this week yet.";
397}
398
399/** "Monday, Oct 5", in the viewer's zone. */
400export function dateLine(now: number, timeZone: string | null): string {
401 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
402 try {
403 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
404 } catch {
405 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
406 }
407}