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

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