fix: dedupe calendar stable releases
This commit is contained in:
@@ -121,6 +121,52 @@ describe('buildReleaseHistoryPage', () => {
|
||||
expect(html.indexOf('Tolaria Alpha 2026.4.20.10')).toBeLessThan(html.indexOf('Tolaria Alpha 2026.4.20.9'))
|
||||
})
|
||||
|
||||
it('deduplicates equivalent stable calendar tags and keeps the richer notes', () => {
|
||||
const html = buildReleaseHistoryPage([
|
||||
{
|
||||
assets: [
|
||||
{
|
||||
browser_download_url: 'https://example.com/Tolaria_2026.5.13_macOS_Silicon.dmg',
|
||||
name: 'Tolaria_2026.5.13_macOS_Silicon.dmg',
|
||||
},
|
||||
],
|
||||
body: '## What's Changed\n\n<ul><li></li></ul>',
|
||||
body_html: '<h2>What's Changed</h2><ul><li></li></ul>',
|
||||
html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/stable-v2026.5.13',
|
||||
name: 'Tolaria 2026.5.13',
|
||||
prerelease: false,
|
||||
published_at: '2026-05-13T09:30:44Z',
|
||||
tag_name: 'stable-v2026.5.13',
|
||||
},
|
||||
{
|
||||
assets: [
|
||||
{
|
||||
browser_download_url: 'https://example.com/Tolaria_2026.5.13_macOS_Silicon.dmg',
|
||||
name: 'Tolaria_2026.5.13_macOS_Silicon.dmg',
|
||||
},
|
||||
],
|
||||
body_html: [
|
||||
'<h2>What's Changed</h2>',
|
||||
'<h3>Features</h3>',
|
||||
'<ul>',
|
||||
'<li>Add AI visibility setting</li>',
|
||||
'<li>Support mounted vault workspaces</li>',
|
||||
'</ul>',
|
||||
].join(''),
|
||||
html_url: 'https://github.com/refactoringhq/tolaria/releases/tag/v2026-05-13',
|
||||
name: 'Tolaria v2026-05-13',
|
||||
prerelease: false,
|
||||
published_at: '2026-05-13T09:21:30Z',
|
||||
tag_name: 'v2026-05-13',
|
||||
},
|
||||
])
|
||||
|
||||
expect(html).toContain('Stable<span class="tab-count">1</span>')
|
||||
expect(html).toContain('Tolaria v2026-05-13')
|
||||
expect(html).toContain('Add AI visibility setting')
|
||||
expect(html).not.toContain('Tolaria 2026.5.13')
|
||||
})
|
||||
|
||||
it('filters draft releases and shows an empty state for channels without published builds', () => {
|
||||
const html = buildReleaseHistoryPage([
|
||||
{
|
||||
|
||||
@@ -516,6 +516,10 @@ const RELEASE_CHANNEL_LABELS: Record<ReleaseChannel, string> = {
|
||||
const RELEASE_CHANNEL_LABELS_BY_CHANNEL = new Map<ReleaseChannel, string>(
|
||||
Object.entries(RELEASE_CHANNEL_LABELS) as Array<[ReleaseChannel, string]>,
|
||||
)
|
||||
const STABLE_TAG_DATE_PATTERNS = [
|
||||
/^stable-v(\d{4})\.(\d{1,2})\.(\d{1,2})$/,
|
||||
/^v(\d{4})-(\d{1,2})-(\d{1,2})$/,
|
||||
]
|
||||
|
||||
function escapeMarkupText(value: string): string {
|
||||
return value
|
||||
@@ -635,6 +639,71 @@ function readableNotesUrlForRelease(channel: ReleaseChannel, tagName: string): s
|
||||
return `release-notes/${encodeURIComponent(tagName)}.md`
|
||||
}
|
||||
|
||||
function normalizeStableTagDate(tagName: string): string | null {
|
||||
for (const pattern of STABLE_TAG_DATE_PATTERNS) {
|
||||
const match = tagName.match(pattern)
|
||||
if (!match) continue
|
||||
|
||||
const [, year, month, day] = match
|
||||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function stableReleaseDedupeKey(release: ReleaseEntry): string {
|
||||
const stableDate = normalizeStableTagDate(release.tagName)
|
||||
return stableDate === null ? `tag:${release.tagName}` : `date:${stableDate}`
|
||||
}
|
||||
|
||||
function releaseNotesListItemCount(notesHtml: string): number {
|
||||
return notesHtml.match(/<li\b/gi)?.length ?? 0
|
||||
}
|
||||
|
||||
function releasePreferenceScore(release: ReleaseEntry): number {
|
||||
return releaseNotesListItemCount(release.notesHtml) + release.downloads.length
|
||||
}
|
||||
|
||||
function mergeReleaseDownloads(primary: ReleaseDownload[], secondary: ReleaseDownload[]): ReleaseDownload[] {
|
||||
const seenUrls = new Set<string>()
|
||||
const downloads: ReleaseDownload[] = []
|
||||
|
||||
for (const download of [...primary, ...secondary]) {
|
||||
if (seenUrls.has(download.url)) continue
|
||||
|
||||
seenUrls.add(download.url)
|
||||
downloads.push(download)
|
||||
}
|
||||
|
||||
return downloads
|
||||
}
|
||||
|
||||
function preferredRelease(left: ReleaseEntry, right: ReleaseEntry): ReleaseEntry {
|
||||
const leftScore = releasePreferenceScore(left)
|
||||
const rightScore = releasePreferenceScore(right)
|
||||
const selectedRelease = rightScore !== leftScore
|
||||
? (rightScore > leftScore ? right : left)
|
||||
: (right.publishedTimestamp > left.publishedTimestamp ? right : left)
|
||||
const fallbackRelease = selectedRelease === right ? left : right
|
||||
|
||||
return {
|
||||
...selectedRelease,
|
||||
downloads: mergeReleaseDownloads(selectedRelease.downloads, fallbackRelease.downloads),
|
||||
}
|
||||
}
|
||||
|
||||
function deduplicateStableReleases(releases: ReleaseEntry[]): ReleaseEntry[] {
|
||||
const releasesByDate = new Map<string, ReleaseEntry>()
|
||||
|
||||
for (const release of releases) {
|
||||
const key = stableReleaseDedupeKey(release)
|
||||
const existingRelease = releasesByDate.get(key)
|
||||
releasesByDate.set(key, existingRelease ? preferredRelease(existingRelease, release) : release)
|
||||
}
|
||||
|
||||
return Array.from(releasesByDate.values())
|
||||
}
|
||||
|
||||
function normalizeReleaseEntry(release: GitHubReleasePayload): [ReleaseChannel, ReleaseEntry] | null {
|
||||
if (release.draft === true) return null
|
||||
|
||||
@@ -678,6 +747,8 @@ function collectReleaseSections(payload: unknown): ReleaseSections {
|
||||
appendReleaseSection(sections, normalizedRelease)
|
||||
}
|
||||
|
||||
sections.stable = deduplicateStableReleases(sections.stable)
|
||||
|
||||
for (const channel of ['stable', 'alpha'] as const) {
|
||||
const section = Reflect.get(sections, channel) as ReleaseEntry[]
|
||||
section.sort((left, right) => right.publishedTimestamp - left.publishedTimestamp)
|
||||
|
||||
Reference in New Issue
Block a user