openvibers/openvibe.blog Commits

Patch notes: one waiting draft per day, so a person can review them (#177)

OP OpenVibers committed 11 days ago · 2680515178 · 4 files, +152 −3

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9d7ed37..8ddfb20 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/server/changelog.js b/server/changelog.js
iindex 4285586..87e88bc 100644
---- a/server/changelog.js
++++ b/server/changelog.js
@@@ -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`),
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, fetch
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. */
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, fetch
stats.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). */
ddiff --git a/server/patch-notes/index.js b/server/patch-notes/index.js
iindex 9498a0d..378c2ca 100644
---- a/server/patch-notes/index.js
++++ b/server/patch-notes/index.js
@@@ -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;
}
ddiff --git a/test/patch-notes.test.js b/test/patch-notes.test.js
iindex b09f78f..da51aeb 100644
---- a/test/patch-notes.test.js
++++ b/test/patch-notes.test.js
@@@ -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();
})();