g1t/apps/web/app/lib/mission.ts
| 1 | /** |
| 2 | * The arithmetic behind mission control and a project's overview: when the |
| 3 | * viewer was last here, what moved since, how a burst of one agent's work |
| 4 | * reads as one line, the week's pulse, and where each pull request stands |
| 5 | * in its way to landing. Pure, so it is tested on its own; it imports only |
| 6 | * types. |
| 7 | */ |
| 8 | import type { AgentRun, G1tEvent, Pull, QueueEntry, RepoPath } from "@g1t/contracts"; |
| 9 | |
| 10 | const MINUTE = 60_000; |
| 11 | const HOUR = 60 * MINUTE; |
| 12 | const DAY = 24 * HOUR; |
| 13 | |
| 14 | // --- Last seen ---------------------------------------------------------------- |
| 15 | |
| 16 | /** The cookie that remembers when the viewer was last on mission control. */ |
| 17 | export const SEEN_COOKIE = "g1t_seen"; |
| 18 | /** A gap this long between page views starts a new visit. */ |
| 19 | export const VISIT_GAP_MS = 30 * MINUTE; |
| 20 | |
| 21 | /** |
| 22 | * Reads the last-seen cookie (`<previous visit>.<last view>`, in epoch ms) |
| 23 | * and works out what to count from and what to write back. Views within |
| 24 | * `VISIT_GAP_MS` of each other are one visit, so the page refreshing |
| 25 | * itself does not reset what is new. `since` is null on a first visit. |
| 26 | */ |
| 27 | export function nextSeen(raw: string | null | undefined, now: number, gap = VISIT_GAP_MS): { since: number | null; value: string } { |
| 28 | const [prevText, atText] = (raw ?? "").split("."); |
| 29 | const prev = Number(prevText); |
| 30 | const at = Number(atText); |
| 31 | if (!Number.isFinite(at) || at <= 0 || at > now) return { since: null, value: `0.${now}` }; |
| 32 | if (now - at > gap) return { since: at, value: `${at}.${now}` }; |
| 33 | const since = Number.isFinite(prev) && prev > 0 && prev <= at ? prev : null; |
| 34 | return { since, value: `${since ?? 0}.${now}` }; |
| 35 | } |
| 36 | |
| 37 | /** Reads one cookie from a `Cookie` header. */ |
| 38 | export function readCookie(header: string | null, name: string): string | null { |
| 39 | if (!header) return null; |
| 40 | for (const part of header.split(";")) { |
| 41 | const [key, ...rest] = part.trim().split("="); |
| 42 | if (key === name) { |
| 43 | try { |
| 44 | return decodeURIComponent(rest.join("=")); |
| 45 | } catch { |
| 46 | return null; |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | return null; |
| 51 | } |
| 52 | |
| 53 | /** "Good morning", "Good afternoon" or "Good evening", for an hour of the day. */ |
| 54 | export function greetingFor(hour: number): string { |
| 55 | if (hour >= 5 && hour < 12) return "Good morning"; |
| 56 | if (hour >= 12 && hour < 17) return "Good afternoon"; |
| 57 | return "Good evening"; |
| 58 | } |
| 59 | |
| 60 | /** The hour of the day in a time zone, or in UTC when it is unknown or invalid. */ |
| 61 | export function hourIn(now: number, timeZone: string | null): number { |
| 62 | try { |
| 63 | const text = new Intl.DateTimeFormat("en-US", { hour: "numeric", hourCycle: "h23", timeZone: timeZone || "UTC" }).format(now); |
| 64 | const hour = Number.parseInt(text, 10); |
| 65 | return Number.isFinite(hour) ? hour % 24 : new Date(now).getUTCHours(); |
| 66 | } catch { |
| 67 | return new Date(now).getUTCHours(); |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | /** What the composer's text makes: its first line the title, all of it the body when there is more. */ |
| 72 | export function splitRequest(text: string, max = 200): { title: string; body: string } { |
| 73 | const trimmed = text.trim(); |
| 74 | const [first = "", ...rest] = trimmed.split(/\r?\n/); |
| 75 | let title = first.trim(); |
| 76 | if (title.length > max) { |
| 77 | const cut = title.slice(0, max - 1); |
| 78 | const space = cut.lastIndexOf(" "); |
| 79 | title = `${(space > max / 2 ? cut.slice(0, space) : cut).trimEnd()}…`; |
| 80 | } |
| 81 | const more = rest.join("\n").trim(); |
| 82 | // A shortened title loses words, so the body keeps all of it. |
| 83 | return { title, body: title !== first.trim() ? trimmed : more }; |
| 84 | } |
| 85 | |
| 86 | // --- Activity --------------------------------------------------------------- |
| 87 | |
| 88 | /** Accounts that are g1t's own agents and machinery. */ |
| 89 | export function isAgent(name: string | null | undefined): boolean { |
| 90 | return name === "g1t-agent" || name === "g1t" || (name ?? "").endsWith("-agent"); |
| 91 | } |
| 92 | |
| 93 | export type Verb = |
| 94 | | "landed" |
| 95 | | "opened_issue" |
| 96 | | "closed_issue" |
| 97 | | "started" |
| 98 | | "ready" |
| 99 | | "checks_passed" |
| 100 | | "checks_failed" |
| 101 | | "approved" |
| 102 | | "changes_requested" |
| 103 | | "commented" |
| 104 | | "asked" |
| 105 | | "deployed" |
| 106 | | "deploy_failed" |
| 107 | | "learned"; |
| 108 | |
| 109 | /** One thing that moved, as the feed shows it. */ |
| 110 | export type ActivityItem = { |
| 111 | id: string; |
| 112 | /** Epoch ms. */ |
| 113 | at: number; |
| 114 | repo: RepoPath; |
| 115 | actor: string | null; |
| 116 | verb: Verb; |
| 117 | /** The issue or pull request it was about. */ |
| 118 | number: number | null; |
| 119 | /** For a memory: what was learned. For a deploy: where. */ |
| 120 | text?: string; |
| 121 | /** Where the line links, when it is not an issue or pull request. */ |
| 122 | to?: string; |
| 123 | }; |
| 124 | |
| 125 | /** An event from the log as a feed item, or null for those not worth a line. */ |
| 126 | export function eventItem(event: G1tEvent, repo: RepoPath): ActivityItem | null { |
| 127 | const base = { id: event.id, at: Date.parse(event.time), repo, actor: event.actor }; |
| 128 | switch (event.type) { |
| 129 | case "pull.merged": |
| 130 | return { ...base, verb: "landed", number: event.data.number }; |
| 131 | case "issue.opened": |
| 132 | return { ...base, verb: "opened_issue", number: event.data.number }; |
| 133 | case "issue.closed": |
| 134 | return event.data.resolvedBy != null ? null : { ...base, verb: "closed_issue", number: event.data.number }; |
| 135 | case "pull.opened": |
| 136 | return { ...base, actor: event.data.agent || event.actor, verb: "started", number: event.data.number }; |
| 137 | case "pull.ready": |
| 138 | return { ...base, verb: "ready", number: event.data.number }; |
| 139 | case "checks.completed": |
| 140 | return { ...base, actor: null, verb: event.data.status === "passed" ? "checks_passed" : "checks_failed", number: event.data.number }; |
| 141 | case "review.completed": |
| 142 | if (!event.data.verdict) return null; |
| 143 | return { ...base, actor: "g1t-agent", verb: event.data.verdict === "approve" ? "approved" : "changes_requested", number: event.data.number }; |
| 144 | case "comment.created": |
| 145 | if (event.data.verdict) { |
| 146 | return { ...base, verb: event.data.verdict === "approve" ? "approved" : "changes_requested", number: event.data.number }; |
| 147 | } |
| 148 | return { ...base, verb: "commented", number: event.data.number }; |
| 149 | case "agent.asked": |
| 150 | return { ...base, verb: "asked", number: event.data.number }; |
| 151 | default: |
| 152 | return null; |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | /** Several things one actor did in one project in a short while, read as one line. */ |
| 157 | export type ActivityGroup = { |
| 158 | id: string; |
| 159 | actor: string | null; |
| 160 | repo: RepoPath; |
| 161 | /** Newest and oldest, epoch ms. */ |
| 162 | at: number; |
| 163 | from: number; |
| 164 | /** Each kind of thing done, in the order first seen, with what it was done to. */ |
| 165 | parts: { verb: Verb; numbers: number[]; texts: string[]; to?: string }[]; |
| 166 | count: number; |
| 167 | }; |
| 168 | |
| 169 | const sameRepo = (a: RepoPath, b: RepoPath) => |
| 170 | a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase(); |
| 171 | |
| 172 | /** |
| 173 | * Groups items, newest first, so that a run of things one actor did in one |
| 174 | * project within `window` of the run's newest reads as one line. |
| 175 | */ |
| 176 | export function groupActivity(items: ActivityItem[], window = 45 * MINUTE): ActivityGroup[] { |
| 177 | const sorted = [...items].sort((a, b) => b.at - a.at); |
| 178 | const groups: ActivityGroup[] = []; |
| 179 | for (const item of sorted) { |
| 180 | const last = groups[groups.length - 1]; |
| 181 | if (last && last.actor === item.actor && sameRepo(last.repo, item.repo) && last.at - item.at <= window) { |
| 182 | let part = last.parts.find((p) => p.verb === item.verb); |
| 183 | if (!part) { |
| 184 | part = { verb: item.verb, numbers: [], texts: [], to: item.to }; |
| 185 | last.parts.push(part); |
| 186 | } |
| 187 | if (item.number != null && !part.numbers.includes(item.number)) part.numbers.push(item.number); |
| 188 | if (item.text && !part.texts.includes(item.text)) part.texts.push(item.text); |
| 189 | last.from = item.at; |
| 190 | last.count += 1; |
| 191 | continue; |
| 192 | } |
| 193 | groups.push({ |
| 194 | id: item.id, |
| 195 | actor: item.actor, |
| 196 | repo: item.repo, |
| 197 | at: item.at, |
| 198 | from: item.at, |
| 199 | parts: [{ verb: item.verb, numbers: item.number != null ? [item.number] : [], texts: item.text ? [item.text] : [], to: item.to }], |
| 200 | count: 1, |
| 201 | }); |
| 202 | } |
| 203 | return groups; |
| 204 | } |
| 205 | |
| 206 | // --- Needs you -------------------------------------------------------------- |
| 207 | |
| 208 | export type NeedKind = "limit" | "deploy" | "stalled" | "conflict" | "review" | "stuck" | "checks" | "ready"; |
| 209 | |
| 210 | /** Something waiting on the viewer, with where to act on it. */ |
| 211 | export type Need = { |
| 212 | key: string; |
| 213 | kind: NeedKind; |
| 214 | title: string; |
| 215 | detail: string; |
| 216 | to: string; |
| 217 | action: string; |
| 218 | /** Epoch ms: when it started waiting. */ |
| 219 | at: number; |
| 220 | where: string | null; |
| 221 | }; |
| 222 | |
| 223 | const NEED_RANK: Record<NeedKind, number> = { |
| 224 | limit: 0, |
| 225 | deploy: 1, |
| 226 | conflict: 2, |
| 227 | stalled: 3, |
| 228 | stuck: 4, |
| 229 | review: 5, |
| 230 | checks: 6, |
| 231 | ready: 7, |
| 232 | }; |
| 233 | |
| 234 | /** Most urgent first: by kind, then what has waited longest. One of each key. */ |
| 235 | export function rankNeeds(needs: Need[]): Need[] { |
| 236 | const seen = new Set<string>(); |
| 237 | return [...needs] |
| 238 | .sort((a, b) => NEED_RANK[a.kind] - NEED_RANK[b.kind] || a.at - b.at) |
| 239 | .filter((need) => (seen.has(need.key) ? false : (seen.add(need.key), true))); |
| 240 | } |
| 241 | |
| 242 | /** Whether a run has gone quiet: running with no new step for `quiet`. */ |
| 243 | export function stuckMinutes(run: Pick<AgentRun, "status" | "updatedAt">, now: number, quiet = 10 * MINUTE): number | null { |
| 244 | if (run.status !== "running") return null; |
| 245 | const idle = now - Date.parse(run.updatedAt); |
| 246 | return idle >= quiet ? Math.floor(idle / MINUTE) : null; |
| 247 | } |
| 248 | |
| 249 | // --- Digest ----------------------------------------------------------------- |
| 250 | |
| 251 | export type DigestCounts = { |
| 252 | landed: number; |
| 253 | reviews: number; |
| 254 | opened: number; |
| 255 | deploys: number; |
| 256 | failedDeploys: number; |
| 257 | /** The longest an agent has been quiet, in minutes; null when none is. */ |
| 258 | stuck: number | null; |
| 259 | }; |
| 260 | |
| 261 | export type DigestPart = { text: string; tone: "fg" | "warn" | "danger" | "accent"; anchor: string }; |
| 262 | |
| 263 | const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; |
| 264 | |
| 265 | /** What moved since the viewer was last here, as the pieces of a sentence. */ |
| 266 | export function digestParts(c: DigestCounts): DigestPart[] { |
| 267 | const parts: DigestPart[] = []; |
| 268 | if (c.landed > 0) parts.push({ text: `${count(c.landed, "change", "changes")} landed`, tone: "accent", anchor: "activity" }); |
| 269 | if (c.reviews > 0) parts.push({ text: `${count(c.reviews, "pull request needs", "pull requests need")} your review`, tone: "warn", anchor: "your-pulls" }); |
| 270 | if (c.stuck != null) parts.push({ text: `an agent has been quiet for ${c.stuck} min`, tone: "warn", anchor: "live" }); |
| 271 | if (c.failedDeploys > 0) parts.push({ text: `${count(c.failedDeploys, "deploy", "deploys")} failed`, tone: "danger", anchor: "needs-you" }); |
| 272 | if (c.deploys > 0) parts.push({ text: `${count(c.deploys, "deploy", "deploys")} went out`, tone: "fg", anchor: "projects" }); |
| 273 | if (c.opened > 0) parts.push({ text: `${count(c.opened, "issue was", "issues were")} opened`, tone: "fg", anchor: "activity" }); |
| 274 | return parts; |
| 275 | } |
| 276 | |
| 277 | // --- Pulse ------------------------------------------------------------------ |
| 278 | |
| 279 | /** Counts per day for the `days` days ending today (UTC), oldest first. */ |
| 280 | export function dailyBuckets(points: { at: number; value?: number }[], days: number, now: number): number[] { |
| 281 | const end = Math.floor(now / DAY); |
| 282 | const buckets = new Array<number>(days).fill(0); |
| 283 | for (const point of points) { |
| 284 | const index = days - 1 - (end - Math.floor(point.at / DAY)); |
| 285 | if (index >= 0 && index < days) buckets[index] += point.value ?? 1; |
| 286 | } |
| 287 | return buckets; |
| 288 | } |
| 289 | |
| 290 | export function median(values: number[]): number | null { |
| 291 | if (values.length === 0) return null; |
| 292 | const sorted = [...values].sort((a, b) => a - b); |
| 293 | const middle = Math.floor(sorted.length / 2); |
| 294 | return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; |
| 295 | } |
| 296 | |
| 297 | /** |
| 298 | * The share of pull requests whose first run of checks passed, from the |
| 299 | * `checks.completed` events of one or more projects. Null with none. |
| 300 | */ |
| 301 | export function firstPassRate(events: { repo: string; number: number; at: number; passed: boolean }[]): { rate: number | null; of: number } { |
| 302 | const first = new Map<string, { at: number; passed: boolean }>(); |
| 303 | for (const event of events) { |
| 304 | const key = `${event.repo}#${event.number}`; |
| 305 | const seen = first.get(key); |
| 306 | if (!seen || event.at < seen.at) first.set(key, { at: event.at, passed: event.passed }); |
| 307 | } |
| 308 | if (first.size === 0) return { rate: null, of: 0 }; |
| 309 | const passed = [...first.values()].filter((run) => run.passed).length; |
| 310 | return { rate: passed / first.size, of: first.size }; |
| 311 | } |
| 312 | |
| 313 | /** The share of check runs that passed. Null with none. */ |
| 314 | export function passRate(results: boolean[]): number | null { |
| 315 | if (results.length === 0) return null; |
| 316 | return results.filter(Boolean).length / results.length; |
| 317 | } |
| 318 | |
| 319 | /** How long from an issue being opened to its change landing, for each that did, in ms. */ |
| 320 | export function issueToMerge( |
| 321 | opened: { repo: string; number: number; at: number }[], |
| 322 | merged: { repo: string; issue: number | null; at: number }[], |
| 323 | ): number[] { |
| 324 | const openedAt = new Map(opened.map((o) => [`${o.repo}#${o.number}`, o.at])); |
| 325 | const spans: number[] = []; |
| 326 | for (const m of merged) { |
| 327 | if (m.issue == null) continue; |
| 328 | const start = openedAt.get(`${m.repo}#${m.issue}`); |
| 329 | if (start != null && m.at >= start) spans.push(m.at - start); |
| 330 | } |
| 331 | return spans; |
| 332 | } |
| 333 | |
| 334 | /** Hours agents spent at work since `since`, counting runs still going up to `now`. */ |
| 335 | export function agentHours(runs: Pick<AgentRun, "kind" | "startedAt" | "finishedAt">[], since: number, now: number): number { |
| 336 | let ms = 0; |
| 337 | for (const run of runs) { |
| 338 | if (!run.startedAt || run.kind === "checks" || run.kind === "queue" || run.kind === "mergecheck") continue; |
| 339 | const start = Math.max(Date.parse(run.startedAt), since); |
| 340 | const end = run.finishedAt ? Date.parse(run.finishedAt) : now; |
| 341 | if (end > start) ms += end - start; |
| 342 | } |
| 343 | return ms / HOUR; |
| 344 | } |
| 345 | |
| 346 | /** "3h", "2d 4h", "40m": a span in the largest units that read well. */ |
| 347 | export function formatSpan(ms: number | null): string { |
| 348 | if (ms == null) return "—"; |
| 349 | if (ms < HOUR) return `${Math.max(1, Math.round(ms / MINUTE))}m`; |
| 350 | if (ms < DAY) return `${Math.round(ms / HOUR)}h`; |
| 351 | const days = Math.floor(ms / DAY); |
| 352 | const hours = Math.round((ms % DAY) / HOUR); |
| 353 | return hours ? `${days}d ${hours}h` : `${days}d`; |
| 354 | } |
| 355 | |
| 356 | /** |
| 357 | * Points for a sparkline `width` by `height`: the values spread across the |
| 358 | * width, scaled so the largest touches the top and zero sits on the |
| 359 | * bottom, with `pad` kept clear for the line's width. |
| 360 | */ |
| 361 | export function sparkPoints(values: number[], width: number, height: number, pad = 2): [number, number][] { |
| 362 | if (values.length === 0) return []; |
| 363 | const max = Math.max(...values, 0); |
| 364 | const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0; |
| 365 | return values.map((value, index) => { |
| 366 | const x = values.length > 1 ? pad + index * step : width / 2; |
| 367 | const y = max > 0 ? height - pad - (Math.max(0, value) / max) * (height - pad * 2) : height - pad; |
| 368 | return [Math.round(x * 100) / 100, Math.round(y * 100) / 100]; |
| 369 | }); |
| 370 | } |
| 371 | |
| 372 | // --- A project's pipeline --------------------------------------------------- |
| 373 | |
| 374 | export type PipelineStage = "working" | "checking" | "reviewing" | "queue" | "landed"; |
| 375 | |
| 376 | export const PIPELINE: { stage: PipelineStage; label: string }[] = [ |
| 377 | { stage: "working", label: "Working" }, |
| 378 | { stage: "checking", label: "Checking" }, |
| 379 | { stage: "reviewing", label: "Reviewing" }, |
| 380 | { stage: "queue", label: "Queue" }, |
| 381 | { stage: "landed", label: "Landed" }, |
| 382 | ]; |
| 383 | |
| 384 | /** |
| 385 | * Where a pull request stands on its way to landing, from what a list of |
| 386 | * pull requests says about it, the run at work on it and the merge queue. |
| 387 | */ |
| 388 | export function pipelineStage( |
| 389 | pull: Pick<Pull, "status" | "checkStatus" | "number">, |
| 390 | run: Pick<AgentRun, "kind"> | undefined, |
| 391 | queued: Set<number>, |
| 392 | ): PipelineStage { |
| 393 | if (pull.status === "merged") return "landed"; |
| 394 | if (queued.has(pull.number)) return "queue"; |
| 395 | if (run) { |
| 396 | if (run.kind === "checks") return "checking"; |
| 397 | if (run.kind === "review") return "reviewing"; |
| 398 | if (run.kind === "queue") return "queue"; |
| 399 | return "working"; |
| 400 | } |
| 401 | if (pull.status === "draft") return "working"; |
| 402 | if (pull.checkStatus === "queued" || pull.checkStatus === "running") return "checking"; |
| 403 | return "reviewing"; |
| 404 | } |
| 405 | |
| 406 | /** The numbers of the pull requests waiting or being tested in a merge queue. */ |
| 407 | export function queuedNumbers(entries: Pick<QueueEntry, "number" | "state">[]): Set<number> { |
| 408 | return new Set(entries.filter((e) => e.state === "waiting" || e.state === "testing" || e.state === "passed").map((e) => e.number)); |
| 409 | } |
| 410 | |
| 411 | /** Open issues by how long they have been open. */ |
| 412 | export function ageBuckets(created: string[], now: number): { label: string; count: number }[] { |
| 413 | const buckets = [ |
| 414 | { label: "Under a day", max: DAY, count: 0 }, |
| 415 | { label: "Under a week", max: 7 * DAY, count: 0 }, |
| 416 | { label: "Under a month", max: 30 * DAY, count: 0 }, |
| 417 | { label: "Older", max: Number.POSITIVE_INFINITY, count: 0 }, |
| 418 | ]; |
| 419 | for (const at of created) { |
| 420 | const age = now - Date.parse(at); |
| 421 | const bucket = buckets.find((b) => age < b.max) ?? buckets[buckets.length - 1]; |
| 422 | bucket.count += 1; |
| 423 | } |
| 424 | return buckets.map(({ label, count }) => ({ label, count })); |
| 425 | } |
| 426 | |
| 427 | export const TIME = { MINUTE, HOUR, DAY }; |