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