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.

Commit

Home's People shows who did what as a chart, and nobody is 'someone' any more: @g1t's chat replies are stored under system:g1t, which Home took for an unnamed person. Now system:g1t, g1t's account and a workspace acting as it are one g1t row, and the card counts agents beside people instead of leaving them out. Each person or agent is a row (agents wear the agent marker) with a bar split by kind of work: pushes, pull requests, issues, shipped, docs, chat. A hover on a segment names it. Under the bars a legend gives each kind's totals in words and links to where they are listed; it replaces the bullet lines. The badge reads '2 people · 1 agent'. The summary sentence above still counts people only. A name that can't be read shows as 'unknown account'. Chart colours are new --color-series-1..6 tokens in app.css, a fixed categorical order checked for colour-blind separation on both surfaces. The landing page's Home tour gets the same People card between What changed and Landed (jordan, alex, claude, g1t). Checked: apps/web tsc -b, npm test (989; new: g1t is one row whichever key, unknown account), the tour's card rendered in a browser. The signed-in Home still needs a look with real data.

G1t-Session:
ses_01m5075p3weck8yhgj6kdx5nqb
syntaqxcommitted Parent4ea2eb8Browse files
6 files+356−1720/6 viewed
+8−0
5959 --color-warn: light-dark(var(--g1t-light-warn), var(--g1t-warn));
6060 --color-danger: light-dark(var(--g1t-light-danger), var(--g1t-danger));
6161
62+ /* Charts' categorical slots, in a fixed order, each stepped for its surface (validated: dataviz palette). */
63+ --color-series-1: light-dark(#2a78d6, #3987e5);
64+ --color-series-2: light-dark(#eb6834, #d95926);
65+ --color-series-3: light-dark(#1baf7a, #199e70);
66+ --color-series-4: light-dark(#eda100, #c98500);
67+ --color-series-5: light-dark(#e87ba4, #d55181);
68+ --color-series-6: light-dark(#008300, #008300);
69+
6270 --animate-fade-up: fade-up 0.6s ease-out both;
6371 /* A row that arrived on a live page (Home): in from above, lit for a moment. */
6472 --animate-arrive: g1t-arrive 0.42s cubic-bezier(0.16, 1, 0.3, 1), g1t-fresh 2s ease-out;
+94−47
3636 type Digest,
3737 type DigestActor,
3838 type DigestColumn,
39+ type WorkKind,
40+ WORK_KINDS,
41+ WORK_LABEL,
3942 type LandedChange,
4043 type RunningRow,
4144 type ShipInfo,
4750 explainQuestion,
4851 landedByDay,
4952 listOf,
53+ plural,
5054 shippedLabel,
5155 timeOfDay,
5256 waited,
312316 return span.key === "last" && span.note == null ? "since you were last here" : span.words.charAt(0).toLowerCase() + span.words.slice(1);
313317 }
314318
315−/** The faces of the most active, with a tooltip naming each, and how many more there were. */
316−const FACES = 5;
319+/** Each kind's colour: the chart's categorical slots, in WORK_KINDS order (app.css --color-series-*). */
320+const WORK_FILL: Record<WorkKind, string> = {
321+ pushes: "bg-series-1",
322+ pulls: "bg-series-2",
323+ issues: "bg-series-3",
324+ shipped: "bg-series-4",
325+ docs: "bg-series-5",
326+ chat: "bg-series-6",
327+};
317328
318−function Faces({ actors }: { actors: DigestActor[] }) {
319− if (actors.length === 0) return null;
320− const shown = actors.slice(0, FACES);
321− const more = actors.length - shown.length;
322− return (
323− <div className="flex items-center gap-1.5">
324− <ul className="flex -space-x-1.5" aria-label="Most active">
325− {shown.map((actor) => (
326− <li key={actor.key} className="rounded-full ring-2 ring-surface">
327− <Tooltip>
328− <TooltipTrigger asChild>
329− <span className="block">
330− <Avatar name={actor.name} size={22} />
331− </span>
332− </TooltipTrigger>
333− <TooltipContent>
334− {actor.name} · {actor.count.toLocaleString("en-US")} {actor.count === 1 ? "thing" : "things"}
335− </TooltipContent>
336− </Tooltip>
337− </li>
338− ))}
339− </ul>
340− {more > 0 && <span className="text-xs text-muted">+{more.toLocaleString("en-US")}</span>}
341− </div>
342− );
329+/** The most rows the chart shows; the rest are counted under it. */
330+const WHO_ROWS = 6;
331+
332+/** What one actor's bar says, for a screen reader and the tooltip's lead. */
333+function actorWords(actor: DigestActor): string {
334+ const parts = WORK_KINDS.filter((kind) => actor.parts[kind]).map((kind) => `${WORK_LABEL[kind].toLowerCase()} ${actor.parts[kind]!.toLocaleString("en-US")}`);
335+ return `${actor.name}${actor.kind === "agent" ? " (agent)" : ""}: ${parts.join(", ")}`;
343336 }
344337
345−/** The lines: each what was done, with its count in the words, linked to where it is listed. */
346−function Lines({ column }: { column: DigestColumn }) {
338+/**
339+ * Who did what: a row per person or agent, most active first, each a bar
340+ * split by kind of work and as long as their share of the busiest. A
341+ * segment names itself on hover; the legend under it is the totals.
342+ */
343+function WhoChart({ column }: { column: DigestColumn }) {
344+ const shown = column.actors.slice(0, WHO_ROWS);
345+ const more = column.actors.length - shown.length;
346+ const top = Math.max(1, ...shown.map((actor) => actor.count));
347347 return (
348− <ul className="mt-3 grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
349− {column.lines.map((line) => (
350− <li key={line.key} className="flex items-start gap-2 text-sm text-fg">
351− <span className="mt-1.75 size-1.5 shrink-0 rounded-full bg-fg-soft/60" aria-hidden />
352− {line.to ? (
353− <Link to={line.to} className="hover:underline">
354− {line.text}
355− </Link>
356− ) : (
357− <span>{line.text}</span>
358− )}
348+ <ul className="mt-4 space-y-2.5" aria-label="Who did what">
349+ {shown.map((actor) => (
350+ <li key={actor.key} className="grid grid-cols-[minmax(0,8.5rem)_minmax(0,1fr)_2.5rem] items-center gap-3" aria-label={actorWords(actor)}>
351+ <span className="flex min-w-0 items-center gap-2">
352+ <Avatar name={actor.name} size={20} agent={actor.kind === "agent"} ring="var(--color-surface)" />
353+ <span className="truncate text-sm text-fg">{actor.name}</span>
354+ </span>
355+ <span className="flex h-2.5 gap-0.5 overflow-hidden rounded-full" style={{ width: `${Math.max(4, (actor.count / top) * 100)}%` }} aria-hidden>
356+ {WORK_KINDS.filter((kind) => actor.parts[kind]).map((kind) => (
357+ <Tooltip key={kind}>
358+ <TooltipTrigger asChild>
359+ <span className={cn("h-full min-w-1 transition-opacity hover:opacity-80", WORK_FILL[kind])} style={{ flexGrow: actor.parts[kind] }} />
360+ </TooltipTrigger>
361+ <TooltipContent>
362+ {actor.name} · {WORK_LABEL[kind]}: {actor.parts[kind]!.toLocaleString("en-US")}
363+ {kind === "pushes" && actor.commits > 0 ? ` (${plural(actor.commits, "commit")})` : ""}
364+ </TooltipContent>
365+ </Tooltip>
366+ ))}
367+ </span>
368+ <span className="text-right text-xs text-muted tabular-nums">{actor.count.toLocaleString("en-US")}</span>
359369 </li>
360370 ))}
371+ {more > 0 && <li className="text-xs text-muted">and {plural(more, "more")}</li>}
372+ </ul>
373+ );
374+}
375+
376+/** The legend: each kind with its colour, what it was in words, linked to where it is listed. */
377+function WorkLegend({ column }: { column: DigestColumn }) {
378+ return (
379+ <ul className="mt-4 grid gap-x-6 gap-y-1.5 border-t border-line pt-3 sm:grid-cols-2">
380+ {column.lines.map((line) => {
381+ const body = (
382+ <>
383+ <span className={cn("mt-1.25 size-2.5 shrink-0 rounded-[3px]", WORK_FILL[line.key])} aria-hidden />
384+ <span className="min-w-0">
385+ <span className="font-medium text-fg">{WORK_LABEL[line.key]}</span> <span className="text-muted">{line.text}</span>
386+ </span>
387+ </>
388+ );
389+ return (
390+ <li key={line.key} className="text-sm">
391+ {line.to ? (
392+ <Link to={line.to} className="group flex items-start gap-2 hover:[&_.text-muted]:text-fg-soft">
393+ {body}
394+ </Link>
395+ ) : (
396+ <span className="flex items-start gap-2">{body}</span>
397+ )}
398+ </li>
399+ );
400+ })}
361401 </ul>
362402 );
363403 }
364404
405+/** "2 people · 1 agent". */
406+function whoCount(actors: DigestActor[]): string {
407+ const people = actors.filter((actor) => actor.kind === "person").length;
408+ const agents = actors.length - people;
409+ return [people > 0 ? plural(people, "person", "people") : null, agents > 0 ? plural(agents, "agent") : null].filter(Boolean).join(" · ");
410+}
411+
365412 /**
366413 * What people did in the span, the top of the span's card: lines with
367414 * counts linked to where each thing is listed, and the most active faces.
387434 </h3>
388435 {col.actors.length > 0 && (
389436 <Badge tone="neutral" size="md" className="tabular-nums">
390− {col.actors.length.toLocaleString("en-US")} active
437+ {whoCount(col.actors)}
391438 </Badge>
392439 )}
393− <span className="ml-auto">
394− <Faces actors={col.actors} />
395− </span>
396440 </div>
397441 {col.lines.length > 0 ? (
398− <Lines column={col} />
442+ <>
443+ {col.actors.length > 0 && <WhoChart column={col} />}
444+ <WorkLegend column={col} />
445+ </>
399446 ) : (
400447 <p className="mt-3 text-sm text-muted">
401448 {silent.length > 0
402449 ? `Nothing from what could be read ${when}.`
403450 : code
404− ? `Nobody pushed, opened, merged, reviewed, edited a doc or said anything ${when}.`
405− : `Nobody edited a doc or said anything ${when}.`}
451+ ? `Nothing pushed, opened, merged, reviewed, edited or said ${when}.`
452+ : `No doc edited and nothing said ${when}.`}
406453 </p>
407454 )}
408455 {silent.length > 0 && (
+68−2
7070 import { codingAgentById } from "@g1t/contracts/coding-agents";
7171
7272 import { cn } from "../lib/cn";
73+import { WORK_KINDS, WORK_LABEL, type WorkKind } from "../lib/home";
7374 import { CHECKS, type CursorTarget, type Frame, LOOP_MS, PILLS, SESSION, SETTLED, SUMMARY_WORDS, type SessionEntry, STILLS, type Scene, frameAt, pillAt, pillProgress, sameFrame } from "../lib/tour";
7475 import { AgentBadge, AgentMark } from "./coding-agent";
7576 import { MarkGlyph } from "./logo";
957958 );
958959 }
959960
961+/** The chart's colour for each kind, as components/home.tsx gives it. */
962+const TOUR_FILL: Record<WorkKind, string> = {
963+ pushes: "bg-series-1",
964+ pulls: "bg-series-2",
965+ issues: "bg-series-3",
966+ shipped: "bg-series-4",
967+ docs: "bg-series-5",
968+ chat: "bg-series-6",
969+};
970+
971+/**
972+ * People: who did what overnight as the real card draws it (components/home.tsx
973+ * WhoChart): a row each for people and agents, a bar split by kind of work,
974+ * and the legend with the totals.
975+ */
976+function PeopleCardMock() {
977+ const rows: { name: string; agent: boolean; parts: Partial<Record<WorkKind, number>> }[] = [
978+ { name: "jordan", agent: false, parts: { pushes: 4, pulls: 2, issues: 3, chat: 9 } },
979+ { name: "alex", agent: false, parts: { pulls: 3, issues: 2, docs: 2, chat: 6 } },
980+ { name: "claude", agent: true, parts: { pushes: 6, pulls: 2 } },
981+ { name: "g1t", agent: true, parts: { shipped: 3, chat: 4 } },
982+ ];
983+ const legend: [WorkKind, string][] = [
984+ ["pushes", "23 commits to 2 projects"],
985+ ["pulls", "4 opened, 3 merged"],
986+ ["issues", "3 opened, 2 closed"],
987+ ["shipped", "3 deploys"],
988+ ["docs", "2 docs edited"],
989+ ["chat", "19 messages in 4 conversations"],
990+ ];
991+ const total = (parts: Partial<Record<WorkKind, number>>) => Object.values(parts).reduce((a, b) => a + b, 0);
992+ const top = Math.max(...rows.map((row) => total(row.parts)));
993+ return (
994+ <HomeCard title="People" hint="2 people · 2 agents">
995+ <ul className="space-y-2 px-4 pb-3">
996+ {rows.map((row) => (
997+ <li key={row.name} className="grid grid-cols-[5.5rem_minmax(0,1fr)_1.5rem] items-center gap-2.5">
998+ <span className="flex min-w-0 items-center gap-1.5">
999+ <Avatar name={row.name} size={16} agent={row.agent} ring="var(--color-surface)" />
1000+ <span className="truncate text-[11.5px] text-fg">{row.name}</span>
1001+ </span>
1002+ <span className="flex h-2 gap-0.5 overflow-hidden rounded-full" style={{ width: `${(total(row.parts) / top) * 100}%` }}>
1003+ {WORK_KINDS.filter((kind) => row.parts[kind]).map((kind) => (
1004+ <span key={kind} className={cn("h-full", TOUR_FILL[kind])} style={{ flexGrow: row.parts[kind] }} />
1005+ ))}
1006+ </span>
1007+ <span className="text-right text-[10.5px] text-muted tabular-nums">{total(row.parts)}</span>
1008+ </li>
1009+ ))}
1010+ </ul>
1011+ <ul className="grid grid-cols-2 gap-x-4 gap-y-1 border-t border-line px-4 py-2.5">
1012+ {legend.map(([kind, text]) => (
1013+ <li key={kind} className="flex min-w-0 items-center gap-1.5 text-[10.5px]">
1014+ <span className={cn("size-2 shrink-0 rounded-[2px]", TOUR_FILL[kind])} />
1015+ <span className="truncate">
1016+ <span className="font-medium text-fg">{WORK_LABEL[kind]}</span> <span className="text-muted">{text}</span>
1017+ </span>
1018+ </li>
1019+ ))}
1020+ </ul>
1021+ </HomeCard>
1022+ );
1023+}
1024+
9601025 /** Landed: each change that came in overnight with whether it is live, the agent's mark on the one it helped make, and Explain. */
9611026 function LandedCardMock() {
9621027 const rows = [
9941059 /**
9951060 * Home, as Priya starts her day on it (routes/workspace/home.tsx): "What are
9961061 * we building today?" over the box that asks g1t, Where you're needed
997− * beside Start here, then Since you were last here with What changed and
998− * Landed. Pressing the first starter brings What changed into view, as the
1062+ * beside Start here, then Since you were last here with What changed,
1063+ * People (who did what, agents beside people) and Landed. Pressing the first starter brings What changed into view, as the
9991064 * real one does, and its answer streams in.
10001065 */
10011066 function HomeMain({ frame, phone = false }: { frame: Frame; phone?: boolean }) {
10611126 <p className="truncate text-[11px] text-muted">Yesterday at 6:12 PM · 15 hours</p>
10621127 </div>
10631128 <WhatChangedCardMock frame={frame} />
1129+ <PeopleCardMock />
10641130 <LandedCardMock />
10651131 </div>
10661132 </div>
+2−1
2121 type User,
2222 type WorkflowRun,
2323 notifyClient,
24+ G1T_KEY,
2425 } from "@g1t/contracts";
2526
2627 import {
194195 const names: Record<string, string> = {};
195196 const userIds: string[] = [];
196197 for (const key of keys.slice(0, MAX_NAMES)) {
197− if (key === G1T_ACTOR || key === "user:g1t" || key.startsWith("user:wsp_")) names[key] = "g1t";
198+ if (key === G1T_ACTOR || key === "user:g1t" || key === G1T_KEY || key.startsWith("user:wsp_")) names[key] = "g1t";
198199 else if (key.startsWith("agent:") || key.startsWith("user:agt_")) continue;
199200 else if (key.startsWith("user:")) userIds.push(key.slice("user:".length));
200201 }
+37−16
393393 assert.equal(actorKind("user:usr_bot", new Set(["user:usr_bot"])), "agent");
394394 });
395395
396−test("the digest: what people did, in lines with counts, and who", () => {
396+test("the digest: what was done, by kind with counts, and who, agents marked", () => {
397397 const digest = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: busyChat, names: NAMES, code: true });
398398 assert.deepEqual(
399− digest.people.lines.map((line) => [line.text, line.to]),
399+ digest.people.lines.map((line) => [line.key, line.text, line.to]),
400400 [
401− ["3 people pushed 41 commits to 4 projects", "/acme/-/repositories"],
402− ["opened 2 pull requests, merged 5", "/acme/-/repositories"],
403− ["reviewed 2 pull requests", "/acme/web/pulls"],
404− ["opened 2 issues, closed 1", "/acme/web/issues"],
405− ["left 4 comments", "/acme/web/issues"],
406− ["2 deploys went out", "/acme/web/deployments"],
407− ["published 1 release", "/acme/web/releases"],
408− ["created 1 doc, edited 6 docs", "/acme/-/artifacts"],
409− ["sent 105 messages in 5 conversations", "/acme/-/chat"],
401+ ["pushes", "47 commits to 4 projects", "/acme/-/repositories"],
402+ ["pulls", "5 opened, 5 merged, 4 reviewed", "/acme/-/repositories"],
403+ ["issues", "2 opened, 2 closed, 7 comments", "/acme/web/issues"],
404+ ["shipped", "2 deploys, 1 release, 1 package version", "/acme/web/deployments"],
405+ ["docs", "1 doc created, 7 docs edited", "/acme/-/artifacts"],
406+ ["chat", "120 messages in 5 conversations", "/acme/-/chat"],
410407 ],
411408 );
412− // Faces: most active first, by name.
409+ // Rows: most active first, people and agents, each marked; nobody is "someone".
413410 assert.deepEqual(
414− digest.people.actors.map((actor) => actor.name),
415− ["ana", "bo", "cy"],
411+ digest.people.actors.map((actor) => [actor.name, actor.kind]),
412+ digest.people.actors.map((actor) => [actor.name, actor.name === "margo" || actor.name === "g1t" ? "agent" : "person"]),
416413 );
414+ assert.deepEqual(new Set(digest.people.actors.map((actor) => actor.name)), new Set(["ana", "bo", "cy", "margo", "g1t"]));
415+ assert.equal(digest.people.actors[0]!.name, "ana");
416+ // Each row's parts add up to its count, and the kinds' counts to every row's.
417+ for (const actor of digest.people.actors) {
418+ assert.equal(Object.values(actor.parts).reduce((a, b) => a + b, 0), actor.count, actor.name);
419+ }
420+ // The summary still counts people only.
417421 assert.deepEqual(digest.summary, { people: 3, commits: 41, merged: 5, said: 105, anything: true, peopleRead: true });
418422 assert.deepEqual(digest.missing, []);
419423 assert.equal(digest.partial, false);
420424 });
421425
426+test("g1t is one row, named, whichever key it used: its account, a workspace, or chat's system:g1t", () => {
427+ const chat: ChatActivity = { ...busyChat, messages: 5, channels: 1, authors: [{ key: "system:g1t", messages: 3 }, { key: ANA, messages: 2 }] };
428+ const activity = { ...busyDay(), folios: null, repos: [{ ...quietRepo("rep_web"), comments: [count(G1T, 2), count("user:wsp_acme", 1)] }] };
429+ const digest = digestOf({ slug: "acme", repos: REPOS, activity, chat, names: { [ANA]: "ana", "user:wsp_acme": "g1t", "system:g1t": "g1t" }, code: true });
430+ assert.deepEqual(
431+ digest.people.actors.map((actor) => [actor.name, actor.kind, actor.count, actor.parts]),
432+ [
433+ ["g1t", "agent", 6, { issues: 3, chat: 3 }],
434+ ["ana", "person", 2, { chat: 2 }],
435+ ],
436+ );
437+ assert.equal(digest.summary.people, 1);
438+ // A person whose name could not be read is an unknown account, never "someone".
439+ const unread = digestOf({ slug: "acme", repos: REPOS, activity: null, chat: { ...chat, authors: [{ key: "user:usr_gone", messages: 1 }] }, names: {}, code: true });
440+ assert.deepEqual(unread.people.actors.map((actor) => actor.name), ["unknown account"]);
441+});
442+
422443 test("the digest says which part didn't answer, and counts what could be read", () => {
423444 const noChat = digestOf({ slug: "acme", repos: REPOS, activity: busyDay(), chat: null, names: NAMES, code: true });
424445 assert.deepEqual(noChat.missing, ["Chat"]);
427448 assert.deepEqual(noCode.missing, ["Code"]);
428449 assert.deepEqual(
429450 noCode.people.lines.map((line) => line.text),
430− ["sent 105 messages in 5 conversations"],
451+ ["120 messages in 5 conversations"],
431452 );
432453 assert.equal(noCode.summary.peopleRead, true);
433454 // Without Code access, events are read for artifacts only: no repositories, nothing missing.
451472 });
452473 assert.deepEqual(
453474 imported.people.lines.map((line) => [line.text, line.to]),
454− [["7 commits pushed to 1 project", "/acme/web/activity"]],
475+ [["7 commits to 1 project", "/acme/web/activity"]],
455476 );
456477 assert.equal(imported.summary.people, 0);
457478 });
+147−106
103103 note: "first" | "capped" | "unknown" | null;
104104 };
105105
106−const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
106+export const plural = (n: number, one: string, many = `${one}s`) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
107107
108108 /** How long a stretch of time is, roughly: "a moment", "40 minutes", "3 hours", "3 days", "2 weeks". */
109109 export function duration(ms: number): string {
213213 export const G1T_ACTOR = "user:usr_g1t_agent";
214214
215215 export function actorKind(key: string, agents?: ReadonlySet<string>): ActorKind {
216− return key.startsWith("agent:") || key === G1T_ACTOR || isRobotKey(key) || agents?.has(key) ? "agent" : "person";
216+ return key.startsWith("agent:") || key.startsWith("system:") || key === G1T_ACTOR || isRobotKey(key) || agents?.has(key) ? "agent" : "person";
217217 }
218218
219219 /**
233233 /** A repository as the digest's links and lines need it. */
234234 export type RepoRef = RepoPath & { defaultBranch: string };
235235
236−/** Someone who did something in the span: for the faces beside the card. */
236+/**
237+ * The kinds of work the span's chart is split into, in the order the
238+ * legend and each bar show them (and the chart's colour slots).
239+ */
240+export const WORK_KINDS = ["pushes", "pulls", "issues", "shipped", "docs", "chat"] as const;
241+export type WorkKind = (typeof WORK_KINDS)[number];
242+
243+/** Each kind's name in the legend. */
244+export const WORK_LABEL: Record<WorkKind, string> = {
245+ pushes: "Pushes",
246+ pulls: "Pull requests",
247+ issues: "Issues",
248+ shipped: "Shipped",
249+ docs: "Docs",
250+ chat: "Chat",
251+};
252+
253+/** A person, or an agent, who did something in the span: a row of the chart. */
237254 export type DigestActor = {
238255 key: string;
239− /** Their username; `someone` when it could not be read. */
256+ /** Their username; g1t for g1t; `unknown account` when it could not be read. */
240257 name: string;
241− /** How many things they did, over every line: the order of the faces. */
258+ kind: ActorKind;
259+ /** How many things they did, over every kind: the order of the rows. */
242260 count: number;
261+ /** The same things by kind (a push counts once, however many commits). */
262+ parts: Partial<Record<WorkKind, number>>;
263+ /** Commits in their pushes. */
264+ commits: number;
243265 };
244266
245−/** One line: what was done, with its count, and where that is listed. */
267+/** One kind of work: what was done, with its count, and where that is listed. */
246268 export type DigestLine = {
247− key: string;
269+ key: WorkKind;
248270 text: string;
271+ /** Things of this kind, in the bars' units. */
272+ count: number;
249273 to: string | null;
250274 };
251275
252276 export type DigestColumn = {
253277 lines: DigestLine[];
254− /** Most active first. */
278+ /** Most active first: people and agents, each marked. */
255279 actors: DigestActor[];
256280 };
257281
258282 /** What the sentence under the heading needs. */
259283 export type DigestSummary = {
260− /** Distinct people who did anything. */
284+ /** Distinct people (not agents) who did anything. */
261285 people: number;
262286 /** Commits people pushed. */
263287 commits: number;
319343 return only ? `/${only.namespace}/${only.name}/${page}` : `/${slug}/-/repositories`;
320344 }
321345
346+/** `3 opened, 5 merged`: the non-zero counts, `#` standing for each. */
347+function counted(parts: [number, string][]): string {
348+ return parts
349+ .filter(([count]) => count > 0)
350+ .map(([count, words]) => words.replace("#", count.toLocaleString("en-US")))
351+ .join(", ");
352+}
353+
322354 /**
323− * What people did in the span, as lines with counts and links, and who
324− * did it. Pure: everything comes from the services' counts and the names
355+ * What was done in the span, by kind, with counts and links, and who did
356+ * it: people and agents both, each marked, so g1t and the agents show
357+ * beside the people instead of being left out (or showing as nobody).
358+ * g1t's keys (its account, its chat key, a workspace acting as it) are one
359+ * row. Pure: everything comes from the services' counts and the names
325360 * looked up for them.
326361 */
327362 export function digestColumn(input: DigestInput): DigestColumn {
328363 const { slug, repos, activity, chat, names, agentKeys } = input;
329364 const lines: DigestLine[] = [];
330− const did = new Map<string, number>();
331− const credit = (actors: Map<string, number>) => {
332− for (const [key, n] of actors) did.set(key, (did.get(key) ?? 0) + n);
365+ const rows = new Map<string, DigestActor>();
366+ const row = (key: string): DigestActor | null => {
367+ if (!key) return null;
368+ const kind = actorKind(key, agentKeys);
369+ const name = names[key] ?? (kind === "agent" ? (key.endsWith(":g1t") || key === G1T_ACTOR ? "g1t" : "agent") : "unknown account");
370+ const id = name === "g1t" ? "g1t" : key;
371+ let found = rows.get(id);
372+ if (!found) {
373+ found = { key: id, name, kind, count: 0, parts: {}, commits: 0 };
374+ rows.set(id, found);
375+ }
376+ return found;
377+ };
378+ /** Credits each actor with their part of a tally; the tally's total. */
379+ const credit = (kind: WorkKind, counts: ActorCount[]) => {
380+ let total = 0;
381+ for (const { actor, count } of counts) {
382+ total += count;
383+ const who = row(actor);
384+ if (!who) continue;
385+ who.count += count;
386+ who.parts[kind] = (who.parts[kind] ?? 0) + count;
387+ }
388+ return total;
333389 };
334390
335391 if (activity) {
336392 // Pushes: commits, projects, and who.
337− const pushers = new Map<string, number>();
338393 const pushed = new Set<string>();
339394 let commits = 0;
340395 let pushes = 0;
341396 for (const repo of activity.repos) {
342− let any = false;
343397 for (const by of repo.pushes.by) {
344− if (actorKind(by.actor, agentKeys) !== "person") continue;
345− any = true;
398+ if (by.count === 0) continue;
399+ pushed.add(repo.repo_id);
346400 commits += by.commits;
347− pushes += by.count;
348− if (by.actor) pushers.set(by.actor, (pushers.get(by.actor) ?? 0) + by.count);
401+ pushes += credit("pushes", [{ actor: by.actor, count: by.count }]);
402+ const who = row(by.actor);
403+ if (who) who.commits += by.commits;
349404 }
350− if (any) pushed.add(repo.repo_id);
351405 }
352406 if (pushes > 0) {
353− const where = `to ${plural(pushed.size, "project")}`;
354− const text = pushers.size > 0 ? `${people(pushers.size)} pushed ${plural(commits, "commit")} ${where}` : `${plural(commits, "commit")} pushed ${where}`;
355− lines.push({ key: "pushes", text, to: placeFor(slug, repos, pushed, "activity") });
356− credit(pushers);
407+ lines.push({ key: "pushes", text: `${plural(commits, "commit")} to ${plural(pushed.size, "project")}`, count: pushes, to: placeFor(slug, repos, pushed, "activity") });
357408 }
358409
359− // Pull requests, reviews, issues, comments, deploys, releases, packages: summed over projects.
360− const sum = (pick: (repo: RepoDigest) => ActorCount[]) => {
361− const total = { count: 0, actors: new Map<string, number>(), repoIds: [] as string[] };
410+ // The other kinds, summed over projects: how many, and where.
411+ const sum = (kind: WorkKind, pick: (repo: RepoDigest) => ActorCount[]) => {
412+ let count = 0;
413+ const repoIds: string[] = [];
362414 for (const repo of activity.repos) {
363− const { count, actors } = part(pick(repo), agentKeys);
364− if (count === 0) continue;
365− total.count += count;
366− total.repoIds.push(repo.repo_id);
367− for (const [key, n] of actors) total.actors.set(key, (total.actors.get(key) ?? 0) + n);
415+ const n = credit(kind, pick(repo));
416+ if (n === 0) continue;
417+ count += n;
418+ repoIds.push(repo.repo_id);
368419 }
369− return total;
420+ return { count, repoIds };
370421 };
371− const opened = sum((repo) => repo.pulls.opened);
372− const merged = sum((repo) => repo.pulls.merged);
373− const closed = sum((repo) => repo.pulls.closed);
374− if (opened.count + merged.count + closed.count > 0) {
375− const parts: string[] = [];
376− if (opened.count > 0) parts.push(`opened ${plural(opened.count, "pull request")}`);
377− if (merged.count > 0) parts.push(`merged ${opened.count > 0 ? merged.count.toLocaleString("en-US") : plural(merged.count, "pull request")}`);
378− if (closed.count > 0) parts.push(`closed ${closed.count.toLocaleString("en-US")} without merging`);
379− lines.push({ key: "pulls", text: parts.join(", "), to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds], "pulls") });
380− credit(opened.actors);
381− credit(merged.actors);
382− credit(closed.actors);
383− }
384− const reviews = sum((repo) => repo.reviews);
385− if (reviews.count > 0) {
386− lines.push({ key: "reviews", text: `reviewed ${plural(reviews.count, "pull request")}`, to: placeFor(slug, repos, reviews.repoIds, "pulls") });
387− credit(reviews.actors);
422+
423+ const opened = sum("pulls", (repo) => repo.pulls.opened);
424+ const merged = sum("pulls", (repo) => repo.pulls.merged);
425+ const closed = sum("pulls", (repo) => repo.pulls.closed);
426+ const reviews = sum("pulls", (repo) => repo.reviews);
427+ const pulls = opened.count + merged.count + closed.count + reviews.count;
428+ if (pulls > 0) {
429+ const text = counted([
430+ [opened.count, "# opened"],
431+ [merged.count, "# merged"],
432+ [closed.count, "# closed without merging"],
433+ [reviews.count, "# reviewed"],
434+ ]);
435+ lines.push({ key: "pulls", text, count: pulls, to: placeFor(slug, repos, [...opened.repoIds, ...merged.repoIds, ...closed.repoIds, ...reviews.repoIds], "pulls") });
388436 }
389− const issuesOpened = sum((repo) => repo.issues.opened);
390− const issuesClosed = sum((repo) => repo.issues.closed);
391− if (issuesOpened.count + issuesClosed.count > 0) {
392− const parts: string[] = [];
393− if (issuesOpened.count > 0) parts.push(`opened ${plural(issuesOpened.count, "issue")}`);
394− if (issuesClosed.count > 0) parts.push(`closed ${issuesOpened.count > 0 ? issuesClosed.count.toLocaleString("en-US") : plural(issuesClosed.count, "issue")}`);
395− lines.push({ key: "issues", text: parts.join(", "), to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds], "issues") });
396− credit(issuesOpened.actors);
397− credit(issuesClosed.actors);
437+
438+ const issuesOpened = sum("issues", (repo) => repo.issues.opened);
439+ const issuesClosed = sum("issues", (repo) => repo.issues.closed);
440+ const comments = sum("issues", (repo) => repo.comments);
441+ const issues = issuesOpened.count + issuesClosed.count + comments.count;
442+ if (issues > 0) {
443+ const text = counted([
444+ [issuesOpened.count, "# opened"],
445+ [issuesClosed.count, "# closed"],
446+ [comments.count, comments.count === 1 ? "# comment" : "# comments"],
447+ ]);
448+ lines.push({ key: "issues", text, count: issues, to: placeFor(slug, repos, [...issuesOpened.repoIds, ...issuesClosed.repoIds, ...comments.repoIds], "issues") });
398449 }
399− const comments = sum((repo) => repo.comments);
400− if (comments.count > 0) {
401− lines.push({ key: "comments", text: `left ${plural(comments.count, "comment")}`, to: placeFor(slug, repos, comments.repoIds, "issues") });
402− credit(comments.actors);
403− }
404− const succeeded = sum((repo) => repo.deployments.succeeded);
405− const failed = sum((repo) => repo.deployments.failed);
406− if (succeeded.count + failed.count > 0) {
407− const text = succeeded.count > 0 ? `${plural(succeeded.count, "deploy")} went out${failed.count > 0 ? `, ${failed.count.toLocaleString("en-US")} failed` : ""}` : `${plural(failed.count, "deploy")} failed`;
408− lines.push({ key: "deploys", text, to: placeFor(slug, repos, [...succeeded.repoIds, ...failed.repoIds], "deployments") });
409− credit(succeeded.actors);
410− credit(failed.actors);
411− }
412− const releases = sum((repo) => tallyOf(repo.releases.map((release) => release.actor)));
413− if (releases.count > 0) {
414− lines.push({ key: "releases", text: `published ${plural(releases.count, "release")}`, to: placeFor(slug, repos, releases.repoIds, "releases") });
415− credit(releases.actors);
416− }
417− const packages = sum((repo) => tallyOf(repo.packages.map((pkg) => pkg.actor)));
418− if (packages.count > 0) {
419− lines.push({ key: "packages", text: `published ${plural(packages.count, "package version")}`, to: `/${slug}/-/packages` });
420− credit(packages.actors);
450+
451+ const succeeded = sum("shipped", (repo) => repo.deployments.succeeded);
452+ const failed = sum("shipped", (repo) => repo.deployments.failed);
453+ const releases = sum("shipped", (repo) => tallyOf(repo.releases.map((release) => release.actor)));
454+ const packages = sum("shipped", (repo) => tallyOf(repo.packages.map((pkg) => pkg.actor)));
455+ const shipped = succeeded.count + failed.count + releases.count + packages.count;
456+ if (shipped > 0) {
457+ const text = counted([
458+ [succeeded.count, succeeded.count === 1 ? "# deploy" : "# deploys"],
459+ [failed.count, "# failed"],
460+ [releases.count, releases.count === 1 ? "# release" : "# releases"],
461+ [packages.count, packages.count === 1 ? "# package version" : "# package versions"],
462+ ]);
463+ const deployed = [...succeeded.repoIds, ...failed.repoIds];
464+ const to =
465+ deployed.length > 0 ? placeFor(slug, repos, deployed, "deployments") : releases.count > 0 ? placeFor(slug, repos, releases.repoIds, "releases") : `/${slug}/-/packages`;
466+ lines.push({ key: "shipped", text, count: shipped, to });
421467 }
422468
423469 // Artifacts: made, and whose content changed.
424470 if (activity.folios) {
425− const made = part(activity.folios.created, agentKeys);
426− const edited = new Set<string>();
427− const editors = new Map<string, number>();
471+ const made = credit("docs", activity.folios.created);
472+ const edits: ActorCount[] = [];
473+ let edited = 0;
428474 let allDocs = true;
429475 for (const folio of activity.folios.edited) {
430− const mine = folio.authors.filter((author) => actorKind(author, agentKeys) === "person");
431− if (mine.length === 0) continue;
432− edited.add(folio.folio_id);
476+ if (folio.authors.length === 0) continue;
477+ edited += 1;
433478 if (folio.kind !== "doc") allDocs = false;
434− for (const author of mine) editors.set(author, (editors.get(author) ?? 0) + 1);
479+ for (const author of folio.authors) edits.push({ actor: author, count: 1 });
435480 }
436− const what = (n: number) => plural(n, allDocs ? "doc" : "artifact");
437− if (made.count + edited.size > 0) {
481+ credit("docs", edits);
482+ const what = (count: number) => plural(count, allDocs ? "doc" : "artifact");
483+ if (made + edited > 0) {
438484 const parts: string[] = [];
439− if (made.count > 0) parts.push(`created ${what(made.count)}`);
440− if (edited.size > 0) parts.push(`edited ${what(edited.size)}`);
441− lines.push({ key: "docs", text: parts.join(", "), to: `/${slug}/-/artifacts` });
442− credit(made.actors);
443− credit(editors);
485+ if (made > 0) parts.push(`${what(made)} created`);
486+ if (edited > 0) parts.push(`${what(edited)} edited`);
487+ lines.push({ key: "docs", text: parts.join(", "), count: made + edits.length, to: `/${slug}/-/artifacts` });
444488 }
445489 }
446490 }
447491
448− // Chat: what people said.
492+ // Chat: what was said.
449493 if (chat) {
450− const said = part(
494+ const said = credit(
495+ "chat",
451496 chat.authors.map((author) => ({ actor: author.key, count: author.messages })),
452− agentKeys,
453497 );
454− if (said.count > 0) {
455− lines.push({ key: "chat", text: `sent ${plural(said.count, "message")} in ${plural(chat.channels, "conversation")}`, to: `/${slug}/-/chat` });
456− credit(said.actors);
498+ if (said > 0) {
499+ lines.push({ key: "chat", text: `${plural(said, "message")} in ${plural(chat.channels, "conversation")}`, count: said, to: `/${slug}/-/chat` });
457500 }
458501 }
459502
460− const actors: DigestActor[] = [...did]
461− .map(([key, count]) => ({ key, name: names[key] ?? "someone", count }))
462− .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
503+ const actors = [...rows.values()].filter((actor) => actor.count > 0).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
463504 return { lines, actors };
464505 }
465506
489530 return {
490531 people: column,
491532 summary: {
492− people: column.actors.length,
533+ people: column.actors.filter((actor) => actor.kind === "person").length,
493534 commits,
494535 merged,
495536 said,

Comments

Sign in to comment.