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