flagon-io/g1t

public

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/apps/web/app/lib/mission-control.ts

533 lines20,844 bytesCodeBlame
1/**
2 * The shaping behind mission control's dashboard: why each thing needs the
3 * viewer, what an agent already knows about it, the three tabs, the week
4 * split into what agents landed alone and what a person merged, and the
5 * one sentence under the greeting. Pure, so it is tested on its own; it
6 * imports only types.
7 */
8import type { AgentRun, CheckStatus, ChangedFile, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts";
9
10import type { Need } from "./mission";
11
12const DAY = 24 * 60 * 60 * 1000;
13
14/** Accounts that are g1t's own agents and machinery: `isAgent` in ./mission, kept here so this module imports only types. */
15export function isAgent(name: string | null | undefined): boolean {
16 return name === "g1t-agent" || name === "g1t" || (name ?? "").endsWith("-agent");
17}
18
19// --- Reasons ----------------------------------------------------------------
20
21/** Why something is waiting on a person, as the chip beside it says. */
22export type Reason = "blocking" | "asked_for_you" | "checks_failing" | "outside_guardrails" | "needs_review" | "stalled" | "ready_to_merge";
23
24export const REASON_LABEL: Record<Reason, string> = {
25 blocking: "Blocking",
26 asked_for_you: "Asked for you",
27 checks_failing: "Checks failing",
28 outside_guardrails: "Outside guardrails",
29 needs_review: "Needs review",
30 stalled: "Stalled",
31 ready_to_merge: "Ready to merge",
32};
33
34/**
35 * The reason a need is shown, from its kind and, for a pull request g1t
36 * stopped seeing through, the sentence it stopped with.
37 */
38export function reasonFor(need: Pick<Need, "kind" | "detail">): Reason {
39 switch (need.kind) {
40 case "limit":
41 case "deploy":
42 case "conflict":
43 return "blocking";
44 case "invitation":
45 case "review":
46 return "asked_for_you";
47 case "checks":
48 return "checks_failing";
49 case "ready":
50 return "ready_to_merge";
51 case "stuck":
52 return "stalled";
53 case "stalled":
54 return stallReason(need.detail);
55 }
56}
57
58/** What a pull request's `needs_you` sentence says it is waiting for. */
59export function stallReason(detail: string): Reason {
60 if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails";
61 if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking";
62 if (/checks? (?:still )?fail|still fails|could not be run/i.test(detail)) return "checks_failing";
63 if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review";
64 return "stalled";
65}
66
67/** Why only a person can move it: the callout beside what the agent knows. */
68export function whyFor(reason: Reason, need: Pick<Need, "kind" | "detail">): string {
69 if (need.kind === "limit") return "Agents start nothing new past the workspace's usage limit. Only an owner can raise it or add a card.";
70 if (need.kind === "deploy")
71 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.";
72 if (need.kind === "invitation") return "An invitation is to you. No one else can accept it.";
73 if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict.";
74 if (need.kind === "stuck")
75 return "A running agent has stopped reporting. It may be working on something long, or stuck; a look at its session tells which.";
76 switch (reason) {
77 case "outside_guardrails":
78 return "The run reached a limit set in Guardrails. g1t does not lift a cap on its own; a person raises it, then asks for the next step.";
79 case "blocking":
80 return "g1t could not land it and stopped rather than guess. A person decides how it lands, or whether it should.";
81 case "checks_failing":
82 return "The agent revised and the checks still fail, so g1t stopped sending it back instead of looping. Guide it, fix it yourself, or close it.";
83 case "needs_review":
84 return "This repository wants a person's verdict before it lands, and the agent has done what it can without one.";
85 case "ready_to_merge":
86 return "Checks passed and it was approved. This repository lands a change only when a person merges it.";
87 case "stalled":
88 return "The agent stopped and g1t does not start it again on its own. Ask for a review, a revision or a catch-up, or close it.";
89 case "asked_for_you":
90 return "It was put to you by name.";
91 }
92}
93
94/** Reasons where nothing moves until a person acts on it. */
95export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]);
96
97// --- What the agent knows ---------------------------------------------------
98
99export type FactTone = "good" | "warn" | "bad" | null;
100export type Fact = { label: string; value: string; tone: FactTone };
101
102const CHECKS: Record<CheckStatus, { text: string; tone: FactTone }> = {
103 passed: { text: "Passed", tone: "good" },
104 failed: { text: "Failing", tone: "bad" },
105 errored: { text: "Could not run", tone: "bad" },
106 running: { text: "Running", tone: null },
107 queued: { text: "Queued", tone: null },
108};
109
110/** Test files, by the names test runners look for. */
111export function isTestFile(path: string): boolean {
112 return /(^|\/)(__tests__|tests?|spec)\//i.test(path) || /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || /_test\.(go|rs|py)$/i.test(path);
113}
114
115const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
116
117/** "$0.42", or "<$0.01" for a sliver. */
118export function usd(value: number): string {
119 if (value > 0 && value < 0.01) return "<$0.01";
120 return `$${value.toFixed(2)}`;
121}
122
123/**
124 * What is known about a pull request without opening it: its checks, how
125 * much it changes, the tests it touches, how often the agent was sent back,
126 * and what its runs cost. Only what is known is listed.
127 */
128export function pullFacts(input: {
129 checkStatus: CheckStatus | null;
130 files: ChangedFile[];
131 lifecycle?: Pick<Lifecycle, "revisions"> | null;
132 runs?: Pick<AgentRun, "costUsd" | "kind">[];
133}): Fact[] {
134 const facts: Fact[] = [];
135 const checks = input.checkStatus ? CHECKS[input.checkStatus] : null;
136 facts.push({ label: "Checks", value: checks?.text ?? "Not run", tone: checks?.tone ?? null });
137 if (input.files.length > 0) {
138 const added = input.files.reduce((sum, f) => sum + f.additions, 0);
139 const removed = input.files.reduce((sum, f) => sum + f.deletions, 0);
140 facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null });
141 facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null });
142 const tests = input.files.filter((f) => isTestFile(f.path)).length;
143 facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" });
144 }
145 if (input.lifecycle && input.lifecycle.revisions > 0) {
146 facts.push({
147 label: "Sent back",
148 value: plural(input.lifecycle.revisions, "time"),
149 tone: input.lifecycle.revisions > 1 ? "warn" : null,
150 });
151 }
152 const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck");
153 if (agentRuns.length > 0) {
154 const costs = agentRuns.filter((run) => run.costUsd != null);
155 const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
156 facts.push({
157 label: "Agent runs",
158 value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length),
159 tone: null,
160 });
161 }
162 return facts;
163}
164
165// --- Rows -------------------------------------------------------------------
166
167/** Someone or something on a row: an agent, a person, or g1t itself. */
168export type Who = { name: string; agent: boolean };
169
170export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null);
171
172/** A form on mission control that acts without leaving it. */
173export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string };
174
175/** One thing that needs the viewer, with what is known about it. */
176export type NeedRow = {
177 key: string;
178 reason: Reason;
179 repo: RepoPath | null;
180 /** "#41", when it is an issue or pull request. */
181 ref: string | null;
182 title: string;
183 /** The ask, in one sentence. */
184 ask: string;
185 /** Who is waiting: the agent on it, or the person who asked. */
186 by: Who | null;
187 /** The person who started it, when an agent did the work. */
188 for: string | null;
189 at: number;
190 to: string;
191 open: string;
192 facts: Fact[];
193 why: string;
194 quick: QuickAction | null;
195 /** Somewhere else to act, when that is the next step. */
196 link: { label: string; to: string } | null;
197};
198
199/** The agents' stages a pull request can wait in, as their chips say them. */
200export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = {
201 working: "Working",
202 checking: "Checking",
203 reviewing: "Reviewing",
204 revising: "Revising",
205 catching_up: "Catching up",
206 answering: "Answering",
207 queued: "In queue",
208};
209
210/** A run's kind, as its chip says it. */
211export const RUN_LABEL: Record<RunKind, string> = {
212 implement: "Working",
213 revise: "Revising",
214 review: "Reviewing",
215 answer: "Answering",
216 update: "Catching up",
217 plan: "Planning",
218 checks: "Checking",
219 queue: "In queue",
220 mergecheck: "Checking",
221};
222
223/** Something agents are doing, which the viewer can leave to them. */
224export type WaitingRow = {
225 key: string;
226 repo: RepoPath;
227 ref: string | null;
228 title: string;
229 chip: string;
230 detail: string;
231 by: Who | null;
232 at: number;
233 to: string;
234 /** Whether a run is going on it right now. */
235 live: boolean;
236 /** The run going on it, to watch. */
237 run: string | null;
238 facts: Fact[];
239};
240
241const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase();
242
243/**
244 * What is in agents' hands: pull requests in an agent's stage, runs going
245 * now, and drafts agents are still making. Each once, and nothing that is
246 * already waiting on the viewer.
247 */
248export function waitingRows(input: {
249 active: {
250 pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] };
251 lifecycle: Lifecycle | null;
252 repo: RepoPath;
253 }[];
254 live: Pick<
255 AgentRun,
256 "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt"
257 >[];
258 drafts: {
259 number: number;
260 title: string;
261 agent: string;
262 updatedAt: string;
263 checkStatus: CheckStatus | null;
264 files: ChangedFile[];
265 repo: RepoPath;
266 }[];
267 needKeys: ReadonlySet<string>;
268}): WaitingRow[] {
269 const rows = new Map<string, WaitingRow>();
270 for (const { pull, lifecycle, repo } of input.active) {
271 if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue;
272 const key = pathKey(repo, pull.number);
273 if (input.needKeys.has(key)) continue;
274 rows.set(key, {
275 key,
276 repo,
277 ref: `#${pull.number}`,
278 title: pull.title,
279 chip: STAGE_CHIP[lifecycle.stage],
280 detail: lifecycle.detail,
281 by: who(pull.agent),
282 at: Date.parse(pull.updatedAt),
283 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
284 live: false,
285 run: null,
286 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }),
287 });
288 }
289 for (const run of input.live) {
290 const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`;
291 if (input.needKeys.has(key)) continue;
292 const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`;
293 const existing = rows.get(key);
294 const started = Date.parse(run.startedAt ?? run.createdAt);
295 const runFacts: Fact[] = [
296 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
297 ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
298 ];
299 if (existing) {
300 existing.live = true;
301 existing.run = runTo;
302 if (run.step) existing.detail = run.step;
303 existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")];
304 existing.at = Math.max(existing.at, Date.parse(run.updatedAt));
305 continue;
306 }
307 rows.set(key, {
308 key,
309 repo: run.repo,
310 ref: run.number != null ? `#${run.number}` : null,
311 title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`,
312 chip: RUN_LABEL[run.kind],
313 detail: run.step ?? "Starting.",
314 by: who(run.agent),
315 at: Number.isFinite(started) ? started : Date.parse(run.updatedAt),
316 to: runTo,
317 live: true,
318 run: runTo,
319 facts: runFacts,
320 });
321 }
322 for (const draft of input.drafts) {
323 const key = pathKey(draft.repo, draft.number);
324 if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue;
325 rows.set(key, {
326 key,
327 repo: draft.repo,
328 ref: `#${draft.number}`,
329 title: draft.title,
330 chip: "Working",
331 detail: `${draft.agent} is making the change.`,
332 by: who(draft.agent),
333 at: Date.parse(draft.updatedAt),
334 to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`,
335 live: false,
336 run: null,
337 facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }),
338 });
339 }
340 // Running now first, then what moved most recently.
341 return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at);
342}
343
344/** The key a need is known by when matching it to agents' work. */
345export const needPathKey = pathKey;
346
347// --- Landed -----------------------------------------------------------------
348
349/** A merged pull request, as the week counts it. */
350export type Merged = {
351 repo: RepoPath;
352 number: number;
353 title: string;
354 agent: string;
355 mergedBy: string | null;
356 mergedAt: string;
357 files: ChangedFile[];
358};
359
360/**
361 * Whether a change landed without a person: g1t merged it, by auto-merge or
362 * from the merge queue, rather than someone pressing merge.
363 */
364export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean {
365 return change.mergedBy == null || isAgent(change.mergedBy);
366}
367
368/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
369export function dayKey(at: number, timeZone: string | null): string {
370 try {
371 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
372 } catch {
373 return new Date(at).toISOString().slice(0, 10);
374 }
375}
376
377export type WeekDay = { key: string; label: string; agents: number; people: number };
378
379export type Week = {
380 days: WeekDay[];
381 /** Changes landed in the last seven days, and how many without a person. */
382 total: number;
383 byAgents: number;
384 /** The seven days before, or null when the lists read do not reach back that far. */
385 previous: number | null;
386};
387
388/**
389 * The last seven days in the viewer's zone, oldest first, each split into
390 * what agents landed alone and what a person merged, and the week before
391 * as one number. `complete` says whether what was read reaches back two
392 * weeks; when it does not, the week before is not guessed.
393 */
394export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week {
395 const days: WeekDay[] = [];
396 const index = new Map<string, number>();
397 for (let back = 6; back >= 0; back -= 1) {
398 const at = now - back * DAY;
399 const key = dayKey(at, timeZone);
400 if (index.has(key)) continue;
401 index.set(key, days.length);
402 let label: string;
403 try {
404 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
405 } catch {
406 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
407 }
408 days.push({ key, label, agents: 0, people: 0 });
409 }
410 const oldest = days[0]?.key ?? "";
411 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
412 let previous = 0;
413 for (const change of changes) {
414 const at = Date.parse(change.mergedAt);
415 if (!Number.isFinite(at) || at > now) continue;
416 const key = dayKey(at, timeZone);
417 const slot = index.get(key);
418 if (slot != null) {
419 if (landedByAgents(change)) days[slot].agents += 1;
420 else days[slot].people += 1;
421 } else if (key < oldest && key >= twoWeeks) {
422 previous += 1;
423 }
424 }
425 const byAgents = days.reduce((sum, day) => sum + day.agents, 0);
426 const total = byAgents + days.reduce((sum, day) => sum + day.people, 0);
427 return { days, total, byAgents, previous: complete ? previous : null };
428}
429
430/** The change from one number to another, as a share; null from nothing. */
431export function change(current: number, previous: number | null): number | null {
432 if (previous == null || previous === 0) return null;
433 return (current - previous) / previous;
434}
435
436/** "+18%", "−5%", "0%". */
437export function signedPercent(share: number): string {
438 const n = Math.round(share * 100);
439 return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
440}
441
442/**
443 * Whether the merged pull requests read for one repository reach back to
444 * `since`: the list is not full, or its oldest goes back that far.
445 */
446export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
447 if (list.length < page) return true;
448 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
449 return oldest <= since;
450}
451
452/** One change that landed, as the third tab lists it. */
453export type LandedRow = {
454 key: string;
455 repo: RepoPath;
456 ref: string;
457 title: string;
458 by: Who | null;
459 agent: string;
460 byAgents: boolean;
461 at: number;
462 to: string;
463 facts: Fact[];
464};
465
466/** What landed on the viewer's calendar day, newest first. */
467export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
468 const today = dayKey(now, timeZone);
469 return changes
470 .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
471 .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
472 .map((change) => ({
473 key: pathKey(change.repo, change.number),
474 repo: change.repo,
475 ref: `#${change.number}`,
476 title: change.title,
477 by: who(change.mergedBy ?? "g1t"),
478 agent: change.agent,
479 byAgents: landedByAgents(change),
480 at: Date.parse(change.mergedAt),
481 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
482 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Checks"),
483 }));
484}
485
486// --- Tabs and sorting -------------------------------------------------------
487
488export type Tab = "needs" | "waiting" | "landed";
489export type Sort = "impact" | "newest";
490
491export const TABS: Tab[] = ["needs", "waiting", "landed"];
492
493export function parseTab(value: string | null): Tab | null {
494 return value === "needs" || value === "waiting" || value === "landed" ? value : null;
495}
496
497export function parseSort(value: string | null): Sort {
498 return value === "newest" ? "newest" : "impact";
499}
500
501/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
502export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
503 return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
504}
505
506// --- The summary ------------------------------------------------------------
507
508/** The sentence under the greeting: the week, honestly, in one line. */
509export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string {
510 const { total, byAgents, live, needs } = input;
511 if (total > 0) {
512 const landed =
513 byAgents === total
514 ? `Agents landed all ${plural(total, "change")} this week without you.`
515 : byAgents === 0
516 ? `${plural(total, "change")} landed this week, each merged by a person.`
517 : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`;
518 return landed;
519 }
520 if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
521 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
522 return "Nothing has landed this week yet. Assign an issue to g1t-agent and it starts in seconds.";
523}
524
525/** "Monday, Oct 5", in the viewer's zone. */
526export function dateLine(now: number, timeZone: string | null): string {
527 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
528 try {
529 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
530 } catch {
531 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
532 }
533}