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 200ms | 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 | */ | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 8 | import 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 200ms | 9 | |
| 10 | import type { Need } from "./mission"; | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 11 | import { microsOf, money } from "./money.ts"; |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 12 | |
| 13 | const 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. */ | |
| 16 | export 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-agent | 17 | return name === "g1t" || (name ?? "").endsWith("-agent"); |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 18 | } |
| 19 | ||
| 20 | // --- Reasons ---------------------------------------------------------------- | |
| 21 | ||
| 22 | /** 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 step | 23 | export 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"; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 32 | |
| 33 | export 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", | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 38 | low_confidence: "Low confidence", |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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 | */ | |
| 48 | export 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": | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 62 | case "runner": |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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. */ | |
| 70 | export 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 step | 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"; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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"; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 75 | 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 200ms | 76 | if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review"; |
| 77 | return "stalled"; | |
| 78 | } | |
| 79 | ||
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 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 | */ | |
| 84 | export 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 | ||
| 89 | const LEVEL: Record<ConfidenceLevel, string> = { low: "Low", medium: "Medium", high: "High" }; | |
| 90 | ||
| 91 | /** "Low — tests not added, 3 revisions": a confidence in one line. */ | |
| 92 | export 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. */ | |
| 97 | export 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 | ||
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 102 | /** 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 step | 103 | export 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 | } | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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."; | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 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."; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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. */ | |
| 148 | export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]); | |
| 149 | ||
| 150 | // --- What the agent knows --------------------------------------------------- | |
| 151 | ||
| 152 | export 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 step | 153 | /** One thing known; a `wide` one takes the whole row and wraps. */ |
| 154 | export type Fact = { label: string; value: string; tone: FactTone; wide?: boolean }; | |
| 155 | ||
| 156 | const 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. */ | |
| 159 | export function confidenceFact(confidence: Pick<Confidence, "level" | "reasons">): Fact { | |
| 160 | return { label: "Confidence", value: confidenceLine(confidence), tone: CONFIDENCE_TONE[confidence.level], wide: true }; | |
| 161 | } | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 162 | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 163 | /** The stages a pull request reaches only once its required checks have passed. */ |
| 164 | const 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 | */ | |
| 171 | export 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 | } | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 187 | |
| 188 | /** Test files, by the names test runners look for. */ | |
| 189 | export 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 were | 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 | ); | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 197 | } |
| 198 | ||
| 199 | const 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 | */ | |
| 206 | export function pullFacts(input: { | |
| 207 | checkStatus: CheckStatus | null; | |
| 208 | files: ChangedFile[]; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 209 | 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 200ms | 210 | 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 step | 211 | confidence?: Pick<Confidence, "level" | "reasons"> | null; |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 212 | }): Fact[] { |
| 213 | const facts: Fact[] = []; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 214 | facts.push( |
| 215 | checksFact(input.checkStatus, input.lifecycle?.stage ? { stage: input.lifecycle.stage, detail: input.lifecycle.detail ?? "" } : null), | |
| 216 | ); | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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", | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 238 | value: costs.length > 0 ? `${agentRuns.length} · ${money(microsOf(cost))}` : String(agentRuns.length), |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 239 | tone: null, |
| 240 | }); | |
| 241 | } | |
| Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step | 242 | 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 200ms | 243 | return facts; |
| 244 | } | |
| 245 | ||
| 246 | // --- Rows ------------------------------------------------------------------- | |
| 247 | ||
| 248 | /** Someone or something on a row: an agent, a person, or g1t itself. */ | |
| 249 | export type Who = { name: string; agent: boolean }; | |
| 250 | ||
| 251 | export 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. */ | |
| 254 | export 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. */ | |
| 257 | export 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. */ | |
| 281 | export 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. */ | |
| 292 | export 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. */ | |
| 305 | export 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 | ||
| 322 | const 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 | */ | |
| 329 | export 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 }, | |
| Money is written one way. A single formatter turns millionths of a dollar into dollars, rounding half up on whole micros rather than on a float, so the same sum reads the same on every page: under a cent reads <$0.01 on a total and exactly nothing is $0.00, while the statement's lines, a session's receipt, the price book and an agent's effort costs carry up to four places where the fraction of a cent is the point; the six formatters that each rounded their own way are gone. This month is the calendar month in UTC from its first day to today everywhere, and billing counts the month's not-yet-closed usage in any range that reaches into the current month, so the top bar's pill, Spend, Home and Usage ask for the same days and get the same figure; Home now reads the usage report the others read instead of adding up statements. Usage's pending sentence says what of that usage the close will charge after the discount and included usage, which the billing API returns as pending_charged_micros. A reconciliation test holds the pill, Spend, Home and Usage to one number for one month. The usage and billing guide says how amounts are written and what this month means. | 378 | ...(run.costUsd != null ? [{ label: "Cost so far", value: money(microsOf(run.costUsd)), tone: null }] : []), |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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. */ | |
| 426 | export const needPathKey = pathKey; | |
| 427 | ||
| 428 | // --- Landed ----------------------------------------------------------------- | |
| 429 | ||
| Mission control counts people's direct pushes to the default branch | 430 | /** |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 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 | */ | |
| 435 | export 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. */ | |
| 438 | export 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. | |
| Mission control counts people's direct pushes to the default branch | 448 | */ |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 449 | export 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 | */ | |
| 463 | export function pushedCommits<C extends { hash: string; parents: string[] }>( | |
| Mission control counts people's direct pushes to the default branch | 464 | history: C[], |
| 465 | before: string | undefined, | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 466 | byG1t: (commit: C) => boolean = () => false, |
| Mission control counts people's direct pushes to the default branch | 467 | ): C[] { |
| 468 | const end = before ? history.findIndex((commit) => commit.hash === before) : -1; | |
| 469 | const brought = end === -1 ? history : history.slice(0, end); | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 470 | return brought.filter((commit) => commit.parents.length <= 1 && !byG1t(commit)); |
| Mission control counts people's direct pushes to the default branch | 471 | } |
| 472 | ||
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 473 | /** |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 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. | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 479 | */ |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 480 | export function placePushes<C extends { hash: string; parents: string[] }>( |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 481 | history: C[], |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 482 | pushes: PushRecord[], |
| 483 | byG1t: (commit: C) => boolean = () => false, | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 484 | ): { hash: string; at: string }[] { |
| 485 | const index = new Map(history.map((commit, i) => [commit.hash, i])); | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 486 | // Each commit's first landing: the time, or null when g1t landed it. |
| 487 | const seen = new Map<string, string | null>(); | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 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; | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 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); | |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 496 | } |
| 497 | } | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 498 | return [...seen].flatMap(([hash, at]) => (at ? [{ hash, at }] : [])); |
| Merge branch 'main' into actions-toolkit-oidc-artifacts | 499 | } |
| 500 | ||
| Merge branch 'worktree-agent-ab9543c492a7ed481' into spend-guardrails | 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 | */ | |
| 506 | export 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 | ||
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 512 | /** A merged pull request, as the week counts it. */ |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 513 | export type Merged = { |
| 514 | repo: RepoPath; | |
| 515 | number: number; | |
| 516 | title: string; | |
| 517 | agent: string; | |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 518 | /** Whether an agent wrote it (its author is g1t or another agent), not a person. */ |
| 519 | authoredByAgent: boolean; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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 | */ | |
| 529 | export 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. */ | |
| 534 | export 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 | ||
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 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 | */ | |
| 547 | export 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 200ms | 548 | |
| 549 | export type Week = { | |
| 550 | days: WeekDay[]; | |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 551 | /** Changes landed in the last seven days. */ |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 552 | total: number; |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 553 | /** 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 200ms | 554 | byAgents: number; |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 555 | /** Agents' changes, with a person or without. */ |
| 556 | agentChanges: number; | |
| 557 | /** People's own changes. */ | |
| 558 | people: number; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 559 | /** The seven days before, or null when the lists read do not reach back that far. */ |
| 560 | previous: number | null; | |
| 561 | }; | |
| 562 | ||
| 563 | /** | |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 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. | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 568 | */ |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 569 | export function weekOf( |
| 570 | changes: Pick<Merged, "mergedAt" | "mergedBy" | "authoredByAgent">[], | |
| 571 | now: number, | |
| 572 | timeZone: string | null, | |
| 573 | complete = true, | |
| 574 | ): Week { | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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 | } | |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 588 | 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 200ms | 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) { | |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 599 | if (!change.authoredByAgent) days[slot].people += 1; |
| 600 | else if (landedByAgents(change)) days[slot].agents += 1; | |
| 601 | else days[slot].assisted += 1; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 602 | } else if (key < oldest && key >= twoWeeks) { |
| 603 | previous += 1; | |
| 604 | } | |
| 605 | } | |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 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 }; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 611 | } |
| 612 | ||
| 613 | /** The change from one number to another, as a share; null from nothing. */ | |
| 614 | export 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%". */ | |
| 620 | export 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 | */ | |
| 629 | export 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. */ | |
| 636 | export 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. */ | |
| 650 | export 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, | |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 662 | byAgents: change.authoredByAgent && landedByAgents(change), |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 663 | at: Date.parse(change.mergedAt), |
| 664 | to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 665 | 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 200ms | 666 | })); |
| 667 | } | |
| 668 | ||
| 669 | // --- Tabs and sorting ------------------------------------------------------- | |
| 670 | ||
| 671 | export type Tab = "needs" | "waiting" | "landed"; | |
| 672 | export type Sort = "impact" | "newest"; | |
| 673 | ||
| 674 | export const TABS: Tab[] = ["needs", "waiting", "landed"]; | |
| 675 | ||
| 676 | export function parseTab(value: string | null): Tab | null { | |
| 677 | return value === "needs" || value === "waiting" || value === "landed" ? value : null; | |
| 678 | } | |
| 679 | ||
| 680 | export 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. */ | |
| 685 | export 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 | ||
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 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 | */ | |
| 696 | export 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; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 705 | if (total > 0) { |
| Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar | 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}.`; | |
| Mission control shows where you are needed and what agents landed without you; git answers in about 200ms | 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."; | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 714 | 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 200ms | 715 | } |
| 716 | ||
| 717 | /** "Monday, Oct 5", in the viewer's zone. */ | |
| 718 | export 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 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.