g1t/apps/web/app/lib/mission-control.ts
| 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 | */ |
| 8 | import type { AgentRun, CheckStatus, ChangedFile, Lifecycle, RepoPath, RunKind, Stage } from "@g1t/contracts"; |
| 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 { |
| 16 | return name === "g1t-agent" || name === "g1t" || (name ?? "").endsWith("-agent"); |
| 17 | } |
| 18 | |
| 19 | // --- Reasons ---------------------------------------------------------------- |
| 20 | |
| 21 | /** Why something is waiting on a person, as the chip beside it says. */ |
| 22 | export type Reason = "blocking" | "asked_for_you" | "checks_failing" | "outside_guardrails" | "needs_review" | "stalled" | "ready_to_merge"; |
| 23 | |
| 24 | export const REASON_LABEL: Record<Reason, string> = { |
| 25 | blocking: "Blocking", |
| 26 | asked_for_you: "Asked for you", |
| 27 | checks_failing: "Checks failing", |
| 28 | outside_guardrails: "Outside guardrails", |
| 29 | needs_review: "Needs review", |
| 30 | stalled: "Stalled", |
| 31 | ready_to_merge: "Ready to merge", |
| 32 | }; |
| 33 | |
| 34 | /** |
| 35 | * The reason a need is shown, from its kind and, for a pull request g1t |
| 36 | * stopped seeing through, the sentence it stopped with. |
| 37 | */ |
| 38 | export function reasonFor(need: Pick<Need, "kind" | "detail">): Reason { |
| 39 | switch (need.kind) { |
| 40 | case "limit": |
| 41 | case "deploy": |
| 42 | case "conflict": |
| 43 | return "blocking"; |
| 44 | case "invitation": |
| 45 | case "review": |
| 46 | return "asked_for_you"; |
| 47 | case "checks": |
| 48 | return "checks_failing"; |
| 49 | case "ready": |
| 50 | return "ready_to_merge"; |
| 51 | case "stuck": |
| 52 | return "stalled"; |
| 53 | case "stalled": |
| 54 | return stallReason(need.detail); |
| 55 | } |
| 56 | } |
| 57 | |
| 58 | /** What a pull request's `needs_you` sentence says it is waiting for. */ |
| 59 | export function stallReason(detail: string): Reason { |
| 60 | if (/cost cap|time cap|unusual CPU/i.test(detail)) return "outside_guardrails"; |
| 61 | if (/conflict|could not (?:be )?merge/i.test(detail)) return "blocking"; |
| 62 | if (/checks? (?:still )?fail|still fails|could not be run/i.test(detail)) return "checks_failing"; |
| 63 | if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review"; |
| 64 | return "stalled"; |
| 65 | } |
| 66 | |
| 67 | /** Why only a person can move it: the callout beside what the agent knows. */ |
| 68 | export function whyFor(reason: Reason, need: Pick<Need, "kind" | "detail">): string { |
| 69 | 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."; |
| 70 | if (need.kind === "deploy") |
| 71 | 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."; |
| 72 | if (need.kind === "invitation") return "An invitation is to you. No one else can accept it."; |
| 73 | if (need.kind === "review") return "You were asked to review it by name, so it waits for your verdict."; |
| 74 | if (need.kind === "stuck") |
| 75 | return "A running agent has stopped reporting. It may be working on something long, or stuck; a look at its session tells which."; |
| 76 | switch (reason) { |
| 77 | case "outside_guardrails": |
| 78 | 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."; |
| 79 | case "blocking": |
| 80 | return "g1t could not land it and stopped rather than guess. A person decides how it lands, or whether it should."; |
| 81 | case "checks_failing": |
| 82 | 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."; |
| 83 | case "needs_review": |
| 84 | return "This repository wants a person's verdict before it lands, and the agent has done what it can without one."; |
| 85 | case "ready_to_merge": |
| 86 | return "Checks passed and it was approved. This repository lands a change only when a person merges it."; |
| 87 | case "stalled": |
| 88 | 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."; |
| 89 | case "asked_for_you": |
| 90 | return "It was put to you by name."; |
| 91 | } |
| 92 | } |
| 93 | |
| 94 | /** Reasons where nothing moves until a person acts on it. */ |
| 95 | export const BLOCKING: ReadonlySet<Reason> = new Set<Reason>(["blocking"]); |
| 96 | |
| 97 | // --- What the agent knows --------------------------------------------------- |
| 98 | |
| 99 | export type FactTone = "good" | "warn" | "bad" | null; |
| 100 | export type Fact = { label: string; value: string; tone: FactTone }; |
| 101 | |
| 102 | const CHECKS: Record<CheckStatus, { text: string; tone: FactTone }> = { |
| 103 | passed: { text: "Passed", tone: "good" }, |
| 104 | failed: { text: "Failing", tone: "bad" }, |
| 105 | errored: { text: "Could not run", tone: "bad" }, |
| 106 | running: { text: "Running", tone: null }, |
| 107 | queued: { text: "Queued", tone: null }, |
| 108 | }; |
| 109 | |
| 110 | /** Test files, by the names test runners look for. */ |
| 111 | export function isTestFile(path: string): boolean { |
| 112 | return /(^|\/)(__tests__|tests?|spec)\//i.test(path) || /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || /_test\.(go|rs|py)$/i.test(path); |
| 113 | } |
| 114 | |
| 115 | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; |
| 116 | |
| 117 | /** "$0.42", or "<$0.01" for a sliver. */ |
| 118 | export function usd(value: number): string { |
| 119 | if (value > 0 && value < 0.01) return "<$0.01"; |
| 120 | return `$${value.toFixed(2)}`; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * What is known about a pull request without opening it: its checks, how |
| 125 | * much it changes, the tests it touches, how often the agent was sent back, |
| 126 | * and what its runs cost. Only what is known is listed. |
| 127 | */ |
| 128 | export function pullFacts(input: { |
| 129 | checkStatus: CheckStatus | null; |
| 130 | files: ChangedFile[]; |
| 131 | lifecycle?: Pick<Lifecycle, "revisions"> | null; |
| 132 | runs?: Pick<AgentRun, "costUsd" | "kind">[]; |
| 133 | }): Fact[] { |
| 134 | const facts: Fact[] = []; |
| 135 | const checks = input.checkStatus ? CHECKS[input.checkStatus] : null; |
| 136 | facts.push({ label: "Checks", value: checks?.text ?? "Not run", tone: checks?.tone ?? null }); |
| 137 | if (input.files.length > 0) { |
| 138 | const added = input.files.reduce((sum, f) => sum + f.additions, 0); |
| 139 | const removed = input.files.reduce((sum, f) => sum + f.deletions, 0); |
| 140 | facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null }); |
| 141 | facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null }); |
| 142 | const tests = input.files.filter((f) => isTestFile(f.path)).length; |
| 143 | facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" }); |
| 144 | } |
| 145 | if (input.lifecycle && input.lifecycle.revisions > 0) { |
| 146 | facts.push({ |
| 147 | label: "Sent back", |
| 148 | value: plural(input.lifecycle.revisions, "time"), |
| 149 | tone: input.lifecycle.revisions > 1 ? "warn" : null, |
| 150 | }); |
| 151 | } |
| 152 | const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck"); |
| 153 | if (agentRuns.length > 0) { |
| 154 | const costs = agentRuns.filter((run) => run.costUsd != null); |
| 155 | const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0); |
| 156 | facts.push({ |
| 157 | label: "Agent runs", |
| 158 | value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length), |
| 159 | tone: null, |
| 160 | }); |
| 161 | } |
| 162 | return facts; |
| 163 | } |
| 164 | |
| 165 | // --- Rows ------------------------------------------------------------------- |
| 166 | |
| 167 | /** Someone or something on a row: an agent, a person, or g1t itself. */ |
| 168 | export type Who = { name: string; agent: boolean }; |
| 169 | |
| 170 | export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null); |
| 171 | |
| 172 | /** A form on mission control that acts without leaving it. */ |
| 173 | export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string }; |
| 174 | |
| 175 | /** One thing that needs the viewer, with what is known about it. */ |
| 176 | export type NeedRow = { |
| 177 | key: string; |
| 178 | reason: Reason; |
| 179 | repo: RepoPath | null; |
| 180 | /** "#41", when it is an issue or pull request. */ |
| 181 | ref: string | null; |
| 182 | title: string; |
| 183 | /** The ask, in one sentence. */ |
| 184 | ask: string; |
| 185 | /** Who is waiting: the agent on it, or the person who asked. */ |
| 186 | by: Who | null; |
| 187 | /** The person who started it, when an agent did the work. */ |
| 188 | for: string | null; |
| 189 | at: number; |
| 190 | to: string; |
| 191 | open: string; |
| 192 | facts: Fact[]; |
| 193 | why: string; |
| 194 | quick: QuickAction | null; |
| 195 | /** Somewhere else to act, when that is the next step. */ |
| 196 | link: { label: string; to: string } | null; |
| 197 | }; |
| 198 | |
| 199 | /** The agents' stages a pull request can wait in, as their chips say them. */ |
| 200 | export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = { |
| 201 | working: "Working", |
| 202 | checking: "Checking", |
| 203 | reviewing: "Reviewing", |
| 204 | revising: "Revising", |
| 205 | catching_up: "Catching up", |
| 206 | answering: "Answering", |
| 207 | queued: "In queue", |
| 208 | }; |
| 209 | |
| 210 | /** A run's kind, as its chip says it. */ |
| 211 | export const RUN_LABEL: Record<RunKind, string> = { |
| 212 | implement: "Working", |
| 213 | revise: "Revising", |
| 214 | review: "Reviewing", |
| 215 | answer: "Answering", |
| 216 | update: "Catching up", |
| 217 | plan: "Planning", |
| 218 | checks: "Checking", |
| 219 | queue: "In queue", |
| 220 | mergecheck: "Checking", |
| 221 | }; |
| 222 | |
| 223 | /** Something agents are doing, which the viewer can leave to them. */ |
| 224 | export type WaitingRow = { |
| 225 | key: string; |
| 226 | repo: RepoPath; |
| 227 | ref: string | null; |
| 228 | title: string; |
| 229 | chip: string; |
| 230 | detail: string; |
| 231 | by: Who | null; |
| 232 | at: number; |
| 233 | to: string; |
| 234 | /** Whether a run is going on it right now. */ |
| 235 | live: boolean; |
| 236 | /** The run going on it, to watch. */ |
| 237 | run: string | null; |
| 238 | facts: Fact[]; |
| 239 | }; |
| 240 | |
| 241 | const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase(); |
| 242 | |
| 243 | /** |
| 244 | * What is in agents' hands: pull requests in an agent's stage, runs going |
| 245 | * now, and drafts agents are still making. Each once, and nothing that is |
| 246 | * already waiting on the viewer. |
| 247 | */ |
| 248 | export function waitingRows(input: { |
| 249 | active: { |
| 250 | pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] }; |
| 251 | lifecycle: Lifecycle | null; |
| 252 | repo: RepoPath; |
| 253 | }[]; |
| 254 | live: Pick< |
| 255 | AgentRun, |
| 256 | "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt" |
| 257 | >[]; |
| 258 | drafts: { |
| 259 | number: number; |
| 260 | title: string; |
| 261 | agent: string; |
| 262 | updatedAt: string; |
| 263 | checkStatus: CheckStatus | null; |
| 264 | files: ChangedFile[]; |
| 265 | repo: RepoPath; |
| 266 | }[]; |
| 267 | needKeys: ReadonlySet<string>; |
| 268 | }): WaitingRow[] { |
| 269 | const rows = new Map<string, WaitingRow>(); |
| 270 | for (const { pull, lifecycle, repo } of input.active) { |
| 271 | if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue; |
| 272 | const key = pathKey(repo, pull.number); |
| 273 | if (input.needKeys.has(key)) continue; |
| 274 | rows.set(key, { |
| 275 | key, |
| 276 | repo, |
| 277 | ref: `#${pull.number}`, |
| 278 | title: pull.title, |
| 279 | chip: STAGE_CHIP[lifecycle.stage], |
| 280 | detail: lifecycle.detail, |
| 281 | by: who(pull.agent), |
| 282 | at: Date.parse(pull.updatedAt), |
| 283 | to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`, |
| 284 | live: false, |
| 285 | run: null, |
| 286 | facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }), |
| 287 | }); |
| 288 | } |
| 289 | for (const run of input.live) { |
| 290 | const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`; |
| 291 | if (input.needKeys.has(key)) continue; |
| 292 | const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`; |
| 293 | const existing = rows.get(key); |
| 294 | const started = Date.parse(run.startedAt ?? run.createdAt); |
| 295 | const runFacts: Fact[] = [ |
| 296 | { label: "Run", value: RUN_LABEL[run.kind], tone: null }, |
| 297 | ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []), |
| 298 | ]; |
| 299 | if (existing) { |
| 300 | existing.live = true; |
| 301 | existing.run = runTo; |
| 302 | if (run.step) existing.detail = run.step; |
| 303 | existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")]; |
| 304 | existing.at = Math.max(existing.at, Date.parse(run.updatedAt)); |
| 305 | continue; |
| 306 | } |
| 307 | rows.set(key, { |
| 308 | key, |
| 309 | repo: run.repo, |
| 310 | ref: run.number != null ? `#${run.number}` : null, |
| 311 | title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`, |
| 312 | chip: RUN_LABEL[run.kind], |
| 313 | detail: run.step ?? "Starting.", |
| 314 | by: who(run.agent), |
| 315 | at: Number.isFinite(started) ? started : Date.parse(run.updatedAt), |
| 316 | to: runTo, |
| 317 | live: true, |
| 318 | run: runTo, |
| 319 | facts: runFacts, |
| 320 | }); |
| 321 | } |
| 322 | for (const draft of input.drafts) { |
| 323 | const key = pathKey(draft.repo, draft.number); |
| 324 | if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue; |
| 325 | rows.set(key, { |
| 326 | key, |
| 327 | repo: draft.repo, |
| 328 | ref: `#${draft.number}`, |
| 329 | title: draft.title, |
| 330 | chip: "Working", |
| 331 | detail: `${draft.agent} is making the change.`, |
| 332 | by: who(draft.agent), |
| 333 | at: Date.parse(draft.updatedAt), |
| 334 | to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`, |
| 335 | live: false, |
| 336 | run: null, |
| 337 | facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }), |
| 338 | }); |
| 339 | } |
| 340 | // Running now first, then what moved most recently. |
| 341 | return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at); |
| 342 | } |
| 343 | |
| 344 | /** The key a need is known by when matching it to agents' work. */ |
| 345 | export const needPathKey = pathKey; |
| 346 | |
| 347 | // --- Landed ----------------------------------------------------------------- |
| 348 | |
| 349 | /** A merged pull request, as the week counts it. */ |
| 350 | export type Merged = { |
| 351 | repo: RepoPath; |
| 352 | number: number; |
| 353 | title: string; |
| 354 | agent: string; |
| 355 | mergedBy: string | null; |
| 356 | mergedAt: string; |
| 357 | files: ChangedFile[]; |
| 358 | }; |
| 359 | |
| 360 | /** |
| 361 | * Whether a change landed without a person: g1t merged it, by auto-merge or |
| 362 | * from the merge queue, rather than someone pressing merge. |
| 363 | */ |
| 364 | export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean { |
| 365 | return change.mergedBy == null || isAgent(change.mergedBy); |
| 366 | } |
| 367 | |
| 368 | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */ |
| 369 | export function dayKey(at: number, timeZone: string | null): string { |
| 370 | try { |
| 371 | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); |
| 372 | } catch { |
| 373 | return new Date(at).toISOString().slice(0, 10); |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | export type WeekDay = { key: string; label: string; agents: number; people: number }; |
| 378 | |
| 379 | export type Week = { |
| 380 | days: WeekDay[]; |
| 381 | /** Changes landed in the last seven days, and how many without a person. */ |
| 382 | total: number; |
| 383 | byAgents: number; |
| 384 | /** The seven days before, or null when the lists read do not reach back that far. */ |
| 385 | previous: number | null; |
| 386 | }; |
| 387 | |
| 388 | /** |
| 389 | * The last seven days in the viewer's zone, oldest first, each split into |
| 390 | * what agents landed alone and what a person merged, and the week before |
| 391 | * as one number. `complete` says whether what was read reaches back two |
| 392 | * weeks; when it does not, the week before is not guessed. |
| 393 | */ |
| 394 | export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week { |
| 395 | const days: WeekDay[] = []; |
| 396 | const index = new Map<string, number>(); |
| 397 | for (let back = 6; back >= 0; back -= 1) { |
| 398 | const at = now - back * DAY; |
| 399 | const key = dayKey(at, timeZone); |
| 400 | if (index.has(key)) continue; |
| 401 | index.set(key, days.length); |
| 402 | let label: string; |
| 403 | try { |
| 404 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at); |
| 405 | } catch { |
| 406 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at); |
| 407 | } |
| 408 | days.push({ key, label, agents: 0, people: 0 }); |
| 409 | } |
| 410 | const oldest = days[0]?.key ?? ""; |
| 411 | const twoWeeks = dayKey(now - 13 * DAY, timeZone); |
| 412 | let previous = 0; |
| 413 | for (const change of changes) { |
| 414 | const at = Date.parse(change.mergedAt); |
| 415 | if (!Number.isFinite(at) || at > now) continue; |
| 416 | const key = dayKey(at, timeZone); |
| 417 | const slot = index.get(key); |
| 418 | if (slot != null) { |
| 419 | if (landedByAgents(change)) days[slot].agents += 1; |
| 420 | else days[slot].people += 1; |
| 421 | } else if (key < oldest && key >= twoWeeks) { |
| 422 | previous += 1; |
| 423 | } |
| 424 | } |
| 425 | const byAgents = days.reduce((sum, day) => sum + day.agents, 0); |
| 426 | const total = byAgents + days.reduce((sum, day) => sum + day.people, 0); |
| 427 | return { days, total, byAgents, previous: complete ? previous : null }; |
| 428 | } |
| 429 | |
| 430 | /** The change from one number to another, as a share; null from nothing. */ |
| 431 | export function change(current: number, previous: number | null): number | null { |
| 432 | if (previous == null || previous === 0) return null; |
| 433 | return (current - previous) / previous; |
| 434 | } |
| 435 | |
| 436 | /** "+18%", "−5%", "0%". */ |
| 437 | export function signedPercent(share: number): string { |
| 438 | const n = Math.round(share * 100); |
| 439 | return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%"; |
| 440 | } |
| 441 | |
| 442 | /** |
| 443 | * Whether the merged pull requests read for one repository reach back to |
| 444 | * `since`: the list is not full, or its oldest goes back that far. |
| 445 | */ |
| 446 | export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean { |
| 447 | if (list.length < page) return true; |
| 448 | const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite)); |
| 449 | return oldest <= since; |
| 450 | } |
| 451 | |
| 452 | /** One change that landed, as the third tab lists it. */ |
| 453 | export type LandedRow = { |
| 454 | key: string; |
| 455 | repo: RepoPath; |
| 456 | ref: string; |
| 457 | title: string; |
| 458 | by: Who | null; |
| 459 | agent: string; |
| 460 | byAgents: boolean; |
| 461 | at: number; |
| 462 | to: string; |
| 463 | facts: Fact[]; |
| 464 | }; |
| 465 | |
| 466 | /** What landed on the viewer's calendar day, newest first. */ |
| 467 | export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] { |
| 468 | const today = dayKey(now, timeZone); |
| 469 | return changes |
| 470 | .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today) |
| 471 | .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt)) |
| 472 | .map((change) => ({ |
| 473 | key: pathKey(change.repo, change.number), |
| 474 | repo: change.repo, |
| 475 | ref: `#${change.number}`, |
| 476 | title: change.title, |
| 477 | by: who(change.mergedBy ?? "g1t"), |
| 478 | agent: change.agent, |
| 479 | byAgents: landedByAgents(change), |
| 480 | at: Date.parse(change.mergedAt), |
| 481 | to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`, |
| 482 | facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Checks"), |
| 483 | })); |
| 484 | } |
| 485 | |
| 486 | // --- Tabs and sorting ------------------------------------------------------- |
| 487 | |
| 488 | export type Tab = "needs" | "waiting" | "landed"; |
| 489 | export type Sort = "impact" | "newest"; |
| 490 | |
| 491 | export const TABS: Tab[] = ["needs", "waiting", "landed"]; |
| 492 | |
| 493 | export function parseTab(value: string | null): Tab | null { |
| 494 | return value === "needs" || value === "waiting" || value === "landed" ? value : null; |
| 495 | } |
| 496 | |
| 497 | export function parseSort(value: string | null): Sort { |
| 498 | return value === "newest" ? "newest" : "impact"; |
| 499 | } |
| 500 | |
| 501 | /** Rows in the order chosen: as ranked (most urgent first), or newest first. */ |
| 502 | export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] { |
| 503 | return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows; |
| 504 | } |
| 505 | |
| 506 | // --- The summary ------------------------------------------------------------ |
| 507 | |
| 508 | /** The sentence under the greeting: the week, honestly, in one line. */ |
| 509 | export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string { |
| 510 | const { total, byAgents, live, needs } = input; |
| 511 | if (total > 0) { |
| 512 | const landed = |
| 513 | byAgents === total |
| 514 | ? `Agents landed all ${plural(total, "change")} this week without you.` |
| 515 | : byAgents === 0 |
| 516 | ? `${plural(total, "change")} landed this week, each merged by a person.` |
| 517 | : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`; |
| 518 | return landed; |
| 519 | } |
| 520 | if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`; |
| 521 | if (needs > 0) return "Nothing has landed this week. What is waiting on you is below."; |
| 522 | return "Nothing has landed this week yet. Assign an issue to g1t-agent and it starts in seconds."; |
| 523 | } |
| 524 | |
| 525 | /** "Monday, Oct 5", in the viewer's zone. */ |
| 526 | export function dateLine(now: number, timeZone: string | null): string { |
| 527 | const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" }; |
| 528 | try { |
| 529 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now); |
| 530 | } catch { |
| 531 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now); |
| 532 | } |
| 533 | } |