feat(updates): "What's New" highlights after update + pre-update teaser
Surfaces release highlights to admins, sourced from the GitHub release notes (no AI at runtime). Bullets are written once per release in CI via GitHub Models (see docs/ci/whatsnew-highlights.yml) into a <!-- whatsnew --> block; the app reads that block and falls back to the changelog's "### Features" for releases without it — so it works against today's releases immediately. - backend utils/whatsNew.parseWhatsNew(body): curated block else Features section, strips scope/PR-links, de-dups, caps at 8 (tested). - GET /admin/system/updates/whatsnew: highlights for every version moved through since the per-instance marker (whatsnew_last_seen_version); fresh installs self-anchor silently. Best-effort, never errors. - POST /admin/system/updates/whatsnew/seen: advance the marker (per-instance). - /admin/system/updates also returns latestHighlights for the teaser. - Frontend: WhatsNewBanner (green bar -> modal with "Full changelog" link) on the dashboard via adminService; UpdateNotification shows a "New features include:" teaser. i18n de/en. No migration (uses app_settings).
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Extract user-facing "What's New" bullets from a GitHub release body.
|
||||
*
|
||||
* Prefers the curated block the release CI injects via GitHub Models:
|
||||
* <!-- whatsnew -->\n- bullet\n- bullet\n<!-- /whatsnew -->
|
||||
* and falls back to the release-please "### Features" section for releases
|
||||
* created before that CI step existed — so the feature works against
|
||||
* today's releases too (just with longer, auto-cleaned lines).
|
||||
*
|
||||
* Returns at most MAX_BULLETS cleaned strings.
|
||||
*/
|
||||
|
||||
const MAX_BULLETS = 8;
|
||||
|
||||
/** Strip list markers, conventional-commit scope, and trailing PR/sha links. */
|
||||
function cleanBullet(line) {
|
||||
return line
|
||||
.replace(/^\s*[-*]\s+/, '') // "- " / "* " marker
|
||||
.replace(/^\*\*([^:*]+):\*\*\s*/, '') // "**scope:** " prefix
|
||||
.replace(/\s*\(\[[^\]]*\]\([^)]*\)\)/g, '') // " ([#41](url))" / " ([sha](url))"
|
||||
.replace(/\s*\(#\d+\)/g, '') // bare " (#41)"
|
||||
.replace(/`/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Pull the lines under a "## Features" / "### Features" heading. */
|
||||
function extractFeaturesSection(body) {
|
||||
const out = [];
|
||||
let inFeatures = false;
|
||||
for (const raw of body.split('\n')) {
|
||||
const line = raw.trimEnd();
|
||||
if (/^#{2,4}\s+Features\b/i.test(line)) { inFeatures = true; continue; }
|
||||
if (inFeatures && /^#{1,4}\s+\S/.test(line)) break; // next heading ends the section
|
||||
if (inFeatures) out.push(line);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} body release notes markdown
|
||||
* @returns {string[]} up to 8 user-facing bullet strings
|
||||
*/
|
||||
function parseWhatsNew(body) {
|
||||
if (!body || typeof body !== 'string') return [];
|
||||
const block = body.match(/<!--\s*whatsnew\s*-->([\s\S]*?)<!--\s*\/whatsnew\s*-->/i);
|
||||
const src = block ? block[1] : extractFeaturesSection(body);
|
||||
const bullets = src
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^[-*]\s+/.test(l))
|
||||
.map(cleanBullet)
|
||||
.filter((l) => l.length > 0);
|
||||
// De-dup while preserving order, then cap.
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const b of bullets) {
|
||||
const key = b.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
unique.push(b);
|
||||
}
|
||||
return unique.slice(0, MAX_BULLETS);
|
||||
}
|
||||
|
||||
module.exports = { parseWhatsNew };
|
||||
Reference in New Issue
Block a user