Skip to content

g1t/apps/web/app/lib/mission-control.ts

663 lines27,282 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, Confidence, ConfidenceLevel, 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" || (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 =
23 | "blocking"
24 | "asked_for_you"
25 | "checks_failing"
26 | "outside_guardrails"
27 | "low_confidence"
28 | "needs_review"
29 | "stalled"
30 | "ready_to_merge";
31
32export const REASON_LABEL: Record<Reason, string> = {
33 blocking: "Blocking",
34 asked_for_you: "Asked for you",
35 checks_failing: "Checks failing",
36 outside_guardrails: "Outside guardrails",
37 low_confidence: "Low confidence",
38 needs_review: "Needs review",
39 stalled: "Stalled",
40 ready_to_merge: "Ready to merge",
41};
42
43/**
44 * The reason a need is shown, from its kind and, for a pull request g1t
45 * stopped seeing through, the sentence it stopped with.
46 */
47export function reasonFor(need: Pick<Need, "kind" | "detail">): Reason {
48 switch (need.kind) {
49 case "limit":
50 case "deploy":
51 case "conflict":
52 return "blocking";
53 case "invitation":
54 case "review":
55 return "asked_for_you";
56 case "checks":
57 return "checks_failing";
58 case "ready":
59 return "ready_to_merge";
60 case "stuck":
61 case "runner":
62 return "stalled";
63 case "stalled":
64 return stallReason(need.detail);
65 }
66}
67
68/** What a pull request's `needs_you` sentence says it is waiting for. */
69export function stallReason(detail: string): Reason {
70 // First: its reasons can name checks, caps or approval in passing.
71 if (/confidence in this change is low/i.test(detail)) return "low_confidence";
72 if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails";
73 if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking";
74 if (/checks? (?:still )?fail|still fails?\b|could not be run|failed in the merge queue/i.test(detail)) return "checks_failing";
75 if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review";
76 return "stalled";
77}
78
79/**
80 * The reason a pull request's need is shown, given how sure g1t is of the
81 * change: one that is ready, or held, with low confidence says so.
82 */
83export function withConfidence(reason: Reason, confidence: Pick<Confidence, "level"> | null | undefined): Reason {
84 if (confidence?.level !== "low") return reason;
85 return reason === "ready_to_merge" || reason === "stalled" || reason === "needs_review" ? "low_confidence" : reason;
86}
87
88const LEVEL: Record<ConfidenceLevel, string> = { low: "Low", medium: "Medium", high: "High" };
89
90/** "Low — tests not added, 3 revisions": a confidence in one line. */
91export function confidenceLine(confidence: Pick<Confidence, "level" | "reasons">): string {
92 return confidence.reasons.length > 0 ? `${LEVEL[confidence.level]} — ${confidence.reasons.join(", ")}` : LEVEL[confidence.level];
93}
94
95/** The ask, for a change held for its low confidence. */
96export function confidenceAsk(confidence: Pick<Confidence, "reasons">): string {
97 const why = confidence.reasons.length > 0 ? `: ${confidence.reasons.join(", ")}` : "";
98 return `The agent finished, but g1t is not sure of the change${why}. Approve it to let it land, or ask for changes.`;
99}
100
101/** Why only a person can move it: the callout beside what the agent knows. */
102export function whyFor(
103 reason: Reason,
104 need: Pick<Need, "kind" | "detail">,
105 confidence?: Pick<Confidence, "reasons" | "uncertainAbout"> | null,
106): string {
107 if (reason === "low_confidence") {
108 const reasons = confidence?.reasons ?? [];
109 const what = reasons.length > 0 ? reasons.join(", ") : "what g1t observed of the change";
110 const unsure =
111 confidence && confidence.uncertainAbout.length > 0 ? ` The agent said it was unsure about ${confidence.uncertainAbout.join("; ")}.` : "";
112 // Held: the repository asks a person first. Otherwise it is ready and waits for a merge anyway.
113 const held =
114 need.kind === "stalled"
115 ? "This repository asks a person before a change like that lands, so nothing merges it until you approve it."
116 : "Look at it before you merge it.";
117 return `g1t rates its confidence in this change low, from ${what}.${unsure} ${held}`;
118 }
119 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.";
120 if (need.kind === "deploy")
121 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.";
122 if (need.kind === "invitation") return "An invitation is to you. No one else can accept it.";
123 if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict.";
124 if (need.kind === "stuck")
125 return "A running agent has stopped reporting. It may be working on something long, or stuck; a look at its session tells which.";
126 if (need.kind === "runner")
127 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.";
128 switch (reason) {
129 case "outside_guardrails":
130 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.";
131 case "blocking":
132 return "g1t could not land it and stopped rather than guess. A person decides how it lands, or whether it should.";
133 case "checks_failing":
134 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.";
135 case "needs_review":
136 return "This repository wants a person's verdict before it lands, and the agent has done what it can without one.";
137 case "ready_to_merge":
138 return "Checks passed and it was approved. This repository lands a change only when a person merges it.";
139 case "stalled":
140 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.";
141 case "asked_for_you":
142 return "It was put to you by name.";
143 }
144}
145
146/** Reasons where nothing moves until a person acts on it. */
147export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]);
148
149// --- What the agent knows ---------------------------------------------------
150
151export type FactTone = "good" | "warn" | "bad" | null;
152/** One thing known; a `wide` one takes the whole row and wraps. */
153export type Fact = { label: string; value: string; tone: FactTone; wide?: boolean };
154
155const CONFIDENCE_TONE: Record<ConfidenceLevel, FactTone> = { low: "bad", medium: "warn", high: "good" };
156
157/** How sure g1t is of the change, with its reasons, as a fact. */
158export function confidenceFact(confidence: Pick<Confidence, "level" | "reasons">): Fact {
159 return { label: "Confidence", value: confidenceLine(confidence), tone: CONFIDENCE_TONE[confidence.level], wide: true };
160}
161
162/** The stages a pull request reaches only once its required checks have passed. */
163const PAST_CHECKS = new Set(["reviewing", "catching_up", "answering", "queued", "ready"]);
164
165/**
166 * How the checks the default branch requires stand on a pull request, from
167 * what a list knows without opening it: the merge queue taking it out, and
168 * for one g1t sees through, the stage it is at and what that stage says.
169 */
170export function checksFact(
171 checkStatus: CheckStatus | null,
172 lifecycle?: Pick<Lifecycle, "stage" | "detail"> | null,
173): Fact {
174 const fact = (value: string, tone: FactTone): Fact => ({ label: "Required checks", value, tone });
175 if (checkStatus === "failed") return fact("Failed in the merge queue", "bad");
176 if (!lifecycle?.stage) return fact(checkStatus === "passed" ? "Passing" : "Not known yet", checkStatus === "passed" ? "good" : null);
177 const detail = lifecycle.detail ?? "";
178 // "CI / pull_request failed", "The required check CI still fails ...".
179 const failing = /\b(?:failed|still fails?)\b/i.test(detail) && !/review|asked for changes|merge queue/i.test(detail);
180 if (lifecycle.stage === "checking") return /to report/i.test(detail) ? fact("Not reported yet", "warn") : fact("Running", null);
181 if (lifecycle.stage === "working") return fact("Not run yet", null);
182 if ((lifecycle.stage === "revising" || lifecycle.stage === "needs_you") && failing) return fact("Failing", "bad");
183 if (PAST_CHECKS.has(lifecycle.stage) || lifecycle.stage === "revising" || lifecycle.stage === "needs_you") return fact("Passing", "good");
184 return fact("Not known yet", null);
185}
186
187/** Test files, by the names test runners look for. */
188export function isTestFile(path: string): boolean {
189 return (
190 /(^|\/)(__tests__|tests?|spec)\//i.test(path) ||
191 /[._-](test|spec)\.[a-z0-9]+$/i.test(path) ||
192 /_test\.(go|rs|py)$/i.test(path) ||
193 // A file named for what it is: test.js, tests.py, spec.rb.
194 /(^|\/)(tests?|specs?)\.[a-z0-9]+$/i.test(path)
195 );
196}
197
198const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
199
200/** "$0.42", or "<$0.01" for a sliver. */
201export function usd(value: number): string {
202 if (value > 0 && value < 0.01) return "<$0.01";
203 return `$${value.toFixed(2)}`;
204}
205
206/**
207 * What is known about a pull request without opening it: its checks, how
208 * much it changes, the tests it touches, how often the agent was sent back,
209 * and what its runs cost. Only what is known is listed.
210 */
211export function pullFacts(input: {
212 checkStatus: CheckStatus | null;
213 files: ChangedFile[];
214 lifecycle?: (Pick<Lifecycle, "revisions"> & Partial<Pick<Lifecycle, "stage" | "detail">>) | null;
215 runs?: Pick<AgentRun, "costUsd" | "kind">[];
216 confidence?: Pick<Confidence, "level" | "reasons"> | null;
217}): Fact[] {
218 const facts: Fact[] = [];
219 facts.push(
220 checksFact(input.checkStatus, input.lifecycle?.stage ? { stage: input.lifecycle.stage, detail: input.lifecycle.detail ?? "" } : null),
221 );
222 if (input.files.length > 0) {
223 const added = input.files.reduce((sum, f) => sum + f.additions, 0);
224 const removed = input.files.reduce((sum, f) => sum + f.deletions, 0);
225 facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null });
226 facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null });
227 const tests = input.files.filter((f) => isTestFile(f.path)).length;
228 facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" });
229 }
230 if (input.lifecycle && input.lifecycle.revisions > 0) {
231 facts.push({
232 label: "Sent back",
233 value: plural(input.lifecycle.revisions, "time"),
234 tone: input.lifecycle.revisions > 1 ? "warn" : null,
235 });
236 }
237 const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck");
238 if (agentRuns.length > 0) {
239 const costs = agentRuns.filter((run) => run.costUsd != null);
240 const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0);
241 facts.push({
242 label: "Agent runs",
243 value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length),
244 tone: null,
245 });
246 }
247 if (input.confidence) facts.push(confidenceFact(input.confidence));
248 return facts;
249}
250
251// --- Rows -------------------------------------------------------------------
252
253/** Someone or something on a row: an agent, a person, or g1t itself. */
254export type Who = { name: string; agent: boolean };
255
256export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null);
257
258/** A form on mission control that acts without leaving it. */
259export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string };
260
261/** One thing that needs the viewer, with what is known about it. */
262export type NeedRow = {
263 key: string;
264 reason: Reason;
265 repo: RepoPath | null;
266 /** "#41", when it is an issue or pull request. */
267 ref: string | null;
268 title: string;
269 /** The ask, in one sentence. */
270 ask: string;
271 /** Who is waiting: the agent on it, or the person who asked. */
272 by: Who | null;
273 /** The person who started it, when an agent did the work. */
274 for: string | null;
275 at: number;
276 to: string;
277 open: string;
278 facts: Fact[];
279 why: string;
280 quick: QuickAction | null;
281 /** Somewhere else to act, when that is the next step. */
282 link: { label: string; to: string } | null;
283};
284
285/** The agents' stages a pull request can wait in, as their chips say them. */
286export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = {
287 working: "Working",
288 checking: "Checking",
289 reviewing: "Reviewing",
290 revising: "Revising",
291 catching_up: "Catching up",
292 answering: "Answering",
293 queued: "In queue",
294};
295
296/** A run's kind, as its chip says it. */
297export const RUN_LABEL: Record<RunKind, string> = {
298 implement: "Working",
299 revise: "Revising",
300 review: "Reviewing",
301 answer: "Answering",
302 update: "Catching up",
303 plan: "Planning",
304 checks: "Checking",
305 queue: "In queue",
306 mergecheck: "Checking",
307};
308
309/** Something agents are doing, which the viewer can leave to them. */
310export type WaitingRow = {
311 key: string;
312 repo: RepoPath;
313 ref: string | null;
314 title: string;
315 chip: string;
316 detail: string;
317 by: Who | null;
318 at: number;
319 to: string;
320 /** Whether a run is going on it right now. */
321 live: boolean;
322 /** The run going on it, to watch. */
323 run: string | null;
324 facts: Fact[];
325};
326
327const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase();
328
329/**
330 * What is in agents' hands: pull requests in an agent's stage, runs going
331 * now, and drafts agents are still making. Each once, and nothing that is
332 * already waiting on the viewer.
333 */
334export function waitingRows(input: {
335 active: {
336 pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] };
337 lifecycle: Lifecycle | null;
338 repo: RepoPath;
339 }[];
340 live: Pick<
341 AgentRun,
342 "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt"
343 >[];
344 drafts: {
345 number: number;
346 title: string;
347 agent: string;
348 updatedAt: string;
349 checkStatus: CheckStatus | null;
350 files: ChangedFile[];
351 repo: RepoPath;
352 }[];
353 needKeys: ReadonlySet<string>;
354}): WaitingRow[] {
355 const rows = new Map<string, WaitingRow>();
356 for (const { pull, lifecycle, repo } of input.active) {
357 if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue;
358 const key = pathKey(repo, pull.number);
359 if (input.needKeys.has(key)) continue;
360 rows.set(key, {
361 key,
362 repo,
363 ref: `#${pull.number}`,
364 title: pull.title,
365 chip: STAGE_CHIP[lifecycle.stage],
366 detail: lifecycle.detail,
367 by: who(pull.agent),
368 at: Date.parse(pull.updatedAt),
369 to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`,
370 live: false,
371 run: null,
372 facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }),
373 });
374 }
375 for (const run of input.live) {
376 const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`;
377 if (input.needKeys.has(key)) continue;
378 const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`;
379 const existing = rows.get(key);
380 const started = Date.parse(run.startedAt ?? run.createdAt);
381 const runFacts: Fact[] = [
382 { label: "Run", value: RUN_LABEL[run.kind], tone: null },
383 ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []),
384 ];
385 if (existing) {
386 existing.live = true;
387 existing.run = runTo;
388 if (run.step) existing.detail = run.step;
389 existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")];
390 existing.at = Math.max(existing.at, Date.parse(run.updatedAt));
391 continue;
392 }
393 rows.set(key, {
394 key,
395 repo: run.repo,
396 ref: run.number != null ? `#${run.number}` : null,
397 title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`,
398 chip: RUN_LABEL[run.kind],
399 detail: run.step ?? "Starting.",
400 by: who(run.agent),
401 at: Number.isFinite(started) ? started : Date.parse(run.updatedAt),
402 to: runTo,
403 live: true,
404 run: runTo,
405 facts: runFacts,
406 });
407 }
408 for (const draft of input.drafts) {
409 const key = pathKey(draft.repo, draft.number);
410 if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue;
411 rows.set(key, {
412 key,
413 repo: draft.repo,
414 ref: `#${draft.number}`,
415 title: draft.title,
416 chip: "Working",
417 detail: `${draft.agent} is making the change.`,
418 by: who(draft.agent),
419 at: Date.parse(draft.updatedAt),
420 to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`,
421 live: false,
422 run: null,
423 facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }),
424 });
425 }
426 // Running now first, then what moved most recently.
427 return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at);
428}
429
430/** The key a need is known by when matching it to agents' work. */
431export const needPathKey = pathKey;
432
433// --- Landed -----------------------------------------------------------------
434
435/** A merged pull request, as the week counts it. */
436/**
437 * The commits a push to the default branch brought, from the history at its
438 * `after` (newest first) back to its `before`: people's own, not merges (a
439 * pull request landing) and not agents'. A push whose `before` is not in
440 * what was read gives what was read.
441 */
442export function pushedCommits<C extends { hash: string; parents: string[]; author: { name: string } }>(
443 history: C[],
444 before: string | undefined,
445): C[] {
446 const end = before ? history.findIndex((commit) => commit.hash === before) : -1;
447 const brought = end === -1 ? history : history.slice(0, end);
448 return brought.filter((commit) => commit.parents.length <= 1 && !isAgent(commit.author.name));
449}
450
451export type Merged = {
452 repo: RepoPath;
453 number: number;
454 title: string;
455 agent: string;
456 /** Whether an agent wrote it (its author is g1t or another agent), not a person. */
457 authoredByAgent: boolean;
458 mergedBy: string | null;
459 mergedAt: string;
460 files: ChangedFile[];
461};
462
463/**
464 * Whether a change landed without a person: g1t merged it, by auto-merge or
465 * from the merge queue, rather than someone pressing merge.
466 */
467export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean {
468 return change.mergedBy == null || isAgent(change.mergedBy);
469}
470
471/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
472export function dayKey(at: number, timeZone: string | null): string {
473 try {
474 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
475 } catch {
476 return new Date(at).toISOString().slice(0, 10);
477 }
478}
479
480/**
481 * One day's changes by who did the work: `agents`, written by an agent and
482 * landed without a person; `assisted`, written by an agent and merged by a
483 * person; `people`, written by a person, however it landed.
484 */
485export type WeekDay = { key: string; label: string; agents: number; assisted: number; people: number };
486
487export type Week = {
488 days: WeekDay[];
489 /** Changes landed in the last seven days. */
490 total: number;
491 /** Of those, agents' changes that landed without a person. */
492 byAgents: number;
493 /** Agents' changes, with a person or without. */
494 agentChanges: number;
495 /** People's own changes. */
496 people: number;
497 /** The seven days before, or null when the lists read do not reach back that far. */
498 previous: number | null;
499};
500
501/**
502 * The last seven days in the viewer's zone, oldest first, each split by who
503 * did the work (see `WeekDay`), and the week before as one number.
504 * `complete` says whether what was read reaches back two weeks; when it
505 * does not, the week before is not guessed.
506 */
507export function weekOf(
508 changes: Pick<Merged, "mergedAt" | "mergedBy" | "authoredByAgent">[],
509 now: number,
510 timeZone: string | null,
511 complete = true,
512): Week {
513 const days: WeekDay[] = [];
514 const index = new Map<string, number>();
515 for (let back = 6; back >= 0; back -= 1) {
516 const at = now - back * DAY;
517 const key = dayKey(at, timeZone);
518 if (index.has(key)) continue;
519 index.set(key, days.length);
520 let label: string;
521 try {
522 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
523 } catch {
524 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
525 }
526 days.push({ key, label, agents: 0, assisted: 0, people: 0 });
527 }
528 const oldest = days[0]?.key ?? "";
529 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
530 let previous = 0;
531 for (const change of changes) {
532 const at = Date.parse(change.mergedAt);
533 if (!Number.isFinite(at) || at > now) continue;
534 const key = dayKey(at, timeZone);
535 const slot = index.get(key);
536 if (slot != null) {
537 if (!change.authoredByAgent) days[slot].people += 1;
538 else if (landedByAgents(change)) days[slot].agents += 1;
539 else days[slot].assisted += 1;
540 } else if (key < oldest && key >= twoWeeks) {
541 previous += 1;
542 }
543 }
544 const sum = (pick: (day: WeekDay) => number) => days.reduce((total, day) => total + pick(day), 0);
545 const byAgents = sum((day) => day.agents);
546 const agentChanges = byAgents + sum((day) => day.assisted);
547 const people = sum((day) => day.people);
548 return { days, total: agentChanges + people, byAgents, agentChanges, people, previous: complete ? previous : null };
549}
550
551/** The change from one number to another, as a share; null from nothing. */
552export function change(current: number, previous: number | null): number | null {
553 if (previous == null || previous === 0) return null;
554 return (current - previous) / previous;
555}
556
557/** "+18%", "−5%", "0%". */
558export function signedPercent(share: number): string {
559 const n = Math.round(share * 100);
560 return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
561}
562
563/**
564 * Whether the merged pull requests read for one repository reach back to
565 * `since`: the list is not full, or its oldest goes back that far.
566 */
567export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
568 if (list.length < page) return true;
569 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
570 return oldest <= since;
571}
572
573/** One change that landed, as the third tab lists it. */
574export type LandedRow = {
575 key: string;
576 repo: RepoPath;
577 ref: string;
578 title: string;
579 by: Who | null;
580 agent: string;
581 byAgents: boolean;
582 at: number;
583 to: string;
584 facts: Fact[];
585};
586
587/** What landed on the viewer's calendar day, newest first. */
588export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
589 const today = dayKey(now, timeZone);
590 return changes
591 .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
592 .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
593 .map((change) => ({
594 key: pathKey(change.repo, change.number),
595 repo: change.repo,
596 ref: `#${change.number}`,
597 title: change.title,
598 by: who(change.mergedBy ?? "g1t"),
599 agent: change.agent,
600 byAgents: change.authoredByAgent && landedByAgents(change),
601 at: Date.parse(change.mergedAt),
602 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
603 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"),
604 }));
605}
606
607// --- Tabs and sorting -------------------------------------------------------
608
609export type Tab = "needs" | "waiting" | "landed";
610export type Sort = "impact" | "newest";
611
612export const TABS: Tab[] = ["needs", "waiting", "landed"];
613
614export function parseTab(value: string | null): Tab | null {
615 return value === "needs" || value === "waiting" || value === "landed" ? value : null;
616}
617
618export function parseSort(value: string | null): Sort {
619 return value === "newest" ? "newest" : "impact";
620}
621
622/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
623export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
624 return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
625}
626
627// --- The summary ------------------------------------------------------------
628
629/**
630 * The sentence under the greeting: the week, honestly, in one line. What
631 * agents landed alone is counted against the agents' own changes, never
632 * against what people wrote.
633 */
634export function summaryLine(input: {
635 total: number;
636 byAgents: number;
637 agentChanges: number;
638 people: number;
639 live: number;
640 needs: number;
641}): string {
642 const { total, byAgents, agentChanges, people, live, needs } = input;
643 if (total > 0) {
644 const theirs = people > 0 ? `, and people landed ${plural(people, "change")} of their own` : "";
645 if (agentChanges === 0) return `People landed ${plural(people, "change")} this week; none were agents'.`;
646 if (byAgents === agentChanges) return `Agents landed all ${plural(agentChanges, "change")} of theirs this week without you${theirs}.`;
647 if (byAgents === 0) return `Agents made ${plural(agentChanges, "change")} this week, each merged by a person${theirs}.`;
648 return `Agents landed ${byAgents} of their ${plural(agentChanges, "change")} this week without you${theirs}.`;
649 }
650 if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
651 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
652 return "Nothing has landed this week yet. Assign an issue to g1t and it starts in seconds.";
653}
654
655/** "Monday, Oct 5", in the viewer's zone. */
656export function dateLine(now: number, timeZone: string | null): string {
657 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
658 try {
659 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
660 } catch {
661 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
662 }
663}