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