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.

428 linesCodeBlameRaw
1/**
2 * The arithmetic behind mission control and a project's overview: when the
3 * viewer was last here, what moved since, how a burst of one person's work
4 * reads as one line, the week's pulse, and how healthy a project is. Pure,
5 * so it is tested on its own; it imports only types.
6 */
7import type { G1tEvent, RepoPath } from "@g1t/contracts";
8
9/** A run as the arithmetic here reads it: its kind, status and times. */
10type RunLike = { kind: string; status: string; updatedAt: string };
11
12const MINUTE = 60_000;
13const HOUR = 60 * MINUTE;
14const DAY = 24 * HOUR;
15
16// --- Last seen ----------------------------------------------------------------
17
18/** The cookie that remembers when the viewer was last on mission control. */
19export const SEEN_COOKIE = "g1t_seen";
20/** A gap this long between page views starts a new visit. */
21export const VISIT_GAP_MS = 30 * MINUTE;
22
23/**
24 * Reads the last-seen cookie (`<previous visit>.<last view>`, in epoch ms)
25 * and works out what to count from and what to write back. Views within
26 * `VISIT_GAP_MS` of each other are one visit, so the page refreshing
27 * itself does not reset what is new. `since` is null on a first visit.
28 */
29export function nextSeen(raw: string | null | undefined, now: number, gap = VISIT_GAP_MS): { since: number | null; value: string } {
30 const [prevText, atText] = (raw ?? "").split(".");
31 const prev = Number(prevText);
32 const at = Number(atText);
33 if (!Number.isFinite(at) || at <= 0 || at > now) return { since: null, value: `0.${now}` };
34 if (now - at > gap) return { since: at, value: `${at}.${now}` };
35 const since = Number.isFinite(prev) && prev > 0 && prev <= at ? prev : null;
36 return { since, value: `${since ?? 0}.${now}` };
37}
38
39/** Reads one cookie from a `Cookie` header. */
40export function readCookie(header: string | null, name: string): string | null {
41 if (!header) return null;
42 for (const part of header.split(";")) {
43 const [key, ...rest] = part.trim().split("=");
44 if (key === name) {
45 try {
46 return decodeURIComponent(rest.join("="));
47 } catch {
48 return null;
49 }
50 }
51 }
52 return null;
53}
54
55/** "Good morning", "Good afternoon" or "Good evening", for an hour of the day. */
56export function greetingFor(hour: number): string {
57 if (hour >= 5 && hour < 12) return "Good morning";
58 if (hour >= 12 && hour < 17) return "Good afternoon";
59 return "Good evening";
60}
61
62/** The hour of the day in a time zone, or in UTC when it is unknown or invalid. */
63export function hourIn(now: number, timeZone: string | null): number {
64 try {
65 const text = new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: timeZone || "UTC" }).format(now);
66 const hour = Number.parseInt(text, 10);
67 return Number.isFinite(hour) ? hour % 24 : new Date(now).getUTCHours();
68 } catch {
69 return new Date(now).getUTCHours();
70 }
71}
72
73// --- Activity ---------------------------------------------------------------
74
75/** Accounts that are g1t's own machinery, as older rows name them: shown as g1t. */
76export function isAgent(name: string | null | undefined): boolean {
77 return name === "g1t" || (name ?? "").endsWith("-agent");
78}
79
80export type Verb =
81 | "landed"
82 | "opened_item"
83 | "closed_item"
84 | "started"
85 | "ready"
86 | "checks_passed"
87 | "checks_failed"
88 | "approved"
89 | "changes_requested"
90 | "commented"
91 | "deployed"
92 | "deploy_failed";
93
94/**
95 * The event types `eventItem` makes a line of. Ask the log for these only:
96 * a repository's newest events are mostly ones no line is made of (session
97 * steps, queue and merge-check changes), which would otherwise fill the
98 * page and leave nothing to show.
99 */
100export const FEED_EVENT_TYPES = [
101 "pull.merged",
102 "work_item.opened",
103 "work_item.closed",
104 "pull.opened",
105 "pull.ready",
106 "checks.completed",
107 "review.completed",
108 "comment.created",
109 "deployment_status.created",
110] as const satisfies readonly G1tEvent["type"][];
111
112/** One thing that moved, as the feed shows it. */
113export type ActivityItem = {
114 id: string;
115 /** Epoch ms. */
116 at: number;
117 repo: RepoPath;
118 actor: string | null;
119 verb: Verb;
120 /** The work item (numbered in `repo`'s workspace) or pull request it was about. */
121 number: number | null;
122 /** For a push: the commit. */
123 text?: string;
124 /** Where the line links, when it is not a work item or pull request. */
125 to?: string;
126};
127
128/** An event from the log as a feed item, or null for those not worth a line. */
129export function eventItem(event: G1tEvent, repo: RepoPath): ActivityItem | null {
130 const base = { id: event.id, at: Date.parse(event.time), repo, actor: event.actor };
131 switch (event.type) {
132 case "pull.merged":
133 return { ...base, verb: "landed", number: event.data.number };
134 case "work_item.opened":
135 return { ...base, verb: "opened_item", number: event.data.number };
136 // One a landing closed is told by the pull request or commit that landed it.
137 case "work_item.closed":
138 return event.data.resolution != null ? null : { ...base, verb: "closed_item", number: event.data.number };
139 case "pull.opened":
140 return { ...base, actor: event.data.agent || event.actor, verb: "started", number: event.data.number };
141 case "pull.ready":
142 return { ...base, verb: "ready", number: event.data.number };
143 case "checks.completed":
144 return { ...base, actor: null, verb: event.data.status === "passed" ? "checks_passed" : "checks_failed", number: event.data.number };
145 case "review.completed":
146 if (!event.data.verdict) return null;
147 return { ...base, actor: "g1t", verb: event.data.verdict === "approve" ? "approved" : "changes_requested", number: event.data.number };
148 case "comment.created":
149 // An agent's advisory review is not a verdict, and its writer is the agent.
150 if (event.data.agent) {
151 return { ...base, actor: `${event.data.agent.displayName} (agent)`, verb: "commented", number: event.data.number };
152 }
153 if (event.data.verdict) {
154 return { ...base, verb: event.data.verdict === "approve" ? "approved" : "changes_requested", number: event.data.number };
155 }
156 return { ...base, verb: "commented", number: event.data.number };
157 case "deployment_status.created": {
158 // Production, once it is up or has failed: g1t.page builds, g1t
159 // Actions jobs and deployments reported through the API alike.
160 const { deployment, deploymentStatus } = event.data;
161 if (!deployment.production_environment) return null;
162 const state = deploymentStatus.state;
163 if (state !== "success" && state !== "failure" && state !== "error") return null;
164 return {
165 ...base,
166 verb: state === "success" ? "deployed" : "deploy_failed",
167 number: null,
168 to: `/${repo.namespace}/${repo.name}/deployments/${deployment.id}`,
169 };
170 }
171 default:
172 return null;
173 }
174}
175
176/** Accounts that act for g1t itself, named in the log by fixed ids. */
177const G1T_ACTORS: Record<string, string> = { usr_g1t_agent: "g1t", g1t_policy: "g1t" };
178/** Who an account the lookup no longer knows was. */
179export const DELETED_USER = "a deleted user";
180
181/** An account id rather than a name: usernames never hold an underscore. */
182const isAccountId = (actor: string) => actor.includes("_");
183
184/**
185 * A workspace acting as itself: a build or deployment run under the
186 * workspace's own identity. That is g1t working for the workspace, not a
187 * person, so it is never named after the workspace, as home.server.ts
188 * names it too.
189 */
190const isWorkspaceId = (actor: string) => actor.startsWith("wsp_");
191
192/** The account ids among `actors` to look up by name, once each. */
193export function actorIds(actors: (string | null)[]): string[] {
194 return [
195 ...new Set(
196 actors.filter((actor): actor is string => actor != null && isAccountId(actor) && !(actor in G1T_ACTORS) && !isWorkspaceId(actor)),
197 ),
198 ];
199}
200
201/**
202 * An actor by name: an account id becomes its username, from `names` (the
203 * identity service's lookup); a workspace is g1t. An id it does not know is
204 * an account since deleted; with no lookup at all, the actor is only
205 * "someone".
206 */
207export function nameActor(actor: string | null, names: Record<string, string> | null): string | null {
208 if (actor == null || !isAccountId(actor)) return actor;
209 if (isWorkspaceId(actor)) return "g1t";
210 return G1T_ACTORS[actor] ?? names?.[actor] ?? (names ? DELETED_USER : "someone");
211}
212
213/** Several things one actor did in one project in a short while, read as one line. */
214export type ActivityGroup = {
215 id: string;
216 actor: string | null;
217 repo: RepoPath;
218 /** Newest and oldest, epoch ms. */
219 at: number;
220 from: number;
221 /** Each kind of thing done, in the order first seen, with what it was done to. */
222 parts: { verb: Verb; numbers: number[]; texts: string[]; to?: string }[];
223 count: number;
224};
225
226const sameRepo = (a: RepoPath, b: RepoPath) =>
227 a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase();
228
229/**
230 * Groups items, newest first, so that a run of things one actor did in one
231 * project within `window` of the run's newest reads as one line.
232 */
233export function groupActivity(items: ActivityItem[], window = 45 * MINUTE): ActivityGroup[] {
234 const sorted = [...items].sort((a, b) => b.at - a.at);
235 const groups: ActivityGroup[] = [];
236 for (const item of sorted) {
237 const last = groups[groups.length - 1];
238 if (last && last.actor === item.actor && sameRepo(last.repo, item.repo) && last.at - item.at <= window) {
239 let part = last.parts.find((p) => p.verb === item.verb);
240 if (!part) {
241 part = { verb: item.verb, numbers: [], texts: [], to: item.to };
242 last.parts.push(part);
243 }
244 if (item.number != null && !part.numbers.includes(item.number)) part.numbers.push(item.number);
245 if (item.text && !part.texts.includes(item.text)) part.texts.push(item.text);
246 last.from = item.at;
247 last.count += 1;
248 continue;
249 }
250 groups.push({
251 id: item.id,
252 actor: item.actor,
253 repo: item.repo,
254 at: item.at,
255 from: item.at,
256 parts: [{ verb: item.verb, numbers: item.number != null ? [item.number] : [], texts: item.text ? [item.text] : [], to: item.to }],
257 count: 1,
258 });
259 }
260 return groups;
261}
262
263// --- Needs you --------------------------------------------------------------
264
265export type NeedKind = "deploy" | "invitation" | "stalled" | "conflict" | "review" | "stuck" | "runner" | "checks" | "ready";
266
267/** Something waiting on the viewer, with where to act on it. */
268export type Need = {
269 key: string;
270 kind: NeedKind;
271 title: string;
272 detail: string;
273 to: string;
274 action: string;
275 /** Epoch ms: when it started waiting. */
276 at: number;
277 where: string | null;
278};
279
280const NEED_RANK: Record<NeedKind, number> = {
281 deploy: 1,
282 invitation: 1.5,
283 conflict: 2,
284 stalled: 3,
285 stuck: 4,
286 runner: 4,
287 review: 5,
288 checks: 6,
289 ready: 7,
290};
291
292/** Most urgent first: by kind, then what has waited longest. One of each key. */
293export function rankNeeds(needs: Need[]): Need[] {
294 const seen = new Set<string>();
295 return [...needs]
296 .sort((a, b) => NEED_RANK[a.kind] - NEED_RANK[b.kind] || a.at - b.at)
297 .filter((need) => (seen.has(need.key) ? false : (seen.add(need.key), true)));
298}
299
300/** How long something has waited, from minutes: "45 min", "17 h", "2 d". */
301export function waitedFor(minutes: number): string {
302 const whole = Math.max(0, Math.floor(minutes));
303 if (whole < 60) return `${whole} min`;
304 if (whole < 24 * 60) return `${Math.floor(whole / 60)} h`;
305 return `${Math.floor(whole / (24 * 60))} d`;
306}
307
308/** Whether a run has gone quiet: running with no new step for `quiet`. */
309export function stuckMinutes(run: Pick<RunLike, "status" | "updatedAt">, now: number, quiet = 10 * MINUTE): number | null {
310 if (run.status !== "running") return null;
311 const idle = now - Date.parse(run.updatedAt);
312 return idle >= quiet ? Math.floor(idle / MINUTE) : null;
313}
314
315// --- Pulse ------------------------------------------------------------------
316
317/** Counts per day for the `days` days ending today (UTC), oldest first. */
318export function dailyBuckets(points: { at: number; value?: number }[], days: number, now: number): number[] {
319 const end = Math.floor(now / DAY);
320 const buckets = new Array<number>(days).fill(0);
321 for (const point of points) {
322 const index = days - 1 - (end - Math.floor(point.at / DAY));
323 if (index >= 0 && index < days) buckets[index] += point.value ?? 1;
324 }
325 return buckets;
326}
327
328export function median(values: number[]): number | null {
329 if (values.length === 0) return null;
330 const sorted = [...values].sort((a, b) => a - b);
331 const middle = Math.floor(sorted.length / 2);
332 return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
333}
334
335/**
336 * The share of pull requests whose first run of checks passed, from the
337 * `checks.completed` events of one or more projects. Null with none.
338 */
339export function firstPassRate(events: { repo: string; number: number; at: number; passed: boolean }[]): { rate: number | null; of: number } {
340 const first = new Map<string, { at: number; passed: boolean }>();
341 for (const event of events) {
342 const key = `${event.repo}#${event.number}`;
343 const seen = first.get(key);
344 if (!seen || event.at < seen.at) first.set(key, { at: event.at, passed: event.passed });
345 }
346 if (first.size === 0) return { rate: null, of: 0 };
347 const passed = [...first.values()].filter((run) => run.passed).length;
348 return { rate: passed / first.size, of: first.size };
349}
350
351/** The share of check runs that passed. Null with none. */
352export function passRate(results: boolean[]): number | null {
353 if (results.length === 0) return null;
354 return results.filter(Boolean).length / results.length;
355}
356
357/**
358 * A project's health from its recent workflow runs, for one that ships
359 * without pull requests (whose checks are what `checks.completed` counts).
360 * Only runs that finished and passed or failed count: cancelled and skipped
361 * say nothing about the code. A run passed on the first try when its first
362 * attempt is the one that passed.
363 */
364export function runHealth(runs: { attempt: number; status: string; conclusion: string | null }[]): { passRate: number | null; firstPass: { rate: number | null; of: number }; checkRuns: number } {
365 const decided = runs.filter((run) => run.status === "completed" && (run.conclusion === "success" || run.conclusion === "failure"));
366 const passed = decided.map((run) => run.conclusion === "success");
367 const first = decided.filter((run) => run.conclusion === "success" && run.attempt <= 1).length;
368 return { passRate: passRate(passed), firstPass: { rate: decided.length ? first / decided.length : null, of: decided.length }, checkRuns: decided.length };
369}
370
371/** How long from a work item being opened to its change landing, for each that did, in ms. */
372export function itemToMerge(
373 opened: { repo: string; number: number; at: number }[],
374 merged: { repo: string; item: number | null; at: number }[],
375): number[] {
376 const openedAt = new Map(opened.map((o) => [`${o.repo}#${o.number}`, o.at]));
377 const spans: number[] = [];
378 for (const m of merged) {
379 if (m.item == null) continue;
380 const start = openedAt.get(`${m.repo}#${m.item}`);
381 if (start != null && m.at >= start) spans.push(m.at - start);
382 }
383 return spans;
384}
385
386/** "3h", "2d 4h", "40m": a span in the largest units that read well. */
387export function formatSpan(ms: number | null): string {
388 if (ms == null) return "—";
389 if (ms < HOUR) return `${Math.max(1, Math.round(ms / MINUTE))}m`;
390 if (ms < DAY) return `${Math.round(ms / HOUR)}h`;
391 const days = Math.floor(ms / DAY);
392 const hours = Math.round((ms % DAY) / HOUR);
393 return hours ? `${days}d ${hours}h` : `${days}d`;
394}
395
396/**
397 * Points for a sparkline `width` by `height`: the values spread across the
398 * width, scaled so the largest touches the top and zero sits on the
399 * bottom, with `pad` kept clear for the line's width.
400 */
401export function sparkPoints(values: number[], width: number, height: number, pad = 2): [number, number][] {
402 if (values.length === 0) return [];
403 const max = Math.max(...values, 0);
404 const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0;
405 return values.map((value, index) => {
406 const x = values.length > 1 ? pad + index * step : width / 2;
407 const y = max > 0 ? height - pad - (Math.max(0, value) / max) * (height - pad * 2) : height - pad;
408 return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
409 });
410}
411
412/** Open work items by how long they have been open. */
413export function ageBuckets(created: string[], now: number): { label: string; count: number }[] {
414 const buckets = [
415 { label: "Under a day", max: DAY, count: 0 },
416 { label: "Under a week", max: 7 * DAY, count: 0 },
417 { label: "Under a month", max: 30 * DAY, count: 0 },
418 { label: "Older", max: Number.POSITIVE_INFINITY, count: 0 },
419 ];
420 for (const at of created) {
421 const age = now - Date.parse(at);
422 const bucket = buckets.find((b) => age < b.max) ?? buckets[buckets.length - 1];
423 bucket.count += 1;
424 }
425 return buckets.map(({ label, count }) => ({ label, count }));
426}
427
428export const TIME = { MINUTE, HOUR, DAY };