Skip to content

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

686 lines28,347 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1/**
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 */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step8import type { AgentRun, CheckStatus, ChangedFile, Confidence, ConfidenceLevel, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms9
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 {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent16 return name === "g1t" || (name ?? "").endsWith("-agent");
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms17}
18
19// --- Reasons ----------------------------------------------------------------
20
21/** Why something is waiting on a person, as the chip beside it says. */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step22export 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";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms31
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",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step37 low_confidence: "Low confidence",
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms38 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":
Usage, Billing settings and prepaid AI credit; fixes from the UX audit61 case "runner":
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms62 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 {
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step70 // 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";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms72 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";
Fast pages, required checks on the branch, self-hosted runners, honest incidents74 if (/checks? (?:still )?fail|still fails?\b|could not be run|failed in the merge queue/i.test(detail)) return "checks_failing";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms75 if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review";
76 return "stalled";
77}
78
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step79/**
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
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms101/** Why only a person can move it: the callout beside what the agent knows. */
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step102export 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 }
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms119 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.";
Usage, Billing settings and prepaid AI credit; fixes from the UX audit126 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.";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms128 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;
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step152/** 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}
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms161
Fast pages, required checks on the branch, self-hosted runners, honest incidents162/** 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}
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms186
187/** Test files, by the names test runners look for. */
188export function isTestFile(path: string): boolean {
A file named test.js or spec.rb counts as a test: confidence no longer says tests not added when they were189 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 );
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms196}
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[];
Fast pages, required checks on the branch, self-hosted runners, honest incidents214 lifecycle?: (Pick<Lifecycle, "revisions"> & Partial<Pick<Lifecycle, "stage" | "detail">>) | null;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms215 runs?: Pick<AgentRun, "costUsd" | "kind">[];
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step216 confidence?: Pick<Confidence, "level" | "reasons"> | null;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms217}): Fact[] {
218 const facts: Fact[] = [];
Fast pages, required checks on the branch, self-hosted runners, honest incidents219 facts.push(
220 checksFact(input.checkStatus, input.lifecycle?.stage ? { stage: input.lifecycle.stage, detail: input.lifecycle.detail ?? "" } : null),
221 );
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms222 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 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step247 if (input.confidence) facts.push(confidenceFact(input.confidence));
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms248 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. */
Mission control counts people's direct pushes to the default branch436/**
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
Merge branch 'main' into actions-toolkit-oidc-artifacts451/**
452 * Each commit of `history` (newest first) a push in `pushes` (newest
453 * first) brought, at that push's time. A commit pushed twice (after a
454 * force push, say) counts once, at its first landing; a push whose `after`
455 * is no longer in the history (rewritten) brings nothing.
456 */
457export function placePushes<C extends { hash: string; parents: string[]; author: { name: string } }>(
458 history: C[],
459 pushes: { time: string; data: { after: string; before?: string } }[],
460): { hash: string; at: string }[] {
461 const index = new Map(history.map((commit, i) => [commit.hash, i]));
462 const seen = new Map<string, string>();
463 for (const push of [...pushes].reverse()) {
464 const start = index.get(push.data.after);
465 if (start === undefined) continue;
466 const end = push.data.before ? index.get(push.data.before) : undefined;
467 for (const commit of pushedCommits(history.slice(start, end ?? history.length), undefined)) {
468 if (!seen.has(commit.hash)) seen.set(commit.hash, push.time);
469 }
470 }
471 return [...seen].map(([hash, at]) => ({ hash, at }));
472}
473
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms474export type Merged = {
475 repo: RepoPath;
476 number: number;
477 title: string;
478 agent: string;
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar479 /** Whether an agent wrote it (its author is g1t or another agent), not a person. */
480 authoredByAgent: boolean;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms481 mergedBy: string | null;
482 mergedAt: string;
483 files: ChangedFile[];
484};
485
486/**
487 * Whether a change landed without a person: g1t merged it, by auto-merge or
488 * from the merge queue, rather than someone pressing merge.
489 */
490export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean {
491 return change.mergedBy == null || isAgent(change.mergedBy);
492}
493
494/** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */
495export function dayKey(at: number, timeZone: string | null): string {
496 try {
497 return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at);
498 } catch {
499 return new Date(at).toISOString().slice(0, 10);
500 }
501}
502
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar503/**
504 * One day's changes by who did the work: `agents`, written by an agent and
505 * landed without a person; `assisted`, written by an agent and merged by a
506 * person; `people`, written by a person, however it landed.
507 */
508export type WeekDay = { key: string; label: string; agents: number; assisted: number; people: number };
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms509
510export type Week = {
511 days: WeekDay[];
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar512 /** Changes landed in the last seven days. */
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms513 total: number;
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar514 /** Of those, agents' changes that landed without a person. */
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms515 byAgents: number;
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar516 /** Agents' changes, with a person or without. */
517 agentChanges: number;
518 /** People's own changes. */
519 people: number;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms520 /** The seven days before, or null when the lists read do not reach back that far. */
521 previous: number | null;
522};
523
524/**
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar525 * The last seven days in the viewer's zone, oldest first, each split by who
526 * did the work (see `WeekDay`), and the week before as one number.
527 * `complete` says whether what was read reaches back two weeks; when it
528 * does not, the week before is not guessed.
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms529 */
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar530export function weekOf(
531 changes: Pick<Merged, "mergedAt" | "mergedBy" | "authoredByAgent">[],
532 now: number,
533 timeZone: string | null,
534 complete = true,
535): Week {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms536 const days: WeekDay[] = [];
537 const index = new Map<string, number>();
538 for (let back = 6; back >= 0; back -= 1) {
539 const at = now - back * DAY;
540 const key = dayKey(at, timeZone);
541 if (index.has(key)) continue;
542 index.set(key, days.length);
543 let label: string;
544 try {
545 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at);
546 } catch {
547 label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at);
548 }
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar549 days.push({ key, label, agents: 0, assisted: 0, people: 0 });
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms550 }
551 const oldest = days[0]?.key ?? "";
552 const twoWeeks = dayKey(now - 13 * DAY, timeZone);
553 let previous = 0;
554 for (const change of changes) {
555 const at = Date.parse(change.mergedAt);
556 if (!Number.isFinite(at) || at > now) continue;
557 const key = dayKey(at, timeZone);
558 const slot = index.get(key);
559 if (slot != null) {
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar560 if (!change.authoredByAgent) days[slot].people += 1;
561 else if (landedByAgents(change)) days[slot].agents += 1;
562 else days[slot].assisted += 1;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms563 } else if (key < oldest && key >= twoWeeks) {
564 previous += 1;
565 }
566 }
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar567 const sum = (pick: (day: WeekDay) => number) => days.reduce((total, day) => total + pick(day), 0);
568 const byAgents = sum((day) => day.agents);
569 const agentChanges = byAgents + sum((day) => day.assisted);
570 const people = sum((day) => day.people);
571 return { days, total: agentChanges + people, byAgents, agentChanges, people, previous: complete ? previous : null };
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms572}
573
574/** The change from one number to another, as a share; null from nothing. */
575export function change(current: number, previous: number | null): number | null {
576 if (previous == null || previous === 0) return null;
577 return (current - previous) / previous;
578}
579
580/** "+18%", "−5%", "0%". */
581export function signedPercent(share: number): string {
582 const n = Math.round(share * 100);
583 return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%";
584}
585
586/**
587 * Whether the merged pull requests read for one repository reach back to
588 * `since`: the list is not full, or its oldest goes back that far.
589 */
590export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean {
591 if (list.length < page) return true;
592 const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite));
593 return oldest <= since;
594}
595
596/** One change that landed, as the third tab lists it. */
597export type LandedRow = {
598 key: string;
599 repo: RepoPath;
600 ref: string;
601 title: string;
602 by: Who | null;
603 agent: string;
604 byAgents: boolean;
605 at: number;
606 to: string;
607 facts: Fact[];
608};
609
610/** What landed on the viewer's calendar day, newest first. */
611export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] {
612 const today = dayKey(now, timeZone);
613 return changes
614 .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today)
615 .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt))
616 .map((change) => ({
617 key: pathKey(change.repo, change.number),
618 repo: change.repo,
619 ref: `#${change.number}`,
620 title: change.title,
621 by: who(change.mergedBy ?? "g1t"),
622 agent: change.agent,
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar623 byAgents: change.authoredByAgent && landedByAgents(change),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms624 at: Date.parse(change.mergedAt),
625 to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`,
Fast pages, required checks on the branch, self-hosted runners, honest incidents626 facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms627 }));
628}
629
630// --- Tabs and sorting -------------------------------------------------------
631
632export type Tab = "needs" | "waiting" | "landed";
633export type Sort = "impact" | "newest";
634
635export const TABS: Tab[] = ["needs", "waiting", "landed"];
636
637export function parseTab(value: string | null): Tab | null {
638 return value === "needs" || value === "waiting" || value === "landed" ? value : null;
639}
640
641export function parseSort(value: string | null): Sort {
642 return value === "newest" ? "newest" : "impact";
643}
644
645/** Rows in the order chosen: as ranked (most urgent first), or newest first. */
646export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] {
647 return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows;
648}
649
650// --- The summary ------------------------------------------------------------
651
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar652/**
653 * The sentence under the greeting: the week, honestly, in one line. What
654 * agents landed alone is counted against the agents' own changes, never
655 * against what people wrote.
656 */
657export function summaryLine(input: {
658 total: number;
659 byAgents: number;
660 agentChanges: number;
661 people: number;
662 live: number;
663 needs: number;
664}): string {
665 const { total, byAgents, agentChanges, people, live, needs } = input;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms666 if (total > 0) {
Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar667 const theirs = people > 0 ? `, and people landed ${plural(people, "change")} of their own` : "";
668 if (agentChanges === 0) return `People landed ${plural(people, "change")} this week; none were agents'.`;
669 if (byAgents === agentChanges) return `Agents landed all ${plural(agentChanges, "change")} of theirs this week without you${theirs}.`;
670 if (byAgents === 0) return `Agents made ${plural(agentChanges, "change")} this week, each merged by a person${theirs}.`;
671 return `Agents landed ${byAgents} of their ${plural(agentChanges, "change")} this week without you${theirs}.`;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms672 }
673 if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`;
674 if (needs > 0) return "Nothing has landed this week. What is waiting on you is below.";
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent675 return "Nothing has landed this week yet. Assign an issue to g1t and it starts in seconds.";
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms676}
677
678/** "Monday, Oct 5", in the viewer's zone. */
679export function dateLine(now: number, timeZone: string | null): string {
680 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" };
681 try {
682 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now);
683 } catch {
684 return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now);
685 }
686}

This file's history is long; its oldest lines are credited to the oldest commit read.