openvibers/openvibe.blog Commits

Classify changelog kinds from commit subjects (#272)

OP OpenVibers committed 3 days ago · 565b5592ad · 5 files, +40 −17

Changes

ddiff --git a/scripts/backfill-changelog-tags.js b/scripts/backfill-changelog-tags.js
iindex 4fd4d11..830ba02 100644
---- a/scripts/backfill-changelog-tags.js
++++ b/scripts/backfill-changelog-tags.js
@@@ -10,6 +10,7 @@
* node scripts/backfill-changelog-tags.js --service live # one site
* node scripts/backfill-changelog-tags.js --limit 200 # at most this many rows (default 500)
* node scripts/backfill-changelog-tags.js --apply # write, spending the AI task's calls
++ * node scripts/backfill-changelog-tags.js --apply --retag # reclassify existing tags too
* node scripts/backfill-changelog-tags.js --apply --production
*
* The dry run writes nothing and never calls the model. --apply needs OV_AI_INTERNAL_URL and the
@@@ -24,7 +25,7 @@ const has = (name) => args.includes(`--${name}`);
async function main() {
if (has('help')) {
-- console.log('usage: node scripts/backfill-changelog-tags.js [--service <id>] [--limit 500] [--chunk 20] [--apply] [--production]');
++ console.log('usage: node scripts/backfill-changelog-tags.js [--service <id>] [--limit 500] [--chunk 20] [--apply] [--retag] [--production]');
return 0;
}
const config = require('../server/config').load();
@@@ -39,7 +40,8 @@ async function main() {
const { openStore } = require('../server/db');
const store = await openStore(config, { log: console });
const db = store.db;
-- const where = ['kind IS NULL'];
++ const retag = has('retag');
++ const where = retag ? ["COALESCE(tagged_by, '') NOT LIKE 'retagged%'"] : ['kind IS NULL'];
const params = [];
if (service) { where.push('service = ?'); params.push(service); }
const rows = await db.prepare(`SELECT id, service, repo, sha, subject FROM changelog_entries WHERE ${where.join(' AND ')} ORDER BY deployed_at, id LIMIT ?`).all(...params, limit);
@@@ -48,13 +50,13 @@ async function main() {
const needModel = [];
for (const row of rows) {
const initial = tags.initial(row.subject);
-- if (initial.kind === 'internal') housekeeping.push({ ...row, tag: initial });
++ if (initial.kind) housekeeping.push({ ...row, tag: initial });
else needModel.push({ ...row, plain: initial.plain });
}
console.log(`[backfill] ${rows.length} untagged entr${rows.length === 1 ? 'y' : 'ies'}${service ? ` on ${service}` : ''} (limit ${limit})`);
-- console.log(`[backfill] ${housekeeping.length} housekeeping → internal without a model; ${needModel.length} need blog.changelog.tag (${Math.ceil(needModel.length / chunk)} run${Math.ceil(needModel.length / chunk) === 1 ? '' : 's'})`);
-- for (const row of housekeeping.slice(0, 40)) console.log(` internal ${row.service} ${row.sha.slice(0, 7)} ${row.tag.plain}`);
-- if (housekeeping.length > 40) console.log(` … ${housekeeping.length - 40} more housekeeping`);
++ console.log(`[backfill] ${housekeeping.length} subject-rule classifications without a model; ${needModel.length} need blog.changelog.tag (${Math.ceil(needModel.length / chunk)} run${Math.ceil(needModel.length / chunk) === 1 ? '' : 's'})`);
++ for (const row of housekeeping.slice(0, 40)) console.log(` ${row.tag.kind.padEnd(8)} ${row.service} ${row.sha.slice(0, 7)} ${row.tag.plain}`);
++ if (housekeeping.length > 40) console.log(` … ${housekeeping.length - 40} more subject-rule classifications`);
for (const row of needModel.slice(0, 40)) console.log(` model ${row.service} ${row.sha.slice(0, 7)} ${row.plain}`);
if (needModel.length > 40) console.log(` … ${needModel.length - 40} more for the model`);
@@@ -64,14 +66,14 @@ async function main() {
return 0;
}
const tagger = createChangelogTagger({ config, log: console });
-- if (!tagger.enabled) {
++ if (needModel.length && !tagger.enabled) {
console.error('[backfill] blog.changelog.tag is not configured (OV_AI_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET)');
await store.close();
return 1;
}
-- const tagStmt = db.prepare('UPDATE changelog_entries SET kind = ?, areas = ?, plain = ?, tagged_at = ?, tagged_by = ? WHERE id = ? AND kind IS NULL');
++ const tagStmt = db.prepare(`UPDATE changelog_entries SET kind = ?, areas = ?, plain = ?, tagged_at = ?, tagged_by = ? WHERE id = ?${retag ? '' : ' AND kind IS NULL'}`);
let tagged = 0;
-- for (const row of housekeeping) tagged += (await tagStmt.run('internal', [], row.tag.plain, store.now(), 'housekeeping', row.id)).changes;
++ for (const row of housekeeping) tagged += (await tagStmt.run(row.tag.kind, row.tag.areas, row.tag.plain, store.now(), retag ? 'retagged-subject' : row.tag.kind === 'internal' ? 'housekeeping' : 'subject-rule', row.id)).changes;
for (let i = 0; i < needModel.length; i += chunk) {
const batch = needModel.slice(i, i + chunk);
try {
@@@ -79,7 +81,7 @@ async function main() {
for (const row of batch) {
const t = answer.get(String(row.id));
if (!t) continue;
-- tagged += (await tagStmt.run(t.kind, t.areas, t.plain, store.now(), 'ai', row.id)).changes;
++ tagged += (await tagStmt.run(t.kind, t.areas, t.plain, store.now(), retag ? 'retagged-ai' : 'ai', row.id)).changes;
}
console.log(`[backfill] tagged ${Math.min(i + chunk, needModel.length)}/${needModel.length} model entries`);
} catch (err) {
ddiff --git a/server/changelog.js b/server/changelog.js
iindex cad543c..da591c0 100644
---- a/server/changelog.js
++++ b/server/changelog.js
@@@ -422,7 +422,7 @@ function createChangelog({ config, store, blogs, posts, reading = null, tagger =
service: e.service, repo: e.repo, sha: e.sha, short: e.sha.slice(0, 7), subject: e.subject, plain: e.plain || tags.plainOf(e.subject),
kind: e.kind && tags.isKind(e.kind) ? e.kind : 'better', areas: Array.isArray(e.areas) ? e.areas.filter(tags.isArea).slice(0, 3) : [],
author: e.author || null, committed_at: e.committed_at, deployed_at: e.deployed_at,
-- major: Boolean(e.major), url: `https://github.com/${e.repo}/commit/${e.sha}`,
++ major: Boolean(e.major), url: `https://git.openvibe.codes/${e.repo}/commit/${e.sha}`,
post_id: e.post_id && e.post_id !== 'history' && e.post_state === 'published' ? e.post_id : null,
pr: pr || null, pr_url: pr ? `https://github.com/${e.repo}/pull/${pr}` : null,
};
ddiff --git a/server/changelog/tags.js b/server/changelog/tags.js
iindex 73a676f..bdbfba9 100644
---- a/server/changelog/tags.js
++++ b/server/changelog/tags.js
@@@ -92,15 +92,29 @@ function isHousekeeping(subject) {
|| /^(?:Add|Update|Fix)\s+(?:a\s+)?(?:test|tests|docs?)\s+(?:only|for)\b/i.test(s);
}
++/** Clear subject verbs win before the model; leave ambiguous summaries for the AI tag task. */
++function subjectKind(subject) {
++ const s = plainOf(subject).replace(/^[^:]{1,24}:\s*/, '').trim();
++ if (/^(?:add|introduce|create|launch|ship|enable|support)\b/i.test(s)) return 'new';
++ if (/^(?:fix|repair|restore|prevent|stop|no longer\b)|\b(?:no longer|stays? whole|keeps? (?:the )?\w+ whole|stops? (?:being|…|\.\.\.))\b/i.test(s)) return 'fixed';
++ return null;
++}
++
++function subjectAreas(subject) {
++ const s = String(subject || '').toLowerCase();
++ return AREA_IDS.filter((id) => new RegExp(`(^|[^a-z0-9])${id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}([^a-z0-9]|$)`).test(s)).slice(0, 3);
++}
++
/**
* What is stored when an entry is first collected: plain words always, and kind internal for
* housekeeping. `kind: null` means the AI task has not answered yet.
*/
function initial(subject) {
++ const housekeeping = isHousekeeping(subject);
return {
plain: plainOf(subject),
-- kind: isHousekeeping(subject) ? 'internal' : null,
-- areas: isHousekeeping(subject) ? [] : [],
++ kind: housekeeping ? 'internal' : subjectKind(subject),
++ areas: housekeeping ? [] : (subjectKind(subject) ? subjectAreas(subject) : []),
};
}
@@@ -122,4 +136,4 @@ function vocabulary() {
return { kinds: [...KINDS], areas: AREAS.map((a) => ({ ...a })) };
}
--module.exports = { KINDS, AREAS, AREA_IDS, AREA_LABELS, vocabulary, isKind, isArea, prOf, plainOf, isHousekeeping, initial, normalizeTag };
++module.exports = { KINDS, AREAS, AREA_IDS, AREA_LABELS, vocabulary, isKind, isArea, prOf, plainOf, isHousekeeping, subjectKind, subjectAreas, initial, normalizeTag };
ddiff --git a/test/changelog-tags.test.js b/test/changelog-tags.test.js
iindex 294a7a9..78567c8 100644
---- a/test/changelog-tags.test.js
++++ b/test/changelog-tags.test.js
@@@ -47,18 +47,25 @@ const { tagsFrom, parseLoose, createChangelogTagger } = require('../server/chang
'0.376.0: Complete the Feed contract',
'Tidy the docs',
]) assert.strictEqual(tags.isHousekeeping(s), true, s);
-- for (const s of ['Fix the chat scroll', 'Recent tools in the launcher', 'Add devblog cards', 'Make sign-in faster']) {
++ for (const s of ['Recent tools in the launcher', 'Make sign-in faster']) {
assert.strictEqual(tags.isHousekeeping(s), false, s);
const initial = tags.initial(s);
assert.strictEqual(initial.kind, null, `${s} waits for the model`);
assert.ok(initial.plain.length > 0);
}
++ assert.strictEqual(tags.initial('Fix the chat scroll').kind, 'fixed');
const hk = tags.initial('openvibe-shared v4.175.0 (was v4.173.0) (#401)');
assert.strictEqual(hk.kind, 'internal');
assert.deepStrictEqual(hk.areas, []);
assert.strictEqual(hk.plain, 'openvibe-shared v4.175.0 (was v4.173.0)');
});
++ await check('clear commit subjects are classified before the AI task', async () => {
++ for (const subject of ['Add a watch page', 'feat: Add project sharing', 'Introduce saved searches']) assert.strictEqual(tags.initial(subject).kind, 'new', subject);
++ for (const subject of ['Fix the player controls', 'fix(chat): stop duplicate messages', 'No longer drops the last frame', 'Keep the image whole']) assert.strictEqual(tags.initial(subject).kind, 'fixed', subject);
++ assert.strictEqual(tags.initial('Improve the chat history').kind, null, 'ambiguous improvements are left to the AI task');
++ });
++
await check('a model answer is made safe: a known kind, up to three known areas, plain words', async () => {
assert.deepStrictEqual(tags.normalizeTag({ kind: 'new', areas: ['video', 'chat'], plain: 'Watch the clip' }), { kind: 'new', areas: ['video', 'chat'], plain: 'Watch the clip' });
assert.deepStrictEqual(tags.normalizeTag({ kind: 'fixed', areas: ['chat', 'chat', 'podcast'], plain: 'Fix the chat (#2)' }), { kind: 'fixed', areas: ['chat'], plain: 'Fix the chat' });
ddiff --git a/test/changelog.test.js b/test/changelog.test.js
iindex 31c1481..334ea4b 100644
---- a/test/changelog.test.js
++++ b/test/changelog.test.js
@@@ -63,11 +63,11 @@ const H = (n) => n.toString(16).padStart(40, 'a');
assert.strictEqual(bySubject.size, 2, 'first lines only');
assert.strictEqual(bySubject.get('Fix the chat scroll').kind, 'fixed');
assert.deepStrictEqual(bySubject.get('Fix the chat scroll').areas, ['chat']);
-- assert.strictEqual(bySubject.get('Fix the chat scroll').plain, 'plain: Fix the chat scroll');
++ assert.strictEqual(bySubject.get('Fix the chat scroll').plain, 'Fix the chat scroll');
assert.strictEqual(bySubject.get('Merge branch main').kind, 'internal', 'merges are housekeeping');
assert.deepStrictEqual(bySubject.get('Merge branch main').areas, []);
assert.ok(!tagCalls.flat().some((x) => /^Merge/.test(x.subject)), 'the model is never asked about housekeeping');
-- assert.strictEqual(bySubject.get('Fix the chat scroll').url, `https://github.com/OpenVibers/OpenVibe.Live/commit/${H(1)}`);
++ assert.strictEqual(bySubject.get('Fix the chat scroll').url, `https://git.openvibe.codes/OpenVibers/OpenVibe.Live/commit/${H(1)}`);
assert.strictEqual(bySubject.get('Fix the chat scroll').major, false);
});