Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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 { | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 90 | return name === "g1t" || (name ?? "").endsWith("-agent"); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 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" | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 107 | | "pushed" |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 108 | | "learned"; |
| 109 | ||
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 110 | /** |
| 111 | * The event types `eventItem` makes a line of. Ask the log for these only: | |
| 112 | * a repository's newest events are mostly ones no line is made of (session | |
| 113 | * steps, queue and merge-check changes), which would otherwise fill the | |
| 114 | * page and leave nothing to show. | |
| 115 | */ | |
| 116 | export const FEED_EVENT_TYPES = [ | |
| 117 | "pull.merged", | |
| 118 | "issue.opened", | |
| 119 | "issue.closed", | |
| 120 | "pull.opened", | |
| 121 | "pull.ready", | |
| 122 | "checks.completed", | |
| 123 | "review.completed", | |
| 124 | "comment.created", | |
| 125 | "agent.asked", | |
| 126 | "deployment_status.created", | |
| 127 | ] as const satisfies readonly G1tEvent["type"][]; | |
| 128 | ||
| 129 | /** With pushes to the default branch too, for one project's feed. */ | |
| 130 | export const PROJECT_FEED_EVENT_TYPES = [...FEED_EVENT_TYPES, "git.push"] as const satisfies readonly G1tEvent["type"][]; | |
| 131 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 132 | /** One thing that moved, as the feed shows it. */ |
| 133 | export type ActivityItem = { | |
| 134 | id: string; | |
| 135 | /** Epoch ms. */ | |
| 136 | at: number; | |
| 137 | repo: RepoPath; | |
| 138 | actor: string | null; | |
| 139 | verb: Verb; | |
| 140 | /** The issue or pull request it was about. */ | |
| 141 | number: number | null; | |
| 142 | /** For a memory: what was learned. For a deploy: where. */ | |
| 143 | text?: string; | |
| 144 | /** Where the line links, when it is not an issue or pull request. */ | |
| 145 | to?: string; | |
| 146 | }; | |
| 147 | ||
| 148 | /** An event from the log as a feed item, or null for those not worth a line. */ | |
| 149 | export function eventItem(event: G1tEvent, repo: RepoPath): ActivityItem | null { | |
| 150 | const base = { id: event.id, at: Date.parse(event.time), repo, actor: event.actor }; | |
| 151 | switch (event.type) { | |
| 152 | case "pull.merged": | |
| 153 | return { ...base, verb: "landed", number: event.data.number }; | |
| 154 | case "issue.opened": | |
| 155 | return { ...base, verb: "opened_issue", number: event.data.number }; | |
| 156 | case "issue.closed": | |
| 157 | return event.data.resolvedBy != null ? null : { ...base, verb: "closed_issue", number: event.data.number }; | |
| 158 | case "pull.opened": | |
| 159 | return { ...base, actor: event.data.agent || event.actor, verb: "started", number: event.data.number }; | |
| 160 | case "pull.ready": | |
| 161 | return { ...base, verb: "ready", number: event.data.number }; | |
| 162 | case "checks.completed": | |
| 163 | return { ...base, actor: null, verb: event.data.status === "passed" ? "checks_passed" : "checks_failed", number: event.data.number }; | |
| 164 | case "review.completed": | |
| 165 | if (!event.data.verdict) return null; | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 166 | return { ...base, actor: "g1t", verb: event.data.verdict === "approve" ? "approved" : "changes_requested", number: event.data.number }; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 167 | case "comment.created": |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 168 | // An agent's advisory review is not a verdict, and its writer is the agent. |
| 169 | if (event.data.agent) { | |
| 170 | return { ...base, actor: `${event.data.agent.displayName} (agent)`, verb: "commented", number: event.data.number }; | |
| 171 | } | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 172 | if (event.data.verdict) { |
| 173 | return { ...base, verb: event.data.verdict === "approve" ? "approved" : "changes_requested", number: event.data.number }; | |
| 174 | } | |
| 175 | return { ...base, verb: "commented", number: event.data.number }; | |
| 176 | case "agent.asked": | |
| 177 | return { ...base, verb: "asked", number: event.data.number }; | |
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 178 | case "deployment_status.created": { |
| 179 | // Production, once it is up or has failed: g1t.page builds, g1t | |
| 180 | // Actions jobs and deployments reported through the API alike. | |
| 181 | const { deployment, deploymentStatus } = event.data; | |
| 182 | if (!deployment.production_environment) return null; | |
| 183 | const state = deploymentStatus.state; | |
| 184 | if (state !== "success" && state !== "failure" && state !== "error") return null; | |
| 185 | return { | |
| 186 | ...base, | |
| 187 | verb: state === "success" ? "deployed" : "deploy_failed", | |
| 188 | number: null, | |
| 189 | to: `/${repo.namespace}/${repo.name}/deployments/${deployment.id}`, | |
| 190 | }; | |
| 191 | } | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 192 | default: |
| 193 | return null; | |
| 194 | } | |
| 195 | } | |
| 196 | ||
| Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md | 197 | /** |
| 198 | * A push to the default branch as a feed line, or null for any other push | |
| 199 | * and for one that only landed a pull request (`merged`: the commits pull | |
| 200 | * requests' merges made), which already has its own line. | |
| 201 | */ | |
| 202 | export function pushItem(event: G1tEvent, repo: RepoPath, merged: ReadonlySet<string>): ActivityItem | null { | |
| 203 | if (event.type !== "git.push" || !event.data.defaultBranch || !event.data.ref.startsWith("refs/heads/")) return null; | |
| 204 | const after = event.data.after; | |
| 205 | if (!after || /^0+$/.test(after) || merged.has(after)) return null; | |
| 206 | return { | |
| 207 | id: event.id, | |
| 208 | at: Date.parse(event.time), | |
| 209 | repo, | |
| 210 | actor: event.actor, | |
| 211 | verb: "pushed", | |
| 212 | number: null, | |
| 213 | text: after.slice(0, 7), | |
| 214 | to: `/${repo.namespace}/${repo.name}/commit/${after}`, | |
| 215 | }; | |
| 216 | } | |
| 217 | ||
| 218 | /** One project's events as its feed: every line `eventItem` makes, and its people's pushes. */ | |
| 219 | export function projectFeed(events: G1tEvent[], repo: RepoPath): ActivityItem[] { | |
| 220 | const merged = new Set(events.flatMap((event) => (event.type === "pull.merged" ? [event.data.commit] : []))); | |
| 221 | return events.flatMap((event) => eventItem(event, repo) ?? pushItem(event, repo, merged) ?? []); | |
| 222 | } | |
| 223 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 224 | /** Accounts that act for g1t itself, named in the log by fixed ids. */ |
| 225 | const G1T_ACTORS: Record<string, string> = { usr_g1t_agent: "g1t", g1t_policy: "g1t" }; | |
| 226 | /** Who an account the lookup no longer knows was. */ | |
| 227 | export const DELETED_USER = "a deleted user"; | |
| 228 | ||
| 229 | /** An account id rather than a name: usernames never hold an underscore. */ | |
| 230 | const isAccountId = (actor: string) => actor.includes("_"); | |
| 231 | ||
| 232 | /** The account ids among `actors` to look up by name, once each. */ | |
| 233 | export function actorIds(actors: (string | null)[]): string[] { | |
| 234 | return [...new Set(actors.filter((actor): actor is string => actor != null && isAccountId(actor) && !(actor in G1T_ACTORS)))]; | |
| 235 | } | |
| 236 | ||
| 237 | /** | |
| 238 | * An actor by name: an account id becomes its username, from `names` (the | |
| 239 | * identity service's lookup). An id it does not know is an account since | |
| 240 | * deleted; with no lookup at all, the actor is only "someone". | |
| 241 | */ | |
| 242 | export function nameActor(actor: string | null, names: Record<string, string> | null): string | null { | |
| 243 | if (actor == null || !isAccountId(actor)) return actor; | |
| 244 | return G1T_ACTORS[actor] ?? names?.[actor] ?? (names ? DELETED_USER : "someone"); | |
| 245 | } | |
| 246 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 247 | /** Several things one actor did in one project in a short while, read as one line. */ |
| 248 | export type ActivityGroup = { | |
| 249 | id: string; | |
| 250 | actor: string | null; | |
| 251 | repo: RepoPath; | |
| 252 | /** Newest and oldest, epoch ms. */ | |
| 253 | at: number; | |
| 254 | from: number; | |
| 255 | /** Each kind of thing done, in the order first seen, with what it was done to. */ | |
| 256 | parts: { verb: Verb; numbers: number[]; texts: string[]; to?: string }[]; | |
| 257 | count: number; | |
| 258 | }; | |
| 259 | ||
| 260 | const sameRepo = (a: RepoPath, b: RepoPath) => | |
| 261 | a.namespace.toLowerCase() === b.namespace.toLowerCase() && a.name.toLowerCase() === b.name.toLowerCase(); | |
| 262 | ||
| 263 | /** | |
| 264 | * Groups items, newest first, so that a run of things one actor did in one | |
| 265 | * project within `window` of the run's newest reads as one line. | |
| 266 | */ | |
| 267 | export function groupActivity(items: ActivityItem[], window = 45 * MINUTE): ActivityGroup[] { | |
| 268 | const sorted = [...items].sort((a, b) => b.at - a.at); | |
| 269 | const groups: ActivityGroup[] = []; | |
| 270 | for (const item of sorted) { | |
| 271 | const last = groups[groups.length - 1]; | |
| 272 | if (last && last.actor === item.actor && sameRepo(last.repo, item.repo) && last.at - item.at <= window) { | |
| 273 | let part = last.parts.find((p) => p.verb === item.verb); | |
| 274 | if (!part) { | |
| 275 | part = { verb: item.verb, numbers: [], texts: [], to: item.to }; | |
| 276 | last.parts.push(part); | |
| 277 | } | |
| 278 | if (item.number != null && !part.numbers.includes(item.number)) part.numbers.push(item.number); | |
| 279 | if (item.text && !part.texts.includes(item.text)) part.texts.push(item.text); | |
| 280 | last.from = item.at; | |
| 281 | last.count += 1; | |
| 282 | continue; | |
| 283 | } | |
| 284 | groups.push({ | |
| 285 | id: item.id, | |
| 286 | actor: item.actor, | |
| 287 | repo: item.repo, | |
| 288 | at: item.at, | |
| 289 | from: item.at, | |
| 290 | parts: [{ verb: item.verb, numbers: item.number != null ? [item.number] : [], texts: item.text ? [item.text] : [], to: item.to }], | |
| 291 | count: 1, | |
| 292 | }); | |
| 293 | } | |
| 294 | return groups; | |
| 295 | } | |
| 296 | ||
| 297 | // --- Needs you -------------------------------------------------------------- | |
| 298 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 299 | export type NeedKind = "limit" | "deploy" | "invitation" | "stalled" | "conflict" | "review" | "stuck" | "runner" | "checks" | "ready"; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 300 | |
| 301 | /** Something waiting on the viewer, with where to act on it. */ | |
| 302 | export type Need = { | |
| 303 | key: string; | |
| 304 | kind: NeedKind; | |
| 305 | title: string; | |
| 306 | detail: string; | |
| 307 | to: string; | |
| 308 | action: string; | |
| 309 | /** Epoch ms: when it started waiting. */ | |
| 310 | at: number; | |
| 311 | where: string | null; | |
| 312 | }; | |
| 313 | ||
| 314 | const NEED_RANK: Record<NeedKind, number> = { | |
| 315 | limit: 0, | |
| 316 | deploy: 1, | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 317 | invitation: 1.5, |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 318 | conflict: 2, |
| 319 | stalled: 3, | |
| 320 | stuck: 4, | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 321 | runner: 4, |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 322 | review: 5, |
| 323 | checks: 6, | |
| 324 | ready: 7, | |
| 325 | }; | |
| 326 | ||
| 327 | /** Most urgent first: by kind, then what has waited longest. One of each key. */ | |
| 328 | export function rankNeeds(needs: Need[]): Need[] { | |
| 329 | const seen = new Set<string>(); | |
| 330 | return [...needs] | |
| 331 | .sort((a, b) => NEED_RANK[a.kind] - NEED_RANK[b.kind] || a.at - b.at) | |
| 332 | .filter((need) => (seen.has(need.key) ? false : (seen.add(need.key), true))); | |
| 333 | } | |
| 334 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 335 | /** How long something has waited, from minutes: "45 min", "17 h", "2 d". */ |
| 336 | export function waitedFor(minutes: number): string { | |
| 337 | const whole = Math.max(0, Math.floor(minutes)); | |
| 338 | if (whole < 60) return `${whole} min`; | |
| 339 | if (whole < 24 * 60) return `${Math.floor(whole / 60)} h`; | |
| 340 | return `${Math.floor(whole / (24 * 60))} d`; | |
| 341 | } | |
| 342 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 343 | /** Whether a run has gone quiet: running with no new step for `quiet`. */ |
| 344 | export function stuckMinutes(run: Pick<AgentRun, "status" | "updatedAt">, now: number, quiet = 10 * MINUTE): number | null { | |
| 345 | if (run.status !== "running") return null; | |
| 346 | const idle = now - Date.parse(run.updatedAt); | |
| 347 | return idle >= quiet ? Math.floor(idle / MINUTE) : null; | |
| 348 | } | |
| 349 | ||
| 350 | // --- Digest ----------------------------------------------------------------- | |
| 351 | ||
| 352 | export type DigestCounts = { | |
| 353 | landed: number; | |
| 354 | reviews: number; | |
| 355 | opened: number; | |
| 356 | deploys: number; | |
| 357 | failedDeploys: number; | |
| 358 | /** The longest an agent has been quiet, in minutes; null when none is. */ | |
| 359 | stuck: number | null; | |
| 360 | }; | |
| 361 | ||
| 362 | export type DigestPart = { text: string; tone: "fg" | "warn" | "danger" | "accent"; anchor: string }; | |
| 363 | ||
| 364 | const count = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`; | |
| 365 | ||
| 366 | /** What moved since the viewer was last here, as the pieces of a sentence. */ | |
| 367 | export function digestParts(c: DigestCounts): DigestPart[] { | |
| 368 | const parts: DigestPart[] = []; | |
| 369 | if (c.landed > 0) parts.push({ text: `${count(c.landed, "change", "changes")} landed`, tone: "accent", anchor: "activity" }); | |
| 370 | if (c.reviews > 0) parts.push({ text: `${count(c.reviews, "pull request needs", "pull requests need")} your review`, tone: "warn", anchor: "your-pulls" }); | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 371 | if (c.stuck != null) parts.push({ text: `an agent has been quiet for ${waitedFor(c.stuck)}`, tone: "warn", anchor: "live" }); |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 372 | if (c.failedDeploys > 0) parts.push({ text: `${count(c.failedDeploys, "deploy", "deploys")} failed`, tone: "danger", anchor: "needs-you" }); |
| 373 | if (c.deploys > 0) parts.push({ text: `${count(c.deploys, "deploy", "deploys")} went out`, tone: "fg", anchor: "projects" }); | |
| 374 | if (c.opened > 0) parts.push({ text: `${count(c.opened, "issue was", "issues were")} opened`, tone: "fg", anchor: "activity" }); | |
| 375 | return parts; | |
| 376 | } | |
| 377 | ||
| 378 | // --- Pulse ------------------------------------------------------------------ | |
| 379 | ||
| 380 | /** Counts per day for the `days` days ending today (UTC), oldest first. */ | |
| 381 | export function dailyBuckets(points: { at: number; value?: number }[], days: number, now: number): number[] { | |
| 382 | const end = Math.floor(now / DAY); | |
| 383 | const buckets = new Array<number>(days).fill(0); | |
| 384 | for (const point of points) { | |
| 385 | const index = days - 1 - (end - Math.floor(point.at / DAY)); | |
| 386 | if (index >= 0 && index < days) buckets[index] += point.value ?? 1; | |
| 387 | } | |
| 388 | return buckets; | |
| 389 | } | |
| 390 | ||
| 391 | export function median(values: number[]): number | null { | |
| 392 | if (values.length === 0) return null; | |
| 393 | const sorted = [...values].sort((a, b) => a - b); | |
| 394 | const middle = Math.floor(sorted.length / 2); | |
| 395 | return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2; | |
| 396 | } | |
| 397 | ||
| 398 | /** | |
| 399 | * The share of pull requests whose first run of checks passed, from the | |
| 400 | * `checks.completed` events of one or more projects. Null with none. | |
| 401 | */ | |
| 402 | export function firstPassRate(events: { repo: string; number: number; at: number; passed: boolean }[]): { rate: number | null; of: number } { | |
| 403 | const first = new Map<string, { at: number; passed: boolean }>(); | |
| 404 | for (const event of events) { | |
| 405 | const key = `${event.repo}#${event.number}`; | |
| 406 | const seen = first.get(key); | |
| 407 | if (!seen || event.at < seen.at) first.set(key, { at: event.at, passed: event.passed }); | |
| 408 | } | |
| 409 | if (first.size === 0) return { rate: null, of: 0 }; | |
| 410 | const passed = [...first.values()].filter((run) => run.passed).length; | |
| 411 | return { rate: passed / first.size, of: first.size }; | |
| 412 | } | |
| 413 | ||
| 414 | /** The share of check runs that passed. Null with none. */ | |
| 415 | export function passRate(results: boolean[]): number | null { | |
| 416 | if (results.length === 0) return null; | |
| 417 | return results.filter(Boolean).length / results.length; | |
| 418 | } | |
| 419 | ||
| Merge branch 'browser-pass' into fast-push | 420 | /** |
| 421 | * A project's health from its recent workflow runs, for one that ships | |
| 422 | * without pull requests (whose checks are what `checks.completed` counts). | |
| 423 | * Only runs that finished and passed or failed count: cancelled and skipped | |
| 424 | * say nothing about the code. A run passed on the first try when its first | |
| 425 | * attempt is the one that passed. | |
| 426 | */ | |
| 427 | export function runHealth(runs: { attempt: number; status: string; conclusion: string | null }[]): { passRate: number | null; firstPass: { rate: number | null; of: number }; checkRuns: number } { | |
| 428 | const decided = runs.filter((run) => run.status === "completed" && (run.conclusion === "success" || run.conclusion === "failure")); | |
| 429 | const passed = decided.map((run) => run.conclusion === "success"); | |
| 430 | const first = decided.filter((run) => run.conclusion === "success" && run.attempt <= 1).length; | |
| 431 | return { passRate: passRate(passed), firstPass: { rate: decided.length ? first / decided.length : null, of: decided.length }, checkRuns: decided.length }; | |
| 432 | } | |
| 433 | ||
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 434 | /** How long from an issue being opened to its change landing, for each that did, in ms. */ |
| 435 | export function issueToMerge( | |
| 436 | opened: { repo: string; number: number; at: number }[], | |
| 437 | merged: { repo: string; issue: number | null; at: number }[], | |
| 438 | ): number[] { | |
| 439 | const openedAt = new Map(opened.map((o) => [`${o.repo}#${o.number}`, o.at])); | |
| 440 | const spans: number[] = []; | |
| 441 | for (const m of merged) { | |
| 442 | if (m.issue == null) continue; | |
| 443 | const start = openedAt.get(`${m.repo}#${m.issue}`); | |
| 444 | if (start != null && m.at >= start) spans.push(m.at - start); | |
| 445 | } | |
| 446 | return spans; | |
| 447 | } | |
| 448 | ||
| 449 | /** Hours agents spent at work since `since`, counting runs still going up to `now`. */ | |
| 450 | export function agentHours(runs: Pick<AgentRun, "kind" | "startedAt" | "finishedAt">[], since: number, now: number): number { | |
| 451 | let ms = 0; | |
| 452 | for (const run of runs) { | |
| 453 | if (!run.startedAt || run.kind === "checks" || run.kind === "queue" || run.kind === "mergecheck") continue; | |
| 454 | const start = Math.max(Date.parse(run.startedAt), since); | |
| 455 | const end = run.finishedAt ? Date.parse(run.finishedAt) : now; | |
| 456 | if (end > start) ms += end - start; | |
| 457 | } | |
| 458 | return ms / HOUR; | |
| 459 | } | |
| 460 | ||
| 461 | /** "3h", "2d 4h", "40m": a span in the largest units that read well. */ | |
| 462 | export function formatSpan(ms: number | null): string { | |
| 463 | if (ms == null) return "—"; | |
| 464 | if (ms < HOUR) return `${Math.max(1, Math.round(ms / MINUTE))}m`; | |
| 465 | if (ms < DAY) return `${Math.round(ms / HOUR)}h`; | |
| 466 | const days = Math.floor(ms / DAY); | |
| 467 | const hours = Math.round((ms % DAY) / HOUR); | |
| 468 | return hours ? `${days}d ${hours}h` : `${days}d`; | |
| 469 | } | |
| 470 | ||
| 471 | /** | |
| 472 | * Points for a sparkline `width` by `height`: the values spread across the | |
| 473 | * width, scaled so the largest touches the top and zero sits on the | |
| 474 | * bottom, with `pad` kept clear for the line's width. | |
| 475 | */ | |
| 476 | export function sparkPoints(values: number[], width: number, height: number, pad = 2): [number, number][] { | |
| 477 | if (values.length === 0) return []; | |
| 478 | const max = Math.max(...values, 0); | |
| 479 | const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0; | |
| 480 | return values.map((value, index) => { | |
| 481 | const x = values.length > 1 ? pad + index * step : width / 2; | |
| 482 | const y = max > 0 ? height - pad - (Math.max(0, value) / max) * (height - pad * 2) : height - pad; | |
| 483 | return [Math.round(x * 100) / 100, Math.round(y * 100) / 100]; | |
| 484 | }); | |
| 485 | } | |
| 486 | ||
| 487 | // --- A project's pipeline --------------------------------------------------- | |
| 488 | ||
| 489 | export type PipelineStage = "working" | "checking" | "reviewing" | "queue" | "landed"; | |
| 490 | ||
| 491 | export const PIPELINE: { stage: PipelineStage; label: string }[] = [ | |
| 492 | { stage: "working", label: "Working" }, | |
| 493 | { stage: "checking", label: "Checking" }, | |
| 494 | { stage: "reviewing", label: "Reviewing" }, | |
| 495 | { stage: "queue", label: "Queue" }, | |
| 496 | { stage: "landed", label: "Landed" }, | |
| 497 | ]; | |
| 498 | ||
| 499 | /** | |
| 500 | * Where a pull request stands on its way to landing, from what a list of | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 501 | * pull requests says about it, the run at work on it, the merge queue, and |
| 502 | * for one g1t sees through, its lifecycle (waiting on its checks). | |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 503 | */ |
| 504 | export function pipelineStage( | |
| 505 | pull: Pick<Pull, "status" | "checkStatus" | "number">, | |
| 506 | run: Pick<AgentRun, "kind"> | undefined, | |
| 507 | queued: Set<number>, | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 508 | lifecycle?: { stage: string } | null, |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 509 | ): PipelineStage { |
| 510 | if (pull.status === "merged") return "landed"; | |
| 511 | if (queued.has(pull.number)) return "queue"; | |
| 512 | if (run) { | |
| 513 | if (run.kind === "checks") return "checking"; | |
| 514 | if (run.kind === "review") return "reviewing"; | |
| 515 | if (run.kind === "queue") return "queue"; | |
| 516 | return "working"; | |
| 517 | } | |
| 518 | if (pull.status === "draft") return "working"; | |
| Fast pages, required checks on the branch, self-hosted runners, honest incidents | 519 | if (lifecycle?.stage === "checking") return "checking"; |
| Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API | 520 | if (pull.checkStatus === "queued" || pull.checkStatus === "running") return "checking"; |
| 521 | return "reviewing"; | |
| 522 | } | |
| 523 | ||
| 524 | /** The numbers of the pull requests waiting or being tested in a merge queue. */ | |
| 525 | export function queuedNumbers(entries: Pick<QueueEntry, "number" | "state">[]): Set<number> { | |
| 526 | return new Set(entries.filter((e) => e.state === "waiting" || e.state === "testing" || e.state === "passed").map((e) => e.number)); | |
| 527 | } | |
| 528 | ||
| 529 | /** Open issues by how long they have been open. */ | |
| 530 | export function ageBuckets(created: string[], now: number): { label: string; count: number }[] { | |
| 531 | const buckets = [ | |
| 532 | { label: "Under a day", max: DAY, count: 0 }, | |
| 533 | { label: "Under a week", max: 7 * DAY, count: 0 }, | |
| 534 | { label: "Under a month", max: 30 * DAY, count: 0 }, | |
| 535 | { label: "Older", max: Number.POSITIVE_INFINITY, count: 0 }, | |
| 536 | ]; | |
| 537 | for (const at of created) { | |
| 538 | const age = now - Date.parse(at); | |
| 539 | const bucket = buckets.find((b) => age < b.max) ?? buckets[buckets.length - 1]; | |
| 540 | bucket.count += 1; | |
| 541 | } | |
| 542 | return buckets.map(({ label, count }) => ({ label, count })); | |
| 543 | } | |
| 544 | ||
| 545 | export const TIME = { MINUTE, HOUR, DAY }; |
This file's history is long; its oldest lines are credited to the oldest commit read.