openvibers/openvibe.blog Commits
Patch notes: one waiting draft per day, so a person can review them (#177)
Changes
@@@ -2,6 +2,11 @@## Unreleased++- Patch notes: one waiting draft per day (2026-11-02). A person reviews every Patch notes post, and drafts filed every++ few hours piled up faster than anyone could read them (27 waiting, none published since v2). A batch whose day++ already has a waiting draft nobody has touched is folded into it: a fresh draft from all of the day's commits, the++ old one soft-deleted, so a reviewer opens one post per day. Each tick folds the backlog the same way. A draft a++ person edited or reviewed is never folded.- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `BLOG_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0).- **Their own blog:** its posts are removed and the blog closed.- **Elsewhere:** their posts stay without their id.@@@ -70,6 +70,9 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetchsites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),mark: db.prepare('UPDATE changelog_entries SET post_id = ? WHERE id = ? AND post_id IS NULL'),++ ownedBy: db.prepare('SELECT * FROM changelog_entries WHERE post_id = ? ORDER BY deployed_at, committed_at, id'),++ move: db.prepare('UPDATE changelog_entries SET post_id = ? WHERE post_id = ?'),++ dropPost: db.prepare('DELETE FROM changelog_posts WHERE post_id = ?'),enrich: db.prepare('UPDATE changelog_entries SET message_body = ?, files = ?, pr_number = ? WHERE service = ? AND sha = ?'),lastPost: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT 1'),putPost: db.prepare(`INSERT INTO changelog_posts (post_id, blog_id, entries, from_at, to_at, reason, ai_draft_id, created_at)@@@ -257,6 +260,17 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetchreturn (await q.pending.all()).map((e) => ({ ...e, url: `https://github.com/${e.repo}/commit/${e.sha}` }));}++ /** The commits a draft owns, oldest first, with their URL (a fold rewrites a day's draft from them). */++ async function entriesOf(postId) {++ return (await q.ownedBy.all(String(postId))).map((e) => ({ ...e, url: `https://github.com/${e.repo}/commit/${e.sha}` }));++ }++++ /** A folded draft's commits go to the draft that replaced it, and its row in the draft log goes. */++ async function moveEntries(fromPostId, toPostId) {++ await q.move.run(String(toPostId), String(fromPostId));++ await q.dropPost.run(String(fromPostId));++ }++/** A draft owns its commits (they are not in a second draft); the feed shows the link once the post publishes. */async function markEntries(list, postId) {for (const e of list) if (e.id != null) await q.mark.run(postId, e.id);@@@ -304,6 +318,9 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetchstats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();try {await collect();++ if (patchNotes && patchNotes.compact) {++ try { await patchNotes.compact(); } catch (err) { log.warn && log.warn(`[changelog] fold skipped: ${err.message}`); }++ }const list = await q.pending.all();const d = await due(list);if (d.due) return await fileDraft(list.slice(0, c.batchSize * 3), d.reason);@@@ -348,7 +365,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */async function sites() { return await q.sites.all(); }-- return { tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };++ return { tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, entriesOf, moveEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };}/** A stable id for a batch (tests). */@@@ -31,6 +31,9 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =voice_card = excluded.voice_card, override = COALESCE(excluded.override, blog_patch_note_drafts.override), updated_at = excluded.updated_at`),checks: db.prepare('UPDATE blog_patch_note_drafts SET checks = ?, updated_at = ? WHERE post_id = ?'),override: db.prepare('UPDATE blog_patch_note_drafts SET override = ?, updated_at = ? WHERE post_id = ?'),++ waiting: db.prepare(`SELECT d.post_id FROM blog_patch_note_drafts d JOIN blog_posts p ON p.id = d.post_id++ WHERE d.blog_id = ? AND p.state = 'draft' ORDER BY d.created_at DESC`),++ reviewed: db.prepare('SELECT 1 AS x FROM blog_post_reviews WHERE entity_id = ? LIMIT 1'),};const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);@@@ -114,11 +117,78 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =return { doc: det, map, card, samples, usedAi, ai, authorship, checks, owner, entries: list };}++ // ── One waiting draft per day (11-02) ──────────────────────────────────────────────────────────────────++ // A person reviews every Patch notes post (devblog §3.6). Filed every few hours, the drafts piled up faster than++ // anyone could read them (27 waiting on 11-02, none published since v2). So a day has one waiting draft: a batch++ // whose day already has one nobody has touched is folded into it. The fold writes a fresh draft from all of the++ // day's commits and soft-deletes the old one (posts.remove); rewriting the old one in place would record the++ // pipeline's second pass as a person's edit. A draft someone edited or reviewed is never folded.++ const dayOf = (list) => {++ const t = (list || []).reduce((m, e) => Math.max(m, Date.parse(e.deployed_at) || Number(e.deployed_at) || 0), 0);++ return t ? new Date(t).toISOString().slice(0, 10) : null;++ };++ const pipelineAuthor = (rev) => /^svc:patch-notes( |$)/.test(String((rev && rev.author) || ''));++++ /** The waiting drafts nobody has touched (one revision, the pipeline's; no review), newest first, with their day. */++ async function waitingDrafts(blog) {++ if (!changelog || !changelog.entriesOf) return [];++ const out = [];++ for (const r of await q.waiting.all(blog.id)) {++ const post = await posts.get(r.post_id);++ if (!post || post.state !== 'draft') continue;++ const head = await posts.head(post);++ if (!head || head.number !== 1 || !pipelineAuthor(head)) continue;++ if (await q.reviewed.get(post.id)) continue;++ const owned = await changelog.entriesOf(post.id);++ if (!owned.length) continue;++ out.push({ post, entries: owned, day: dayOf(owned) });++ }++ return out;++ }++++ /** A fresh draft from the drafts' commits and any new ones; the drafts it replaces are soft-deleted. */++ async function fold(drafts, extra = [], opts = {}) {++ const seen = new Set();++ const all = [...drafts.flatMap((d) => d.entries), ...extra].filter((e) => (e.id == null || !seen.has(e.id)) && (e.id == null || seen.add(e.id)))++ .sort((a, b) => (Date.parse(a.deployed_at) || 0) - (Date.parse(b.deployed_at) || 0) || Number(a.id || 0) - Number(b.id || 0));++ const out = await draftPending({ ...opts, entries: all, reason: opts.reason || 'folded', noFold: true });++ if (!out || !out.created) return out;++ const owner = ownerSubject();++ for (const d of drafts) {++ await changelog.moveEntries(d.post.id, out.post.id);++ await posts.remove({ kind: 'service', service: 'svc:patch-notes', origin: 'patch-notes', subject: owner }, d.post);++ }++ log.log && log.log(`[patch-notes] folded ${drafts.length} waiting draft(s) of ${dayOf(all)} into ${out.post.id} (${all.length} commits)`);++ return { ...out, folded: drafts.map((d) => d.post.id) };++ }++++ /** The backlog: every day with more than one untouched waiting draft becomes one. Returns the days folded. */++ async function compact() {++ const blog = await blogs.byHandle(config.changelog.blogHandle);++ if (!blog) return [];++ const byDay = new Map();++ for (const d of await waitingDrafts(blog)) {++ if (!byDay.has(d.day)) byDay.set(d.day, []);++ byDay.get(d.day).push(d);++ }++ const done = [];++ for (const [day, list] of byDay) {++ if (list.length < 2) continue;++ const out = await fold(list);++ if (out && out.created) done.push(day);++ }++ return done;++ }++/** Write the draft (never publish). Returns { created, post, url, checks, doc }. */-- async function draftPending({ entries = null, pullRequests = null, reason = 'manual', account = 'official', useAi = true, traceparent = null } = {}) {++ async function draftPending({ entries = null, pullRequests = null, reason = 'manual', account = 'official', useAi = true, traceparent = null, noFold = false } = {}) {const blog = await blogs.byHandle(config.changelog.blogHandle) || await blogs.ensureOfficial();let list = entries;if (!list) list = changelog ? await changelog.pendingEntries() : [];++ if (!noFold && list.length) {++ const same = (await waitingDrafts(blog)).filter((d) => d.day === dayOf(list));++ if (same.length) return await fold(same, list, { pullRequests, reason, account, useAi, traceparent });++ }if (changelog && changelog.enrich && list.length && list.some((e) => e.id != null)) {try { list = await changelog.enrich(list); } catch (err) { log.warn && log.warn(`[patch-notes] enrichment skipped: ${err.message}`); }}@@@ -198,7 +268,7 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =async function storyMap(postId) { const row = await load(postId); return row ? row.stories : []; }async function isPatchNotes(postId) { return Boolean(await q.get.get(String(postId))); }-- const api = { plan, draftPending, check, gate, load, setOverride, storyMap, isPatchNotes, attach, voice, ownerSubject, samplesOf };++ const api = { plan, draftPending, compact, waitingDrafts, check, gate, load, setOverride, storyMap, isPatchNotes, attach, voice, ownerSubject, samplesOf };return api;}@@@ -242,6 +242,63 @@ const entry = (service, sha, subject, body = null) => ({assert.ok(page.text.includes('Checklist'));});++ // ── one waiting draft per day (11-02) ─────────────────────++ let seq = 0;++ const put = async (day, subject) => {++ seq++;++ const sha = String(seq).padStart(40, '0');++ await db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id, message_body)++ VALUES ('help', 'OpenVibers/OpenVibe.Help', ?, ?, 'OpenVibers', ?, ?, 'r2', 5, 0, NULL, ?)`).run(sha, subject, `${day}T0${seq % 9}:00:00.000Z`, `${day}T0${seq % 9}:30:00.000Z`, subject);++ };++ const pend = async () => await t.ctx.changelog.pendingEntries();++ const draftNow = async (o = {}) => await t.ctx.patchNotes.draftPending({ entries: await pend(), pullRequests: [], useAi: false, reason: 'test', ...o });++++ await check('a later batch of the same day folds into its waiting draft: one draft, every commit, the old one gone', async () => {++ await put('2026-11-01', 'Help: every answer says when it last changed (#201)');++ const first = await draftNow();++ assert.ok(first.created);++ await put('2026-11-01', 'Help: the ticket form keeps a draft (#202)');++ const second = await draftNow();++ assert.ok(second.created, JSON.stringify(second).slice(0, 200));++ assert.deepStrictEqual(second.folded, [first.post.id], 'the first draft was folded');++ assert.strictEqual((await t.ctx.posts.get(first.post.id)).state, 'deleted', 'soft-deleted, not removed');++ const owned = await t.ctx.changelog.entriesOf(second.post.id);++ assert.strictEqual(owned.length, 2, 'the new draft owns both batches');++ assert.strictEqual((await t.ctx.patchNotes.waitingDrafts(await t.ctx.blogs.byHandle(t.ctx.config.changelog.blogHandle))).filter((d) => d.day === '2026-11-01').length, 1);++ });++++ await check('another day is another draft; a draft a person edited is never folded', async () => {++ await put('2026-11-02', 'Help: phones keep the search box in view (#203)');++ const nextDay = await draftNow();++ assert.ok(nextDay.created && !nextDay.folded, 'a new day starts its own draft');++ // A person edits the 11-02 draft: it is theirs now.++ const post = await t.ctx.posts.get(nextDay.post.id);++ const head = await t.ctx.posts.head(post);++ await t.ctx.posts.update(owner, post, { title: 'Help, rewritten by a person', expectedRevision: head.number });++ await put('2026-11-02', 'Help: answers link their sources (#204)');++ const after = await draftNow();++ assert.ok(after.created && !after.folded, 'not folded into an edited draft');++ assert.strictEqual((await t.ctx.posts.get(nextDay.post.id)).state, 'draft', 'the edited draft stays');++ });++++ await check('the backlog: compact() makes each day one draft, and leaves the edited one', async () => {++ await put('2026-10-31', 'Help: one (#205)');++ const a1 = await draftNow({ noFold: true });++ await put('2026-10-31', 'Help: two (#206)');++ const a2 = await draftNow({ noFold: true });++ await put('2026-10-31', 'Help: three (#207)');++ const a3 = await draftNow({ noFold: true });++ assert.ok(a1.created && a2.created && a3.created);++ const days = await t.ctx.patchNotes.compact();++ assert.deepStrictEqual(days, ['2026-10-31']);++ for (const d of [a1, a2, a3]) assert.strictEqual((await t.ctx.posts.get(d.post.id)).state, 'deleted');++ const waiting = await t.ctx.patchNotes.waitingDrafts(await t.ctx.blogs.byHandle(t.ctx.config.changelog.blogHandle));++ const oct31 = waiting.filter((d) => d.day === '2026-10-31');++ assert.strictEqual(oct31.length, 1, 'one draft for the day');++ assert.strictEqual(oct31[0].entries.length, 3, 'with all three commits');++ assert.deepStrictEqual(await t.ctx.patchNotes.compact(), [], 'nothing left to fold');++ });++await t.close();done();})();
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
## Unreleased
## Unreleased
+- Patch notes: one waiting draft per day (2026-11-02). A person reviews every Patch notes post, and drafts filed every
+ few hours piled up faster than anyone could read them (27 waiting, none published since v2). A batch whose day
+ already has a waiting draft nobody has touched is folded into it: a fresh draft from all of the day's commits, the
+ old one soft-deleted, so a reviewer opens one post per day. Each tick folds the backlog the same way. A draft a
+ person edited or reviewed is never folded.
- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `BLOG_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0).
- Account export and deletion (ADR-033): `POST /internal/events` (loopback, `BLOG_EVENTS_SECRET`) answers network.account.export_requested and network.account.deleted through `server/domain/account-data.js` over `openvibe-sdk/account-data` (SDK v0.37.0, was v0.35.0).
- **Their own blog:** its posts are removed and the blog closed.
- **Their own blog:** its posts are removed and the blog closed.
- **Elsewhere:** their posts stay without their id.
- **Elsewhere:** their posts stay without their id.
@@ -70,6 +70,9 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
@@ -70,6 +70,9 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
sites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),
sites: db.prepare(`SELECT service, COUNT(*) AS entries, MAX(deployed_at) AS latest_at FROM changelog_entries GROUP BY service ORDER BY latest_at DESC`),
pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),
pending: db.prepare('SELECT * FROM changelog_entries WHERE post_id IS NULL ORDER BY deployed_at, committed_at, id'),
mark: db.prepare('UPDATE changelog_entries SET post_id = ? WHERE id = ? AND post_id IS NULL'),
mark: db.prepare('UPDATE changelog_entries SET post_id = ? WHERE id = ? AND post_id IS NULL'),
+ ownedBy: db.prepare('SELECT * FROM changelog_entries WHERE post_id = ? ORDER BY deployed_at, committed_at, id'),
+ move: db.prepare('UPDATE changelog_entries SET post_id = ? WHERE post_id = ?'),
+ dropPost: db.prepare('DELETE FROM changelog_posts WHERE post_id = ?'),
enrich: db.prepare('UPDATE changelog_entries SET message_body = ?, files = ?, pr_number = ? WHERE service = ? AND sha = ?'),
enrich: db.prepare('UPDATE changelog_entries SET message_body = ?, files = ?, pr_number = ? WHERE service = ? AND sha = ?'),
lastPost: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT 1'),
lastPost: db.prepare('SELECT * FROM changelog_posts ORDER BY created_at DESC LIMIT 1'),
putPost: db.prepare(`INSERT INTO changelog_posts (post_id, blog_id, entries, from_at, to_at, reason, ai_draft_id, created_at)
putPost: db.prepare(`INSERT INTO changelog_posts (post_id, blog_id, entries, from_at, to_at, reason, ai_draft_id, created_at)
@@ -257,6 +260,17 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
@@ -257,6 +260,17 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
return (await q.pending.all()).map((e) => ({ ...e, url: `https://github.com/${e.repo}/commit/${e.sha}` }));
return (await q.pending.all()).map((e) => ({ ...e, url: `https://github.com/${e.repo}/commit/${e.sha}` }));
}
}
+ /** The commits a draft owns, oldest first, with their URL (a fold rewrites a day's draft from them). */
+ async function entriesOf(postId) {
+ return (await q.ownedBy.all(String(postId))).map((e) => ({ ...e, url: `https://github.com/${e.repo}/commit/${e.sha}` }));
+ }
+
+ /** A folded draft's commits go to the draft that replaced it, and its row in the draft log goes. */
+ async function moveEntries(fromPostId, toPostId) {
+ await q.move.run(String(toPostId), String(fromPostId));
+ await q.dropPost.run(String(fromPostId));
+ }
+
/** A draft owns its commits (they are not in a second draft); the feed shows the link once the post publishes. */
/** A draft owns its commits (they are not in a second draft); the feed shows the link once the post publishes. */
async function markEntries(list, postId) {
async function markEntries(list, postId) {
for (const e of list) if (e.id != null) await q.mark.run(postId, e.id);
for (const e of list) if (e.id != null) await q.mark.run(postId, e.id);
@@ -304,6 +318,9 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
@@ -304,6 +318,9 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
stats.runs++; stats.lastRunAt = new Date(nowMs()).toISOString();
try {
try {
await collect();
await collect();
+ if (patchNotes && patchNotes.compact) {
+ try { await patchNotes.compact(); } catch (err) { log.warn && log.warn(`[changelog] fold skipped: ${err.message}`); }
+ }
const list = await q.pending.all();
const list = await q.pending.all();
const d = await due(list);
const d = await due(list);
if (d.due) return await fileDraft(list.slice(0, c.batchSize * 3), d.reason);
if (d.due) return await fileDraft(list.slice(0, c.batchSize * 3), d.reason);
@@ -348,7 +365,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
@@ -348,7 +365,7 @@ function createChangelog({ config, store, blogs, posts, patchNotes = null, fetch
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
/** Every site with entries: [{ service, entries, latest_at }], most recently shipped first. */
async function sites() { return await q.sites.all(); }
async function sites() { return await q.sites.all(); }
- return { tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
+ return { tick, collect, due, fileDraft, enrich, pendingEntries, markEntries, entriesOf, moveEntries, openPulls, start, stop, entries, page, latestPost, recentPosts, sites, importHistory, stats: async () => ({ enabled: c.enabled, ...stats, github_token: cachedToken.source, pending: (await q.pending.all()).length }) };
}
}
/** A stable id for a batch (tests). */
/** A stable id for a batch (tests). */
@@ -31,6 +31,9 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
@@ -31,6 +31,9 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
voice_card = excluded.voice_card, override = COALESCE(excluded.override, blog_patch_note_drafts.override), updated_at = excluded.updated_at`),
voice_card = excluded.voice_card, override = COALESCE(excluded.override, blog_patch_note_drafts.override), updated_at = excluded.updated_at`),
checks: db.prepare('UPDATE blog_patch_note_drafts SET checks = ?, updated_at = ? WHERE post_id = ?'),
checks: db.prepare('UPDATE blog_patch_note_drafts SET checks = ?, updated_at = ? WHERE post_id = ?'),
override: db.prepare('UPDATE blog_patch_note_drafts SET override = ?, updated_at = ? WHERE post_id = ?'),
override: db.prepare('UPDATE blog_patch_note_drafts SET override = ?, updated_at = ? WHERE post_id = ?'),
+ waiting: db.prepare(`SELECT d.post_id FROM blog_patch_note_drafts d JOIN blog_posts p ON p.id = d.post_id
+ WHERE d.blog_id = ? AND p.state = 'draft' ORDER BY d.created_at DESC`),
+ reviewed: db.prepare('SELECT 1 AS x FROM blog_post_reviews WHERE entity_id = ? LIMIT 1'),
};
};
const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
const parse = (v) => (v == null ? null : typeof v === 'string' ? JSON.parse(v) : v);
@@ -114,11 +117,78 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
@@ -114,11 +117,78 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
return { doc: det, map, card, samples, usedAi, ai, authorship, checks, owner, entries: list };
return { doc: det, map, card, samples, usedAi, ai, authorship, checks, owner, entries: list };
}
}
+ // ── One waiting draft per day (11-02) ──────────────────────────────────────────────────────────────────
+ // A person reviews every Patch notes post (devblog §3.6). Filed every few hours, the drafts piled up faster than
+ // anyone could read them (27 waiting on 11-02, none published since v2). So a day has one waiting draft: a batch
+ // whose day already has one nobody has touched is folded into it. The fold writes a fresh draft from all of the
+ // day's commits and soft-deletes the old one (posts.remove); rewriting the old one in place would record the
+ // pipeline's second pass as a person's edit. A draft someone edited or reviewed is never folded.
+ const dayOf = (list) => {
+ const t = (list || []).reduce((m, e) => Math.max(m, Date.parse(e.deployed_at) || Number(e.deployed_at) || 0), 0);
+ return t ? new Date(t).toISOString().slice(0, 10) : null;
+ };
+ const pipelineAuthor = (rev) => /^svc:patch-notes( |$)/.test(String((rev && rev.author) || ''));
+
+ /** The waiting drafts nobody has touched (one revision, the pipeline's; no review), newest first, with their day. */
+ async function waitingDrafts(blog) {
+ if (!changelog || !changelog.entriesOf) return [];
+ const out = [];
+ for (const r of await q.waiting.all(blog.id)) {
+ const post = await posts.get(r.post_id);
+ if (!post || post.state !== 'draft') continue;
+ const head = await posts.head(post);
+ if (!head || head.number !== 1 || !pipelineAuthor(head)) continue;
+ if (await q.reviewed.get(post.id)) continue;
+ const owned = await changelog.entriesOf(post.id);
+ if (!owned.length) continue;
+ out.push({ post, entries: owned, day: dayOf(owned) });
+ }
+ return out;
+ }
+
+ /** A fresh draft from the drafts' commits and any new ones; the drafts it replaces are soft-deleted. */
+ async function fold(drafts, extra = [], opts = {}) {
+ const seen = new Set();
+ const all = [...drafts.flatMap((d) => d.entries), ...extra].filter((e) => (e.id == null || !seen.has(e.id)) && (e.id == null || seen.add(e.id)))
+ .sort((a, b) => (Date.parse(a.deployed_at) || 0) - (Date.parse(b.deployed_at) || 0) || Number(a.id || 0) - Number(b.id || 0));
+ const out = await draftPending({ ...opts, entries: all, reason: opts.reason || 'folded', noFold: true });
+ if (!out || !out.created) return out;
+ const owner = ownerSubject();
+ for (const d of drafts) {
+ await changelog.moveEntries(d.post.id, out.post.id);
+ await posts.remove({ kind: 'service', service: 'svc:patch-notes', origin: 'patch-notes', subject: owner }, d.post);
+ }
+ log.log && log.log(`[patch-notes] folded ${drafts.length} waiting draft(s) of ${dayOf(all)} into ${out.post.id} (${all.length} commits)`);
+ return { ...out, folded: drafts.map((d) => d.post.id) };
+ }
+
+ /** The backlog: every day with more than one untouched waiting draft becomes one. Returns the days folded. */
+ async function compact() {
+ const blog = await blogs.byHandle(config.changelog.blogHandle);
+ if (!blog) return [];
+ const byDay = new Map();
+ for (const d of await waitingDrafts(blog)) {
+ if (!byDay.has(d.day)) byDay.set(d.day, []);
+ byDay.get(d.day).push(d);
+ }
+ const done = [];
+ for (const [day, list] of byDay) {
+ if (list.length < 2) continue;
+ const out = await fold(list);
+ if (out && out.created) done.push(day);
+ }
+ return done;
+ }
+
/** Write the draft (never publish). Returns { created, post, url, checks, doc }. */
/** Write the draft (never publish). Returns { created, post, url, checks, doc }. */
- async function draftPending({ entries = null, pullRequests = null, reason = 'manual', account = 'official', useAi = true, traceparent = null } = {}) {
+ async function draftPending({ entries = null, pullRequests = null, reason = 'manual', account = 'official', useAi = true, traceparent = null, noFold = false } = {}) {
const blog = await blogs.byHandle(config.changelog.blogHandle) || await blogs.ensureOfficial();
const blog = await blogs.byHandle(config.changelog.blogHandle) || await blogs.ensureOfficial();
let list = entries;
let list = entries;
if (!list) list = changelog ? await changelog.pendingEntries() : [];
if (!list) list = changelog ? await changelog.pendingEntries() : [];
+ if (!noFold && list.length) {
+ const same = (await waitingDrafts(blog)).filter((d) => d.day === dayOf(list));
+ if (same.length) return await fold(same, list, { pullRequests, reason, account, useAi, traceparent });
+ }
if (changelog && changelog.enrich && list.length && list.some((e) => e.id != null)) {
if (changelog && changelog.enrich && list.length && list.some((e) => e.id != null)) {
try { list = await changelog.enrich(list); } catch (err) { log.warn && log.warn(`[patch-notes] enrichment skipped: ${err.message}`); }
try { list = await changelog.enrich(list); } catch (err) { log.warn && log.warn(`[patch-notes] enrichment skipped: ${err.message}`); }
}
}
@@ -198,7 +268,7 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
@@ -198,7 +268,7 @@ function createPatchNotes({ config, store, blogs, posts, publication, aiDrafts =
async function storyMap(postId) { const row = await load(postId); return row ? row.stories : []; }
async function storyMap(postId) { const row = await load(postId); return row ? row.stories : []; }
async function isPatchNotes(postId) { return Boolean(await q.get.get(String(postId))); }
async function isPatchNotes(postId) { return Boolean(await q.get.get(String(postId))); }
- const api = { plan, draftPending, check, gate, load, setOverride, storyMap, isPatchNotes, attach, voice, ownerSubject, samplesOf };
+ const api = { plan, draftPending, compact, waitingDrafts, check, gate, load, setOverride, storyMap, isPatchNotes, attach, voice, ownerSubject, samplesOf };
return api;
return api;
}
}
@@ -242,6 +242,63 @@ const entry = (service, sha, subject, body = null) => ({
@@ -242,6 +242,63 @@ const entry = (service, sha, subject, body = null) => ({
assert.ok(page.text.includes('Checklist'));
assert.ok(page.text.includes('Checklist'));
});
});
+ // ── one waiting draft per day (11-02) ─────────────────────
+ let seq = 0;
+ const put = async (day, subject) => {
+ seq++;
+ const sha = String(seq).padStart(40, '0');
+ await db.prepare(`INSERT INTO changelog_entries (service, repo, sha, subject, author, committed_at, deployed_at, release, release_lines, major, post_id, message_body)
+ VALUES ('help', 'OpenVibers/OpenVibe.Help', ?, ?, 'OpenVibers', ?, ?, 'r2', 5, 0, NULL, ?)`).run(sha, subject, `${day}T0${seq % 9}:00:00.000Z`, `${day}T0${seq % 9}:30:00.000Z`, subject);
+ };
+ const pend = async () => await t.ctx.changelog.pendingEntries();
+ const draftNow = async (o = {}) => await t.ctx.patchNotes.draftPending({ entries: await pend(), pullRequests: [], useAi: false, reason: 'test', ...o });
+
+ await check('a later batch of the same day folds into its waiting draft: one draft, every commit, the old one gone', async () => {
+ await put('2026-11-01', 'Help: every answer says when it last changed (#201)');
+ const first = await draftNow();
+ assert.ok(first.created);
+ await put('2026-11-01', 'Help: the ticket form keeps a draft (#202)');
+ const second = await draftNow();
+ assert.ok(second.created, JSON.stringify(second).slice(0, 200));
+ assert.deepStrictEqual(second.folded, [first.post.id], 'the first draft was folded');
+ assert.strictEqual((await t.ctx.posts.get(first.post.id)).state, 'deleted', 'soft-deleted, not removed');
+ const owned = await t.ctx.changelog.entriesOf(second.post.id);
+ assert.strictEqual(owned.length, 2, 'the new draft owns both batches');
+ assert.strictEqual((await t.ctx.patchNotes.waitingDrafts(await t.ctx.blogs.byHandle(t.ctx.config.changelog.blogHandle))).filter((d) => d.day === '2026-11-01').length, 1);
+ });
+
+ await check('another day is another draft; a draft a person edited is never folded', async () => {
+ await put('2026-11-02', 'Help: phones keep the search box in view (#203)');
+ const nextDay = await draftNow();
+ assert.ok(nextDay.created && !nextDay.folded, 'a new day starts its own draft');
+ // A person edits the 11-02 draft: it is theirs now.
+ const post = await t.ctx.posts.get(nextDay.post.id);
+ const head = await t.ctx.posts.head(post);
+ await t.ctx.posts.update(owner, post, { title: 'Help, rewritten by a person', expectedRevision: head.number });
+ await put('2026-11-02', 'Help: answers link their sources (#204)');
+ const after = await draftNow();
+ assert.ok(after.created && !after.folded, 'not folded into an edited draft');
+ assert.strictEqual((await t.ctx.posts.get(nextDay.post.id)).state, 'draft', 'the edited draft stays');
+ });
+
+ await check('the backlog: compact() makes each day one draft, and leaves the edited one', async () => {
+ await put('2026-10-31', 'Help: one (#205)');
+ const a1 = await draftNow({ noFold: true });
+ await put('2026-10-31', 'Help: two (#206)');
+ const a2 = await draftNow({ noFold: true });
+ await put('2026-10-31', 'Help: three (#207)');
+ const a3 = await draftNow({ noFold: true });
+ assert.ok(a1.created && a2.created && a3.created);
+ const days = await t.ctx.patchNotes.compact();
+ assert.deepStrictEqual(days, ['2026-10-31']);
+ for (const d of [a1, a2, a3]) assert.strictEqual((await t.ctx.posts.get(d.post.id)).state, 'deleted');
+ const waiting = await t.ctx.patchNotes.waitingDrafts(await t.ctx.blogs.byHandle(t.ctx.config.changelog.blogHandle));
+ const oct31 = waiting.filter((d) => d.day === '2026-10-31');
+ assert.strictEqual(oct31.length, 1, 'one draft for the day');
+ assert.strictEqual(oct31[0].entries.length, 3, 'with all three commits');
+ assert.deepStrictEqual(await t.ctx.patchNotes.compact(), [], 'nothing left to fold');
+ });
+
await t.close();
await t.close();
done();
done();
})();
})();