diff --git a/web/app/[locale]/admin/admin-client.tsx b/web/app/[locale]/admin/admin-client.tsx index 51275d493e..273069fbae 100644 --- a/web/app/[locale]/admin/admin-client.tsx +++ b/web/app/[locale]/admin/admin-client.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { draftStorageKey, type AgentDraft } from "@/lib/community-agent"; +import { draftStorageKey, reviewedBodyHash, type AgentDraft } from "@/lib/community-agent"; interface Props { drafts: AgentDraft[]; @@ -17,13 +17,17 @@ export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) { const [editBody, setEditBody] = useState(""); const [loading, setLoading] = useState(null); - const handleAction = async (draftKey: string, action: "post" | "discard", editedBody?: string) => { + const handleAction = async (draft: AgentDraft, action: "post" | "discard", editedBody?: string) => { + const draftKey = draftStorageKey(draft); setLoading(draftKey); try { + // Binds the action to the text shown here; the route refuses it if the + // stored draft was regenerated since this page loaded. + const reviewedSha256 = await reviewedBodyHash(isZh ? draft.bodyZh : draft.bodyEn); const res = await fetch("/api/admin/post", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action, draftKey, editedBody, lang: isZh ? "zh" : "en" }), + body: JSON.stringify({ action, draftKey, editedBody, lang: isZh ? "zh" : "en", reviewedSha256 }), }); const data = await res.json(); if (data.ok) { @@ -37,6 +41,9 @@ export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) { } } setEditing(null); + // The post went through but something after it did not (draft state + // not saved, or the digest was not published on /digest). + if (typeof data.warning === "string") alert(data.warning); } else { alert(`Error: ${data.error}`); } @@ -90,7 +97,7 @@ export function AdminClient({ drafts, posted, isZh, typeLabels }: Props) { />
diff --git a/web/app/api/admin/post/route.ts b/web/app/api/admin/post/route.ts index 2b917e442d..ddcbaf1eaf 100644 --- a/web/app/api/admin/post/route.ts +++ b/web/app/api/admin/post/route.ts @@ -1,11 +1,22 @@ +import { revalidatePath } from "next/cache"; import { NextResponse } from "next/server"; +import { BodyReadError, readBoundedBody } from "@/lib/bounded-body"; import { + approveDigestRecord, + claimDraft, + clearDraftResolution, + deleteDigestRecord, deleteDraft, getAgentEnv, getDraft, + getDraftResolution, + markDraftResolved, parseDraftKey, + releaseDraftClaim, + reviewedBodyHash, validateSession, type CommunityAgentEnv, + type DraftClaimResult, } from "@/lib/community-agent"; export const dynamic = "force-dynamic"; @@ -35,6 +46,41 @@ const ALLOWED_ACTIONS = new Set(["post", "discard"]); const ALLOWED_ORIGINS = new Set(["https://codewhale.net", "https://www.codewhale.net"]); const MAX_BODY_BYTES = 65_536; +/** Refresh the ISR copy of /digest after its published set changes. */ +function revalidateDigestPage() { + try { + revalidatePath("/[locale]/digest", "page"); + } catch (e) { + // The page still refreshes on its hourly revalidate. + console.error("digest revalidation failed", e); + } +} + +/** + * A GitHub 4xx other than 408/429 means the post was not created, so the + * claim can be released. A 5xx, 408 or 429 can come back after GitHub already + * created the comment or issue, so its outcome is unknown. + */ +function githubDefinitelyRejected(status: number): boolean { + return status >= 400 && status < 500 && status !== 408 && status !== 429; +} + +/** The answer for a claim that was not taken. */ +function claimRefused(result: Exclude) { + if (result.reason === "unconfirmed") { + return NextResponse.json({ error: "could not confirm the draft claim; nothing was done, retry" }, { status: 503 }); + } + if (result.reason === "resolved") { + return NextResponse.json({ error: `draft already ${result.resolution.state}` }, { status: 409 }); + } + return NextResponse.json( + { error: "another request is acting on this draft; check GitHub, then retry in a minute" }, + { status: 409 } + ); +} + +const discarded = () => NextResponse.json({ ok: true, action: "discarded" }); + export async function POST(req: Request) { const env = await getAgentEnv(); @@ -51,46 +97,196 @@ export async function POST(req: Request) { ); } - const contentLength = Number(req.headers.get("content-length") ?? "0"); - if (contentLength > MAX_BODY_BYTES) { - return NextResponse.json({ error: "payload too large" }, { status: 413 }); + // Count the real body bytes (Content-Length is only an early rejection) + // and answer malformed JSON with a 400 instead of an unhandled 500. + let body: unknown; + try { + const bytes = await readBoundedBody(req, MAX_BODY_BYTES); + body = JSON.parse(new TextDecoder().decode(bytes)); + } catch (e) { + if (e instanceof BodyReadError) { + return NextResponse.json({ error: e.message }, { status: e.status }); + } + return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); + } + const { action, draftKey, editedBody, lang, reviewedSha256 } = body as { + action?: unknown; + draftKey?: unknown; + editedBody?: unknown; + lang?: unknown; + reviewedSha256?: unknown; + }; - const body = await req.json() as { action: string; draftKey: string; editedBody?: string; lang?: "en" | "zh" }; - const { action, draftKey, editedBody, lang } = body; - - if (!ALLOWED_ACTIONS.has(action)) { + if (typeof action !== "string" || !ALLOWED_ACTIONS.has(action)) { return NextResponse.json({ error: "unknown action" }, { status: 400 }); } if (typeof draftKey !== "string" || !draftKey || draftKey.length > 256) { return NextResponse.json({ error: "missing or invalid draftKey" }, { status: 400 }); } - if (!parseDraftKey(draftKey)) { + const parsedKey = parseDraftKey(draftKey); + if (!parsedKey) { return NextResponse.json({ error: "invalid draftKey namespace" }, { status: 400 }); } - if (editedBody !== undefined && (typeof editedBody !== "string" || editedBody.length > MAX_BODY_BYTES)) { + if (editedBody !== undefined && typeof editedBody !== "string") { + return NextResponse.json({ error: "invalid editedBody" }, { status: 400 }); + } + if (editedBody !== undefined && editedBody.length > MAX_BODY_BYTES) { return NextResponse.json({ error: "editedBody too long" }, { status: 413 }); } if (lang !== undefined && lang !== "en" && lang !== "zh") { return NextResponse.json({ error: "invalid lang" }, { status: 400 }); } + if (typeof reviewedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(reviewedSha256)) { + return NextResponse.json({ error: "missing or invalid reviewedSha256" }, { status: 400 }); + } + const reviewLang = lang === "zh" ? "zh" : "en"; const draft = await getDraft(env.CURATED_KV, draftKey); if (!draft) { return NextResponse.json({ error: "draft not found" }, { status: 404 }); } + // Act only on the exact text the maintainer was shown. A draft regenerated + // after the admin page loaded must be reviewed again, not posted unseen. + const originalBody = reviewLang === "zh" ? draft.bodyZh : draft.bodyEn; + if ((await reviewedBodyHash(originalBody)) !== reviewedSha256) { + return NextResponse.json( + { error: "draft changed since it was loaded; reload and review it again" }, + { status: 409 } + ); + } if (action === "discard") { - await deleteDraft(env.CURATED_KV, draftKey); - return NextResponse.json({ ok: true, action: "discarded" }); + // A posted draft is already public on GitHub (and, for a digest, on + // /digest); discarding it would silently unpublish or relabel it. + const resolution = await getDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id); + if (draft.posted || resolution?.state === "posted" || resolution?.state === "posting") { + return NextResponse.json({ error: "draft already posted" }, { status: 409 }); + } + // A repeated discard (double click) is a no-op, not an error: whether the + // first one already finished or is still running. + if (resolution?.state === "discarded") return discarded(); + // Hold the same claim a post takes, so a discard and a post of one draft + // do not both run. + let result: DraftClaimResult; + try { + result = await claimDraft(env, parsedKey.type, parsedKey.id, "discard"); + } catch (e) { + return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 }); + } + if (!result.ok) { + if (result.reason === "resolved" && result.resolution.state === "discarded") return discarded(); + if (result.reason === "held" && result.holder === "discard") return discarded(); + return claimRefused(result); + } + const claim = result.claim; + let recorded = false; + try { + // The marker stops the next cron run from regenerating this draft. + await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "discarded"); + await deleteDraft(env.CURATED_KV, draftKey); + if (draft.type === "digest") { + await deleteDigestRecord(env.CURATED_KV, draft.id); + } + recorded = true; + } catch (e) { + return NextResponse.json({ error: `discard failed: ${String(e)}` }, { status: 500 }); + } finally { + // The marker (or, if it failed, the draft) now carries the decision. + await releaseDraftClaim(env.CURATED_KV, claim, { recorded }).catch(() => undefined); + } + if (draft.type === "digest") revalidateDigestPage(); + return discarded(); } if (action === "post") { if (!env.MAINTAINER_GITHUB_PAT) { return NextResponse.json({ error: "MAINTAINER_GITHUB_PAT not configured" }, { status: 500 }); } + if (draft.type !== "digest" && !draft.targetNumber) { + return NextResponse.json({ error: "no target number" }, { status: 400 }); + } - const commentBody = editedBody ?? (lang === "zh" ? draft.bodyZh : draft.bodyEn); + // Posting is not idempotent on GitHub: refuse a draft that is already + // posted or has a post in flight (second tab, retry after a partial + // failure), then claim it before the GitHub call. + if (draft.posted) { + return NextResponse.json({ error: "draft already posted" }, { status: 409 }); + } + const resolution = await getDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id); + if (resolution) { + return NextResponse.json({ error: `draft already ${resolution.state}` }, { status: 409 }); + } + let result: DraftClaimResult; + try { + result = await claimDraft(env, parsedKey.type, parsedKey.id, "post"); + } catch (e) { + return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 }); + } + if (!result.ok) return claimRefused(result); + const heldClaim = result.claim; + try { + // Keeps the cron from regenerating the draft while the post is in flight. + await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "posting"); + } catch (e) { + await releaseDraftClaim(env.CURATED_KV, heldClaim).catch(() => undefined); + return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 }); + } + + const commentBody = editedBody ?? originalBody; + + // After GitHub accepted the post, bookkeeping failures must not turn into + // an error the maintainer would "fix" by posting again. + const recordPosted = async (): Promise => { + try { + await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "posted"); + // The marker now carries the decision, so a later claim sees it; a + // reopened draft (new activity clears the marker) is postable again. + await releaseDraftClaim(env.CURATED_KV, heldClaim, { recorded: true }).catch(() => undefined); + await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 }); + return undefined; + } catch (e) { + return `Posted to GitHub, but saving the draft state failed (${String(e)}). Do not post it again; it may reappear as pending.`; + } + }; + + const postToGitHub = async (url: string, payload: unknown, authScheme: "token" | "Bearer") => { + try { + return await fetch(url, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`, + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + } catch { + // Outcome unknown: keep the short-lived claim so an immediate retry + // cannot double-post. + return null; + } + }; + const unknownOutcome = (detail: string) => + NextResponse.json( + { error: `${detail}; check GitHub before retrying (retry unlocks in 15 minutes)` }, + { status: 502 } + ); + const githubFailed = async (res: Response) => { + const text = await res.text().catch(() => ""); + if (!githubDefinitelyRejected(res.status)) { + // Keep the claim: GitHub may have created the post anyway. + return unknownOutcome(`GitHub ${res.status}: ${text}`); + } + try { + await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id); + await releaseDraftClaim(env.CURATED_KV, heldClaim); + } catch { /* the claim expires on its own */ } + return NextResponse.json({ error: `GitHub ${res.status}: ${text}` }, { status: 502 }); + }; if (draft.type === "digest") { const digestBody = commentBody; @@ -100,60 +296,61 @@ export async function POST(req: Request) { const digestRepo = env.GITHUB_REPO ?? "Hmbown/CodeWhale"; const issuesUrl = `https://api.github.com/repos/${digestRepo}/issues`; - const digestRes = await fetch(issuesUrl, { - method: "POST", - headers: { - Accept: "application/vnd.github+json", - Authorization: `token ${env.MAINTAINER_GITHUB_PAT}`, - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ title, body: digestBody, labels: ["digest"] }), - }); - - if (!digestRes.ok) { - const text = await digestRes.text(); - return NextResponse.json({ error: `GitHub ${digestRes.status}: ${text}` }, { status: 502 }); - } + const digestRes = await postToGitHub(issuesUrl, { title, body: digestBody, labels: ["digest"] }, "token"); + if (!digestRes) return unknownOutcome("GitHub request failed"); + if (!digestRes.ok) return githubFailed(digestRes); - const issue = await digestRes.json() as { number: number; html_url: string }; + const issue = await digestRes.json().catch(() => ({})) as { number?: number; html_url?: string }; draft.posted = true; - draft.targetNumber = issue.number; - draft.targetUrl = issue.html_url; - await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 }); + if (typeof issue.number === "number") draft.targetNumber = issue.number; + if (typeof issue.html_url === "string") draft.targetUrl = issue.html_url; + let warning = await recordPosted(); - return NextResponse.json({ ok: true, action: "posted", number: issue.number, url: issue.html_url }); - } + // Publishing to /digest is the approval, for the language shown to the + // maintainer only, and only of the record that renders to exactly the + // text they reviewed. An edited digest is posted to GitHub but not + // published there, since the record holds the unedited text. + let published = false; + if (editedBody === undefined || editedBody === originalBody) { + try { + const approval = await approveDigestRecord(env.CURATED_KV, draft, reviewLang); + published = approval === "published"; + if (published) revalidateDigestPage(); + else if (approval === "missing") { + warning ??= "Posted to GitHub, but the weekly record is gone, so /digest does not show it."; + } else { + warning ??= "Posted to GitHub, but the stored weekly record does not match the reviewed text, so /digest does not show it."; + } + } catch (e) { + warning ??= `Posted to GitHub, but publishing the digest page failed (${String(e)}).`; + } + } else { + warning ??= "Posted to GitHub. The text was edited, so /digest does not show this week."; + } - if (!draft.targetNumber) { - return NextResponse.json({ error: "no target number" }, { status: 400 }); + return NextResponse.json({ + ok: true, + action: "posted", + number: issue.number, + url: issue.html_url, + published, + ...(warning ? { warning } : {}), + }); } const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale"; const commentUrl = `https://api.github.com/repos/${repo}/issues/${draft.targetNumber}/comments`; - const ghRes = await fetch(commentUrl, { - method: "POST", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${env.MAINTAINER_GITHUB_PAT}`, - "X-GitHub-Api-Version": "2022-11-28", - "Content-Type": "application/json", - }, - body: JSON.stringify({ body: commentBody }), - }); - - if (!ghRes.ok) { - const text = await ghRes.text(); - return NextResponse.json({ error: `GitHub ${ghRes.status}: ${text}` }, { status: 502 }); - } + const ghRes = await postToGitHub(commentUrl, { body: commentBody }, "Bearer"); + if (!ghRes) return unknownOutcome("GitHub request failed"); + if (!ghRes.ok) return githubFailed(ghRes); // Mark as posted draft.posted = true; - await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 }); + const warning = await recordPosted(); - return NextResponse.json({ ok: true, action: "posted" }); + return NextResponse.json({ ok: true, action: "posted", ...(warning ? { warning } : {}) }); } // ALLOWED_ACTIONS guard above means this is unreachable. diff --git a/web/lib/community-agent-review-state.test.ts b/web/lib/community-agent-review-state.test.ts new file mode 100644 index 0000000000..f71e5db486 --- /dev/null +++ b/web/lib/community-agent-review-state.test.ts @@ -0,0 +1,933 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + agentChat: vi.fn(), + getAgentEnv: vi.fn(), + validateSession: vi.fn(), + fetchRepoStats: vi.fn(), +})); + +vi.mock("@/lib/community-agent", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + agentChat: mocks.agentChat, + getAgentEnv: mocks.getAgentEnv, + validateSession: mocks.validateSession, + }; +}); + +vi.mock("@/lib/github", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, fetchRepoStats: mocks.fetchRepoStats }; +}); + +import { POST as adminPost } from "../app/api/admin/post/route"; +import { + isPublishedDigest, + listDrafts, + MAX_LISTED_DRAFTS, + reviewedBodyHash, + saveDraft, + type AgentDraft, +} from "./community-agent"; +import { runDigest, runDupes, runTriage } from "./community-agent-tasks"; +import { FakeDraftClaimLock } from "./draft-claim-lock.fake"; + +/** In-memory KV that pages like Cloudflare KV (max 1000 keys per list call). */ +class FakeKv { + readonly values = new Map(); + failPutsMatching: RegExp | null = null; + /** Yield to the event loop inside every call, like a network round trip. */ + yieldEachOp = false; + pageSize = 1000; + failListAtCursor: string | null = null; + /** Awaited before a put lands, to hold a request at an exact step. */ + beforePut: ((key: string, value: string) => Promise) | null = null; + + private async tick() { + if (this.yieldEachOp) await new Promise((resolve) => setTimeout(resolve, 0)); + } + + async get(key: string): Promise { + await this.tick(); + return this.values.get(key) ?? null; + } + + async put(key: string, value: string): Promise { + await this.tick(); + if (this.beforePut) await this.beforePut(key, value); + if (this.failPutsMatching?.test(key)) throw new Error("kv put failed"); + this.values.set(key, value); + } + + async list(options?: { prefix?: string; limit?: number; cursor?: string }) { + if (options?.cursor && options.cursor === this.failListAtCursor) throw new Error("kv list failed"); + const prefix = options?.prefix ?? ""; + const limit = Math.min(options?.limit ?? 1000, this.pageSize); + const start = options?.cursor ? Number(options.cursor) : 0; + const all = [...this.values.keys()].filter((k) => k.startsWith(prefix)).sort(); + const page = all.slice(start, start + limit); + const next = start + page.length; + const complete = next >= all.length; + return { + keys: page.map((name) => ({ name })), + list_complete: complete, + ...(complete ? {} : { cursor: String(next) }), + }; + } + + async delete(key: string): Promise { + this.values.delete(key); + } +} + +function draft(overrides: Partial = {}): AgentDraft { + return { + id: "42", + type: "triage", + targetNumber: 42, + bodyEn: "English body", + bodyZh: "中文正文", + generatedAt: "2026-01-01T00:00:00.000Z", + posted: false, + ...overrides, + }; +} + +function jsonResponse(value: unknown, status = 200): Response { + return new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }); +} + +function inputUrl(input: string | URL | Request): string { + if (typeof input === "string") return input; + return input instanceof URL ? input.toString() : input.url; +} + +function adminRequest(body: BodyInit, headers: Record = {}): Request { + return new Request("https://codewhale.net/api/admin/post", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: "mt_sid=test-session", + origin: "https://codewhale.net", + ...headers, + }, + body, + // Required by undici for a streamed request body. + ...(body instanceof ReadableStream ? { duplex: "half" } : {}), + } as RequestInit); +} + +function postBody(value: Record): string { + return JSON.stringify(value); +} + +/** + * An admin action as the admin page sends it: bound to the stored draft text + * in the selected language, as shown to the maintainer. + */ +async function act(kv: FakeKv, fields: Record): Promise { + let reviewedSha256 = fields.reviewedSha256; + if (reviewedSha256 === undefined) { + const raw = kv.values.get(String(fields.draftKey)); + const stored = raw ? (JSON.parse(raw) as AgentDraft) : null; + reviewedSha256 = await reviewedBodyHash( + stored ? (fields.lang === "zh" ? stored.bodyZh : stored.bodyEn) : "" + ); + } + return adminPost(adminRequest(postBody({ ...fields, reviewedSha256 }))); +} + +function stubAdminEnv(kv: FakeKv, lock?: FakeDraftClaimLock) { + mocks.getAgentEnv.mockResolvedValue({ + CURATED_KV: kv, + ...(lock ? { DRAFT_CLAIM_LOCK: lock } : {}), + MAINTAINER_TOKEN: "configured", + MAINTAINER_GITHUB_PAT: "ghp_test", + GITHUB_REPO: "Hmbown/CodeWhale", + }); +} + +/** GitHub stub that records every comment/issue creation. */ +function stubGitHub(status = 201) { + const posts: string[] = []; + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = inputUrl(input); + posts.push(url); + if (url.endsWith("/issues")) { + return jsonResponse({ number: 900, html_url: "https://github.com/Hmbown/CodeWhale/issues/900" }, status); + } + return jsonResponse({ id: 1 }, status); + }); + vi.stubGlobal("fetch", fetchMock); + return posts; +} + +const DIGEST_MODEL_OUTPUT = { + titleEn: "Weekly Digest", + titleZh: "每周摘要", + summaryEn: "A quiet week.", + summaryZh: "平静的一周。", + sections: [{ heading: "Shipped", items: ["PR #1: fix"] }], +}; + +function stubDigestSources() { + mocks.fetchRepoStats.mockResolvedValue({ stars: 1, forks: 1 }); + vi.stubGlobal("fetch", vi.fn(async (input: string | URL | Request) => { + const url = inputUrl(input); + if (url.includes("/issues?") || url.includes("/pulls?")) return jsonResponse([]); + throw new Error(`unexpected URL: ${url}`); + })); +} + +function onlyKey(kv: FakeKv, prefix: string): string { + const keys = [...kv.values.keys()].filter((k) => k.startsWith(prefix)); + expect(keys).toHaveLength(1); + return keys[0]; +} + +beforeEach(() => { + mocks.agentChat.mockReset(); + mocks.getAgentEnv.mockReset(); + mocks.fetchRepoStats.mockReset(); + mocks.validateSession.mockReset(); + mocks.validateSession.mockResolvedValue(true); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("weekly digest publication requires maintainer approval", () => { + it("stages the cron digest unapproved and publishes it only when the maintainer posts it", async () => { + const kv = new FakeKv(); + stubDigestSources(); + // Model output cannot self-approve. + mocks.agentChat.mockResolvedValue({ + content: JSON.stringify({ ...DIGEST_MODEL_OUTPUT, approved: true }), + usage: { input: 1, output: 1 }, + }); + + await expect(runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: true }); + const recordKey = onlyKey(kv, "digest:weekly-"); + const staged: unknown = JSON.parse(kv.values.get(recordKey)!); + expect(staged).toMatchObject({ approved: false }); + expect(isPublishedDigest(staged)).toBe(false); + + stubAdminEnv(kv); + const posts = stubGitHub(); + const draftKey = onlyKey(kv, "draft:digest:"); + const res = await act(kv, { action: "post", draftKey, lang: "en" }); + await expect(res.json()).resolves.toMatchObject({ ok: true, published: true, number: 900 }); + expect(posts).toHaveLength(1); + const published = JSON.parse(kv.values.get(recordKey)!); + expect(isPublishedDigest(published)).toBe(true); + // Only the reviewed language is published. + expect(published).toMatchObject({ approvedLang: "en" }); + }); + + it("records the Chinese review as the published language when posted from the zh admin", async () => { + const kv = new FakeKv(); + stubDigestSources(); + mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } }); + await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" }); + + stubAdminEnv(kv); + stubGitHub(); + const draftKey = onlyKey(kv, "draft:digest:"); + const res = await act(kv, { action: "post", draftKey, lang: "zh" }); + await expect(res.json()).resolves.toMatchObject({ ok: true, published: true }); + expect(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!)).toMatchObject({ approvedLang: "zh" }); + }); + + it("refuses to discard a posted digest, which would silently unpublish it", async () => { + const kv = new FakeKv(); + stubDigestSources(); + mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } }); + await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" }); + + stubAdminEnv(kv); + stubGitHub(); + const draftKey = onlyKey(kv, "draft:digest:"); + expect((await act(kv, { action: "post", draftKey, lang: "en" })).status).toBe(200); + + const discard = await act(kv, { action: "discard", draftKey }); + expect(discard.status).toBe(409); + expect(isPublishedDigest(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!))).toBe(true); + }); + + it("does not publish an edited digest's unedited model text", async () => { + const kv = new FakeKv(); + stubDigestSources(); + mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } }); + await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" }); + + stubAdminEnv(kv); + stubGitHub(); + const draftKey = onlyKey(kv, "draft:digest:"); + const res = await act(kv, { action: "post", draftKey, lang: "en", editedBody: "# Edited" }); + // The maintainer is told why the digest is not on /digest. + await expect(res.json()).resolves.toMatchObject({ + ok: true, + published: false, + warning: expect.stringContaining("/digest"), + }); + expect(isPublishedDigest(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!))).toBe(false); + }); + + it("discarding a digest removes the staged record and the cron does not regenerate it", async () => { + const kv = new FakeKv(); + stubDigestSources(); + mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } }); + await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" }); + + stubAdminEnv(kv); + const draftKey = onlyKey(kv, "draft:digest:"); + const res = await act(kv, { action: "discard", draftKey }); + await expect(res.json()).resolves.toMatchObject({ ok: true, action: "discarded" }); + expect([...kv.values.keys()].filter((k) => k.startsWith("digest:weekly-"))).toEqual([]); + + mocks.agentChat.mockClear(); + await expect(runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ skipped: true }); + expect(mocks.agentChat).not.toHaveBeenCalled(); + expect(kv.values.has(draftKey)).toBe(false); + }); + + it("surfaces a GitHub failure on a digest post as a 502 and does not publish", async () => { + const kv = new FakeKv(); + stubDigestSources(); + mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } }); + await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" }); + + stubAdminEnv(kv); + stubGitHub(422); + const draftKey = onlyKey(kv, "draft:digest:"); + const res = await act(kv, { action: "post", draftKey, lang: "en" }); + expect(res.status).toBe(502); + const payload = await res.json(); + expect(payload.ok).toBeUndefined(); + expect(payload.error).toMatch(/^GitHub 422/); + expect(isPublishedDigest(JSON.parse(kv.values.get(onlyKey(kv, "digest:weekly-"))!))).toBe(false); + }); + + it("never publishes an older record when a later run saved its draft but not its record", async () => { + const kv = new FakeKv(); + stubDigestSources(); + mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } }); + await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" }); + const recordKey = onlyKey(kv, "digest:weekly-"); + const recordA = kv.values.get(recordKey)!; + + // Run B saves its draft, then its record write fails: draft B + record A. + mocks.agentChat.mockResolvedValue({ + content: JSON.stringify({ ...DIGEST_MODEL_OUTPUT, titleEn: "Digest B", summaryEn: "Run B." }), + usage: { input: 1, output: 1 }, + }); + kv.failPutsMatching = /^digest:weekly-/; + await expect(runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" })).resolves.toMatchObject({ ok: false }); + kv.failPutsMatching = null; + const draftKey = onlyKey(kv, "draft:digest:"); + expect(JSON.parse(kv.values.get(draftKey)!).bodyEn).toContain("Digest B"); + expect(kv.values.get(recordKey)).toBe(recordA); + + stubAdminEnv(kv); + const bodies: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_input: string | URL | Request, init?: RequestInit) => { + bodies.push(String(init?.body)); + return jsonResponse({ number: 900, html_url: "https://github.com/Hmbown/CodeWhale/issues/900" }, 201); + })); + const res = await act(kv, { action: "post", draftKey, lang: "en" }); + await expect(res.json()).resolves.toMatchObject({ + ok: true, + published: false, + warning: expect.stringContaining("does not match the reviewed text"), + }); + // GitHub got the reviewed B; /digest shows nothing rather than A. + expect(bodies).toHaveLength(1); + expect(JSON.parse(bodies[0]).title).toBe("Digest B"); + expect(isPublishedDigest(JSON.parse(kv.values.get(recordKey)!))).toBe(false); + }); + + it("publishes only the fields of a record that matches the reviewed text", async () => { + const kv = new FakeKv(); + stubDigestSources(); + mocks.agentChat.mockResolvedValue({ content: JSON.stringify(DIGEST_MODEL_OUTPUT), usage: { input: 1, output: 1 } }); + await runDigest({ CURATED_KV: kv, DEEPSEEK_API_KEY: "k" }); + const recordKey = onlyKey(kv, "digest:weekly-"); + kv.values.set(recordKey, JSON.stringify({ ...JSON.parse(kv.values.get(recordKey)!), extra: "