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.

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