| 1 | /** |
| 2 | * Coordinating work, as the site shows it: who is on which issue (claims), |
| 3 | * which harness sessions are running, what the merge queue found when it |
| 4 | * combined changes, and the decision records behind the result. Pure: the |
| 5 | * pages and components/coordination.tsx draw what these say. |
| 6 | */ |
| 7 | import type { |
| 8 | Claim, |
| 9 | ClaimState, |
| 10 | Decision, |
| 11 | DecisionConfidence, |
| 12 | EventType, |
| 13 | G1tEvent, |
| 14 | Harness, |
| 15 | LinkVia, |
| 16 | SessionState, |
| 17 | TouchKind, |
| 18 | WorkSession, |
| 19 | } from "@g1t/contracts"; |
| 20 | |
| 21 | /** How each harness is named. */ |
| 22 | export const HARNESS_NAMES: Record<Harness, string> = { |
| 23 | claude_code: "Claude Code", |
| 24 | claude_managed_agents: "Claude Managed Agents", |
| 25 | codex: "Codex", |
| 26 | cursor: "Cursor", |
| 27 | copilot: "Copilot", |
| 28 | other: "Another harness", |
| 29 | }; |
| 30 | |
| 31 | export function harnessName(harness: Harness | null | undefined): string | null { |
| 32 | return harness ? (HARNESS_NAMES[harness] ?? harness) : null; |
| 33 | } |
| 34 | |
| 35 | /** The coordination events the Work page lists, newest first. */ |
| 36 | export const COORDINATION_TYPES = [ |
| 37 | "work.claimed", |
| 38 | "work.released", |
| 39 | "work.claim_expired", |
| 40 | "work.handoff_requested", |
| 41 | "session.started", |
| 42 | "session.ended", |
| 43 | "dependency.changed", |
| 44 | "overlap.detected", |
| 45 | "integration.failed", |
| 46 | "integration.passed", |
| 47 | "decision.recorded", |
| 48 | ] as const satisfies readonly EventType[]; |
| 49 | |
| 50 | export type CoordinationType = (typeof COORDINATION_TYPES)[number]; |
| 51 | |
| 52 | // ── Time ──────────────────────────────────────────────────────────────── |
| 53 | |
| 54 | const MINUTE = 60_000; |
| 55 | |
| 56 | /** "4m", "1h 20m", "2h": a span, for "expires in …". */ |
| 57 | export function span(ms: number): string { |
| 58 | const minutes = Math.max(0, Math.floor(ms / MINUTE)); |
| 59 | if (minutes < 1) return "less than a minute"; |
| 60 | if (minutes < 60) return `${minutes}m`; |
| 61 | const hours = Math.floor(minutes / 60); |
| 62 | const rest = minutes % 60; |
| 63 | return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`; |
| 64 | } |
| 65 | |
| 66 | /** When an active claim lapses, in words; null for one that is not active. */ |
| 67 | export function expiresIn(claim: Pick<Claim, "state" | "expiresAt">, now: number): string | null { |
| 68 | if (claim.state !== "active") return null; |
| 69 | const left = new Date(claim.expiresAt).getTime() - now; |
| 70 | return left <= 0 ? "expiring now" : `expires in ${span(left)}`; |
| 71 | } |
| 72 | |
| 73 | /** What became of a claim that is no longer active. */ |
| 74 | export const CLAIM_ENDED: Record<Exclude<ClaimState, "active">, string> = { |
| 75 | released: "Released", |
| 76 | expired: "Expired", |
| 77 | handed_off: "Handed off", |
| 78 | }; |
| 79 | |
| 80 | export const SESSION_STATES: Record<SessionState, { label: string; hint: string }> = { |
| 81 | active: { label: "Active", hint: "Heard from in the last 15 minutes." }, |
| 82 | idle: { label: "Idle", hint: "Not heard from for 15 minutes. It may still come back." }, |
| 83 | ended: { label: "Ended", hint: "Ended, or not heard from for 2 hours." }, |
| 84 | }; |
| 85 | |
| 86 | // ── Session titles ────────────────────────────────────────────────────── |
| 87 | |
| 88 | /** The most of a prompt's intent that titles a session. */ |
| 89 | export const TITLE_LIMIT = 80; |
| 90 | |
| 91 | /** |
| 92 | * What a prompt is for, as a session's title: its first line or sentence, |
| 93 | * without code blocks or markdown, whitespace collapsed, clipped at |
| 94 | * TITLE_LIMIT on a word with "…". Null for a slash command, a turn the |
| 95 | * harness starts itself, or nothing to say. The Claude Code plugin |
| 96 | * (plugins/claude-code/hooks/g1t.mjs) titles its sessions the same way. |
| 97 | */ |
| 98 | export function intentTitle(prompt: string | null | undefined): string | null { |
| 99 | let text = String(prompt ?? "").replace(/^\uFEFF/, ""); |
| 100 | if (/^\s*\//.test(text) || /^\s*<[a-z][\w-]*>/i.test(text)) return null; |
| 101 | text = text |
| 102 | .replace(/```[\s\S]*?(```|$)/g, "\n") |
| 103 | .replace(/~~~[\s\S]*?(~~~|$)/g, "\n") |
| 104 | .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") |
| 105 | .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") |
| 106 | .replace(/`([^`]*)`/g, "$1"); |
| 107 | const line = text |
| 108 | .split(/\r?\n/) |
| 109 | .map((l) => l.replace(/^\s*(?:#{1,6}\s+|>\s*|[-*+]\s+|\d+[.)]\s+)+/, "").replace(/(\*\*|__|~~)(.+?)\1/g, "$2").trim()) |
| 110 | .find((l) => /[\p{L}\p{N}]/u.test(l)); |
| 111 | if (!line) return null; |
| 112 | let intent = line.replace(/\s+/g, " ").split(/(?<=[.!?])\s+(?=\S)/)[0].trim(); |
| 113 | if (intent.length > TITLE_LIMIT) { |
| 114 | const cut = intent.slice(0, TITLE_LIMIT - 1); |
| 115 | const space = cut.lastIndexOf(" "); |
| 116 | intent = `${(space > TITLE_LIMIT / 2 ? cut.slice(0, space) : cut).replace(/[\s,;:.\-–—]+$/, "")}…`; |
| 117 | } else { |
| 118 | intent = intent.replace(/[\s,;:.]+$/, ""); |
| 119 | } |
| 120 | if (intent.length < 2) return null; |
| 121 | return intent[0].toLocaleUpperCase() + intent.slice(1); |
| 122 | } |
| 123 | |
| 124 | /** |
| 125 | * What a session is called, never nothing: its title, else what its first |
| 126 | * prompt was for, else the branch it is on, else "Untitled session". |
| 127 | * `firstPrompt` stands in for the session's own when given. |
| 128 | */ |
| 129 | export function sessionTitle( |
| 130 | session: Pick<WorkSession, "title" | "branch" | "firstPrompt">, |
| 131 | firstPrompt: string | null | undefined = session.firstPrompt, |
| 132 | ): string { |
| 133 | const title = session.title?.trim(); |
| 134 | if (title) return title; |
| 135 | const intent = intentTitle(firstPrompt); |
| 136 | if (intent) return intent; |
| 137 | return session.branch ? `Session on ${session.branch}` : "Untitled session"; |
| 138 | } |
| 139 | |
| 140 | // ── Live now ──────────────────────────────────────────────────────────── |
| 141 | |
| 142 | /** One issue being worked on: its claims and the sessions on it. */ |
| 143 | export type LiveIssue = { issue: number; claims: Claim[]; sessions: WorkSession[] }; |
| 144 | |
| 145 | /** Sessions on no issue yet, together: on one pull request, on one branch of their own, or on neither. */ |
| 146 | export type LiveGroup = |
| 147 | | { kind: "pull"; pull: number; sessions: WorkSession[] } |
| 148 | | { kind: "branch"; branch: string; sessions: WorkSession[] } |
| 149 | | { kind: "none"; sessions: WorkSession[] }; |
| 150 | |
| 151 | const latestSeen = (sessions: WorkSession[]) => Math.max(0, ...sessions.map((session) => Date.parse(session.lastSeenAt))); |
| 152 | |
| 153 | /** |
| 154 | * Active claims and sessions grouped by the issue they are on, the issue |
| 155 | * with the most recent activity first. Sessions on no issue are grouped |
| 156 | * after them (`groups`): by their pull request, else by their branch when |
| 157 | * it is not `defaultBranch`, the most recently heard from first; the rest, |
| 158 | * on no branch of their own, last together. `loose` is every session on no |
| 159 | * issue, as the groups hold them. |
| 160 | */ |
| 161 | export function liveByIssue( |
| 162 | claims: Claim[], |
| 163 | sessions: WorkSession[], |
| 164 | defaultBranch?: string | null, |
| 165 | ): { issues: LiveIssue[]; groups: LiveGroup[]; loose: WorkSession[] } { |
| 166 | const byIssue = new Map<number, LiveIssue>(); |
| 167 | const at = (issue: number) => { |
| 168 | let group = byIssue.get(issue); |
| 169 | if (!group) byIssue.set(issue, (group = { issue, claims: [], sessions: [] })); |
| 170 | return group; |
| 171 | }; |
| 172 | for (const claim of claims) at(claim.issue).claims.push(claim); |
| 173 | const byPull = new Map<number, LiveGroup & { kind: "pull" }>(); |
| 174 | const byBranch = new Map<string, LiveGroup & { kind: "branch" }>(); |
| 175 | const rest: WorkSession[] = []; |
| 176 | for (const session of sessions) { |
| 177 | if (session.issue != null) at(session.issue).sessions.push(session); |
| 178 | else if (session.pull != null) { |
| 179 | const group = byPull.get(session.pull) ?? { kind: "pull" as const, pull: session.pull, sessions: [] }; |
| 180 | byPull.set(session.pull, group); |
| 181 | group.sessions.push(session); |
| 182 | } else if (session.branch && session.branch !== defaultBranch) { |
| 183 | const group = byBranch.get(session.branch) ?? { kind: "branch" as const, branch: session.branch, sessions: [] }; |
| 184 | byBranch.set(session.branch, group); |
| 185 | group.sessions.push(session); |
| 186 | } else rest.push(session); |
| 187 | } |
| 188 | const latest = (group: LiveIssue) => Math.max(latestSeen(group.sessions), ...group.claims.map((claim) => Date.parse(claim.createdAt))); |
| 189 | const recent = (a: LiveGroup, b: LiveGroup) => latestSeen(b.sessions) - latestSeen(a.sessions); |
| 190 | const groups: LiveGroup[] = [...byPull.values(), ...byBranch.values()].sort(recent); |
| 191 | if (rest.length > 0) groups.push({ kind: "none", sessions: rest }); |
| 192 | for (const group of groups) group.sessions.sort((a, b) => Date.parse(b.lastSeenAt) - Date.parse(a.lastSeenAt)); |
| 193 | return { |
| 194 | issues: [...byIssue.values()].sort((a, b) => latest(b) - latest(a) || a.issue - b.issue), |
| 195 | groups, |
| 196 | loose: groups.flatMap((group) => group.sessions), |
| 197 | }; |
| 198 | } |
| 199 | |
| 200 | // ── Decisions ─────────────────────────────────────────────────────────── |
| 201 | |
| 202 | export const CONFIDENCE: Record<DecisionConfidence, { label: string; hint: string; tone: "neutral" | "info" | "success" }> = { |
| 203 | claimed: { label: "Claimed", hint: "Someone said so: an agent or a person recorded it.", tone: "neutral" }, |
| 204 | tested: { label: "Tested", hint: "A check or the merge queue proved it.", tone: "info" }, |
| 205 | approved: { label: "Approved", hint: "A person approved it.", tone: "success" }, |
| 206 | }; |
| 207 | |
| 208 | export const DECISION_KINDS: Record<Decision["kind"], string> = { |
| 209 | proposal: "Proposal", |
| 210 | claim: "Claim", |
| 211 | integration_failure: "Failed together", |
| 212 | integration_passed: "Passed together", |
| 213 | fix: "Fix", |
| 214 | approval: "Approval", |
| 215 | merge: "Merge", |
| 216 | note: "Note", |
| 217 | }; |
| 218 | |
| 219 | /** The check a decision's evidence names. */ |
| 220 | export type EvidenceCheck = { name: string; conclusion: string; url: string | null }; |
| 221 | |
| 222 | /** What a decision rests on, read from its evidence as far as it says. */ |
| 223 | export type Evidence = { |
| 224 | commit: string | null; |
| 225 | check: EvidenceCheck | null; |
| 226 | /** The pull requests tested together, in queue order. */ |
| 227 | combination: number[]; |
| 228 | /** Those ahead it fails with. */ |
| 229 | suspects: number[]; |
| 230 | bisected: boolean; |
| 231 | }; |
| 232 | |
| 233 | function numbers(value: unknown): number[] { |
| 234 | return Array.isArray(value) ? value.filter((n): n is number => typeof n === "number" && Number.isInteger(n)) : []; |
| 235 | } |
| 236 | |
| 237 | export function evidenceOf(decision: Pick<Decision, "evidence">): Evidence { |
| 238 | const raw = (decision.evidence ?? {}) as Record<string, unknown>; |
| 239 | const check = raw.check && typeof raw.check === "object" ? (raw.check as Record<string, unknown>) : null; |
| 240 | return { |
| 241 | commit: typeof raw.commit === "string" ? raw.commit : null, |
| 242 | check: |
| 243 | check && typeof check.name === "string" |
| 244 | ? { |
| 245 | name: check.name, |
| 246 | conclusion: typeof check.conclusion === "string" ? check.conclusion : "failure", |
| 247 | url: typeof check.url === "string" && check.url ? check.url : null, |
| 248 | } |
| 249 | : null, |
| 250 | combination: numbers(raw.combination), |
| 251 | suspects: numbers(raw.suspects), |
| 252 | bisected: raw.bisected === true, |
| 253 | }; |
| 254 | } |
| 255 | |
| 256 | /** Where a check's address leads on the site: its path when it is g1t's own, else the address as it is. */ |
| 257 | export function checkHref(url: string | null): string | null { |
| 258 | if (!url) return null; |
| 259 | try { |
| 260 | const parsed = new URL(url); |
| 261 | if (parsed.hostname === "g1t.sh" || parsed.hostname.endsWith(".g1t.sh")) return parsed.pathname + parsed.search; |
| 262 | return url; |
| 263 | } catch { |
| 264 | return url.startsWith("/") ? url : null; |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * The integration failure a pull request still answers for: its latest |
| 270 | * `integration_failure`, unless it passed in the queue or merged since. |
| 271 | * `decisions` in any order. |
| 272 | */ |
| 273 | export function standingFailure(decisions: Decision[], pull?: number): Decision | null { |
| 274 | const mine = decisions |
| 275 | .filter((decision) => pull == null || decision.pull === pull) |
| 276 | .filter((decision) => ["integration_failure", "integration_passed", "merge"].includes(decision.kind)) |
| 277 | .sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt)); |
| 278 | return mine[0]?.kind === "integration_failure" ? mine[0] : null; |
| 279 | } |
| 280 | |
| 281 | /** The decisions after a failure that answer it: a fix recorded since, and a combination that then passed. */ |
| 282 | export function resolutionOf(decisions: Decision[], failure: Decision): { fix: Decision | null; passed: Decision | null } { |
| 283 | const after = decisions |
| 284 | .filter((decision) => Date.parse(decision.createdAt) > Date.parse(failure.createdAt)) |
| 285 | .sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt)); |
| 286 | return { |
| 287 | fix: after.find((decision) => decision.kind === "fix" && (decision.pull === failure.pull || decision.issue === failure.issue)) ?? null, |
| 288 | passed: after.find((decision) => decision.kind === "integration_passed" && decision.pull === failure.pull) ?? null, |
| 289 | }; |
| 290 | } |
| 291 | |
| 292 | // ── The feed ──────────────────────────────────────────────────────────── |
| 293 | |
| 294 | /** One line of the coordination feed: what happened, in words, and what it links to. */ |
| 295 | export type FeedLine = { |
| 296 | id: string; |
| 297 | time: string; |
| 298 | type: CoordinationType; |
| 299 | /** Who did it, by username; null for g1t itself. */ |
| 300 | who: string | null; |
| 301 | /** The harness it came from, when it says. */ |
| 302 | harness: Harness | null; |
| 303 | /** The sentence, after the name. Numbers in it are written `#N`. */ |
| 304 | text: string; |
| 305 | /** The issue or pull request it is about. */ |
| 306 | number: number | null; |
| 307 | /** The harness session it is about, by id, for a link to its page. */ |
| 308 | session?: string; |
| 309 | tone: "neutral" | "accent" | "success" | "warn" | "danger" | "info"; |
| 310 | }; |
| 311 | |
| 312 | const list = (all: number[]) => all.map((n) => `#${n}`).join(", "); |
| 313 | const plural = (n: number, one: string, many: string) => (n === 1 ? one : many); |
| 314 | |
| 315 | /** A coordination event as a line of the feed, or null for one it does not show. */ |
| 316 | export function feedLine(event: G1tEvent): FeedLine | null { |
| 317 | const base = { id: event.id, time: event.time, harness: null as Harness | null, number: null as number | null }; |
| 318 | const data = event.data as Record<string, unknown> & { number?: number }; |
| 319 | const number = typeof data.number === "number" ? data.number : null; |
| 320 | switch (event.type) { |
| 321 | case "work.claimed": { |
| 322 | const { claim, others = [], tookOver } = event.data; |
| 323 | const also = others.length > 0 ? ` (${others.map((other) => other.actor.username).join(", ")} ${plural(others.length, "is", "are")} on it too)` : ""; |
| 324 | return { |
| 325 | ...base, |
| 326 | type: event.type, |
| 327 | who: claim.actor.username, |
| 328 | harness: claim.harness, |
| 329 | text: `${tookOver ? "took over" : "claimed"} #${claim.issue}${also}`, |
| 330 | number: claim.issue, |
| 331 | tone: others.length > 0 ? "warn" : "accent", |
| 332 | }; |
| 333 | } |
| 334 | case "work.released": { |
| 335 | const { claim, reason } = event.data; |
| 336 | return { |
| 337 | ...base, |
| 338 | type: event.type, |
| 339 | who: claim.actor.username, |
| 340 | harness: claim.harness, |
| 341 | text: reason === "issue_closed" ? `no longer holds #${claim.issue}: the issue closed` : `released #${claim.issue}${reason ? `: ${reason}` : ""}`, |
| 342 | number: claim.issue, |
| 343 | tone: "neutral", |
| 344 | }; |
| 345 | } |
| 346 | case "work.claim_expired": { |
| 347 | const { claim } = event.data; |
| 348 | return { ...base, type: event.type, who: claim.actor.username, harness: claim.harness, text: `let the claim on #${claim.issue} expire`, number: claim.issue, tone: "neutral" }; |
| 349 | } |
| 350 | case "work.handoff_requested": { |
| 351 | const { claim, to, note } = event.data; |
| 352 | return { |
| 353 | ...base, |
| 354 | type: event.type, |
| 355 | who: claim.actor.username, |
| 356 | harness: claim.harness, |
| 357 | text: `handed #${claim.issue} off to ${to ?? "anyone"}${note ? `: ${note}` : ""}`, |
| 358 | number: claim.issue, |
| 359 | tone: "info", |
| 360 | }; |
| 361 | } |
| 362 | case "session.started": |
| 363 | case "session.ended": { |
| 364 | const { session } = event.data; |
| 365 | const on = session.issue != null ? ` on #${session.issue}` : session.pull != null ? ` on #${session.pull}` : ""; |
| 366 | const why = event.type === "session.ended" && session.endReason === "timed_out" ? " (timed out)" : ""; |
| 367 | return { |
| 368 | ...base, |
| 369 | type: event.type, |
| 370 | who: session.actor.username, |
| 371 | harness: session.harness, |
| 372 | text: `${event.type === "session.started" ? "started a session" : "ended a session"}${on}${why}`, |
| 373 | number: session.issue ?? session.pull, |
| 374 | tone: event.type === "session.started" ? "accent" : "neutral", |
| 375 | }; |
| 376 | } |
| 377 | case "dependency.changed": { |
| 378 | const { issue, added, removed, cleared, remaining, mergedPull } = event.data; |
| 379 | const parts = [ |
| 380 | added.length > 0 && `now waits on ${list(added)}`, |
| 381 | removed.length > 0 && `no longer waits on ${list(removed)}`, |
| 382 | cleared.length > 0 && `${list(cleared)} ${mergedPull ? `landed (#${mergedPull} merged)` : "closed"}`, |
| 383 | ].filter(Boolean); |
| 384 | // What it still waits on, unless that is only what was just added. |
| 385 | const said = remaining.every((n) => added.includes(n)) && parts.length > 0; |
| 386 | const left = remaining.length === 0 ? "unblocked" : said ? null : `still waits on ${list(remaining)}`; |
| 387 | return { |
| 388 | ...base, |
| 389 | type: event.type, |
| 390 | who: null, |
| 391 | text: `#${issue} ${[...parts, left].filter(Boolean).join("; ")}`, |
| 392 | number: issue, |
| 393 | tone: remaining.length === 0 ? "success" : "info", |
| 394 | }; |
| 395 | } |
| 396 | case "overlap.detected": { |
| 397 | if ("sessionId" in event.data) { |
| 398 | const { sessionId, otherActor, files, issue } = event.data; |
| 399 | return { |
| 400 | ...base, |
| 401 | type: event.type, |
| 402 | who: otherActor, |
| 403 | text: `touched the same ${plural(files.length, "file", "files")} in a session as another session: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`, |
| 404 | number: issue ?? number, |
| 405 | session: sessionId, |
| 406 | tone: "warn", |
| 407 | }; |
| 408 | } |
| 409 | const { pull, otherPull, files } = event.data; |
| 410 | return { |
| 411 | ...base, |
| 412 | type: event.type, |
| 413 | who: null, |
| 414 | text: `#${pull} and #${otherPull} change the same ${plural(files.length, "file", "files")}: ${files.slice(0, 3).join(", ")}${files.length > 3 ? ` and ${files.length - 3} more` : ""}`, |
| 415 | number: pull, |
| 416 | tone: "warn", |
| 417 | }; |
| 418 | } |
| 419 | case "integration.failed": { |
| 420 | const { pull, combination, check, suspects } = event.data; |
| 421 | const together = combination.length > 1 ? ` combined as ${list(combination)}` : " on its own"; |
| 422 | const with_ = suspects.length > 0 ? `; fails with ${list(suspects)}` : ""; |
| 423 | return { ...base, type: event.type, who: null, text: `#${pull} failed in the merge queue${together}: ${check.name}${with_}`, number: pull, tone: "danger" }; |
| 424 | } |
| 425 | case "integration.passed": { |
| 426 | const { pull, combination } = event.data; |
| 427 | return { |
| 428 | ...base, |
| 429 | type: event.type, |
| 430 | who: null, |
| 431 | text: combination.length > 1 ? `#${pull} passed in the merge queue combined as ${list(combination)}` : `#${pull} passed in the merge queue`, |
| 432 | number: pull, |
| 433 | tone: "success", |
| 434 | }; |
| 435 | } |
| 436 | case "decision.recorded": { |
| 437 | const { decision } = event.data; |
| 438 | const kind = DECISION_KINDS[decision.kind as Decision["kind"]]?.toLowerCase() ?? decision.kind; |
| 439 | return { |
| 440 | ...base, |
| 441 | type: event.type, |
| 442 | who: decision.actor, |
| 443 | text: `recorded ${/^[aeiou]/.test(kind) ? "an" : "a"} ${kind}, ${decision.confidence}: ${decision.summary}`, |
| 444 | number: decision.pull ?? decision.issue ?? number, |
| 445 | tone: decision.kind === "integration_failure" ? "danger" : decision.confidence === "approved" ? "success" : "neutral", |
| 446 | }; |
| 447 | } |
| 448 | // Steps of a session as it goes: for the pages showing it, not the feed. |
| 449 | case "session.progressed": |
| 450 | default: |
| 451 | return null; |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | // ── Session trees ─────────────────────────────────────────────────────── |
| 456 | |
| 457 | /** A session with its subagents under it, and theirs under them. */ |
| 458 | export type SessionNode = { session: WorkSession; children: SessionNode[] }; |
| 459 | |
| 460 | /** |
| 461 | * Sessions as trees, by `parentId`: the roots in the order given, each |
| 462 | * with its subagents in the order given. One whose parent is not in the |
| 463 | * list is a root of its own. |
| 464 | */ |
| 465 | export function sessionTree(sessions: WorkSession[]): SessionNode[] { |
| 466 | const nodes = new Map<string, SessionNode>(); |
| 467 | for (const session of sessions) if (!nodes.has(session.id)) nodes.set(session.id, { session, children: [] }); |
| 468 | const roots: SessionNode[] = []; |
| 469 | for (const node of nodes.values()) { |
| 470 | const parent = node.session.parentId ? nodes.get(node.session.parentId) : undefined; |
| 471 | // A parent that is the session itself or under it (a cycle) does not count. |
| 472 | if (parent && !contains(node, parent)) parent.children.push(node); |
| 473 | else roots.push(node); |
| 474 | } |
| 475 | return roots; |
| 476 | } |
| 477 | |
| 478 | /** The sessions of several lists, each once (as the first list to have it says), in the order first given. */ |
| 479 | export function mergeSessions(...lists: (WorkSession[] | null | undefined)[]): WorkSession[] { |
| 480 | const seen = new Map<string, WorkSession>(); |
| 481 | for (const list of lists) for (const session of list ?? []) if (!seen.has(session.id)) seen.set(session.id, session); |
| 482 | return [...seen.values()]; |
| 483 | } |
| 484 | |
| 485 | function contains(node: SessionNode, target: SessionNode): boolean { |
| 486 | return node === target || node.children.some((child) => contains(child, target)); |
| 487 | } |
| 488 | |
| 489 | /** How many sessions are under a tree's root, at any depth. */ |
| 490 | export function treeSize(node: SessionNode): number { |
| 491 | return node.children.reduce((total, child) => total + 1 + treeSize(child), 0); |
| 492 | } |
| 493 | |
| 494 | /** How a session was tied to an issue or pull request, in a few words. */ |
| 495 | export const VIA_LABELS: Record<LinkVia, string> = { |
| 496 | attached: "attached", |
| 497 | claim: "claimed", |
| 498 | branch: "its branch", |
| 499 | commit: "its commit", |
| 500 | trailer: "commit trailer", |
| 501 | opened: "opened it", |
| 502 | ref: "named it", |
| 503 | }; |
| 504 | |
| 505 | export function viaLabel(via: LinkVia): string { |
| 506 | return VIA_LABELS[via] ?? via; |
| 507 | } |
| 508 | |
| 509 | /** What each kind of thing a session touched is called. */ |
| 510 | export const TOUCH_KINDS: Record<TouchKind, string> = { |
| 511 | file: "Files", |
| 512 | commit: "Commits", |
| 513 | branch: "Branches", |
| 514 | pull: "Pull requests", |
| 515 | issue: "Issues", |
| 516 | }; |
| 517 | |
| 518 | /** What was touched, grouped by kind in TOUCH_KINDS' order, each value once, in the order given. */ |
| 519 | export function touchedByKind(touched: { kind: TouchKind; value: string }[]): { kind: TouchKind; values: string[] }[] { |
| 520 | return (Object.keys(TOUCH_KINDS) as TouchKind[]) |
| 521 | .map((kind) => ({ kind, values: [...new Set(touched.filter((one) => one.kind === kind).map((one) => one.value))] })) |
| 522 | .filter((group) => group.values.length > 0); |
| 523 | } |
| 524 | |
| 525 | /** "12s", "4m", "1h 20m": how long a session ran, or has run until `now`. */ |
| 526 | export function duration(session: Pick<WorkSession, "startedAt" | "endedAt" | "lastSeenAt" | "state">, now: number): string { |
| 527 | const end = session.endedAt ? Date.parse(session.endedAt) : session.state === "ended" ? Date.parse(session.lastSeenAt) : now; |
| 528 | const ms = Math.max(0, end - Date.parse(session.startedAt)); |
| 529 | return ms < MINUTE ? `${Math.floor(ms / 1000)}s` : span(ms); |
| 530 | } |
| 531 | |
| 532 | /** Splits text at `#N`, for linking each number. */ |
| 533 | export function numberedParts(text: string): (string | number)[] { |
| 534 | const parts: (string | number)[] = []; |
| 535 | let last = 0; |
| 536 | for (const match of text.matchAll(/#(\d+)\b/g)) { |
| 537 | if (match.index > last) parts.push(text.slice(last, match.index)); |
| 538 | parts.push(Number(match[1])); |
| 539 | last = match.index + match[0].length; |
| 540 | } |
| 541 | if (last < text.length) parts.push(text.slice(last)); |
| 542 | return parts; |
| 543 | } |
| 544 | |
| 545 | /** `blocked_by` as typed: numbers, with or without `#`, by commas or spaces; null when any is not one. */ |
| 546 | export function parseBlockedBy(text: string): number[] | null { |
| 547 | const words = text.split(/[\s,]+/).map((word) => word.trim().replace(/^#/, "")).filter(Boolean); |
| 548 | if (words.some((word) => !/^\d+$/.test(word) || Number(word) <= 0)) return null; |
| 549 | return [...new Set(words.map(Number))]; |
| 550 | } |