openvibers/openvibe.blog Commits
Classify changelog kinds from commit subjects (#272)
Changes
@@@ -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) {@@@ -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,};@@@ -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 };@@@ -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' });@@@ -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);});
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* node scripts/backfill-changelog-tags.js --service live # one site
* 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 --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 # 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
* 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
* 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}`);
@@ -24,7 +25,7 @@ const has = (name) => args.includes(`--${name}`);
async function main() {
async function main() {
if (has('help')) {
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;
return 0;
}
}
const config = require('../server/config').load();
const config = require('../server/config').load();
@@ -39,7 +40,8 @@ async function main() {
@@ -39,7 +40,8 @@ async function main() {
const { openStore } = require('../server/db');
const { openStore } = require('../server/db');
const store = await openStore(config, { log: console });
const store = await openStore(config, { log: console });
const db = store.db;
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 = [];
const params = [];
if (service) { where.push('service = ?'); params.push(service); }
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);
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() {
@@ -48,13 +50,13 @@ async function main() {
const needModel = [];
const needModel = [];
for (const row of rows) {
for (const row of rows) {
const initial = tags.initial(row.subject);
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 });
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] ${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}`);
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`);
if (needModel.length > 40) console.log(` … ${needModel.length - 40} more for the model`);
@@ -64,14 +66,14 @@ async function main() {
@@ -64,14 +66,14 @@ async function main() {
return 0;
return 0;
}
}
const tagger = createChangelogTagger({ config, log: console });
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)');
console.error('[backfill] blog.changelog.tag is not configured (OV_AI_INTERNAL_URL and OV_OAUTH_CLIENT_SECRET)');
await store.close();
await store.close();
return 1;
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;
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) {
for (let i = 0; i < needModel.length; i += chunk) {
const batch = needModel.slice(i, i + chunk);
const batch = needModel.slice(i, i + chunk);
try {
try {
@@ -79,7 +81,7 @@ async function main() {
@@ -79,7 +81,7 @@ async function main() {
for (const row of batch) {
for (const row of batch) {
const t = answer.get(String(row.id));
const t = answer.get(String(row.id));
if (!t) continue;
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`);
console.log(`[backfill] tagged ${Math.min(i + chunk, needModel.length)}/${needModel.length} model entries`);
} catch (err) {
} catch (err) {
@@ -422,7 +422,7 @@ function createChangelog({ config, store, blogs, posts, reading = null, tagger =
@@ -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),
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) : [],
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,
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,
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,
pr: pr || null, pr_url: pr ? `https://github.com/${e.repo}/pull/${pr}` : null,
};
};
@@ -92,15 +92,29 @@ function isHousekeeping(subject) {
@@ -92,15 +92,29 @@ function isHousekeeping(subject) {
|| /^(?:Add|Update|Fix)\s+(?:a\s+)?(?:test|tests|docs?)\s+(?:only|for)\b/i.test(s);
|| /^(?: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
* 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.
* housekeeping. `kind: null` means the AI task has not answered yet.
*/
*/
function initial(subject) {
function initial(subject) {
+ const housekeeping = isHousekeeping(subject);
return {
return {
plain: plainOf(subject),
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() {
@@ -122,4 +136,4 @@ function vocabulary() {
return { kinds: [...KINDS], areas: AREAS.map((a) => ({ ...a })) };
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 };
@@ -47,18 +47,25 @@ const { tagsFrom, parseLoose, createChangelogTagger } = require('../server/chang
@@ -47,18 +47,25 @@ const { tagsFrom, parseLoose, createChangelogTagger } = require('../server/chang
'0.376.0: Complete the Feed contract',
'0.376.0: Complete the Feed contract',
'Tidy the docs',
'Tidy the docs',
]) assert.strictEqual(tags.isHousekeeping(s), true, s);
]) 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);
assert.strictEqual(tags.isHousekeeping(s), false, s);
const initial = tags.initial(s);
const initial = tags.initial(s);
assert.strictEqual(initial.kind, null, `${s} waits for the model`);
assert.strictEqual(initial.kind, null, `${s} waits for the model`);
assert.ok(initial.plain.length > 0);
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)');
const hk = tags.initial('openvibe-shared v4.175.0 (was v4.173.0) (#401)');
assert.strictEqual(hk.kind, 'internal');
assert.strictEqual(hk.kind, 'internal');
assert.deepStrictEqual(hk.areas, []);
assert.deepStrictEqual(hk.areas, []);
assert.strictEqual(hk.plain, 'openvibe-shared v4.175.0 (was v4.173.0)');
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 () => {
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: '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' });
assert.deepStrictEqual(tags.normalizeTag({ kind: 'fixed', areas: ['chat', 'chat', 'podcast'], plain: 'Fix the chat (#2)' }), { kind: 'fixed', areas: ['chat'], plain: 'Fix the chat' });
@@ -63,11 +63,11 @@ const H = (n) => n.toString(16).padStart(40, 'a');
@@ -63,11 +63,11 @@ const H = (n) => n.toString(16).padStart(40, 'a');
assert.strictEqual(bySubject.size, 2, 'first lines only');
assert.strictEqual(bySubject.size, 2, 'first lines only');
assert.strictEqual(bySubject.get('Fix the chat scroll').kind, 'fixed');
assert.strictEqual(bySubject.get('Fix the chat scroll').kind, 'fixed');
assert.deepStrictEqual(bySubject.get('Fix the chat scroll').areas, ['chat']);
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.strictEqual(bySubject.get('Merge branch main').kind, 'internal', 'merges are housekeeping');
assert.deepStrictEqual(bySubject.get('Merge branch main').areas, []);
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.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);
assert.strictEqual(bySubject.get('Fix the chat scroll').major, false);
});
});