Skip to content
531 linesCodeBlameRaw

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 API1/**
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 */
8import type { AgentRun, G1tEvent, Pull, QueueEntry, RepoPath } from "@g1t/contracts";
9
10const MINUTE = 60_000;
11const HOUR = 60 * MINUTE;
12const DAY = 24 * HOUR;
13
14// --- Last seen ----------------------------------------------------------------
15
16/** The cookie that remembers when the viewer was last on mission control. */
17export const SEEN_COOKIE = "g1t_seen";
18/** A gap this long between page views starts a new visit. */
19export 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 */
27export 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. */
38export 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. */
54export 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. */
61export 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. */
72export 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. */
89export 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-agent90 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 API91}
92
93export 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.md107 | "pushed"
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API108 | "learned";
109
Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md110/**
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 */
116export 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. */
130export 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 API132/** One thing that moved, as the feed shows it. */
133export 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. */
149export 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-agent166 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 API167 case "comment.created":
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar168 // 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 API172 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.md178 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 API192 default:
193 return null;
194 }
195}
196
Merge demo dry run fixes: settled checks, other attempts, queue links, clone box, activity feed, DEMO.md197/**
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 */
202export 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. */
219export 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 audit224/** Accounts that act for g1t itself, named in the log by fixed ids. */
225const G1T_ACTORS: Record<string, string> = { usr_g1t_agent: "g1t", g1t_policy: "g1t" };
226/** Who an account the lookup no longer knows was. */
227export const DELETED_USER = "a deleted user";
228
229/** An account id rather than a name: usernames never hold an underscore. */
230const isAccountId = (actor: string) => actor.includes("_");
231
232/** The account ids among `actors` to look up by name, once each. */
233export 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 */
242export 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 API247/** Several things one actor did in one project in a short while, read as one line. */
248export 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
260const 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 */
267export 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 audit299export 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 API300
301/** Something waiting on the viewer, with where to act on it. */
302export 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
314const 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 look317 invitation: 1.5,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API318 conflict: 2,
319 stalled: 3,
320 stuck: 4,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit321 runner: 4,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API322 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. */
328export 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 audit335/** How long something has waited, from minutes: "45 min", "17 h", "2 d". */
336export 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 API343/** Whether a run has gone quiet: running with no new step for `quiet`. */
344export 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
352export 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
362export type DigestPart = { text: string; tone: "fg" | "warn" | "danger" | "accent"; anchor: string };
363
364const 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. */
367export 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 audit371 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 API372 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. */
381export 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
391export 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 */
402export 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. */
415export function passRate(results: boolean[]): number | null {
416 if (results.length === 0) return null;
417 return results.filter(Boolean).length / results.length;
418}
419
420/** How long from an issue being opened to its change landing, for each that did, in ms. */
421export function issueToMerge(
422 opened: { repo: string; number: number; at: number }[],
423 merged: { repo: string; issue: number | null; at: number }[],
424): number[] {
425 const openedAt = new Map(opened.map((o) => [`${o.repo}#${o.number}`, o.at]));
426 const spans: number[] = [];
427 for (const m of merged) {
428 if (m.issue == null) continue;
429 const start = openedAt.get(`${m.repo}#${m.issue}`);
430 if (start != null && m.at >= start) spans.push(m.at - start);
431 }
432 return spans;
433}
434
435/** Hours agents spent at work since `since`, counting runs still going up to `now`. */
436export function agentHours(runs: Pick<AgentRun, "kind" | "startedAt" | "finishedAt">[], since: number, now: number): number {
437 let ms = 0;
438 for (const run of runs) {
439 if (!run.startedAt || run.kind === "checks" || run.kind === "queue" || run.kind === "mergecheck") continue;
440 const start = Math.max(Date.parse(run.startedAt), since);
441 const end = run.finishedAt ? Date.parse(run.finishedAt) : now;
442 if (end > start) ms += end - start;
443 }
444 return ms / HOUR;
445}
446
447/** "3h", "2d 4h", "40m": a span in the largest units that read well. */
448export function formatSpan(ms: number | null): string {
449 if (ms == null) return "—";
450 if (ms < HOUR) return `${Math.max(1, Math.round(ms / MINUTE))}m`;
451 if (ms < DAY) return `${Math.round(ms / HOUR)}h`;
452 const days = Math.floor(ms / DAY);
453 const hours = Math.round((ms % DAY) / HOUR);
454 return hours ? `${days}d ${hours}h` : `${days}d`;
455}
456
457/**
458 * Points for a sparkline `width` by `height`: the values spread across the
459 * width, scaled so the largest touches the top and zero sits on the
460 * bottom, with `pad` kept clear for the line's width.
461 */
462export function sparkPoints(values: number[], width: number, height: number, pad = 2): [number, number][] {
463 if (values.length === 0) return [];
464 const max = Math.max(...values, 0);
465 const step = values.length > 1 ? (width - pad * 2) / (values.length - 1) : 0;
466 return values.map((value, index) => {
467 const x = values.length > 1 ? pad + index * step : width / 2;
468 const y = max > 0 ? height - pad - (Math.max(0, value) / max) * (height - pad * 2) : height - pad;
469 return [Math.round(x * 100) / 100, Math.round(y * 100) / 100];
470 });
471}
472
473// --- A project's pipeline ---------------------------------------------------
474
475export type PipelineStage = "working" | "checking" | "reviewing" | "queue" | "landed";
476
477export const PIPELINE: { stage: PipelineStage; label: string }[] = [
478 { stage: "working", label: "Working" },
479 { stage: "checking", label: "Checking" },
480 { stage: "reviewing", label: "Reviewing" },
481 { stage: "queue", label: "Queue" },
482 { stage: "landed", label: "Landed" },
483];
484
485/**
486 * 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 incidents487 * pull requests says about it, the run at work on it, the merge queue, and
488 * 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 API489 */
490export function pipelineStage(
491 pull: Pick<Pull, "status" | "checkStatus" | "number">,
492 run: Pick<AgentRun, "kind"> | undefined,
493 queued: Set<number>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents494 lifecycle?: { stage: string } | null,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API495): PipelineStage {
496 if (pull.status === "merged") return "landed";
497 if (queued.has(pull.number)) return "queue";
498 if (run) {
499 if (run.kind === "checks") return "checking";
500 if (run.kind === "review") return "reviewing";
501 if (run.kind === "queue") return "queue";
502 return "working";
503 }
504 if (pull.status === "draft") return "working";
Fast pages, required checks on the branch, self-hosted runners, honest incidents505 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 API506 if (pull.checkStatus === "queued" || pull.checkStatus === "running") return "checking";
507 return "reviewing";
508}
509
510/** The numbers of the pull requests waiting or being tested in a merge queue. */
511export function queuedNumbers(entries: Pick<QueueEntry, "number" | "state">[]): Set<number> {
512 return new Set(entries.filter((e) => e.state === "waiting" || e.state === "testing" || e.state === "passed").map((e) => e.number));
513}
514
515/** Open issues by how long they have been open. */
516export function ageBuckets(created: string[], now: number): { label: string; count: number }[] {
517 const buckets = [
518 { label: "Under a day", max: DAY, count: 0 },
519 { label: "Under a week", max: 7 * DAY, count: 0 },
520 { label: "Under a month", max: 30 * DAY, count: 0 },
521 { label: "Older", max: Number.POSITIVE_INFINITY, count: 0 },
522 ];
523 for (const at of created) {
524 const age = now - Date.parse(at);
525 const bucket = buckets.find((b) => age < b.max) ?? buckets[buckets.length - 1];
526 bucket.count += 1;
527 }
528 return buckets.map(({ label, count }) => ({ label, count }));
529}
530
531export const TIME = { MINUTE, HOUR, DAY };

This file's history is long; its oldest lines are credited to the oldest commit read.