Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

1,101 linesCodeBlameRaw
1/**
2 * The daily signal: what a workspace's work says, worked out from the
3 * work service's `workspaceSignals` (per repository, added up here) and
4 * billing's usage. Home leads with it (the week's pulse and the findings
5 * worth a look, each with its evidence and where to see it), and the
6 * Workspace overview draws the rest: spend threaded to the work it
7 * produced, what is moving and what is waiting, and quiet changes in who
8 * works where. Every number is worked out here, so each definition is
9 * tested on its own (signals.test.ts) and written down in the Home guide.
10 * Nothing here ranks people or teams against each other or against other
11 * workspaces: a workspace is compared with its own last weeks.
12 * Imports only types.
13 */
14import type {
15 ProviderCosts,
16 RepoPath,
17 SignalAuthor,
18 SignalDay,
19 SignalHarness,
20 SignalItem,
21 SignalMilestone,
22 SignalPull,
23 SignalWeek,
24 Usage,
25 WaitingPull,
26 WorkspaceSignals,
27} from "@g1t/contracts";
28
29const DAY_MS = 86_400_000;
30
31/** Weeks of history the pulse and the trends read. */
32export const SIGNAL_WEEKS = 10;
33/** Days of history the rhythm of the work draws: last week and this one. */
34export const SIGNAL_DAYS = 14;
35/** How far back pull requests are read: a milestone's build, and the quarter. */
36export const SIGNAL_REACH_DAYS = 120;
37
38// --- Days and weeks ---------------------------------------------------------------
39
40/** `YYYY-MM-DD` of a time, in UTC. */
41export function utcDay(at: number): string {
42 return new Date(at).toISOString().slice(0, 10);
43}
44
45export function addDays(day: string, days: number): string {
46 return utcDay(Date.parse(`${day}T00:00:00Z`) + days * DAY_MS);
47}
48
49/** The first day of each of `weeks` weeks of seven days, the last ending with `until`. */
50export function weekStarts(until: string, weeks = SIGNAL_WEEKS): string[] {
51 return Array.from({ length: weeks }, (_, i) => addDays(until, -7 * (weeks - 1 - i) - 6));
52}
53
54/** Each of `days` days ending with `until`. */
55export function dayList(until: string, days = SIGNAL_DAYS): string[] {
56 return Array.from({ length: days }, (_, i) => addDays(until, i - days + 1));
57}
58
59/** The first day of the quarter `day` is in. */
60export function quarterStart(day: string): string {
61 const month = Number(day.slice(5, 7));
62 const first = month - ((month - 1) % 3);
63 return `${day.slice(0, 4)}-${String(first).padStart(2, "0")}-01`;
64}
65
66/** What the loader asks the work service for, for a page drawn at `now`. */
67export function signalsRange(now: number): { until: string; since: string; weeks: number; days: number; quarter: string } {
68 const until = utcDay(now);
69 const quarter = quarterStart(until);
70 const reach = addDays(until, -SIGNAL_REACH_DAYS);
71 return { until, since: quarter < reach ? quarter : reach, weeks: SIGNAL_WEEKS, days: SIGNAL_DAYS, quarter };
72}
73
74// --- Adding up batches ------------------------------------------------------------
75
76export function emptySignals(): WorkspaceSignals {
77 return { weeks: [], days: [], waiting: [], pulls: [], pullsCapped: false, authors: [], harnesses: [], items: [], milestones: [] };
78}
79
80/** The batches' answers as one: rows are per repository, so they add; items and milestones once each. */
81export function mergeSignals(batches: WorkspaceSignals[]): WorkspaceSignals {
82 const all = emptySignals();
83 const items = new Map<number, SignalItem>();
84 const milestones = new Map<number, SignalMilestone>();
85 for (const batch of batches) {
86 all.weeks.push(...batch.weeks);
87 all.days.push(...batch.days);
88 all.waiting.push(...batch.waiting);
89 all.pulls.push(...batch.pulls);
90 all.authors.push(...batch.authors);
91 all.harnesses.push(...(batch.harnesses ?? []));
92 all.pullsCapped ||= batch.pullsCapped;
93 for (const item of batch.items) items.set(item.number, item);
94 for (const milestone of batch.milestones) milestones.set(milestone.number, milestone);
95 }
96 all.waiting.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
97 all.pulls.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
98 all.items = [...items.values()];
99 all.milestones = [...milestones.values()];
100 return all;
101}
102
103/** One week's measures, every repository's added up. */
104export type WeekTotals = Omit<SignalWeek, "repoId">;
105
106const ZERO_WEEK = (start: string): WeekTotals => ({
107 start,
108 merged: 0,
109 agentMerged: 0,
110 agentReworked: 0,
111 agentDrafted: 0,
112 agentDraftSeconds: 0,
113 reviewed: 0,
114 reviewSeconds: 0,
115 stalled: 0,
116 sessionCostUsd: 0,
117 agentCommits: 0,
118});
119
120/** Every week in `starts`, quiet ones too, for the repositories in `only` (every one when not given). */
121export function weekTotals(rows: SignalWeek[], starts: string[], only?: ReadonlySet<string>): WeekTotals[] {
122 const byStart = new Map(starts.map((start) => [start, ZERO_WEEK(start)]));
123 for (const row of rows) {
124 const week = byStart.get(row.start);
125 if (!week || (only && !only.has(row.repoId))) continue;
126 week.merged += row.merged;
127 week.agentMerged += row.agentMerged;
128 week.agentReworked += row.agentReworked;
129 week.agentDrafted += row.agentDrafted;
130 week.agentDraftSeconds += row.agentDraftSeconds;
131 week.reviewed += row.reviewed;
132 week.reviewSeconds += row.reviewSeconds;
133 week.stalled += row.stalled;
134 week.sessionCostUsd += row.sessionCostUsd ?? 0;
135 week.agentCommits += row.agentCommits ?? 0;
136 }
137 return starts.map((start) => byStart.get(start)!);
138}
139
140/**
141 * What the agents' usage came to in each week, from usage's `byDay`
142 * (`YYYY-MM-DD/task`). Null when usage did not answer.
143 */
144export function weeklySpend(usage: Usage | null, starts: string[]): number[] | null {
145 if (!usage) return null;
146 const spend = starts.map(() => 0);
147 for (const slice of usage.byDay) {
148 const day = slice.key.slice(0, 10);
149 // The week a day falls in: the last start on or before it.
150 for (let i = starts.length - 1; i >= 0; i--) {
151 if (day >= starts[i]! && day < addDays(starts[i]!, 7)) {
152 spend[i]! += slice.micros;
153 break;
154 }
155 }
156 }
157 return spend;
158}
159
160// --- Measures -------------------------------------------------------------------
161
162/** Seconds a first review waited, on average, or null with none given. */
163export function reviewWait(week: WeekTotals): number | null {
164 return week.reviewed > 0 ? week.reviewSeconds / week.reviewed : null;
165}
166
167/** Seconds from an agent's session starting to its pull request opening, on average. */
168export function draftTime(week: WeekTotals): number | null {
169 return week.agentDrafted > 0 ? week.agentDraftSeconds / week.agentDrafted : null;
170}
171
172/** The share of an agent's merged pull requests a person asked for changes on, 0 to 1. */
173export function reworkRate(week: WeekTotals): number | null {
174 return week.agentMerged > 0 ? week.agentReworked / week.agentMerged : null;
175}
176
177/**
178 * What agents landed in a week: their merged pull requests, and the
179 * commits their sessions pushed outside any pull request (to the default
180 * branch, most of all). A workspace that pushes straight to main lands
181 * most of its work the second way.
182 */
183export function agentChanges(week: WeekTotals): number {
184 return week.agentMerged + (week.agentCommits ?? 0);
185}
186
187/** Everything that landed in a week: merged pull requests, and agents' direct commits. */
188export function allChanges(week: WeekTotals): number {
189 return week.merged + (week.agentCommits ?? 0);
190}
191
192/** The share of what landed that was an agent's, 0 to 1. */
193export function agentShare(week: WeekTotals): number | null {
194 return allChanges(week) > 0 ? agentChanges(week) / allChanges(week) : null;
195}
196
197/** Usage per agent change (merge or direct commit), in micros. */
198export function spendPerMerge(micros: number | null, week: WeekTotals): number | null {
199 return micros != null && agentChanges(week) > 0 ? micros / agentChanges(week) : null;
200}
201
202// --- Words ------------------------------------------------------------------------
203
204const WORDS = ["No", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten"];
205
206/** A count as a sentence starts it: "Three", or "12". */
207export function countWord(n: number): string {
208 return WORDS[n] ?? String(n);
209}
210
211/** A wait in words: "48 minutes", "5 hours", "2.1 days". */
212export function spoken(seconds: number): string {
213 if (seconds < 90) return "a minute";
214 if (seconds < 3600) return `${Math.round(seconds / 60)} minutes`;
215 if (seconds < 1.5 * 3600) return "an hour";
216 if (seconds < 20 * 3600) return `${Math.round(seconds / 3600)} hours`;
217 if (seconds < 36 * 3600) return "a day";
218 const days = seconds / 86400;
219 return `${days < 10 ? days.toFixed(1).replace(/\.0$/, "") : Math.round(days)} days`;
220}
221
222/** A wait in a few characters, for figures: "48m", "5h", "2.1d". */
223export function short(seconds: number | null): string {
224 if (seconds == null) return "—";
225 if (seconds < 3600) return `${Math.max(1, Math.round(seconds / 60))}m`;
226 if (seconds < 86400) return `${(seconds / 3600).toFixed(seconds < 36_000 ? 1 : 0).replace(/\.0$/, "")}h`;
227 return `${(seconds / 86400).toFixed(1).replace(/\.0$/, "")}d`;
228}
229
230export function percent(share: number | null): string {
231 return share == null ? "—" : `${Math.round(share * 100)}%`;
232}
233
234/** Money in micros as the pulse shows it: whole dollars, compact from $10K. */
235export function dollars(micros: number | null): string {
236 if (micros == null) return "—";
237 const value = micros / 1_000_000;
238 if (value >= 10_000) return `$${(value / 1000).toFixed(1).replace(/\.0$/, "")}K`;
239 if (value >= 10) return `$${Math.round(value).toLocaleString("en-US")}`;
240 return `$${value.toFixed(2)}`;
241}
242
243/** The day a page is drawn on, as the daily signal heads it: "Sunday, October 18". */
244export function signalDate(now: number, tz: string | null): string {
245 const options: Intl.DateTimeFormatOptions = { weekday: "long", month: "long", day: "numeric" };
246 try {
247 return new Date(now).toLocaleDateString("en-US", { ...options, timeZone: tz ?? "UTC" });
248 } catch {
249 return new Date(now).toLocaleDateString("en-US", { ...options, timeZone: "UTC" });
250 }
251}
252
253/** A week's first day as a trend's point names it: "Oct 12". */
254export function weekLabel(start: string): string {
255 return new Date(`${start}T00:00:00Z`).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
256}
257
258// --- The pulse --------------------------------------------------------------------
259
260export type Tone = "good" | "bad" | "flat";
261
262export type PulseTile = {
263 key: "merges" | "spend" | "stalled" | "rework";
264 label: string;
265 /** This week's figure. */
266 value: string;
267 /** Against last week: the difference as a chip says it, and whether that is good news. */
268 delta: { text: string; tone: Tone } | null;
269 /** Each week's figure, oldest first; null for a week with nothing to measure. */
270 series: (number | null)[];
271 /** Each week's point, as its hover says it. */
272 points: string[];
273 /** What it counts, in a sentence. */
274 hint: string;
275 to: string;
276};
277
278function tone(change: number, upIsGood: boolean): Tone {
279 if (change === 0) return "flat";
280 return change > 0 === upIsGood ? "good" : "bad";
281}
282
283function signed(n: number, format: (abs: number) => string): string {
284 return `${n > 0 ? "+" : n < 0 ? "−" : "±"}${format(Math.abs(n))}`;
285}
286
287function deltaOf(now: number | null, before: number | null, format: (abs: number) => string, upIsGood: boolean) {
288 if (now == null || before == null) return null;
289 const change = now - before;
290 return { text: signed(change, format), tone: tone(change, upIsGood) };
291}
292
293export type PulseInput = {
294 slug: string;
295 weeks: WeekTotals[];
296 /** Usage per week, aligned with `weeks`; null when usage did not answer. */
297 spend: number[] | null;
298};
299
300/**
301 * The past week in four figures, each against the week before and drawn
302 * over the last ten: what agents landed (merges and direct commits), usage
303 * per agent change, stalled changes, and how often people asked for
304 * changes on an agent's merged work.
305 */
306export function pulse({ slug, weeks, spend }: PulseInput): PulseTile[] {
307 const at = weeks.length - 1;
308 const now = weeks[at];
309 const before = weeks[at - 1] ?? null;
310 if (!now) return [];
311 const labels = weeks.map((week) => `Week of ${weekLabel(week.start)}`);
312 const perMerge = weeks.map((week, i) => spendPerMerge(spend?.[i] ?? null, week));
313 const rework = weeks.map(reworkRate);
314 const count = (n: number) => String(Math.round(n));
315 return [
316 {
317 key: "merges",
318 label: "Agent-authored changes",
319 value: String(agentChanges(now)),
320 delta: deltaOf(agentChanges(now), before ? agentChanges(before) : null, count, true),
321 series: weeks.map(agentChanges),
322 points: weeks.map((week, i) => `${labels[i]}: ${week.agentMerged} merged, ${week.agentCommits ?? 0} pushed directly, of ${allChanges(week)} changes`),
323 hint: "Agents' merged pull requests, and the commits their sessions pushed outside one",
324 to: `/${slug}/-/overview?tab=landed`,
325 },
326 {
327 key: "spend",
328 label: "Spend per agent change",
329 value: dollars(perMerge[at] ?? null),
330 delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false),
331 series: perMerge,
332 points: perMerge.map((micros, i) => `${labels[i]}: ${micros == null ? "no agent changes" : dollars(micros)}`),
333 hint: "g1t's agent usage at price and harness sessions' cost at list price, over what agents landed",
334 to: `/${slug}/-/usage`,
335 },
336 {
337 key: "stalled",
338 label: "Stalled changes",
339 value: String(now.stalled),
340 delta: deltaOf(now.stalled, before?.stalled ?? null, count, false),
341 series: weeks.map((week) => week.stalled),
342 points: weeks.map((week, i) => `${labels[i]}: ${week.stalled} with nothing for three days`),
343 hint: "Open pull requests with no push, comment, review, check or session for three days",
344 to: `/${slug}/-/overview?tab=needs#work`,
345 },
346 {
347 key: "rework",
348 label: "Agent rework rate",
349 value: percent(rework[at] ?? null),
350 delta: deltaOf(rework[at] ?? null, before ? (rework[at - 1] ?? null) : null, (abs) => `${(abs * 100).toFixed(1).replace(/\.0$/, "")} pts`, false),
351 series: rework,
352 points: rework.map((share, i) => `${labels[i]}: ${share == null ? "no agent merges" : percent(share)}`),
353 hint: "Of the agent pull requests that merged, the share a person asked for changes on first",
354 to: `/${slug}/-/insights`,
355 },
356 ];
357}
358
359// --- The findings -----------------------------------------------------------------
360
361export type Signal = {
362 key: string;
363 /** The finding, in one sentence. */
364 headline: string;
365 /** The evidence, as short facts: "48 min to draft", "2.1 days to first review". */
366 facts: string[];
367 link: { label: string; to: string } | null;
368 /** A measure over the weeks behind the finding, when it is a trend. */
369 trend?: { values: (number | null)[]; points: string[]; caption: string; range: string };
370 /** How much it matters today: findings are shown highest first. */
371 weight: number;
372 /** What Ask is asked, to look into it. */
373 ask: string;
374};
375
376export type SignalContext = {
377 slug: string;
378 now: number;
379 repos: Record<string, RepoPath>;
380 signals: WorkspaceSignals;
381 weeks: WeekTotals[];
382 /** The quarter's usage (by pull request and project); null when usage did not answer. */
383 quarterUsage: Usage | null;
384 quarter: string;
385};
386
387export function pullPath(repo: RepoPath, number: number): string {
388 return `/${repo.namespace}/${repo.name}/pull/${number}`;
389}
390
391export function itemPath(slug: string, number: number): string {
392 return `/${slug}/-/work/${number}`;
393}
394
395export function milestonePath(slug: string, number: number): string {
396 return `/${slug}/-/work/milestones/${number}`;
397}
398
399function ago(now: number, at: string): number {
400 return Math.max(0, (now - Date.parse(at)) / 1000);
401}
402
403/** Several work items wait on one change no one has reviewed: the one holding up the most. */
404export function blockedSignal(ctx: SignalContext): Signal | null {
405 const top = ctx.signals.waiting
406 .filter((pull) => pull.blocks.length > 0 && ctx.repos[pull.repoId])
407 .sort((a, b) => b.blocks.length - a.blocks.length || a.createdAt.localeCompare(b.createdAt))[0];
408 if (!top) return null;
409 const repo = ctx.repos[top.repoId]!;
410 const n = top.blocks.length;
411 const shown = top.blocks.slice(0, 3).map((number) => `#${number}`);
412 return {
413 key: "blocked",
414 headline: n === 1 ? "A work item is waiting on a change no one has reviewed." : `${countWord(n)} work items are waiting on one unreviewed change.`,
415 facts: [
416 `${repo.name}#${top.number} ${top.title}`,
417 `open ${spoken(ago(ctx.now, top.createdAt))}`,
418 `holds up ${shown.join(", ")}${n > 3 ? ` and ${n - 3} more` : ""}`,
419 ],
420 link: { label: "See what's blocked", to: pullPath(repo, top.number) },
421 weight: 60 + 8 * n,
422 ask: `Why is ${repo.namespace}/${repo.name}#${top.number} still waiting for review, and what is waiting on it?`,
423 };
424}
425
426/**
427 * Agents draft quickly and the change then waits for a person: this
428 * week's first reviews against how long the agents took to open them,
429 * with first-review time over the weeks. Or, without that gap, first
430 * reviews that slowed against the weeks before.
431 */
432export function reviewSignal(ctx: SignalContext): Signal | null {
433 const weeks = ctx.weeks;
434 const now = weeks[weeks.length - 1];
435 if (!now) return null;
436 const wait = reviewWait(now);
437 if (wait == null) return null;
438 const draft = draftTime(now);
439 const series = weeks.map(reviewWait);
440 const earlier = series.slice(0, -1).filter((value): value is number => value != null);
441 const usual = earlier.length ? earlier.reduce((sum, value) => sum + value, 0) / earlier.length : null;
442 const first = series.find((value) => value != null) ?? null;
443 const trend = {
444 values: series,
445 points: series.map((value, i) => `Week of ${weekLabel(weeks[i]!.start)}: ${value == null ? "no first reviews" : short(value)}`),
446 caption: "Time to first review",
447 range: `${short(first)} → ${short(wait)} over ${weeks.length} weeks`,
448 };
449 const link = { label: "See the review queue", to: `/${ctx.slug}/-/overview?tab=needs#work` };
450 if (draft != null && wait >= 4 * draft && wait >= 4 * 3600) {
451 return {
452 key: "review",
453 headline: `Changes an agent drafts in ${draft < 3600 ? "under an hour" : spoken(draft)} are waiting ${spoken(wait)} for first review.`,
454 facts: [`${short(draft)} to draft`, `${short(wait)} to first review`, "this week"],
455 link,
456 trend,
457 weight: 40 + Math.min(40, (wait / Math.max(draft, 60)) * 2),
458 ask: "Which agent pull requests are waiting longest for a first review, and who could review them?",
459 };
460 }
461 if (usual != null && wait >= 1.5 * usual && wait - usual >= 2 * 3600) {
462 return {
463 key: "review",
464 headline: `First reviews are taking ${spoken(wait)}, up from ${spoken(usual)} in the weeks before.`,
465 facts: [`${now.reviewed} first reviews this week`, `${short(wait)} on average`],
466 link,
467 trend,
468 weight: 30 + Math.min(30, (wait / usual) * 5),
469 ask: "Why are first reviews slower this week than usual?",
470 };
471 }
472 return null;
473}
474
475const MICROS = 1_000_000;
476
477/** Whether a pull request's work falls in the quarter: opened in it, or merged in it. */
478function inQuarter(pull: SignalPull, quarter: string): boolean {
479 return pull.createdAt.slice(0, 10) >= quarter || (pull.mergedAt != null && pull.mergedAt.slice(0, 10) >= quarter);
480}
481
482/**
483 * What the quarter's agent work cost, by pull request (`namespace/name#number`)
484 * and in all: g1t's metered usage on each pull request (usage's `byPull`), and
485 * each pull request's share of what its harness sessions cost, estimated at
486 * list price from their token usage. The total also holds g1t usage on pull
487 * requests the reading doesn't list. Null when usage did not answer.
488 */
489export function quarterSpend(
490 ctx: Pick<SignalContext, "repos" | "signals" | "quarterUsage" | "quarter">,
491): { byPull: Map<string, number>; total: number; sessions: number } | null {
492 if (!ctx.quarterUsage) return null;
493 const byPull = new Map<string, number>();
494 let total = 0;
495 for (const slice of ctx.quarterUsage.byPull) {
496 const key = slice.key.toLowerCase();
497 byPull.set(key, (byPull.get(key) ?? 0) + slice.micros);
498 total += slice.micros;
499 }
500 let sessions = 0;
501 for (const pull of ctx.signals.pulls) {
502 const repo = ctx.repos[pull.repoId];
503 const micros = Math.round((pull.sessionCostUsd ?? 0) * MICROS);
504 if (!repo || micros <= 0 || !inQuarter(pull, ctx.quarter)) continue;
505 const key = pullKey(repo, pull.number);
506 byPull.set(key, (byPull.get(key) ?? 0) + micros);
507 total += micros;
508 sessions += micros;
509 }
510 return { byPull, total, sessions };
511}
512
513function pullKey(repo: RepoPath, number: number): string {
514 return `${repo.namespace}/${repo.name}#${number}`.toLowerCase();
515}
516
517/** Where the quarter's agent usage went, by milestone (else work item, else project): the biggest share. */
518export function spendSignal(ctx: SignalContext): Signal | null {
519 const quarter = quarterSpend(ctx);
520 if (!quarter || quarter.total <= 0) return null;
521 const { byPull: spend, total } = quarter;
522 const items = new Map(ctx.signals.items.map((item) => [item.number, item]));
523 const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone]));
524 const groups = new Map<string, { label: string; to: string; micros: number }>();
525 for (const pull of ctx.signals.pulls) {
526 const repo = ctx.repos[pull.repoId];
527 if (!repo) continue;
528 const micros = spend.get(pullKey(repo, pull.number));
529 if (!micros) continue;
530 const milestone = pull.milestone != null ? milestones.get(pull.milestone) : undefined;
531 const item = pull.item != null ? items.get(pull.item) : undefined;
532 const group = milestone
533 ? { key: `m${milestone.number}`, label: milestone.title, to: milestonePath(ctx.slug, milestone.number) }
534 : item
535 ? { key: `i${item.number}`, label: item.title, to: itemPath(ctx.slug, item.number) }
536 : { key: `r${pull.repoId}`, label: repo.name, to: `/${repo.namespace}/${repo.name}` };
537 const entry = groups.get(group.key) ?? { label: group.label, to: group.to, micros: 0 };
538 entry.micros += micros;
539 groups.set(group.key, entry);
540 }
541 const top = [...groups.values()].sort((a, b) => b.micros - a.micros)[0];
542 if (!top) return null;
543 const share = top.micros / total;
544 if (share < 0.35 || groups.size < 2) return null;
545 const amount = share > 0.5 ? "More than half" : share > 1 / 3 ? "More than a third" : `${Math.round(share * 100)}%`;
546 return {
547 key: "spend",
548 headline: `${amount} of this quarter's agent spend is going into ${top.label}.`,
549 facts: [`${dollars(top.micros)} of ${dollars(total)}`, "quarter to date"],
550 link: { label: "See the spend", to: `/${ctx.slug}/-/workspace#spend` },
551 weight: 25 + share * 40,
552 ask: `What has the agent spend on ${top.label} produced this quarter?`,
553 };
554}
555
556const WEEK_MS = 7 * DAY_MS;
557
558/** A milestone closed in the last week, against its due date, with what it took. */
559export function shippedSignal(ctx: SignalContext): Signal | null {
560 const recent = ctx.signals.milestones
561 .filter((milestone) => milestone.state === "closed" && milestone.closedAt && ctx.now - Date.parse(milestone.closedAt) <= WEEK_MS)
562 .sort((a, b) => b.closedAt!.localeCompare(a.closedAt!))[0];
563 if (!recent) return null;
564 const closed = Date.parse(recent.closedAt!);
565 const pulls = ctx.signals.pulls.filter((pull) => pull.milestone === recent.number && pull.status === "merged");
566 const started = Math.min(Date.parse(recent.createdAt), ...pulls.map((pull) => Date.parse(pull.createdAt)));
567 const weeksIn = Math.max(1, Math.round((closed - started) / WEEK_MS));
568 const projects = new Set(pulls.map((pull) => pull.repoId)).size;
569 const weekday = new Date(closed).toLocaleDateString("en-US", { weekday: "long", timeZone: "UTC" });
570 let schedule = "";
571 let weight = 35;
572 if (recent.dueOn) {
573 const early = Math.round((Date.parse(`${recent.dueOn}T23:59:59Z`) - closed) / DAY_MS);
574 if (early >= 14) {
575 schedule = `, ${countWord(Math.round(early / 7)).toLowerCase()} weeks ahead of schedule`;
576 weight = 45;
577 } else if (early >= 2) schedule = `, ${early} days ahead of schedule`;
578 else if (early >= 0) schedule = ", on schedule";
579 else schedule = `, ${spoken(-early * 86400)} late`;
580 }
581 return {
582 key: "shipped",
583 headline: `${recent.title} shipped on ${weekday}${schedule}.`,
584 facts: [
585 `${weeksIn} ${weeksIn === 1 ? "week" : "weeks"} in build`,
586 `${pulls.length} ${pulls.length === 1 ? "change" : "changes"}`,
587 ...(projects > 0 ? [`${projects} ${projects === 1 ? "project" : "projects"}`] : []),
588 ],
589 link: { label: "See the work", to: milestonePath(ctx.slug, recent.number) },
590 weight,
591 ask: `Summarize what shipped in the milestone ${recent.title}.`,
592 };
593}
594
595/** More changes stalled than last week. */
596export function stalledSignal(ctx: SignalContext): Signal | null {
597 const now = ctx.weeks[ctx.weeks.length - 1];
598 const before = ctx.weeks[ctx.weeks.length - 2];
599 if (!now || !before) return null;
600 const more = now.stalled - before.stalled;
601 if (more < 3 || now.stalled < 1.25 * before.stalled) return null;
602 return {
603 key: "stalled",
604 headline: `${countWord(now.stalled)} open changes have gone three days with nothing happening, ${more} more than last week.`,
605 facts: [`${before.stalled} last week`, `${now.stalled} now`],
606 link: { label: "See what's stalled", to: `/${ctx.slug}/-/overview?tab=needs#work` },
607 trend: {
608 values: ctx.weeks.map((week) => week.stalled),
609 points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`),
610 caption: "Stalled changes",
611 range: `${ctx.weeks[0]!.stalled} → ${now.stalled} over ${ctx.weeks.length} weeks`,
612 },
613 weight: 30 + Math.min(25, more * 2),
614 ask: "Which pull requests have stalled this week, and what are they waiting on?",
615 };
616}
617
618/** People asking for changes on agents' work more often, or less, than the weeks before. */
619export function reworkSignal(ctx: SignalContext): Signal | null {
620 const now = ctx.weeks[ctx.weeks.length - 1];
621 if (!now || now.agentMerged < 5) return null;
622 const rate = reworkRate(now)!;
623 const earlier = ctx.weeks.slice(-5, -1);
624 const merged = earlier.reduce((sum, week) => sum + week.agentMerged, 0);
625 if (merged < 5) return null;
626 const usual = earlier.reduce((sum, week) => sum + week.agentReworked, 0) / merged;
627 const change = rate - usual;
628 if (Math.abs(change) < 0.08) return null;
629 const up = change > 0;
630 return {
631 key: "rework",
632 headline: up
633 ? `People asked for changes on ${percent(rate)} of agents' merged work this week, up from ${percent(usual)}.`
634 : `Agents' work is landing with fewer requests for changes: ${percent(rate)} this week, down from ${percent(usual)}.`,
635 facts: [`${now.agentReworked} of ${now.agentMerged} agent merges reworked`, "against the four weeks before"],
636 link: { label: "See Insights", to: `/${ctx.slug}/-/insights` },
637 weight: up ? 30 + change * 100 : 15 + Math.abs(change) * 60,
638 ask: up ? "What did reviewers ask agents to change this week?" : "What changed in how agents' work is reviewed this week?",
639 };
640}
641
642// --- Who works where --------------------------------------------------------------
643
644/** People who never merged to a project before, now have: in the last `days`, with merges elsewhere first. */
645export type NewGround = { ownerId: string; name: string; repoId: string; firstMergedAt: string; elsewhere: number };
646
647export function newGround(authors: SignalAuthor[], now: number, days = 14): NewGround[] {
648 const byOwner = new Map<string, SignalAuthor[]>();
649 for (const row of authors) byOwner.set(row.ownerId, [...(byOwner.get(row.ownerId) ?? []), row]);
650 const found: NewGround[] = [];
651 for (const rows of byOwner.values()) {
652 for (const row of rows) {
653 if (now - Date.parse(row.firstMergedAt) > days * DAY_MS) continue;
654 // Merges elsewhere before this one: someone new to the project, not new to the workspace.
655 const elsewhere = rows
656 .filter((other) => other.repoId !== row.repoId && other.firstMergedAt < row.firstMergedAt)
657 .reduce((sum, other) => sum + other.merged, 0);
658 if (elsewhere >= 3) found.push({ ownerId: row.ownerId, name: row.ownerName, repoId: row.repoId, firstMergedAt: row.firstMergedAt, elsewhere });
659 }
660 }
661 return found.sort((a, b) => b.firstMergedAt.localeCompare(a.firstMergedAt));
662}
663
664/**
665 * A project where one person has done most of the recent work: their
666 * share of its merges in the last 90 days, and whoever else has started
667 * merging there in the last 30 (knowledge spreading, or not yet). Only in
668 * a workspace where three people or more merged lately.
669 */
670export type Silo = {
671 repoId: string;
672 ownerId: string;
673 name: string;
674 share: number;
675 theirs: number;
676 recent: number;
677 joining: { ownerId: string; name: string }[];
678};
679
680export function silos(authors: SignalAuthor[], now: number, { minRecent = 6, minShare = 0.75, minPeople = 3 } = {}): Silo[] {
681 // Fewer than `minPeople` people merging anywhere lately: every project rests on someone, and saying so tells nothing.
682 const people = new Set(authors.filter((row) => row.recent > 0).map((row) => row.ownerId));
683 if (people.size < minPeople) return [];
684 const byRepo = new Map<string, SignalAuthor[]>();
685 for (const row of authors) byRepo.set(row.repoId, [...(byRepo.get(row.repoId) ?? []), row]);
686 const found: Silo[] = [];
687 for (const [repoId, rows] of byRepo) {
688 const recent = rows.reduce((sum, row) => sum + row.recent, 0);
689 if (recent < minRecent) continue;
690 const top = [...rows].sort((a, b) => b.recent - a.recent)[0]!;
691 const share = top.recent / recent;
692 if (share < minShare) continue;
693 const joining = rows
694 .filter((row) => row.ownerId !== top.ownerId && row.recent > 0 && now - Date.parse(row.firstMergedAt) <= 30 * DAY_MS)
695 .map((row) => ({ ownerId: row.ownerId, name: row.ownerName }));
696 found.push({ repoId, ownerId: top.ownerId, name: top.ownerName, share, theirs: top.recent, recent, joining });
697 }
698 return found.sort((a, b) => b.recent * b.share - a.recent * a.share);
699}
700
701/** People whose first merged agent pull request came in the last `days`, after merging their own work before. */
702export type NewHabit = { ownerId: string; name: string; firstAgentAt: string; repoId: string; byHand: number };
703
704export function newHabits(pulls: SignalPull[], now: number, days = 14): NewHabit[] {
705 const byOwner = new Map<string, SignalPull[]>();
706 for (const pull of pulls) if (pull.status === "merged" && pull.mergedAt) byOwner.set(pull.ownerId, [...(byOwner.get(pull.ownerId) ?? []), pull]);
707 const found: NewHabit[] = [];
708 for (const merged of byOwner.values()) {
709 merged.sort((a, b) => a.mergedAt!.localeCompare(b.mergedAt!));
710 const first = merged.find((pull) => pull.agent);
711 if (!first || now - Date.parse(first.mergedAt!) > days * DAY_MS) continue;
712 const byHand = merged.filter((pull) => !pull.agent && pull.mergedAt! < first.mergedAt!).length;
713 if (byHand >= 3) found.push({ ownerId: first.ownerId, name: first.ownerName, firstAgentAt: first.mergedAt!, repoId: first.repoId, byHand });
714 }
715 return found.sort((a, b) => b.firstAgentAt.localeCompare(a.firstAgentAt));
716}
717
718/**
719 * A way of working catching on: a harness that someone took up first,
720 * then at least two more people started using in the last `days`, at
721 * least two weeks after them. Over the repositories in `only` (a team's),
722 * or all of them; the newest spread first.
723 */
724export type Spread = {
725 harness: string;
726 first: { ownerId: string; name: string; at: string };
727 recent: { ownerId: string; name: string; at: string }[];
728 people: number;
729};
730
731export function spreading(harnesses: SignalHarness[], now: number, { days = 30, only }: { days?: number; only?: ReadonlySet<string> } = {}): Spread[] {
732 // Each person's first session with each harness, over the repositories asked about.
733 const firsts = new Map<string, Map<string, { ownerId: string; name: string; at: string }>>();
734 for (const row of harnesses) {
735 if (only && !only.has(row.repoId)) continue;
736 const people = firsts.get(row.harness) ?? new Map();
737 const known = people.get(row.ownerId);
738 if (!known || row.firstAt < known.at) people.set(row.ownerId, { ownerId: row.ownerId, name: row.ownerName, at: row.firstAt });
739 firsts.set(row.harness, people);
740 }
741 const found: Spread[] = [];
742 for (const [harness, people] of firsts) {
743 const order = [...people.values()].sort((a, b) => a.at.localeCompare(b.at));
744 const first = order[0]!;
745 const recent = order.filter((person) => now - Date.parse(person.at) <= days * DAY_MS && Date.parse(person.at) - Date.parse(first.at) >= 14 * DAY_MS);
746 if (recent.length >= 2) found.push({ harness, first, recent, people: order.length });
747 }
748 return found.sort((a, b) => b.recent[b.recent.length - 1]!.at.localeCompare(a.recent[a.recent.length - 1]!.at));
749}
750
751const HARNESS_WORDS: Record<string, string> = {
752 claude_code: "Claude Code",
753 claude_managed_agents: "Claude Managed Agents",
754 codex: "Codex",
755 cursor: "Cursor",
756 copilot: "Copilot",
757};
758
759/** A harness as a sentence names it. */
760export function harnessWords(harness: string): string {
761 return HARNESS_WORDS[harness] ?? "another harness";
762}
763
764/** Names in a sentence: "ana", "ana and bo", "ana, bo and 2 others". */
765export function namesWords(names: string[], max = 3): string {
766 if (names.length <= 1) return names[0] ?? "";
767 if (names.length <= max) return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
768 return `${names.slice(0, max - 1).join(", ")} and ${names.length - max + 1} others`;
769}
770
771/** A practice catching on, as a finding. */
772export function spreadSignal(ctx: SignalContext): Signal | null {
773 const spread = spreading(ctx.signals.harnesses ?? [], ctx.now)[0];
774 if (!spread) return null;
775 const tool = harnessWords(spread.harness);
776 return {
777 key: "spread",
778 headline: `${tool} is catching on: ${namesWords(spread.recent.map((person) => person.name))} started using it this month, after ${spread.first.name}.`,
779 facts: [
780 `${spread.people} people use it now`,
781 `${spread.first.name} first, ${spoken(Math.max(0, (Date.parse(spread.recent[0]!.at) - Date.parse(spread.first.at)) / 1000))} before the next`,
782 ],
783 link: { label: "See the sessions", to: `/${ctx.slug}/-/work/sessions` },
784 weight: 22 + Math.min(12, spread.recent.length * 3),
785 ask: `How have ${namesWords(spread.recent.map((person) => person.name))} been using ${tool}, and what did ${spread.first.name} do with it first?`,
786 };
787}
788
789/** The quiet changes as one finding: someone moving into a project one person has carried, else anyone new to one. */
790export function groundSignal(ctx: SignalContext): Signal | null {
791 const spreading = silos(ctx.signals.authors, ctx.now).find((silo) => silo.joining.length > 0 && ctx.repos[silo.repoId]);
792 if (spreading) {
793 const repo = ctx.repos[spreading.repoId]!;
794 const joiner = spreading.joining[0]!;
795 return {
796 key: "ground",
797 headline: `${joiner.name} is starting to work in ${repo.name}, which ${spreading.name} has carried almost alone.`,
798 facts: [`${spreading.theirs} of ${spreading.recent} merges in 90 days were ${spreading.name}'s`, `${joiner.name} started this month`],
799 link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` },
800 weight: 28,
801 ask: `What has ${joiner.name} worked on in ${repo.namespace}/${repo.name} so far?`,
802 };
803 }
804 const moved = newGround(ctx.signals.authors, ctx.now, 7).filter((row) => ctx.repos[row.repoId]);
805 if (moved.length === 0) return null;
806 const first = moved[0]!;
807 const repo = ctx.repos[first.repoId]!;
808 const others = new Set(moved.map((row) => row.ownerId)).size - 1;
809 return {
810 key: "ground",
811 headline:
812 others > 0
813 ? `${first.name} and ${others === 1 ? "one other person" : `${others} others`} merged their first change to a project new to them this week.`
814 : `${first.name} merged a first change to ${repo.name} this week, new ground for them.`,
815 facts: [`${first.elsewhere} merges elsewhere before`, `first in ${repo.name}`],
816 link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` },
817 weight: 20 + Math.min(10, others * 3),
818 ask: `What did ${first.name} change in ${repo.namespace}/${repo.name}?`,
819 };
820}
821
822const FINDINGS = [blockedSignal, reviewSignal, shippedSignal, spendSignal, stalledSignal, reworkSignal, groundSignal, spreadSignal];
823
824/** Every finding worth a look today, the one that matters most first. A finding that cannot be worked out is left out. */
825export function findings(ctx: SignalContext): Signal[] {
826 const found: Signal[] = [];
827 for (const make of FINDINGS) {
828 try {
829 const signal = make(ctx);
830 if (signal) found.push(signal);
831 } catch {
832 // One finding's arithmetic never takes the rest down.
833 }
834 }
835 return found.sort((a, b) => b.weight - a.weight);
836}
837
838/** The headline when nothing stands out: still a sentence about the week. */
839export function quietHeadline(week: WeekTotals | undefined): string {
840 const landed = week ? allChanges(week) : 0;
841 if (!week || landed === 0) return "A quiet week: nothing has landed yet, and nothing is stuck.";
842 const share = agentShare(week);
843 return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`;
844}
845
846/** What Home shows: the pulse, and the findings with the one that leads. */
847export type DailySignal = {
848 tiles: PulseTile[];
849 findings: Signal[];
850 /** The headline when no finding stands out. */
851 quiet: string;
852 complete: boolean;
853};
854
855export function dailySignal(data: {
856 slug: string;
857 now: number;
858 repos: Record<string, RepoPath>;
859 signals: WorkspaceSignals;
860 complete: boolean;
861 weeks: WeekTotals[];
862 spend: number[] | null;
863 quarterUsage: Usage | null;
864 range: { quarter: string };
865}): DailySignal {
866 return {
867 tiles: pulse({ slug: data.slug, weeks: data.weeks, spend: data.spend }),
868 findings: findings({
869 slug: data.slug,
870 now: data.now,
871 repos: data.repos,
872 signals: data.signals,
873 weeks: data.weeks,
874 quarterUsage: data.quarterUsage,
875 quarter: data.range.quarter,
876 }),
877 quiet: quietHeadline(data.weeks[data.weeks.length - 1]),
878 complete: data.complete,
879 };
880}
881
882// --- Spend threaded to the work ---------------------------------------------------
883
884/** How much of a node's work was an agent's: 0 (none) to 4 (nearly all), the flow's five steps. */
885export function agentStep(share: number): 0 | 1 | 2 | 3 | 4 {
886 if (share < 0.2) return 0;
887 if (share < 0.4) return 1;
888 if (share < 0.6) return 2;
889 if (share < 0.8) return 3;
890 return 4;
891}
892
893export type FlowPull = { key: string; repo: string; number: number; title: string; agent: boolean; micros: number; status: SignalPull["status"]; to: string };
894
895export type FlowNode = {
896 key: string;
897 label: string;
898 /** Where its own page is, when it has one. */
899 to: string | null;
900 pulls: number;
901 agent: number;
902 micros: number;
903 children: FlowNode[];
904 /** Its pull requests, for the last level: the costliest (else newest) first. */
905 list?: FlowPull[];
906};
907
908/** Nodes a column shows before the rest fold into one. */
909export const FLOW_NODES = 5;
910/** Pull requests listed for a change set. */
911export const FLOW_PULLS = 7;
912
913function fold(nodes: FlowNode[], label: string): FlowNode[] {
914 nodes.sort((a, b) => b.micros - a.micros || b.pulls - a.pulls);
915 if (nodes.length <= FLOW_NODES) return nodes;
916 const rest = nodes.slice(FLOW_NODES - 1);
917 const other: FlowNode = {
918 key: `other:${rest.map((node) => node.key).join(",")}`.slice(0, 120),
919 label,
920 to: null,
921 pulls: rest.reduce((sum, node) => sum + node.pulls, 0),
922 agent: rest.reduce((sum, node) => sum + node.agent, 0),
923 micros: rest.reduce((sum, node) => sum + node.micros, 0),
924 children: fold(
925 rest.flatMap((node) => node.children),
926 label,
927 ),
928 list: rest.flatMap((node) => node.list ?? []).slice(0, FLOW_PULLS),
929 };
930 return [...nodes.slice(0, FLOW_NODES - 1), other];
931}
932
933/**
934 * The quarter's work as a tree: milestone, work item, the work item's
935 * pull requests in each project (its change set there), and the pull
936 * requests, each node with its pull requests, how many were an agent's,
937 * and what the agents' usage on them came to. Work with no milestone or
938 * no work item has a node of its own.
939 */
940export function spendFlow(ctx: Pick<SignalContext, "slug" | "repos" | "signals" | "quarterUsage" | "quarter">): FlowNode[] {
941 const spend = quarterSpend(ctx)?.byPull ?? new Map<string, number>();
942 const items = new Map(ctx.signals.items.map((item) => [item.number, item]));
943 const milestones = new Map(ctx.signals.milestones.map((milestone) => [milestone.number, milestone]));
944 type Build = FlowNode & { index: Map<string, Build> };
945 const make = (key: string, label: string, to: string | null): Build => ({ key, label, to, pulls: 0, agent: 0, micros: 0, children: [], index: new Map() });
946 const roots = new Map<string, Build>();
947 const child = (parent: Map<string, Build>, key: string, label: string, to: string | null) => {
948 let node = parent.get(key);
949 if (!node) parent.set(key, (node = make(key, label, to)));
950 return node;
951 };
952 for (const pull of ctx.signals.pulls) {
953 const repo = ctx.repos[pull.repoId];
954 if (!repo) continue;
955 if (pull.createdAt.slice(0, 10) < ctx.quarter && !(pull.mergedAt && pull.mergedAt.slice(0, 10) >= ctx.quarter)) continue;
956 const micros = spend.get(pullKey(repo, pull.number)) ?? 0;
957 const milestone = pull.milestone != null ? milestones.get(pull.milestone) : undefined;
958 const item = pull.item != null ? items.get(pull.item) : undefined;
959 const path = [
960 child(roots, milestone ? `m${milestone.number}` : "m-", milestone?.title ?? "No milestone", milestone ? milestonePath(ctx.slug, milestone.number) : null),
961 ];
962 path.push(
963 child(
964 path[0]!.index,
965 `${path[0]!.key}/${item ? `i${item.number}` : "i-"}`,
966 item ? item.title : "No work item",
967 item ? itemPath(ctx.slug, item.number) : null,
968 ),
969 );
970 path.push(child(path[1]!.index, `${path[1]!.key}/${pull.repoId}`, repo.name, `/${repo.namespace}/${repo.name}`));
971 for (const node of path) {
972 node.pulls += 1;
973 node.agent += pull.agent ? 1 : 0;
974 node.micros += micros;
975 }
976 const set = path[2]!;
977 (set.list ??= []).push({
978 key: pullKey(repo, pull.number),
979 repo: repo.name,
980 number: pull.number,
981 title: pull.title,
982 agent: pull.agent,
983 micros,
984 status: pull.status,
985 to: pullPath(repo, pull.number),
986 });
987 }
988 const finish = (nodes: Map<string, Build>, label: string, depth: number): FlowNode[] =>
989 fold(
990 [...nodes.values()].map(({ index, ...node }) => ({
991 ...node,
992 children: depth < 2 ? finish(index, depth === 0 ? "Other work items" : "Other projects", depth + 1) : [],
993 list: node.list?.sort((a, b) => b.micros - a.micros || b.number - a.number).slice(0, FLOW_PULLS),
994 })),
995 label,
996 );
997 return finish(roots, "Other milestones", 0);
998}
999
1000// --- The rhythm of the work -------------------------------------------------------
1001
1002export type RhythmDay = { day: string; opened: number; merged: number; reworked: number; reviews: number; sessions: number; commits: number };
1003
1004export type RhythmRow = {
1005 key: string;
1006 label: string;
1007 /** "3 people", or what the row is. */
1008 note: string;
1009 to: string;
1010 days: RhythmDay[];
1011 /** Everything in the fortnight, to rank and to say so. */
1012 total: number;
1013};
1014
1015export type Group = { key: string; label: string; note: string; to: string; repoIds: string[] };
1016
1017/** Rows of the rhythm: each group's days, quiet days too; busiest first, at most `max`. */
1018export function rhythm(days: SignalDay[], list: string[], groups: Group[], max = 6): RhythmRow[] {
1019 const rows = groups.map((group) => {
1020 const ids = new Set(group.repoIds);
1021 const byDay = new Map(list.map((day) => [day, { day, opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 }]));
1022 for (const row of days) {
1023 const day = byDay.get(row.day);
1024 if (!day || !ids.has(row.repoId)) continue;
1025 day.opened += row.opened;
1026 day.merged += row.merged;
1027 day.reworked += row.reworked;
1028 day.reviews += row.reviews;
1029 day.sessions += row.sessions;
1030 day.commits += row.agentCommits ?? 0;
1031 }
1032 const out = list.map((day) => byDay.get(day)!);
1033 const total = out.reduce((sum, day) => sum + day.opened + day.merged + day.reworked + day.reviews + day.sessions + day.commits, 0);
1034 return { key: group.key, label: group.label, note: group.note, to: group.to, days: out, total };
1035 });
1036 return rows
1037 .filter((row) => row.total > 0)
1038 .sort((a, b) => b.total - a.total)
1039 .slice(0, max);
1040}
1041
1042/** A row's week, added up, as its hover and its line say it. */
1043export function rhythmSum(days: RhythmDay[]) {
1044 return days.reduce(
1045 (sum, day) => ({
1046 opened: sum.opened + day.opened,
1047 merged: sum.merged + day.merged,
1048 reworked: sum.reworked + day.reworked,
1049 reviews: sum.reviews + day.reviews,
1050 sessions: sum.sessions + day.sessions,
1051 commits: sum.commits + day.commits,
1052 }),
1053 { opened: 0, merged: 0, reworked: 0, reviews: 0, sessions: 0, commits: 0 },
1054 );
1055}
1056
1057/** How a row's this week compares with its last, in a few words. */
1058export function rhythmLine(row: RhythmRow): string {
1059 const half = Math.floor(row.days.length / 2);
1060 const last = rhythmSum(row.days.slice(0, half));
1061 const now = rhythmSum(row.days.slice(half));
1062 const prs = (sum: ReturnType<typeof rhythmSum>) => sum.opened + sum.merged + sum.commits;
1063 if (prs(now) === 0 && prs(last) > 0) return "Quiet this week after a busy last week";
1064 if (now.reviews === 0 && now.opened > 0) return `${now.opened} opened this week, no reviews yet`;
1065 if (prs(now) > 1.5 * prs(last) && prs(now) >= 4) return "Busier than last week";
1066 if (now.reworked > Math.max(2, last.reworked * 2)) return "More rework than last week";
1067 return `${now.merged + now.commits} landed this week, ${last.merged + last.commits} last week`;
1068}
1069
1070export type WaitingRow = WaitingPull & { repo: RepoPath; to: string };
1071
1072/** The unreviewed pull requests with their paths, for the Workspace overview's waiting list. */
1073export function waitingRows(waiting: WaitingPull[], repos: Record<string, RepoPath>, max = 8): WaitingRow[] {
1074 return waiting
1075 .filter((pull) => repos[pull.repoId])
1076 .slice(0, max)
1077 .map((pull) => ({ ...pull, repo: repos[pull.repoId]!, to: pullPath(repos[pull.repoId]!, pull.number) }));
1078}
1079
1080// --- What providers reported ------------------------------------------------------
1081
1082export type ProviderQuarter = {
1083 /** Providers connected, with what each reported since the quarter began, the most first. */
1084 providers: { provider: string; micros: number }[];
1085 total: number;
1086 /** Every connection's last read worked and reaches back to the quarter. */
1087 complete: boolean;
1088};
1089
1090/**
1091 * What the connected providers' cost reports say the organization spent
1092 * since `quarter` began: their whole bill, agents' work g1t sees and
1093 * everything else. Null when none is connected.
1094 */
1095export function providerQuarter(costs: ProviderCosts | null, quarter: string): ProviderQuarter | null {
1096 if (!costs || costs.connections.length === 0) return null;
1097 const by = new Map<string, number>(costs.connections.map((connection) => [connection.provider, 0]));
1098 for (const day of costs.days) if (day.day >= quarter) by.set(day.provider, (by.get(day.provider) ?? 0) + day.micros);
1099 const providers = [...by].map(([provider, micros]) => ({ provider, micros })).sort((a, b) => b.micros - a.micros);
1100 return { providers, total: providers.reduce((sum, row) => sum + row.micros, 0), complete: costs.complete };
1101}