From cf887ff3a2644203e1276a27a8ce3f38d6f661d0 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:02:56 -0400 Subject: [PATCH 1/9] docs(spec): My Work reorder + structured cards + retroactive summary resync design --- .../2026-07-03-mywork-restructure-design.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-03-mywork-restructure-design.md diff --git a/docs/superpowers/specs/2026-07-03-mywork-restructure-design.md b/docs/superpowers/specs/2026-07-03-mywork-restructure-design.md new file mode 100644 index 0000000..39ba878 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-mywork-restructure-design.md @@ -0,0 +1,140 @@ +# My Work: reorder, structured cards, retroactive summary resync + +Date: 2026-07-03 + +## Problem + +The My Work screen (`web/src/render.ts`, `myWorkView`) shows two sections: "Previous +activity" (merged/closed PRs) above "To-do" (open assigned issues). Two things are off: + +1. To-do — the actionable list — renders below the read-only activity history. It + should be on top. +2. Both sections are thin on structure. `todoCard` is a single truncated line with no + room for a full title or an updated-at signal. `prActivityCard`'s body is one + undifferentiated AI-generated prose blob — no labeled fields. + +Separately, "Sync GitHub" (the admin-only backfill button) only looks at PRs closed in +the last 14 days, and only (re)generates a PR's summary when its event is *newly* +captured — an already-captured PR's summary is never touched again. That means +existing PRs would stay on the old unstructured prose format forever, even after this +change ships. + +## Design + +### 1. Reorder (`web/src/render.ts`, `myWorkView`) + +Swap section composition from `${hero}${activity}${todo}` to `${hero}${todo}${activity}`. +Section labels ("To-do", "Previous activity") stay as-is. + +### 2. To-do card restructure (`todoCard`) + +From a single-line truncated row to a two-line card, still one ``: + +- Row 1: priority badge + `#number` + title. Title wraps up to 2 lines (CSS + line-clamp) instead of single-line ellipsis truncation. +- Row 2: up to 3 labels (cap unchanged, keeps the card bounded) + a right-aligned + relative "updated" timestamp from `t.updatedAt` (captured today, currently unused + in the card). + +### 3. Structured PR summaries + +**New shared module `shared/prSummary.ts`** — the single source of truth for +recognizing the structured-summary convention, imported by both the Worker and the +web build: + +```ts +export interface StructuredPrSummary { + what: string; + why: string | null; +} + +export function parseStructuredSummary(raw: string): StructuredPrSummary | null; +``` + +It matches markdown of the shape: + +``` +**What changed:** <1-2 factual sentences> +**Why:** <1 sentence — omitted entirely when no rationale is stated> +``` + +Returns `null` when the text doesn't match (old-style prose, the deterministic +excerpt fallback, or a malformed AI response) — callers treat `null` as "render/treat +as plain prose," never as an error. + +**Backend (`src/tools/summarize.ts`)** — `workersAiSummarizer`'s system prompt +changes to require exactly that two-field shape (omitting the `**Why:**` line when +the PR body states no rationale), instead of "2-3 short sentences." No signature +change. `excerptSummary` (the deterministic no-AI fallback) is untouched — it keeps +producing plain prose, which is intentional: a parse miss just falls back to today's +rendering, never a broken UI. `pr_summaries.summary` stays a single `TEXT` column; no +migration — the structure lives in the markdown convention, not the schema. + +**Frontend (`web/src/render.ts`, `prActivityCard`)** — the summary body now branches +on `parseStructuredSummary(pr.summary)`: +- Matched → two labeled rows ("What changed", and "Why" only when present), each a + small uppercase caption (scaled-down `MW_LABEL` idiom) above its markdown body. +- Not matched → today's single prose block, unchanged. + +### 4. Retroactive resync (`src/tools/backfill.ts`, `runBackfill`) + +Two independent changes to `runBackfill`: + +**a. Full PR history.** Remove the `DAYS_BACK`/cutoff logic on the closed-PR fetch — +paginate through every closed PR the repo has, not just the last 14 days. The open- +issues fetch is untouched (it already has no window). + +**b. Decouple summary (re)generation from event-capture outcome.** Today, +`storePrSummary` only runs inside `if (res.outcome === "written")` — i.e., only for +brand-new events. Change: for every PR in the fetched list (regardless of whether its +event was newly captured or already existed), look up its existing `pr_summaries` row +and call `storePrSummary` unless `parseStructuredSummary(existing.summary) !== null` +(i.e., skip only when it's already in the new structured format). `ev.raw` (built +before `ingestEvent` runs) always has the PR's title/body available regardless of the +event's write outcome, so no extra DB read is needed to get the summarizer input — +only one extra read (existing `pr_summaries` row) to decide skip-or-regenerate. + +**c. Observability.** `BackfillResult` gains `summarized: number` — incremented each +time `storePrSummary` actually runs (not skipped). Threaded through: +- `src/routes.ts` `/admin/backfill` response (already returns the whole result object, + no route change beyond the type flowing through). +- `web/src/api.ts` `adminBackfill()` return type. +- `web/src/main.ts`'s flash message: `Synced: ${r.captured} captured, ${r.unchanged} + unchanged, ${r.summarized} summaries updated`. +- `web/src/render.ts`'s button title: "Fetch recent GitHub PRs + issues" → + "Fetch all GitHub PRs + issues" (no longer just recent). + +**Accepted limitation:** unbounded full-history pagination plus one AI call per +un-migrated PR has no hard cap or resumable cursor. Fine at this project's current +size (a first Sync after this ships does a one-time migration of every existing PR to +the structured format; later clicks are cheap since already-structured PRs are +skipped). Would need a real bound/cursor if the repo's PR history grows a lot — +explicitly not building that now (YAGNI). + +## Testing + +- `test/render.mywork.test.ts` (pure, no DOM): `todoCard` shows `updatedAt` and + doesn't collapse a long title to one truncated line; `prActivityCard` renders two + labeled rows for a structured summary (with and without a `why`), and falls back to + the existing prose rendering for a non-conforming summary. +- New test file (or colocated in `test/summarize.test.ts`) for + `shared/prSummary.ts`'s `parseStructuredSummary`: matches the two-field shape, + matches "What changed" only (no "Why" line), returns `null` for old-style prose and + for empty/malformed input. +- `test/backfill.test.ts`: the existing "oldPr excluded by the 14-day window" + assertion is now wrong on purpose and must be updated to reflect full-history + fetch. New cases: a PR with an existing non-structured summary gets re-summarized + on a second run (`summarized` increments, `storePrSummary`/summarizer called + again); a PR with an existing structured summary is skipped on a second run + (`summarized` stays 0 for it, summarizer not called again — assert via a + call-counting stub summarizer). +- No new migration, so `test/apply-migrations.ts` is untouched. + +## Out of scope + +- No change to the live webhook capture path (`src/webhook.ts`) — a real-time merge + event only ever fires once, so the "already captured, skip" question doesn't arise + there. +- No change to issue capture/backfill scope (already unbounded). +- No resumable/bounded backfill cursor for large repos (see accepted limitation + above). From c79120d8a23c1832e5c40ac26aaf4aa3c7638218 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:39:47 -0400 Subject: [PATCH 2/9] =?UTF-8?q?feat(shared):=20parseStructuredSummary=20?= =?UTF-8?q?=E2=80=94=20What=20changed/Why=20markdown=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shared/prSummary.ts | 28 +++++++++++++++++++++++++ test/prSummary.test.ts | 46 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 shared/prSummary.ts create mode 100644 test/prSummary.test.ts diff --git a/shared/prSummary.ts b/shared/prSummary.ts new file mode 100644 index 0000000..bcb90d5 --- /dev/null +++ b/shared/prSummary.ts @@ -0,0 +1,28 @@ +// Recognizes the "What changed / Why" markdown convention emitted by the PR +// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's +// already-structured check, src/tools/backfill.ts) and the web build (card +// rendering, web/src/render.ts) agree on what counts as a structured summary. +// No schema change backs this — pr_summaries.summary stays a single markdown +// TEXT column; the structure lives in this convention, not a stored shape, so +// old prose summaries and the deterministic excerpt fallback degrade +// gracefully to a `null` parse instead of erroring. + +export interface StructuredPrSummary { + what: string; + why: string | null; +} + +const STRUCTURED_RE = /^\s*\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i; + +/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown. + * Returns null when the text doesn't match (old-style prose, the excerpt + * fallback, or a malformed AI response) — callers treat null as "render/treat + * as plain prose," never as an error. */ +export function parseStructuredSummary(raw: string): StructuredPrSummary | null { + const m = raw.match(STRUCTURED_RE); + if (!m) return null; + const what = m[1].trim(); + if (!what) return null; + const why = m[2]?.trim() || null; + return { what, why }; +} diff --git a/test/prSummary.test.ts b/test/prSummary.test.ts new file mode 100644 index 0000000..05176a6 --- /dev/null +++ b/test/prSummary.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { parseStructuredSummary } from "@shared/prSummary"; + +describe("parseStructuredSummary", () => { + it("parses What changed + Why into separate fields", () => { + const raw = "**What changed:** Fixed the login bug.\n**Why:** Users were being logged out unexpectedly."; + expect(parseStructuredSummary(raw)).toEqual({ + what: "Fixed the login bug.", + why: "Users were being logged out unexpectedly.", + }); + }); + + it("parses What changed alone, why:null, when there is no Why line", () => { + const raw = "**What changed:** Fixed the login bug."; + expect(parseStructuredSummary(raw)).toEqual({ what: "Fixed the login bug.", why: null }); + }); + + it("handles What changed and Why on the same line", () => { + const raw = "**What changed:** Fixed the login bug. **Why:** Users were affected."; + expect(parseStructuredSummary(raw)).toEqual({ + what: "Fixed the login bug.", + why: "Users were affected.", + }); + }); + + it("is case-insensitive on the labels", () => { + const raw = "**what changed:** Fixed the login bug.\n**why:** Users were affected."; + expect(parseStructuredSummary(raw)).toEqual({ + what: "Fixed the login bug.", + why: "Users were affected.", + }); + }); + + it("returns null for old-style prose with no convention", () => { + expect(parseStructuredSummary("Fixed a bug that was breaking the login flow.")).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(parseStructuredSummary("")).toBeNull(); + }); + + it("returns null when the What field is empty even if a Why is present", () => { + const raw = "**What changed:** \n**Why:** something"; + expect(parseStructuredSummary(raw)).toBeNull(); + }); +}); From af5f344953cd4f1efd6e2033c87be523b8370175 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:43:17 -0400 Subject: [PATCH 3/9] feat(summarize): require the What changed/Why structured markdown convention --- src/tools/summarize.ts | 17 ++++++++++++----- test/summarize.test.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/src/tools/summarize.ts b/src/tools/summarize.ts index 8cf5fdc..3c64caf 100644 --- a/src/tools/summarize.ts +++ b/src/tools/summarize.ts @@ -15,6 +15,17 @@ export interface Summarizer { const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct"; +// Exported for a content assertion in test/summarize.test.ts — the two-field +// shape here is exactly what shared/prSummary.ts's parseStructuredSummary +// recognizes; keep them in sync if either changes. +export const SUMMARIZER_SYSTEM_PROMPT = + "Summarize this one pull request's description for a team activity feed. " + + "Respond with ONLY this exact markdown structure, nothing else:\n" + + "**What changed:** <1-2 short factual sentences>\n" + + "**Why:** <1 short sentence stating the description's own stated rationale>\n" + + 'If the description states no rationale, omit the "**Why:**" line entirely. ' + + "Do not speculate beyond the text."; + /** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other * context is sent. Never throws: any failure (network, empty output, malformed * response) resolves to null so the caller falls back to excerptSummary. */ @@ -25,11 +36,7 @@ export function workersAiSummarizer(ai: Ai): Summarizer { try { const result = await ai.run(WORKERS_AI_MODEL, { messages: [ - { - role: "system", - content: - "Summarize this one pull request's description in 2-3 short markdown sentences for a team activity feed. Do not speculate beyond the text.", - }, + { role: "system", content: SUMMARIZER_SYSTEM_PROMPT }, { role: "user", content: `Title: ${title}\n\nBody: ${body}` }, ], }); diff --git a/test/summarize.test.ts b/test/summarize.test.ts index 9cfe386..95aed54 100644 --- a/test/summarize.test.ts +++ b/test/summarize.test.ts @@ -4,7 +4,7 @@ import { all, run, nowIso } from "../src/db"; import type { PrSummaryRow } from "@shared/rows"; import type { Env } from "../src/env"; import type { Summarizer } from "../src/tools/summarize"; -import { storePrSummary, excerptSummary } from "../src/tools/summarize"; +import { storePrSummary, excerptSummary, SUMMARIZER_SYSTEM_PROMPT } from "../src/tools/summarize"; import { handleGithubWebhook } from "../src/webhook"; import prMerged from "./fixtures/gh-pr-merged.json"; import issueAssigned from "./fixtures/gh-issue-assigned.json"; @@ -191,3 +191,10 @@ describe("webhook → summarize wiring", () => { expect(rows[0].model).toBe("excerpt"); }); }); + +describe("SUMMARIZER_SYSTEM_PROMPT", () => { + it("requires the structured What changed / Why convention", () => { + expect(SUMMARIZER_SYSTEM_PROMPT).toContain("**What changed:**"); + expect(SUMMARIZER_SYSTEM_PROMPT).toContain("**Why:**"); + }); +}); From de744f9cba67e8f522f22f7c301896cab8385ddb Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:47:51 -0400 Subject: [PATCH 4/9] feat(mywork): render structured What changed/Why PR summaries as labeled rows --- test/render.mywork.test.ts | 23 +++++++++++++++++++++++ web/src/render.ts | 30 ++++++++++++++++++++++++++---- 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/test/render.mywork.test.ts b/test/render.mywork.test.ts index 59b2cec..8eab342 100644 --- a/test/render.mywork.test.ts +++ b/test/render.mywork.test.ts @@ -89,6 +89,29 @@ describe("prActivityCard", () => { expect(html).toContain('href="#"'); expect(html).not.toContain("javascript:alert(1)"); }); + + it("renders structured What changed + Why as separate labeled rows", () => { + const pr = makePr({ summary: "**What changed:** Fixed the login bug.\n**Why:** Users were logged out unexpectedly." }); + const html = prActivityCard(pr, mockMd); + expect(html).toContain("What changed"); + expect(html).toContain("Why"); + expect(html).toContain("Fixed the login bug."); + expect(html).toContain("Users were logged out unexpectedly."); + }); + + it("omits the Why row when the structured summary has no Why", () => { + const pr = makePr({ summary: "**What changed:** Fixed the login bug." }); + const html = prActivityCard(pr, mockMd); + expect(html).toContain("What changed"); + expect(html).not.toContain("Why"); + }); + + it("falls back to the raw prose block for a non-conforming summary (legacy/excerpt)", () => { + const pr = makePr({ summary: "Fixed the login bug that was affecting users." }); + const html = prActivityCard(pr, mockMd); + expect(html).not.toContain("What changed"); + expect(html).toContain("mock-md"); + }); }); // ── todoCard ────────────────────────────────────────────────────────────────── diff --git a/web/src/render.ts b/web/src/render.ts index f87e6d4..5f3b818 100644 --- a/web/src/render.ts +++ b/web/src/render.ts @@ -8,6 +8,7 @@ import type { FeedRow, DocRow, DocVersionRow } from "@shared/rows"; import type { QueryResult, QueryPrimary, QueryPointer, Authority, MilestoneWithProgress, PlanView, StagedProposal } from "./api"; import type { AdrRow, NeedsTriageRow, MilestoneProposalRow } from "@shared/rows"; import type { DashboardData, MyWorkPr, MyWorkTodo } from "@shared/dashboard"; +import { parseStructuredSummary, type StructuredPrSummary } from "@shared/prSummary"; import { TAGS } from "@shared/vocabulary"; import { renderMarkdown } from "./markdown"; import { REPO_URL } from "./github"; @@ -1329,6 +1330,7 @@ function settingsView(s: AppState): string { // ── my work (personal dashboard) ────────────────────────────────────────────── const MW_LABEL = "font-size:11px;font-weight:600;font-family:var(--mono);text-transform:uppercase;letter-spacing:.1em;color:var(--fg-40)"; +const MW_FIELD_LABEL = "font-size:10px;font-weight:600;font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em;color:var(--fg-40);margin-bottom:3px"; function wrapMyWork(inner: string): string { return `
${inner}
`; @@ -1351,14 +1353,34 @@ function mwDegradedHint(text: string): string { return `
${text}
`; } -/** A merged/closed PR card: #number → pr.url, title, relTime, MERGED/CLOSED chip, markdown summary. */ +/** Renders a structured {what, why} summary as labeled rows (small caption + markdown body each). */ +function structuredSummaryBody(structured: StructuredPrSummary, markdownFn: (body: string) => string): string { + const whatRow = ` +
What changed
+
${markdownFn(structured.what)}
+ `; + const whyRow = structured.why + ? `
+
Why
+
${markdownFn(structured.why)}
+
` + : ""; + return whatRow + whyRow; +} + +/** A merged/closed PR card: #number → pr.url, title, relTime, MERGED/CLOSED chip, + * and a summary body — labeled "What changed"/"Why" rows when pr.summary matches + * the structured convention, else the raw markdown blob (legacy/excerpt fallback). */ export function prActivityCard(pr: MyWorkPr, markdownFn: (body: string) => string): string { const chip = pr.merged ? `MERGED` : `CLOSED`; - const body = pr.summary !== null - ? `
${markdownFn(pr.summary)}
` - : `
${linkifyRefs("No summary recorded for this PR.")}
`; + const structured = pr.summary !== null ? parseStructuredSummary(pr.summary) : null; + const body = pr.summary === null + ? `
${linkifyRefs("No summary recorded for this PR.")}
` + : structured !== null + ? structuredSummaryBody(structured, markdownFn) + : `
${markdownFn(pr.summary)}
`; return `
From cb20694d0b470d1e84e064b33d719d124847ce2f Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:53:22 -0400 Subject: [PATCH 5/9] test(mywork): strengthen prActivityCard structured-summary tests to detect a reverted feature --- test/render.mywork.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/render.mywork.test.ts b/test/render.mywork.test.ts index 8eab342..43dbe78 100644 --- a/test/render.mywork.test.ts +++ b/test/render.mywork.test.ts @@ -97,6 +97,8 @@ describe("prActivityCard", () => { expect(html).toContain("Why"); expect(html).toContain("Fixed the login bug."); expect(html).toContain("Users were logged out unexpectedly."); + expect(html).not.toContain("**What changed:**"); + expect(html).not.toContain("**Why:**"); }); it("omits the Why row when the structured summary has no Why", () => { @@ -104,6 +106,7 @@ describe("prActivityCard", () => { const html = prActivityCard(pr, mockMd); expect(html).toContain("What changed"); expect(html).not.toContain("Why"); + expect(html).not.toContain("**What changed:**"); }); it("falls back to the raw prose block for a non-conforming summary (legacy/excerpt)", () => { From 55b11db2efa30ac26480f6369e01105753a78f06 Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 15:57:37 -0400 Subject: [PATCH 6/9] feat(mywork): reorder To-do above Previous activity; richer To-do cards --- test/render.mywork.test.ts | 23 +++++++++++++++++++++++ web/src/render.ts | 20 +++++++++++++------- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/test/render.mywork.test.ts b/test/render.mywork.test.ts index 43dbe78..231fe24 100644 --- a/test/render.mywork.test.ts +++ b/test/render.mywork.test.ts @@ -161,6 +161,18 @@ describe("todoCard", () => { expect(html).not.toContain("P2"); expect(html).not.toContain("P3"); }); + + it("shows a relative 'updated' time derived from t.updatedAt", () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + const html = todoCard(makeTodo({ updatedAt: threeDaysAgo })); + expect(html).toContain("3d ago"); + }); + + it("wraps a long title across lines instead of truncating to one line", () => { + const html = todoCard(makeTodo({ title: "A very long issue title that should wrap across more than one line of text" })); + expect(html).toContain("-webkit-line-clamp:2"); + expect(html).not.toContain("text-overflow:ellipsis"); + }); }); // ── full render() — My Work screen composition ────────────────────────────── @@ -243,4 +255,15 @@ describe("render() — My Work screen", () => { const html = render(stateWithDashboard(data, false)); expect(html).not.toContain('data-act="adminBackfill"'); }); + + it("renders To-do before Previous activity", () => { + const data: DashboardData = { + person: "alice", + previousActivity: [makePr({ summary: null })], + todo: [makeTodo()], + degraded: false, + }; + const html = render(stateWithDashboard(data)); + expect(html.indexOf("To-do")).toBeLessThan(html.indexOf("Previous activity")); + }); }); diff --git a/web/src/render.ts b/web/src/render.ts index 5f3b818..d388b8c 100644 --- a/web/src/render.ts +++ b/web/src/render.ts @@ -1396,15 +1396,21 @@ export function prActivityCard(pr: MyWorkPr, markdownFn: (body: string) => strin
`; } -/** An assigned-issue card — priority + #number + title + up-to-3 labels, no markdown. */ +/** An assigned-issue card — priority + #number + title (wraps up to 2 lines) on + * row 1, labels (capped at 3) + relative updated-at on row 2. No markdown. */ export function todoCard(t: MyWorkTodo): string { const prio = t.priority ? `${esc(t.priority)}` : ""; const labels = t.labels.slice(0, 3).map((l) => `${esc(l)}`).join(""); - return `
- ${prio} - #${t.number} - ${esc(t.title)} - ${labels} + return ` +
+ ${prio} + #${t.number} + ${esc(t.title)} +
+
+ ${labels} + ${relTime(t.updatedAt)} +
`; } @@ -1435,7 +1441,7 @@ function myWorkView(s: AppState): string { const activity = mwSection("Previous activity", activityBody); const todo = mwSection("To-do", todoBody); - return wrapMyWork(`${hero}${activity}${todo}`); + return wrapMyWork(`${hero}${todo}${activity}`); } // ── root ───────────────────────────────────────────────────────────────────── From 279583ed2f6b539909f795d7f9780ab9eb30553f Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:03:50 -0400 Subject: [PATCH 7/9] feat(backfill): retroactively resync PR summaries to the structured format; drop the 14-day window --- src/tools/backfill.ts | 50 +++++++++++++----------- test/backfill.test.ts | 91 +++++++++++++++++++++++++++++++------------ web/src/api.ts | 2 +- web/src/main.ts | 2 +- web/src/render.ts | 2 +- 5 files changed, 97 insertions(+), 50 deletions(-) diff --git a/src/tools/backfill.ts b/src/tools/backfill.ts index 914f975..8a482bb 100644 --- a/src/tools/backfill.ts +++ b/src/tools/backfill.ts @@ -1,9 +1,11 @@ import type { Env } from "../env"; -import { nowIso } from "../db"; +import type { PrSummaryRow } from "@shared/rows"; +import { first } from "../db"; import { ingestEvent } from "../consumer"; import { eventsFromDelivery } from "../webhook"; import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize"; import { applyEventProgress } from "./progress"; +import { parseStructuredSummary } from "@shared/prSummary"; // Admin-triggered server-side GitHub backfill. Unlike scripts/backfill-events.mjs // (which signs synthetic webhook deliveries with the webhook secret), this runs @@ -19,13 +21,13 @@ import { applyEventProgress } from "./progress"; const GH_API = "application/vnd.github+json"; const USER_AGENT = "canopy"; -const DAYS_BACK = 14; export interface BackfillResult { ok: boolean; error?: string; captured: number; unchanged: number; + summarized: number; prs: number; issues: number; } @@ -120,12 +122,12 @@ function issueDelivery(issue: GhIssueListItem) { export async function runBackfill( env: Env, principalLogin: string, - opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null; now?: string } + opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null } ): Promise { const token = env.GITHUB_SERVICE_TOKEN; const repo = env.GITHUB_REPO; if (!token || !repo) { - return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, prs: 0, issues: 0 }; + return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 }; } const doFetch = opts?.fetchImpl ?? fetch; @@ -136,26 +138,18 @@ export async function runBackfill( "user-agent": USER_AGENT, "x-github-api-version": "2022-11-28", }; - const cutoffMs = new Date(opts?.now ?? nowIso()).getTime() - DAYS_BACK * 24 * 60 * 60 * 1000; - // (a) Closed PRs updated in the last 14 days. Sorted updated-desc, so we stop - // paginating at the first item whose updated_at predates the cutoff. + // (a) All closed PRs, fully paginated — full history, not just recent + // activity, so a Sync also surfaces PRs merged before this route existed. const prList: GhPrListItem[] = []; { let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`; - let done = false; - while (url && !done) { + while (url) { const res: Response = await doFetch(url, { headers }); if (!res.ok) break; const page = (await res.json()) as GhPrListItem[]; - for (const pr of page) { - if (pr.updated_at && new Date(pr.updated_at).getTime() < cutoffMs) { - done = true; - break; - } - prList.push(pr); - } - url = done ? null : nextLink(res); + prList.push(...page); + url = nextLink(res); } } @@ -178,6 +172,7 @@ export async function runBackfill( let captured = 0; let unchanged = 0; + let summarized = 0; for (const pr of prList) { const payload = prClosedDelivery(pr); @@ -186,8 +181,20 @@ export async function runBackfill( const res = await ingestEvent(env.DB, ev, principalLogin); if (res.outcome === "written") { captured++; - // Mirror handleGithubWebhook's summary seam: parse THIS PR's own raw and - // store a capture-time summary (storePrSummary never throws). + } else { + unchanged++; + } + + // (Re)summarize unless it's already in the structured format — decoupled + // from the event-capture outcome so a Sync also migrates PRs captured + // before the structured format existed, not just brand-new ones. + const existing = await first( + env.DB, + `SELECT summary FROM pr_summaries WHERE semantic_key = ?`, + ev.semantic_key + ); + const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null; + if (!alreadyStructured) { const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } }; await storePrSummary(env.DB, summarizer, { semantic_key: ev.semantic_key, @@ -195,8 +202,7 @@ export async function runBackfill( title: parsed.pr.title, body: parsed.pr.body ?? "", }); - } else { - unchanged++; + summarized++; } } } @@ -216,5 +222,5 @@ export async function runBackfill( } } - return { ok: true, captured, unchanged, prs: prList.length, issues: issueList.length }; + return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length }; } diff --git a/test/backfill.test.ts b/test/backfill.test.ts index 17294bf..bc4a324 100644 --- a/test/backfill.test.ts +++ b/test/backfill.test.ts @@ -6,10 +6,6 @@ import type { Env } from "../src/env"; import type { Summarizer } from "../src/tools/summarize"; import type { EventRow, PrSummaryRow } from "@shared/rows"; -// Fixed "now" so the 14-day window is deterministic. threeDaysAgo is inside the -// window; twentyDaysAgo is outside it (must be excluded — and, being sorted -// updated-desc, must also stop pagination). -const NOW = "2026-07-01T00:00:00Z"; const threeDaysAgo = "2026-06-28T00:00:00Z"; const twentyDaysAgo = "2026-06-11T00:00:00Z"; @@ -24,8 +20,19 @@ function stubFetch(prs: unknown[], issues: unknown[]): typeof fetch { }) as unknown as typeof fetch; } -// Deterministic summarizer stub — never touches Workers AI. -const summarizer: Summarizer = { model: "test-model", summarize: async () => "AI summary" }; +// Deterministic summarizer stub — never touches Workers AI. Counts calls so +// tests can assert the retroactive-resummarize / skip-if-structured behavior. +function countingSummarizer(summary: string): Summarizer & { calls: number } { + const s = { + model: "test-model", + calls: 0, + async summarize() { + s.calls++; + return summary; + }, + }; + return s; +} function envWith(overrides: Partial = {}): Env { return { ...(env as unknown as Env), GITHUB_SERVICE_TOKEN: "svc-token", GITHUB_REPO: "o/r", ...overrides }; @@ -42,14 +49,14 @@ const mergedPr = { user: { login: "octocat" }, milestone: null, }; -const oldPr = { +const olderPr = { number: 5, title: "Old PR", body: "old", html_url: "https://github.com/o/r/pull/5", merged_at: twentyDaysAgo, closed_at: twentyDaysAgo, - updated_at: twentyDaysAgo, // predates the cutoff → excluded (and stops pagination) + updated_at: twentyDaysAgo, // older than the old 14-day window — now included too (full history, no cutoff) user: { login: "octocat" }, milestone: null, }; @@ -78,21 +85,22 @@ const prAsIssue = { }; describe("runBackfill", () => { - it("captures in-window closed PRs + open issues as backfill events written by the admin principal", async () => { + it("captures ALL closed PRs (full history, no recency window) + open issues, written by the admin principal", async () => { + const summarizer = countingSummarizer("AI summary"); const res = await runBackfill(envWith(), "admin-user", { - fetchImpl: stubFetch([mergedPr, oldPr], [openIssue, prAsIssue]), + fetchImpl: stubFetch([mergedPr, olderPr], [openIssue, prAsIssue]), summarizer, - now: NOW, }); expect(res.ok).toBe(true); - expect(res.prs).toBe(1); // oldPr excluded by the 14-day window + expect(res.prs).toBe(2); // both mergedPr and olderPr — no cutoff anymore expect(res.issues).toBe(1); // prAsIssue excluded (pull_request present) - expect(res.captured).toBe(2); + expect(res.captured).toBe(3); // 2 PR events + 1 issue event expect(res.unchanged).toBe(0); + expect(res.summarized).toBe(2); // one summary per newly-captured PR const events = await all(env.DB, `SELECT * FROM events ORDER BY ref_number`); - expect(events).toHaveLength(2); + expect(events).toHaveLength(3); for (const ev of events) { expect(ev.provenance).toBe("backfill"); // provenance post-mapped from "webhook" expect(ev.recorded_by).toBe("admin-user"); // writer is the ADMIN principal, not "github-webhook" @@ -102,33 +110,66 @@ describe("runBackfill", () => { expect(pr.event_type).toBe("pr_merged"); expect(pr.subject_login).toBe("octocat"); - // The PR summary projection ran for the newly-written PR event. + // The PR summary projection ran for both newly-written PR events. const summary = await first(env.DB, `SELECT * FROM pr_summaries WHERE pr_number = ?`, 10); expect(summary).toBeTruthy(); expect(summary?.summary).toBe("AI summary"); }); - it("is idempotent — a second run over the same GitHub state writes nothing new", async () => { + it("is idempotent on event capture — a second run over the same GitHub state writes no new events", async () => { + const summarizer = countingSummarizer("**What changed:** AI summary"); const fetchImpl = stubFetch([mergedPr], [openIssue]); - const first = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW }); - expect(first.captured).toBe(2); + const firstRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer }); + expect(firstRun.captured).toBe(2); - const second = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer, now: NOW }); - expect(second.ok).toBe(true); - expect(second.captured).toBe(0); - expect(second.unchanged).toBe(2); + const secondRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer }); + expect(secondRun.ok).toBe(true); + expect(secondRun.captured).toBe(0); + expect(secondRun.unchanged).toBe(2); expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(2); // INSERT OR IGNORE on semantic_key }); + it("retroactively re-summarizes a PR whose existing summary is NOT structured", async () => { + const plainSummarizer = countingSummarizer("Plain prose summary, not structured."); + const fetchImpl = stubFetch([mergedPr], []); + const firstRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: plainSummarizer }); + expect(firstRun.summarized).toBe(1); + expect(plainSummarizer.calls).toBe(1); + + // Second run: the event is unchanged, but the stored summary is still + // plain prose (doesn't match the structured convention) → re-summarized. + const secondRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: plainSummarizer }); + expect(secondRun.captured).toBe(0); + expect(secondRun.unchanged).toBe(1); + expect(secondRun.summarized).toBe(1); + expect(plainSummarizer.calls).toBe(2); + }); + + it("skips re-summarizing a PR whose existing summary is already structured", async () => { + const structuredSummarizer = countingSummarizer("**What changed:** Fixed the thing."); + const fetchImpl = stubFetch([mergedPr], []); + const firstRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: structuredSummarizer }); + expect(firstRun.summarized).toBe(1); + expect(structuredSummarizer.calls).toBe(1); + + // Second run: the stored summary already matches the structured convention + // → skipped, no second summarizer call. + const secondRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: structuredSummarizer }); + expect(secondRun.summarized).toBe(0); + expect(structuredSummarizer.calls).toBe(1); + + const summary = await first(env.DB, `SELECT * FROM pr_summaries WHERE pr_number = ?`, 10); + expect(summary?.summary).toBe("**What changed:** Fixed the thing."); + }); + it("returns {ok:false} (no throw, no writes) when the service token is missing", async () => { const res = await runBackfill(envWith({ GITHUB_SERVICE_TOKEN: undefined }), "admin-user", { fetchImpl: stubFetch([mergedPr], [openIssue]), - summarizer, - now: NOW, + summarizer: countingSummarizer("AI summary"), }); expect(res.ok).toBe(false); expect(res.error).toContain("service token or repo"); - expect(res).toMatchObject({ captured: 0, unchanged: 0, prs: 0, issues: 0 }); + expect(res).toMatchObject({ captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 }); expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(0); }); }); diff --git a/web/src/api.ts b/web/src/api.ts index cbcd9a5..40cc3ca 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -126,7 +126,7 @@ export function getMe(): Promise { // ADMIN action: trigger the server-side GitHub backfill (admin-only route). The // worker holds the service token and fetches GitHub directly — no webhook secret. -export function adminBackfill(): Promise<{ ok: boolean; captured: number; unchanged: number; prs: number; issues: number }> { +export function adminBackfill(): Promise<{ ok: boolean; captured: number; unchanged: number; summarized: number; prs: number; issues: number }> { return postJson("/admin/backfill", {}); } diff --git a/web/src/main.ts b/web/src/main.ts index ff5f655..bf4fdf5 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -482,7 +482,7 @@ function dispatch(act: string, arg: string | null, value: string | null): void { case "adminBackfill": { flash("Syncing GitHub…"); adminBackfill() - .then((r) => { flash(`Synced: ${r.captured} captured, ${r.unchanged} unchanged`); loadMyWork(); }) + .then((r) => { flash(`Synced: ${r.captured} captured, ${r.unchanged} unchanged, ${r.summarized} summaries updated`); loadMyWork(); }) .catch((e) => { if (e instanceof Unauthorized) { state.view = "auth"; state.authStep = "login"; rerender(); return; } flash(e instanceof ApiError ? e.message : "Sync failed"); diff --git a/web/src/render.ts b/web/src/render.ts index d388b8c..2e14f56 100644 --- a/web/src/render.ts +++ b/web/src/render.ts @@ -340,7 +340,7 @@ function header(s: AppState): string { // ADMIN-only, My Work screen: trigger the server-side GitHub backfill. Rendered // only when /auth/me returned admin:true (outline button, promote-class action). const myworkControls = s.screen === "mywork" && s.me?.admin - ? `` : ""; From 2fa5ae9961a75eb952d77bffc6eb38fdf61464ea Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:19:22 -0400 Subject: [PATCH 8/9] fix(shared): tolerate a leading AI preamble before the What changed marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit STRUCTURED_RE was anchored with ^\s* at the start of the string, so an 8B instruct model prepending a preamble like "Here's the summary:" caused parseStructuredSummary to return null even though the structured content was present later — the PR silently fell back to prose rendering and the backfill re-summarized it on every future click. Drop the start-of-string anchor; the trailing $ still requires everything after the marker to be consumed as what/why content. --- shared/prSummary.ts | 2 +- test/prSummary.test.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/shared/prSummary.ts b/shared/prSummary.ts index bcb90d5..ffc762b 100644 --- a/shared/prSummary.ts +++ b/shared/prSummary.ts @@ -12,7 +12,7 @@ export interface StructuredPrSummary { why: string | null; } -const STRUCTURED_RE = /^\s*\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i; +const STRUCTURED_RE = /\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i; /** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown. * Returns null when the text doesn't match (old-style prose, the excerpt diff --git a/test/prSummary.test.ts b/test/prSummary.test.ts index 05176a6..6efb54b 100644 --- a/test/prSummary.test.ts +++ b/test/prSummary.test.ts @@ -43,4 +43,12 @@ describe("parseStructuredSummary", () => { const raw = "**What changed:** \n**Why:** something"; expect(parseStructuredSummary(raw)).toBeNull(); }); + + it("tolerates a leading preamble before the What changed marker (AI non-conformance)", () => { + const raw = "Here's the summary:\n\n**What changed:** Fixed the login bug.\n**Why:** Users were affected."; + expect(parseStructuredSummary(raw)).toEqual({ + what: "Fixed the login bug.", + why: "Users were affected.", + }); + }); }); From 278e0357b5060eaa1cfa6f822059676a755e82cf Mon Sep 17 00:00:00 2001 From: AndresL230 <190146319+AndresL230@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:29:56 -0400 Subject: [PATCH 9/9] docs(plan): My Work reorder + structured cards + retroactive summary resync implementation plan --- .../2026-07-03-mywork-restructure-plan.md | 955 ++++++++++++++++++ 1 file changed, 955 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md diff --git a/docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md b/docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md new file mode 100644 index 0000000..97eebcb --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-mywork-restructure-plan.md @@ -0,0 +1,955 @@ +# My Work Restructure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reorder My Work so To-do renders above Previous activity, give both sections +richer structured cards, and make the admin "Sync GitHub" backfill retroactively +migrate every historical merged PR (not just brand-new ones) to the new structured +summary format. + +**Architecture:** A new shared module (`shared/prSummary.ts`) defines a markdown +convention (`**What changed:** ... **Why:** ...`) and a parser for it — the single +source of truth both the Worker (to decide whether a PR summary needs regenerating) +and the web build (to decide how to render it) import. No database migration: the +structure lives in the markdown convention inside the existing `pr_summaries.summary` +TEXT column, so old prose and the deterministic excerpt fallback degrade gracefully to +today's plain rendering instead of erroring. + +**Tech Stack:** TypeScript, Cloudflare Workers (Hono), D1, Vitest + Miniflare, plain +DOM-less server-rendered HTML strings (`web/src/render.ts`). + +## Global Constraints + +- Design source: `docs/superpowers/specs/2026-07-03-mywork-restructure-design.md`. +- No new D1 migration — `pr_summaries.summary` stays a single `TEXT` column. +- `shared/` is the only cross-layer import location (`@shared/...` alias) — new shared + logic goes there, never duplicated in `src/` and `web/` separately. +- Backend tests run against real Miniflare D1 (`npx vitest run test/.test.ts`); + GitHub I/O and the summarizer are dependency-injected (`fetchImpl`, `summarizer`) — + never hit the network in tests. +- Frontend render tests are pure (no DOM/DOMPurify) — mirror the existing `mockMd` + pattern in `test/render.mywork.test.ts`. +- Run `npm run typecheck` after any task touching `shared/` or crossing the + Worker/web boundary — it does not run as part of `npm test`. + +--- + +### Task 1: Shared structured-summary parser + +**Files:** +- Create: `shared/prSummary.ts` +- Create: `test/prSummary.test.ts` + +**Interfaces:** +- Produces: `interface StructuredPrSummary { what: string; why: string | null }` and + `function parseStructuredSummary(raw: string): StructuredPrSummary | null`, imported + as `@shared/prSummary` by Task 3 (frontend rendering) and Task 5 (backend backfill + skip-check). + +- [ ] **Step 1: Write the failing tests** + +Create `test/prSummary.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { parseStructuredSummary } from "@shared/prSummary"; + +describe("parseStructuredSummary", () => { + it("parses What changed + Why into separate fields", () => { + const raw = "**What changed:** Fixed the login bug.\n**Why:** Users were being logged out unexpectedly."; + expect(parseStructuredSummary(raw)).toEqual({ + what: "Fixed the login bug.", + why: "Users were being logged out unexpectedly.", + }); + }); + + it("parses What changed alone, why:null, when there is no Why line", () => { + const raw = "**What changed:** Fixed the login bug."; + expect(parseStructuredSummary(raw)).toEqual({ what: "Fixed the login bug.", why: null }); + }); + + it("handles What changed and Why on the same line", () => { + const raw = "**What changed:** Fixed the login bug. **Why:** Users were affected."; + expect(parseStructuredSummary(raw)).toEqual({ + what: "Fixed the login bug.", + why: "Users were affected.", + }); + }); + + it("is case-insensitive on the labels", () => { + const raw = "**what changed:** Fixed the login bug.\n**why:** Users were affected."; + expect(parseStructuredSummary(raw)).toEqual({ + what: "Fixed the login bug.", + why: "Users were affected.", + }); + }); + + it("returns null for old-style prose with no convention", () => { + expect(parseStructuredSummary("Fixed a bug that was breaking the login flow.")).toBeNull(); + }); + + it("returns null for empty input", () => { + expect(parseStructuredSummary("")).toBeNull(); + }); + + it("returns null when the What field is empty even if a Why is present", () => { + const raw = "**What changed:** \n**Why:** something"; + expect(parseStructuredSummary(raw)).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/prSummary.test.ts` +Expected: FAIL — `@shared/prSummary` does not exist yet. + +- [ ] **Step 3: Write the implementation** + +Create `shared/prSummary.ts`: + +```ts +// Recognizes the "What changed / Why" markdown convention emitted by the PR +// summarizer (src/tools/summarize.ts) so both the Worker (the backfill's +// already-structured check, src/tools/backfill.ts) and the web build (card +// rendering, web/src/render.ts) agree on what counts as a structured summary. +// No schema change backs this — pr_summaries.summary stays a single markdown +// TEXT column; the structure lives in this convention, not a stored shape, so +// old prose summaries and the deterministic excerpt fallback degrade +// gracefully to a `null` parse instead of erroring. + +export interface StructuredPrSummary { + what: string; + why: string | null; +} + +const STRUCTURED_RE = /^\s*\*\*What changed:\*\*\s*([\s\S]*?)(?:\s*\*\*Why:\*\*\s*([\s\S]*))?$/i; + +/** Parses "**What changed:** ... **Why:** ..." out of a PR summary's markdown. + * Returns null when the text doesn't match (old-style prose, the excerpt + * fallback, or a malformed AI response) — callers treat null as "render/treat + * as plain prose," never as an error. */ +export function parseStructuredSummary(raw: string): StructuredPrSummary | null { + const m = raw.match(STRUCTURED_RE); + if (!m) return null; + const what = m[1].trim(); + if (!what) return null; + const why = m[2]?.trim() || null; + return { what, why }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/prSummary.test.ts` +Expected: PASS (7 tests) + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck` +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add shared/prSummary.ts test/prSummary.test.ts +git commit -m "feat(shared): parseStructuredSummary — What changed/Why markdown convention" +``` + +--- + +### Task 2: Backend — structured summarizer prompt + +**Files:** +- Modify: `src/tools/summarize.ts:16-45` +- Modify: `test/summarize.test.ts:1-11` (import), append a new `describe` block + +**Interfaces:** +- Consumes: nothing new. +- Produces: exported `SUMMARIZER_SYSTEM_PROMPT` constant (the two-field shape it + requires is what Task 1's `parseStructuredSummary` recognizes — keep them in sync + if either changes). `workersAiSummarizer`'s exported signature is unchanged. + +- [ ] **Step 1: Write the failing test** + +In `test/summarize.test.ts`, change the import on line 7 from: + +```ts +import { storePrSummary, excerptSummary } from "../src/tools/summarize"; +``` + +to: + +```ts +import { storePrSummary, excerptSummary, SUMMARIZER_SYSTEM_PROMPT } from "../src/tools/summarize"; +``` + +Then append this block at the end of the file (after the `webhook → summarize wiring` +`describe` block): + +```ts +describe("SUMMARIZER_SYSTEM_PROMPT", () => { + it("requires the structured What changed / Why convention", () => { + expect(SUMMARIZER_SYSTEM_PROMPT).toContain("**What changed:**"); + expect(SUMMARIZER_SYSTEM_PROMPT).toContain("**Why:**"); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `npx vitest run test/summarize.test.ts` +Expected: FAIL — `SUMMARIZER_SYSTEM_PROMPT` is not exported yet. + +- [ ] **Step 3: Write the implementation** + +In `src/tools/summarize.ts`, replace lines 16-45 (from `const WORKERS_AI_MODEL = ...` +through the end of `workersAiSummarizer`) with: + +```ts +const WORKERS_AI_MODEL = "@cf/meta/llama-3.1-8b-instruct"; + +// Exported for a content assertion in test/summarize.test.ts — the two-field +// shape here is exactly what shared/prSummary.ts's parseStructuredSummary +// recognizes; keep them in sync if either changes. +export const SUMMARIZER_SYSTEM_PROMPT = + "Summarize this one pull request's description for a team activity feed. " + + "Respond with ONLY this exact markdown structure, nothing else:\n" + + "**What changed:** <1-2 short factual sentences>\n" + + "**Why:** <1 short sentence stating the description's own stated rationale>\n" + + 'If the description states no rationale, omit the "**Why:**" line entirely. ' + + "Do not speculate beyond the text."; + +/** Workers AI-backed summarizer. Bounded to THAT PR's own title+body — no other + * context is sent. Never throws: any failure (network, empty output, malformed + * response) resolves to null so the caller falls back to excerptSummary. */ +export function workersAiSummarizer(ai: Ai): Summarizer { + return { + model: WORKERS_AI_MODEL, + async summarize({ title, body }) { + try { + const result = await ai.run(WORKERS_AI_MODEL, { + messages: [ + { role: "system", content: SUMMARIZER_SYSTEM_PROMPT }, + { role: "user", content: `Title: ${title}\n\nBody: ${body}` }, + ], + }); + const response = (result as { response?: unknown } | null)?.response; + if (typeof response !== "string") return null; + const trimmed = response.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } + }, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/summarize.test.ts` +Expected: PASS (all tests, including the new one) + +- [ ] **Step 5: Commit** + +```bash +git add src/tools/summarize.ts test/summarize.test.ts +git commit -m "feat(summarize): require the What changed/Why structured markdown convention" +``` + +--- + +### Task 3: Frontend — structured "Previous activity" cards + +**Files:** +- Modify: `web/src/render.ts:10` (import), `:1331` (label style), `:1354-1375` + (`prActivityCard`) +- Modify: `test/render.mywork.test.ts` — append cases to the `prActivityCard` + `describe` block + +**Interfaces:** +- Consumes: `parseStructuredSummary`, `StructuredPrSummary` from `@shared/prSummary` + (Task 1). +- Produces: `prActivityCard`'s exported signature is unchanged + (`(pr: MyWorkPr, markdownFn: (body: string) => string) => string`) — only its + internal rendering changes, so no other file needs to change its call site. + +- [ ] **Step 1: Write the failing tests** + +In `test/render.mywork.test.ts`, inside the existing `describe("prActivityCard", ...)` +block, append these three tests (after the last existing `it(...)`, before the +block's closing `});`): + +```ts + it("renders structured What changed + Why as separate labeled rows", () => { + const pr = makePr({ summary: "**What changed:** Fixed the login bug.\n**Why:** Users were logged out unexpectedly." }); + const html = prActivityCard(pr, mockMd); + expect(html).toContain("What changed"); + expect(html).toContain("Why"); + expect(html).toContain("Fixed the login bug."); + expect(html).toContain("Users were logged out unexpectedly."); + }); + + it("omits the Why row when the structured summary has no Why", () => { + const pr = makePr({ summary: "**What changed:** Fixed the login bug." }); + const html = prActivityCard(pr, mockMd); + expect(html).toContain("What changed"); + expect(html).not.toContain("Why"); + }); + + it("falls back to the raw prose block for a non-conforming summary (legacy/excerpt)", () => { + const pr = makePr({ summary: "Fixed the login bug that was affecting users." }); + const html = prActivityCard(pr, mockMd); + expect(html).not.toContain("What changed"); + expect(html).toContain("mock-md"); + }); +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `npx vitest run test/render.mywork.test.ts` +Expected: the 3 new tests FAIL (current `prActivityCard` always renders the raw +prose block, never labeled rows); all pre-existing tests in the file still PASS. + +- [ ] **Step 3: Write the implementation** + +In `web/src/render.ts`, change the import block. Line 10 currently reads: + +```ts +import type { DashboardData, MyWorkPr, MyWorkTodo } from "@shared/dashboard"; +``` + +Add a new line directly after it: + +```ts +import type { DashboardData, MyWorkPr, MyWorkTodo } from "@shared/dashboard"; +import { parseStructuredSummary, type StructuredPrSummary } from "@shared/prSummary"; +``` + +Then, at line 1331, directly after the `MW_LABEL` constant, add: + +```ts +const MW_LABEL = "font-size:11px;font-weight:600;font-family:var(--mono);text-transform:uppercase;letter-spacing:.1em;color:var(--fg-40)"; +const MW_FIELD_LABEL = "font-size:10px;font-weight:600;font-family:var(--mono);text-transform:uppercase;letter-spacing:.08em;color:var(--fg-40);margin-bottom:3px"; +``` + +Then replace the whole `prActivityCard` function (lines 1354-1375, from the +`/** A merged/closed PR card...` comment through its closing `}`) with: + +```ts +/** Renders a structured {what, why} summary as labeled rows (small caption + markdown body each). */ +function structuredSummaryBody(structured: StructuredPrSummary, markdownFn: (body: string) => string): string { + const whatRow = ` +
What changed
+
${markdownFn(structured.what)}
+
`; + const whyRow = structured.why + ? `
+
Why
+
${markdownFn(structured.why)}
+
` + : ""; + return whatRow + whyRow; +} + +/** A merged/closed PR card: #number → pr.url, title, relTime, MERGED/CLOSED chip, + * and a summary body — labeled "What changed"/"Why" rows when pr.summary matches + * the structured convention, else the raw markdown blob (legacy/excerpt fallback). */ +export function prActivityCard(pr: MyWorkPr, markdownFn: (body: string) => string): string { + const chip = pr.merged + ? `MERGED` + : `CLOSED`; + const structured = pr.summary !== null ? parseStructuredSummary(pr.summary) : null; + const body = pr.summary === null + ? `
${linkifyRefs("No summary recorded for this PR.")}
` + : structured !== null + ? structuredSummaryBody(structured, markdownFn) + : `
${markdownFn(pr.summary)}
`; + return `
+
+
+ #${pr.number} + ${esc(pr.title)} +
+
+ ${chip} + ${relTime(pr.occurredAt)} +
+
+ ${body} +
`; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/render.mywork.test.ts` +Expected: PASS (all tests, including the 3 new ones and every pre-existing one — +the default `makePr()` summary "Fixed **the thing** that was broken." doesn't match +the structured convention, so it still falls into the prose-block path unchanged). + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck` +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add web/src/render.ts test/render.mywork.test.ts +git commit -m "feat(mywork): render structured What changed/Why PR summaries as labeled rows" +``` + +--- + +### Task 4: Frontend — To-do card restructure + reorder + +**Files:** +- Modify: `web/src/render.ts:1377-1387` (`todoCard`), `:1413-1416` (`myWorkView` + composition order) +- Modify: `test/render.mywork.test.ts` — append cases to the `todoCard` and + `render() — My Work screen` `describe` blocks + +**Interfaces:** +- Consumes: existing `relTime` helper (already in scope in `render.ts`). +- Produces: `todoCard`'s exported signature is unchanged (`(t: MyWorkTodo) => string`). + +- [ ] **Step 1: Write the failing tests** + +In `test/render.mywork.test.ts`, inside `describe("todoCard", ...)`, append (after +the last existing `it(...)`, before the block's closing `});`): + +```ts + it("shows a relative 'updated' time derived from t.updatedAt", () => { + const threeDaysAgo = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(); + const html = todoCard(makeTodo({ updatedAt: threeDaysAgo })); + expect(html).toContain("3d ago"); + }); + + it("wraps a long title across lines instead of truncating to one line", () => { + const html = todoCard(makeTodo({ title: "A very long issue title that should wrap across more than one line of text" })); + expect(html).toContain("-webkit-line-clamp:2"); + expect(html).not.toContain("text-overflow:ellipsis"); + }); +``` + +Inside `describe("render() — My Work screen", ...)`, append (after the last existing +`it(...)`, before the block's closing `});`): + +```ts + it("renders To-do before Previous activity", () => { + const data: DashboardData = { + person: "alice", + previousActivity: [makePr({ summary: null })], + todo: [makeTodo()], + degraded: false, + }; + const html = render(stateWithDashboard(data)); + expect(html.indexOf("To-do")).toBeLessThan(html.indexOf("Previous activity")); + }); +``` + +- [ ] **Step 2: Run tests to verify the new ones fail** + +Run: `npx vitest run test/render.mywork.test.ts` +Expected: the 3 new tests FAIL (current `todoCard` has no updated-at and truncates +via `white-space:nowrap`/`text-overflow:ellipsis`; `myWorkView` renders activity +before todo). Pre-existing tests still PASS. + +- [ ] **Step 3: Write the implementation** + +In `web/src/render.ts`, replace `todoCard` (lines 1377-1387) with: + +```ts +/** An assigned-issue card — priority + #number + title (wraps up to 2 lines) on + * row 1, labels (capped at 3) + relative updated-at on row 2. No markdown. */ +export function todoCard(t: MyWorkTodo): string { + const prio = t.priority ? `${esc(t.priority)}` : ""; + const labels = t.labels.slice(0, 3).map((l) => `${esc(l)}`).join(""); + return ` +
+ ${prio} + #${t.number} + ${esc(t.title)} +
+
+ ${labels} + ${relTime(t.updatedAt)} +
+
`; +} +``` + +Then, in `myWorkView`, change the composition (lines 1413-1416) from: + +```ts + const activity = mwSection("Previous activity", activityBody); + const todo = mwSection("To-do", todoBody); + + return wrapMyWork(`${hero}${activity}${todo}`); +``` + +to: + +```ts + const activity = mwSection("Previous activity", activityBody); + const todo = mwSection("To-do", todoBody); + + return wrapMyWork(`${hero}${todo}${activity}`); +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `npx vitest run test/render.mywork.test.ts` +Expected: PASS (all tests) + +- [ ] **Step 5: Typecheck** + +Run: `npm run typecheck` +Expected: no errors + +- [ ] **Step 6: Commit** + +```bash +git add web/src/render.ts test/render.mywork.test.ts +git commit -m "feat(mywork): reorder To-do above Previous activity; richer To-do cards" +``` + +--- + +### Task 5: Backend — retroactive resync in Sync GitHub + +**Files:** +- Modify: `src/tools/backfill.ts:1-6` (imports), `:22` (remove `DAYS_BACK`), + `:24-31` (`BackfillResult`), `:120-219` (`runBackfill`) +- Modify: `test/backfill.test.ts` (full rewrite of fixtures/tests below) +- Modify: `web/src/api.ts:129` (`adminBackfill` return type) +- Modify: `web/src/main.ts:482-491` (`adminBackfill` flash message) +- Modify: `web/src/render.ts:342` (Sync GitHub button title text) + +**Interfaces:** +- Consumes: `parseStructuredSummary` from `@shared/prSummary` (Task 1). +- Produces: `BackfillResult` gains `summarized: number`; `runBackfill`'s `opts` no + longer accepts `now` (it had no purpose once the recency cutoff is removed). + +- [ ] **Step 1: Rewrite the failing tests** + +Replace the entire contents of `test/backfill.test.ts` with: + +```ts +import { describe, it, expect } from "vitest"; +import { env } from "cloudflare:test"; +import { all, first } from "../src/db"; +import { runBackfill } from "../src/tools/backfill"; +import type { Env } from "../src/env"; +import type { Summarizer } from "../src/tools/summarize"; +import type { EventRow, PrSummaryRow } from "@shared/rows"; + +const threeDaysAgo = "2026-06-28T00:00:00Z"; +const twentyDaysAgo = "2026-06-11T00:00:00Z"; + +// A Response-level fetch stub (the pool exports no fetch mock) — mirrors +// test/roadmap.test.ts / test/progress.test.ts. Routes by path substring: the +// pulls list vs the issues list. +function stubFetch(prs: unknown[], issues: unknown[]): typeof fetch { + return (async (url: string | URL | Request) => { + const u = String(url); + const body = u.includes("/pulls") ? prs : u.includes("/issues") ? issues : []; + return new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } }); + }) as unknown as typeof fetch; +} + +// Deterministic summarizer stub — never touches Workers AI. Counts calls so +// tests can assert the retroactive-resummarize / skip-if-structured behavior. +function countingSummarizer(summary: string): Summarizer & { calls: number } { + const s = { + model: "test-model", + calls: 0, + async summarize() { + s.calls++; + return summary; + }, + }; + return s; +} + +function envWith(overrides: Partial = {}): Env { + return { ...(env as unknown as Env), GITHUB_SERVICE_TOKEN: "svc-token", GITHUB_REPO: "o/r", ...overrides }; +} + +const mergedPr = { + number: 10, + title: "Add feature", + body: "This PR adds a feature.", + html_url: "https://github.com/o/r/pull/10", + merged_at: threeDaysAgo, // merged → derived merged:true from merged_at != null + closed_at: threeDaysAgo, + updated_at: threeDaysAgo, + user: { login: "octocat" }, + milestone: null, +}; +const olderPr = { + number: 5, + title: "Old PR", + body: "old", + html_url: "https://github.com/o/r/pull/5", + merged_at: twentyDaysAgo, + closed_at: twentyDaysAgo, + updated_at: twentyDaysAgo, // older than the old 14-day window — now included too (full history, no cutoff) + user: { login: "octocat" }, + milestone: null, +}; +const openIssue = { + number: 20, + title: "Fix bug", + html_url: "https://github.com/o/r/issues/20", + state: "open", + updated_at: threeDaysAgo, + user: { login: "octocat" }, + assignees: [{ login: "octocat" }], // has an assignee → "assigned" + labels: ["bug"], + milestone: null, +}; +const prAsIssue = { + number: 21, + title: "A PR the issues endpoint also returned", + html_url: "https://github.com/o/r/pull/21", + state: "open", + updated_at: threeDaysAgo, + user: { login: "octocat" }, + pull_request: { url: "https://api.github.com/repos/o/r/pulls/21" }, // → skipped + assignees: [], + labels: [], + milestone: null, +}; + +describe("runBackfill", () => { + it("captures ALL closed PRs (full history, no recency window) + open issues, written by the admin principal", async () => { + const summarizer = countingSummarizer("AI summary"); + const res = await runBackfill(envWith(), "admin-user", { + fetchImpl: stubFetch([mergedPr, olderPr], [openIssue, prAsIssue]), + summarizer, + }); + + expect(res.ok).toBe(true); + expect(res.prs).toBe(2); // both mergedPr and olderPr — no cutoff anymore + expect(res.issues).toBe(1); // prAsIssue excluded (pull_request present) + expect(res.captured).toBe(3); // 2 PR events + 1 issue event + expect(res.unchanged).toBe(0); + expect(res.summarized).toBe(2); // one summary per newly-captured PR + + const events = await all(env.DB, `SELECT * FROM events ORDER BY ref_number`); + expect(events).toHaveLength(3); + for (const ev of events) { + expect(ev.provenance).toBe("backfill"); // provenance post-mapped from "webhook" + expect(ev.recorded_by).toBe("admin-user"); // writer is the ADMIN principal, not "github-webhook" + } + // The merged PR was captured as pr_merged (merged_at != null). + const pr = events.find((e) => e.ref_number === 10)!; + expect(pr.event_type).toBe("pr_merged"); + expect(pr.subject_login).toBe("octocat"); + + // The PR summary projection ran for both newly-written PR events. + const summary = await first(env.DB, `SELECT * FROM pr_summaries WHERE pr_number = ?`, 10); + expect(summary).toBeTruthy(); + expect(summary?.summary).toBe("AI summary"); + }); + + it("is idempotent on event capture — a second run over the same GitHub state writes no new events", async () => { + const summarizer = countingSummarizer("**What changed:** AI summary"); + const fetchImpl = stubFetch([mergedPr], [openIssue]); + const firstRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer }); + expect(firstRun.captured).toBe(2); + + const secondRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer }); + expect(secondRun.ok).toBe(true); + expect(secondRun.captured).toBe(0); + expect(secondRun.unchanged).toBe(2); + expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(2); // INSERT OR IGNORE on semantic_key + }); + + it("retroactively re-summarizes a PR whose existing summary is NOT structured", async () => { + const plainSummarizer = countingSummarizer("Plain prose summary, not structured."); + const fetchImpl = stubFetch([mergedPr], []); + const firstRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: plainSummarizer }); + expect(firstRun.summarized).toBe(1); + expect(plainSummarizer.calls).toBe(1); + + // Second run: the event is unchanged, but the stored summary is still + // plain prose (doesn't match the structured convention) → re-summarized. + const secondRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: plainSummarizer }); + expect(secondRun.captured).toBe(0); + expect(secondRun.unchanged).toBe(1); + expect(secondRun.summarized).toBe(1); + expect(plainSummarizer.calls).toBe(2); + }); + + it("skips re-summarizing a PR whose existing summary is already structured", async () => { + const structuredSummarizer = countingSummarizer("**What changed:** Fixed the thing."); + const fetchImpl = stubFetch([mergedPr], []); + const firstRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: structuredSummarizer }); + expect(firstRun.summarized).toBe(1); + expect(structuredSummarizer.calls).toBe(1); + + // Second run: the stored summary already matches the structured convention + // → skipped, no second summarizer call. + const secondRun = await runBackfill(envWith(), "admin-user", { fetchImpl, summarizer: structuredSummarizer }); + expect(secondRun.summarized).toBe(0); + expect(structuredSummarizer.calls).toBe(1); + + const summary = await first(env.DB, `SELECT * FROM pr_summaries WHERE pr_number = ?`, 10); + expect(summary?.summary).toBe("**What changed:** Fixed the thing."); + }); + + it("returns {ok:false} (no throw, no writes) when the service token is missing", async () => { + const res = await runBackfill(envWith({ GITHUB_SERVICE_TOKEN: undefined }), "admin-user", { + fetchImpl: stubFetch([mergedPr], [openIssue]), + summarizer: countingSummarizer("AI summary"), + }); + expect(res.ok).toBe(false); + expect(res.error).toContain("service token or repo"); + expect(res).toMatchObject({ captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 }); + expect(await all(env.DB, `SELECT * FROM events`)).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `npx vitest run test/backfill.test.ts` +Expected: FAIL — `res.summarized` is `undefined` (not yet on `BackfillResult`); the +14-day-window assertion (`res.prs).toBe(2)`) also fails against current behavior. + +- [ ] **Step 3: Write the implementation** + +In `src/tools/backfill.ts`, replace the import block (lines 1-6) with: + +```ts +import type { Env } from "../env"; +import type { PrSummaryRow } from "@shared/rows"; +import { first } from "../db"; +import { ingestEvent } from "../consumer"; +import { eventsFromDelivery } from "../webhook"; +import { type Summarizer, workersAiSummarizer, storePrSummary } from "./summarize"; +import { applyEventProgress } from "./progress"; +import { parseStructuredSummary } from "@shared/prSummary"; +``` + +Delete the `const DAYS_BACK = 14;` line (line 22). + +Replace the `BackfillResult` interface (lines 24-31) with: + +```ts +export interface BackfillResult { + ok: boolean; + error?: string; + captured: number; + unchanged: number; + summarized: number; + prs: number; + issues: number; +} +``` + +Replace the function signature and early return (lines 120-129) with: + +```ts +export async function runBackfill( + env: Env, + principalLogin: string, + opts?: { fetchImpl?: typeof fetch; summarizer?: Summarizer | null } +): Promise { + const token = env.GITHUB_SERVICE_TOKEN; + const repo = env.GITHUB_REPO; + if (!token || !repo) { + return { ok: false, error: "service token or repo not configured", captured: 0, unchanged: 0, summarized: 0, prs: 0, issues: 0 }; + } +``` + +Replace the fetch setup block (lines 131-139) — this removes the `cutoffMs` line: + +```ts + const doFetch = opts?.fetchImpl ?? fetch; + const summarizer = opts?.summarizer ?? (env.AI ? workersAiSummarizer(env.AI) : null); + const headers = { + authorization: `Bearer ${token}`, + accept: GH_API, + "user-agent": USER_AGENT, + "x-github-api-version": "2022-11-28", + }; +``` + +Replace the PR list fetch (lines 141-160) with: + +```ts + // (a) All closed PRs, fully paginated — full history, not just recent + // activity, so a Sync also surfaces PRs merged before this route existed. + const prList: GhPrListItem[] = []; + { + let url: string | null = `https://api.github.com/repos/${repo}/pulls?state=closed&sort=updated&direction=desc&per_page=100`; + while (url) { + const res: Response = await doFetch(url, { headers }); + if (!res.ok) break; + const page = (await res.json()) as GhPrListItem[]; + prList.push(...page); + url = nextLink(res); + } + } +``` + +Replace the PR loop and its counters (lines 179-202) with: + +```ts + let captured = 0; + let unchanged = 0; + let summarized = 0; + + for (const pr of prList) { + const payload = prClosedDelivery(pr); + for (const base of eventsFromDelivery("pull_request", payload)) { + const ev = { ...base, provenance: "backfill" as const }; + const res = await ingestEvent(env.DB, ev, principalLogin); + if (res.outcome === "written") { + captured++; + } else { + unchanged++; + } + + // (Re)summarize unless it's already in the structured format — decoupled + // from the event-capture outcome so a Sync also migrates PRs captured + // before the structured format existed, not just brand-new ones. + const existing = await first( + env.DB, + `SELECT summary FROM pr_summaries WHERE semantic_key = ?`, + ev.semantic_key + ); + const alreadyStructured = existing !== null && parseStructuredSummary(existing.summary) !== null; + if (!alreadyStructured) { + const parsed = JSON.parse(ev.raw) as { pr: { number: number; title: string; body: string | null } }; + await storePrSummary(env.DB, summarizer, { + semantic_key: ev.semantic_key, + pr_number: parsed.pr.number, + title: parsed.pr.title, + body: parsed.pr.body ?? "", + }); + summarized++; + } + } + } +``` + +Leave the issues loop (originally lines 204-217) exactly as-is — issues are never +summarized and already have no recency window. + +Replace the final return (originally line 219) with: + +```ts + return { ok: true, captured, unchanged, summarized, prs: prList.length, issues: issueList.length }; +``` + +Then wire `summarized` through the web layer. In `web/src/api.ts`, change line 129 +from: + +```ts +export function adminBackfill(): Promise<{ ok: boolean; captured: number; unchanged: number; prs: number; issues: number }> { +``` + +to: + +```ts +export function adminBackfill(): Promise<{ ok: boolean; captured: number; unchanged: number; summarized: number; prs: number; issues: number }> { +``` + +In `web/src/main.ts`, change the `case "adminBackfill":` block (lines 482-491) from: + +```ts + case "adminBackfill": { + flash("Syncing GitHub…"); + adminBackfill() + .then((r) => { flash(`Synced: ${r.captured} captured, ${r.unchanged} unchanged`); loadMyWork(); }) + .catch((e) => { + if (e instanceof Unauthorized) { state.view = "auth"; state.authStep = "login"; rerender(); return; } + flash(e instanceof ApiError ? e.message : "Sync failed"); + }); + return; + } +``` + +to: + +```ts + case "adminBackfill": { + flash("Syncing GitHub…"); + adminBackfill() + .then((r) => { flash(`Synced: ${r.captured} captured, ${r.unchanged} unchanged, ${r.summarized} summaries updated`); loadMyWork(); }) + .catch((e) => { + if (e instanceof Unauthorized) { state.view = "auth"; state.authStep = "login"; rerender(); return; } + flash(e instanceof ApiError ? e.message : "Sync failed"); + }); + return; + } +``` + +In `web/src/render.ts`, on line 342, change: + +```ts + ? `