| 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, Confidence, ConfidenceLevel, 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" || (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 = |
| 23 | | "blocking" |
| 24 | | "asked_for_you" |
| 25 | | "checks_failing" |
| 26 | | "outside_guardrails" |
| 27 | | "low_confidence" |
| 28 | | "needs_review" |
| 29 | | "stalled" |
| 30 | | "ready_to_merge"; |
| 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", |
| 37 | low_confidence: "Low confidence", |
| 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 { |
| 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"; |
| 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"; |
| 73 | if (/checks? (?:still )?fail|still fails?\b|could not be run|failed in the merge queue/i.test(detail)) return "checks_failing"; |
| 74 | if (/approv|asked for changes|review (?:still|could not)/i.test(detail)) return "needs_review"; |
| 75 | return "stalled"; |
| 76 | } |
| 77 | |
| 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 | |
| 100 | /** Why only a person can move it: the callout beside what the agent knows. */ |
| 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 | } |
| 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; |
| 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 | } |
| 158 | |
| 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 | } |
| 183 | |
| 184 | /** Test files, by the names test runners look for. */ |
| 185 | export function isTestFile(path: string): boolean { |
| 186 | return ( |
| 187 | /(^|\/)(__tests__|tests?|spec)\//i.test(path) || |
| 188 | /[._-](test|spec)\.[a-z0-9]+$/i.test(path) || |
| 189 | /_test\.(go|rs|py)$/i.test(path) || |
| 190 | // A file named for what it is: test.js, tests.py, spec.rb. |
| 191 | /(^|\/)(tests?|specs?)\.[a-z0-9]+$/i.test(path) |
| 192 | ); |
| 193 | } |
| 194 | |
| 195 | const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`; |
| 196 | |
| 197 | /** "$0.42", or "<$0.01" for a sliver. */ |
| 198 | export function usd(value: number): string { |
| 199 | if (value > 0 && value < 0.01) return "<$0.01"; |
| 200 | return `$${value.toFixed(2)}`; |
| 201 | } |
| 202 | |
| 203 | /** |
| 204 | * What is known about a pull request without opening it: its checks, how |
| 205 | * much it changes, the tests it touches, how often the agent was sent back, |
| 206 | * and what its runs cost. Only what is known is listed. |
| 207 | */ |
| 208 | export function pullFacts(input: { |
| 209 | checkStatus: CheckStatus | null; |
| 210 | files: ChangedFile[]; |
| 211 | lifecycle?: (Pick<Lifecycle, "revisions"> & Partial<Pick<Lifecycle, "stage" | "detail">>) | null; |
| 212 | runs?: Pick<AgentRun, "costUsd" | "kind">[]; |
| 213 | confidence?: Pick<Confidence, "level" | "reasons"> | null; |
| 214 | }): Fact[] { |
| 215 | const facts: Fact[] = []; |
| 216 | facts.push( |
| 217 | checksFact(input.checkStatus, input.lifecycle?.stage ? { stage: input.lifecycle.stage, detail: input.lifecycle.detail ?? "" } : null), |
| 218 | ); |
| 219 | if (input.files.length > 0) { |
| 220 | const added = input.files.reduce((sum, f) => sum + f.additions, 0); |
| 221 | const removed = input.files.reduce((sum, f) => sum + f.deletions, 0); |
| 222 | facts.push({ label: "Files changed", value: input.files.length.toLocaleString("en-US"), tone: null }); |
| 223 | facts.push({ label: "Lines", value: `+${added.toLocaleString("en-US")} −${removed.toLocaleString("en-US")}`, tone: null }); |
| 224 | const tests = input.files.filter((f) => isTestFile(f.path)).length; |
| 225 | facts.push({ label: "Tests", value: tests > 0 ? plural(tests, "file") : "None touched", tone: tests > 0 ? "good" : "warn" }); |
| 226 | } |
| 227 | if (input.lifecycle && input.lifecycle.revisions > 0) { |
| 228 | facts.push({ |
| 229 | label: "Sent back", |
| 230 | value: plural(input.lifecycle.revisions, "time"), |
| 231 | tone: input.lifecycle.revisions > 1 ? "warn" : null, |
| 232 | }); |
| 233 | } |
| 234 | const agentRuns = (input.runs ?? []).filter((run) => run.kind !== "checks" && run.kind !== "queue" && run.kind !== "mergecheck"); |
| 235 | if (agentRuns.length > 0) { |
| 236 | const costs = agentRuns.filter((run) => run.costUsd != null); |
| 237 | const cost = costs.reduce((sum, run) => sum + (run.costUsd ?? 0), 0); |
| 238 | facts.push({ |
| 239 | label: "Agent runs", |
| 240 | value: costs.length > 0 ? `${agentRuns.length} · ${usd(cost)}` : String(agentRuns.length), |
| 241 | tone: null, |
| 242 | }); |
| 243 | } |
| 244 | if (input.confidence) facts.push(confidenceFact(input.confidence)); |
| 245 | return facts; |
| 246 | } |
| 247 | |
| 248 | // --- Rows ------------------------------------------------------------------- |
| 249 | |
| 250 | /** Someone or something on a row: an agent, a person, or g1t itself. */ |
| 251 | export type Who = { name: string; agent: boolean }; |
| 252 | |
| 253 | export const who = (name: string | null | undefined): Who | null => (name ? { name, agent: isAgent(name) } : null); |
| 254 | |
| 255 | /** A form on mission control that acts without leaving it. */ |
| 256 | export type QuickAction = { label: string; to: string; fields: Record<string, string>; done: string }; |
| 257 | |
| 258 | /** One thing that needs the viewer, with what is known about it. */ |
| 259 | export type NeedRow = { |
| 260 | key: string; |
| 261 | reason: Reason; |
| 262 | repo: RepoPath | null; |
| 263 | /** "#41", when it is an issue or pull request. */ |
| 264 | ref: string | null; |
| 265 | title: string; |
| 266 | /** The ask, in one sentence. */ |
| 267 | ask: string; |
| 268 | /** Who is waiting: the agent on it, or the person who asked. */ |
| 269 | by: Who | null; |
| 270 | /** The person who started it, when an agent did the work. */ |
| 271 | for: string | null; |
| 272 | at: number; |
| 273 | to: string; |
| 274 | open: string; |
| 275 | facts: Fact[]; |
| 276 | why: string; |
| 277 | quick: QuickAction | null; |
| 278 | /** Somewhere else to act, when that is the next step. */ |
| 279 | link: { label: string; to: string } | null; |
| 280 | }; |
| 281 | |
| 282 | /** The agents' stages a pull request can wait in, as their chips say them. */ |
| 283 | export const STAGE_CHIP: Record<Exclude<Stage, "needs_you" | "ready">, string> = { |
| 284 | working: "Working", |
| 285 | checking: "Checking", |
| 286 | reviewing: "Reviewing", |
| 287 | revising: "Revising", |
| 288 | catching_up: "Catching up", |
| 289 | answering: "Answering", |
| 290 | queued: "In queue", |
| 291 | }; |
| 292 | |
| 293 | /** A run's kind, as its chip says it. */ |
| 294 | export const RUN_LABEL: Record<RunKind, string> = { |
| 295 | implement: "Working", |
| 296 | revise: "Revising", |
| 297 | review: "Reviewing", |
| 298 | answer: "Answering", |
| 299 | update: "Catching up", |
| 300 | plan: "Planning", |
| 301 | checks: "Checking", |
| 302 | queue: "In queue", |
| 303 | mergecheck: "Checking", |
| 304 | }; |
| 305 | |
| 306 | /** Something agents are doing, which the viewer can leave to them. */ |
| 307 | export type WaitingRow = { |
| 308 | key: string; |
| 309 | repo: RepoPath; |
| 310 | ref: string | null; |
| 311 | title: string; |
| 312 | chip: string; |
| 313 | detail: string; |
| 314 | by: Who | null; |
| 315 | at: number; |
| 316 | to: string; |
| 317 | /** Whether a run is going on it right now. */ |
| 318 | live: boolean; |
| 319 | /** The run going on it, to watch. */ |
| 320 | run: string | null; |
| 321 | facts: Fact[]; |
| 322 | }; |
| 323 | |
| 324 | const pathKey = (repo: RepoPath, number: number | null) => `${repo.namespace}/${repo.name}#${number ?? "-"}`.toLowerCase(); |
| 325 | |
| 326 | /** |
| 327 | * What is in agents' hands: pull requests in an agent's stage, runs going |
| 328 | * now, and drafts agents are still making. Each once, and nothing that is |
| 329 | * already waiting on the viewer. |
| 330 | */ |
| 331 | export function waitingRows(input: { |
| 332 | active: { |
| 333 | pull: { number: number; title: string; agent: string; updatedAt: string; checkStatus: CheckStatus | null; files: ChangedFile[] }; |
| 334 | lifecycle: Lifecycle | null; |
| 335 | repo: RepoPath; |
| 336 | }[]; |
| 337 | live: Pick< |
| 338 | AgentRun, |
| 339 | "id" | "repo" | "number" | "title" | "kind" | "agent" | "step" | "costUsd" | "startedAt" | "createdAt" | "updatedAt" |
| 340 | >[]; |
| 341 | drafts: { |
| 342 | number: number; |
| 343 | title: string; |
| 344 | agent: string; |
| 345 | updatedAt: string; |
| 346 | checkStatus: CheckStatus | null; |
| 347 | files: ChangedFile[]; |
| 348 | repo: RepoPath; |
| 349 | }[]; |
| 350 | needKeys: ReadonlySet<string>; |
| 351 | }): WaitingRow[] { |
| 352 | const rows = new Map<string, WaitingRow>(); |
| 353 | for (const { pull, lifecycle, repo } of input.active) { |
| 354 | if (!lifecycle || lifecycle.stage === "needs_you" || lifecycle.stage === "ready") continue; |
| 355 | const key = pathKey(repo, pull.number); |
| 356 | if (input.needKeys.has(key)) continue; |
| 357 | rows.set(key, { |
| 358 | key, |
| 359 | repo, |
| 360 | ref: `#${pull.number}`, |
| 361 | title: pull.title, |
| 362 | chip: STAGE_CHIP[lifecycle.stage], |
| 363 | detail: lifecycle.detail, |
| 364 | by: who(pull.agent), |
| 365 | at: Date.parse(pull.updatedAt), |
| 366 | to: `/${repo.namespace}/${repo.name}/pull/${pull.number}`, |
| 367 | live: false, |
| 368 | run: null, |
| 369 | facts: pullFacts({ checkStatus: pull.checkStatus, files: pull.files, lifecycle }), |
| 370 | }); |
| 371 | } |
| 372 | for (const run of input.live) { |
| 373 | const key = run.number != null ? pathKey(run.repo, run.number) : `run:${run.id}`; |
| 374 | if (input.needKeys.has(key)) continue; |
| 375 | const runTo = `/${run.repo.namespace}/${run.repo.name}/agents/runs/${run.id}`; |
| 376 | const existing = rows.get(key); |
| 377 | const started = Date.parse(run.startedAt ?? run.createdAt); |
| 378 | const runFacts: Fact[] = [ |
| 379 | { label: "Run", value: RUN_LABEL[run.kind], tone: null }, |
| 380 | ...(run.costUsd != null ? [{ label: "Cost so far", value: usd(run.costUsd), tone: null }] : []), |
| 381 | ]; |
| 382 | if (existing) { |
| 383 | existing.live = true; |
| 384 | existing.run = runTo; |
| 385 | if (run.step) existing.detail = run.step; |
| 386 | existing.facts = [...runFacts, ...existing.facts.filter((f) => f.label !== "Run")]; |
| 387 | existing.at = Math.max(existing.at, Date.parse(run.updatedAt)); |
| 388 | continue; |
| 389 | } |
| 390 | rows.set(key, { |
| 391 | key, |
| 392 | repo: run.repo, |
| 393 | ref: run.number != null ? `#${run.number}` : null, |
| 394 | title: run.title ?? `${RUN_LABEL[run.kind]} in ${run.repo.name}`, |
| 395 | chip: RUN_LABEL[run.kind], |
| 396 | detail: run.step ?? "Starting.", |
| 397 | by: who(run.agent), |
| 398 | at: Number.isFinite(started) ? started : Date.parse(run.updatedAt), |
| 399 | to: runTo, |
| 400 | live: true, |
| 401 | run: runTo, |
| 402 | facts: runFacts, |
| 403 | }); |
| 404 | } |
| 405 | for (const draft of input.drafts) { |
| 406 | const key = pathKey(draft.repo, draft.number); |
| 407 | if (rows.has(key) || input.needKeys.has(key) || !isAgent(draft.agent)) continue; |
| 408 | rows.set(key, { |
| 409 | key, |
| 410 | repo: draft.repo, |
| 411 | ref: `#${draft.number}`, |
| 412 | title: draft.title, |
| 413 | chip: "Working", |
| 414 | detail: `${draft.agent} is making the change.`, |
| 415 | by: who(draft.agent), |
| 416 | at: Date.parse(draft.updatedAt), |
| 417 | to: `/${draft.repo.namespace}/${draft.repo.name}/pull/${draft.number}`, |
| 418 | live: false, |
| 419 | run: null, |
| 420 | facts: pullFacts({ checkStatus: draft.checkStatus, files: draft.files }), |
| 421 | }); |
| 422 | } |
| 423 | // Running now first, then what moved most recently. |
| 424 | return [...rows.values()].sort((a, b) => Number(b.live) - Number(a.live) || b.at - a.at); |
| 425 | } |
| 426 | |
| 427 | /** The key a need is known by when matching it to agents' work. */ |
| 428 | export const needPathKey = pathKey; |
| 429 | |
| 430 | // --- Landed ----------------------------------------------------------------- |
| 431 | |
| 432 | /** A merged pull request, as the week counts it. */ |
| 433 | /** |
| 434 | * The commits a push to the default branch brought, from the history at its |
| 435 | * `after` (newest first) back to its `before`: people's own, not merges (a |
| 436 | * pull request landing) and not agents'. A push whose `before` is not in |
| 437 | * what was read gives what was read. |
| 438 | */ |
| 439 | export function pushedCommits<C extends { hash: string; parents: string[]; author: { name: string } }>( |
| 440 | history: C[], |
| 441 | before: string | undefined, |
| 442 | ): C[] { |
| 443 | const end = before ? history.findIndex((commit) => commit.hash === before) : -1; |
| 444 | const brought = end === -1 ? history : history.slice(0, end); |
| 445 | return brought.filter((commit) => commit.parents.length <= 1 && !isAgent(commit.author.name)); |
| 446 | } |
| 447 | |
| 448 | export type Merged = { |
| 449 | repo: RepoPath; |
| 450 | number: number; |
| 451 | title: string; |
| 452 | agent: string; |
| 453 | /** Whether an agent wrote it (its author is g1t or another agent), not a person. */ |
| 454 | authoredByAgent: boolean; |
| 455 | mergedBy: string | null; |
| 456 | mergedAt: string; |
| 457 | files: ChangedFile[]; |
| 458 | }; |
| 459 | |
| 460 | /** |
| 461 | * Whether a change landed without a person: g1t merged it, by auto-merge or |
| 462 | * from the merge queue, rather than someone pressing merge. |
| 463 | */ |
| 464 | export function landedByAgents(change: Pick<Merged, "mergedBy">): boolean { |
| 465 | return change.mergedBy == null || isAgent(change.mergedBy); |
| 466 | } |
| 467 | |
| 468 | /** A day's date in a time zone, as `YYYY-MM-DD`; UTC when the zone is unknown. */ |
| 469 | export function dayKey(at: number, timeZone: string | null): string { |
| 470 | try { |
| 471 | return new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", timeZone: timeZone || "UTC" }).format(at); |
| 472 | } catch { |
| 473 | return new Date(at).toISOString().slice(0, 10); |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | /** |
| 478 | * One day's changes by who did the work: `agents`, written by an agent and |
| 479 | * landed without a person; `assisted`, written by an agent and merged by a |
| 480 | * person; `people`, written by a person, however it landed. |
| 481 | */ |
| 482 | export type WeekDay = { key: string; label: string; agents: number; assisted: number; people: number }; |
| 483 | |
| 484 | export type Week = { |
| 485 | days: WeekDay[]; |
| 486 | /** Changes landed in the last seven days. */ |
| 487 | total: number; |
| 488 | /** Of those, agents' changes that landed without a person. */ |
| 489 | byAgents: number; |
| 490 | /** Agents' changes, with a person or without. */ |
| 491 | agentChanges: number; |
| 492 | /** People's own changes. */ |
| 493 | people: number; |
| 494 | /** The seven days before, or null when the lists read do not reach back that far. */ |
| 495 | previous: number | null; |
| 496 | }; |
| 497 | |
| 498 | /** |
| 499 | * The last seven days in the viewer's zone, oldest first, each split by who |
| 500 | * did the work (see `WeekDay`), and the week before as one number. |
| 501 | * `complete` says whether what was read reaches back two weeks; when it |
| 502 | * does not, the week before is not guessed. |
| 503 | */ |
| 504 | export function weekOf( |
| 505 | changes: Pick<Merged, "mergedAt" | "mergedBy" | "authoredByAgent">[], |
| 506 | now: number, |
| 507 | timeZone: string | null, |
| 508 | complete = true, |
| 509 | ): Week { |
| 510 | const days: WeekDay[] = []; |
| 511 | const index = new Map<string, number>(); |
| 512 | for (let back = 6; back >= 0; back -= 1) { |
| 513 | const at = now - back * DAY; |
| 514 | const key = dayKey(at, timeZone); |
| 515 | if (index.has(key)) continue; |
| 516 | index.set(key, days.length); |
| 517 | let label: string; |
| 518 | try { |
| 519 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: timeZone || "UTC" }).format(at); |
| 520 | } catch { |
| 521 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at); |
| 522 | } |
| 523 | days.push({ key, label, agents: 0, assisted: 0, people: 0 }); |
| 524 | } |
| 525 | const oldest = days[0]?.key ?? ""; |
| 526 | const twoWeeks = dayKey(now - 13 * DAY, timeZone); |
| 527 | let previous = 0; |
| 528 | for (const change of changes) { |
| 529 | const at = Date.parse(change.mergedAt); |
| 530 | if (!Number.isFinite(at) || at > now) continue; |
| 531 | const key = dayKey(at, timeZone); |
| 532 | const slot = index.get(key); |
| 533 | if (slot != null) { |
| 534 | if (!change.authoredByAgent) days[slot].people += 1; |
| 535 | else if (landedByAgents(change)) days[slot].agents += 1; |
| 536 | else days[slot].assisted += 1; |
| 537 | } else if (key < oldest && key >= twoWeeks) { |
| 538 | previous += 1; |
| 539 | } |
| 540 | } |
| 541 | const sum = (pick: (day: WeekDay) => number) => days.reduce((total, day) => total + pick(day), 0); |
| 542 | const byAgents = sum((day) => day.agents); |
| 543 | const agentChanges = byAgents + sum((day) => day.assisted); |
| 544 | const people = sum((day) => day.people); |
| 545 | return { days, total: agentChanges + people, byAgents, agentChanges, people, previous: complete ? previous : null }; |
| 546 | } |
| 547 | |
| 548 | /** The change from one number to another, as a share; null from nothing. */ |
| 549 | export function change(current: number, previous: number | null): number | null { |
| 550 | if (previous == null || previous === 0) return null; |
| 551 | return (current - previous) / previous; |
| 552 | } |
| 553 | |
| 554 | /** "+18%", "−5%", "0%". */ |
| 555 | export function signedPercent(share: number): string { |
| 556 | const n = Math.round(share * 100); |
| 557 | return n > 0 ? `+${n}%` : n < 0 ? `−${Math.abs(n)}%` : "0%"; |
| 558 | } |
| 559 | |
| 560 | /** |
| 561 | * Whether the merged pull requests read for one repository reach back to |
| 562 | * `since`: the list is not full, or its oldest goes back that far. |
| 563 | */ |
| 564 | export function reachesBack(list: { mergedAt: string | null; updatedAt: string }[], since: number, page: number): boolean { |
| 565 | if (list.length < page) return true; |
| 566 | const oldest = Math.min(...list.map((pull) => Date.parse(pull.mergedAt ?? pull.updatedAt)).filter(Number.isFinite)); |
| 567 | return oldest <= since; |
| 568 | } |
| 569 | |
| 570 | /** One change that landed, as the third tab lists it. */ |
| 571 | export type LandedRow = { |
| 572 | key: string; |
| 573 | repo: RepoPath; |
| 574 | ref: string; |
| 575 | title: string; |
| 576 | by: Who | null; |
| 577 | agent: string; |
| 578 | byAgents: boolean; |
| 579 | at: number; |
| 580 | to: string; |
| 581 | facts: Fact[]; |
| 582 | }; |
| 583 | |
| 584 | /** What landed on the viewer's calendar day, newest first. */ |
| 585 | export function landedToday(changes: Merged[], now: number, timeZone: string | null): LandedRow[] { |
| 586 | const today = dayKey(now, timeZone); |
| 587 | return changes |
| 588 | .filter((change) => dayKey(Date.parse(change.mergedAt), timeZone) === today) |
| 589 | .sort((a, b) => Date.parse(b.mergedAt) - Date.parse(a.mergedAt)) |
| 590 | .map((change) => ({ |
| 591 | key: pathKey(change.repo, change.number), |
| 592 | repo: change.repo, |
| 593 | ref: `#${change.number}`, |
| 594 | title: change.title, |
| 595 | by: who(change.mergedBy ?? "g1t"), |
| 596 | agent: change.agent, |
| 597 | byAgents: change.authoredByAgent && landedByAgents(change), |
| 598 | at: Date.parse(change.mergedAt), |
| 599 | to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`, |
| 600 | facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"), |
| 601 | })); |
| 602 | } |
| 603 | |
| 604 | // --- Tabs and sorting ------------------------------------------------------- |
| 605 | |
| 606 | export type Tab = "needs" | "waiting" | "landed"; |
| 607 | export type Sort = "impact" | "newest"; |
| 608 | |
| 609 | export const TABS: Tab[] = ["needs", "waiting", "landed"]; |
| 610 | |
| 611 | export function parseTab(value: string | null): Tab | null { |
| 612 | return value === "needs" || value === "waiting" || value === "landed" ? value : null; |
| 613 | } |
| 614 | |
| 615 | export function parseSort(value: string | null): Sort { |
| 616 | return value === "newest" ? "newest" : "impact"; |
| 617 | } |
| 618 | |
| 619 | /** Rows in the order chosen: as ranked (most urgent first), or newest first. */ |
| 620 | export function sortRows<T extends { at: number }>(rows: T[], sort: Sort): T[] { |
| 621 | return sort === "newest" ? [...rows].sort((a, b) => b.at - a.at) : rows; |
| 622 | } |
| 623 | |
| 624 | // --- The summary ------------------------------------------------------------ |
| 625 | |
| 626 | /** |
| 627 | * The sentence under the greeting: the week, honestly, in one line. What |
| 628 | * agents landed alone is counted against the agents' own changes, never |
| 629 | * against what people wrote. |
| 630 | */ |
| 631 | export function summaryLine(input: { |
| 632 | total: number; |
| 633 | byAgents: number; |
| 634 | agentChanges: number; |
| 635 | people: number; |
| 636 | live: number; |
| 637 | needs: number; |
| 638 | }): string { |
| 639 | const { total, byAgents, agentChanges, people, live, needs } = input; |
| 640 | if (total > 0) { |
| 641 | const theirs = people > 0 ? `, and people landed ${plural(people, "change")} of their own` : ""; |
| 642 | if (agentChanges === 0) return `People landed ${plural(people, "change")} this week; none were agents'.`; |
| 643 | if (byAgents === agentChanges) return `Agents landed all ${plural(agentChanges, "change")} of theirs this week without you${theirs}.`; |
| 644 | if (byAgents === 0) return `Agents made ${plural(agentChanges, "change")} this week, each merged by a person${theirs}.`; |
| 645 | return `Agents landed ${byAgents} of their ${plural(agentChanges, "change")} this week without you${theirs}.`; |
| 646 | } |
| 647 | if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`; |
| 648 | if (needs > 0) return "Nothing has landed this week. What is waiting on you is below."; |
| 649 | return "Nothing has landed this week yet. Assign an issue to g1t and it starts in seconds."; |
| 650 | } |
| 651 | |
| 652 | /** "Monday, Oct 5", in the viewer's zone. */ |
| 653 | export function dateLine(now: number, timeZone: string | null): string { |
| 654 | const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "short", day: "numeric" }; |
| 655 | try { |
| 656 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: timeZone || "UTC" }).format(now); |
| 657 | } catch { |
| 658 | return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(now); |
| 659 | } |
| 660 | } |