Skip to content

Commit 6346e7a

Browse files
docs-botCopilotheiskr
authored
feat: add ReleaseNotesTransformer for article API (#61436)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Kevin Heis <heiskr@users.noreply.github.com>
1 parent 72fe4fb commit 6346e7a

3 files changed

Lines changed: 315 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { describe, expect, test, vi } from 'vitest'
2+
3+
import type { Context, GHESReleasePatch, Page } from '@/types'
4+
import {
5+
ReleaseNotesTransformer,
6+
renderReleaseNotesMarkdown,
7+
} from '@/article-api/transformers/release-notes-transformer'
8+
9+
// Mock renderContent so the unit tests don't need fixtures and so we can
10+
// assert that markdownRequested: true is always threaded through. The mock
11+
// returns the input unchanged so we can verify the output shape.
12+
vi.mock('@/content-render/index', () => ({
13+
renderContent: vi.fn(async (template: string, ctx: { markdownRequested?: boolean }) => {
14+
if (!ctx?.markdownRequested) {
15+
throw new Error('renderContent was called without markdownRequested: true')
16+
}
17+
return template
18+
}),
19+
}))
20+
21+
// Mock the release notes loader so the unit tests don't depend on fixtures
22+
// existing on disk. Returns empty data, which makes the "release not found"
23+
// branch in transform() the deterministic outcome.
24+
vi.mock('@/release-notes/middleware/get-release-notes', () => ({
25+
getReleaseNotes: vi.fn(() => ({})),
26+
}))
27+
28+
const makeContext = (overrides: Partial<Context> = {}): Context =>
29+
({
30+
currentVersion: 'enterprise-server@3.21',
31+
currentLanguage: 'en',
32+
...overrides,
33+
}) as Context
34+
35+
const makePage = (overrides: Partial<Page> = {}): Page =>
36+
({
37+
layout: 'release-notes',
38+
title: 'GitHub Enterprise Server 3.21 release notes',
39+
intro: '',
40+
renderProp: async () => '',
41+
...overrides,
42+
}) as unknown as Page
43+
44+
const samplePatch = (): GHESReleasePatch => ({
45+
version: '3.21.0',
46+
patchVersion: '0',
47+
downloadVersion: '3.21.0',
48+
release: '3.21',
49+
date: '2026-05-26',
50+
intro: 'Intro paragraph for this patch.',
51+
sections: {
52+
features: ['A new feature note.'],
53+
bugs: ['A bug was fixed.', 'Another bug was fixed.'],
54+
known_issues: [
55+
{ heading: 'Instance administration', notes: ['Sub note one.', 'Sub note two.'] },
56+
],
57+
security_fixes: ['**CRITICAL**: Something serious.'],
58+
},
59+
})
60+
61+
describe('ReleaseNotesTransformer', () => {
62+
describe('canTransform', () => {
63+
test('matches pages with layout: release-notes', () => {
64+
const transformer = new ReleaseNotesTransformer()
65+
expect(transformer.canTransform(makePage({ layout: 'release-notes' }))).toBe(true)
66+
})
67+
68+
test('does not match pages with other layouts', () => {
69+
const transformer = new ReleaseNotesTransformer()
70+
expect(transformer.canTransform(makePage({ layout: 'default' } as unknown as Page))).toBe(
71+
false,
72+
)
73+
expect(transformer.canTransform(makePage({ layout: undefined } as unknown as Page))).toBe(
74+
false,
75+
)
76+
})
77+
})
78+
79+
describe('transform error paths', () => {
80+
test('throws when currentVersion is missing', async () => {
81+
const transformer = new ReleaseNotesTransformer()
82+
await expect(
83+
transformer.transform(
84+
makePage(),
85+
'/x',
86+
makeContext({ currentVersion: undefined as unknown as string }),
87+
),
88+
).rejects.toThrow(/No currentVersion/)
89+
})
90+
91+
test('throws when plan is not enterprise-server', async () => {
92+
const transformer = new ReleaseNotesTransformer()
93+
await expect(
94+
transformer.transform(
95+
makePage(),
96+
'/x',
97+
makeContext({ currentVersion: 'free-pro-team@latest' }),
98+
),
99+
).rejects.toThrow(/only supports enterprise-server/)
100+
})
101+
102+
test('throws when release is not found', async () => {
103+
const transformer = new ReleaseNotesTransformer()
104+
await expect(
105+
transformer.transform(
106+
makePage(),
107+
'/x',
108+
makeContext({ currentVersion: 'enterprise-server@0.0' }),
109+
),
110+
).rejects.toThrow(/No release notes found/)
111+
})
112+
})
113+
})
114+
115+
describe('renderReleaseNotesMarkdown', () => {
116+
test('builds H1 title and intro', async () => {
117+
const out = await renderReleaseNotesMarkdown('My Title', 'Intro line.', [], makeContext())
118+
expect(out).toMatch(/^# My Title\n\nIntro line\.$/)
119+
})
120+
121+
test('renders H2 per patch and H3 per section with bulleted notes', async () => {
122+
const out = await renderReleaseNotesMarkdown('Title', '', [samplePatch()], makeContext())
123+
124+
expect(out).toContain('## 3.21.0')
125+
expect(out).toContain('**Release date:** 2026-05-26')
126+
expect(out).toContain('Intro paragraph for this patch.')
127+
128+
expect(out).toContain('### Features')
129+
expect(out).toContain('- A new feature note.')
130+
131+
expect(out).toContain('### Bug fixes')
132+
expect(out).toContain('- A bug was fixed.')
133+
expect(out).toContain('- Another bug was fixed.')
134+
135+
expect(out).toContain('### Security fixes')
136+
expect(out).toContain('- **CRITICAL**: Something serious.')
137+
})
138+
139+
test('renders { heading, notes } as a nested bulleted list', async () => {
140+
const out = await renderReleaseNotesMarkdown('Title', '', [samplePatch()], makeContext())
141+
142+
expect(out).toContain('### Known issues')
143+
// Heading is a top-level bullet, sub-notes are nested under it.
144+
expect(out).toContain('- **Instance administration**')
145+
expect(out).toMatch(
146+
/- \*\*Instance administration\*\*\n {2}- Sub note one\.\n {2}- Sub note two\./,
147+
)
148+
})
149+
150+
test('preserves multi-line notes with continuation indent', async () => {
151+
const patch: GHESReleasePatch = {
152+
...samplePatch(),
153+
sections: { features: ['Line one.\n\nLine two.'] },
154+
}
155+
const out = await renderReleaseNotesMarkdown('Title', '', [patch], makeContext())
156+
expect(out).toMatch(/- Line one\.\n\n {2}Line two\./)
157+
})
158+
159+
test('throws on unrecognized note shape', async () => {
160+
const patch: GHESReleasePatch = {
161+
...samplePatch(),
162+
sections: { features: [{ unknown: 'shape' } as unknown as string] },
163+
}
164+
await expect(renderReleaseNotesMarkdown('Title', '', [patch], makeContext())).rejects.toThrow(
165+
/Unrecognized release note shape/,
166+
)
167+
})
168+
169+
test('uses raw section key when label is unknown', async () => {
170+
const patch: GHESReleasePatch = {
171+
...samplePatch(),
172+
sections: { custom_section: ['A note.'] } as unknown as GHESReleasePatch['sections'],
173+
}
174+
const out = await renderReleaseNotesMarkdown('Title', '', [patch], makeContext())
175+
expect(out).toContain('### custom_section')
176+
})
177+
178+
test('skips empty sections', async () => {
179+
const patch: GHESReleasePatch = {
180+
...samplePatch(),
181+
sections: { features: [], bugs: ['A bug.'] },
182+
}
183+
const out = await renderReleaseNotesMarkdown('Title', '', [patch], makeContext())
184+
expect(out).not.toContain('### Features')
185+
expect(out).toContain('### Bug fixes')
186+
})
187+
})

src/article-api/transformers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { JourneyLandingTransformer } from './journey-landing-transformer'
1515
import { CategoryLandingTransformer } from './category-landing-transformer'
1616
import { DiscoveryLandingTransformer } from './discovery-landing-transformer'
1717
import { SearchPageTransformer } from './search-page-transformer'
18+
import { ReleaseNotesTransformer } from './release-notes-transformer'
1819
import { ArticleTransformer } from './article-transformer'
1920

2021
/**
@@ -39,6 +40,7 @@ transformerRegistry.register(new JourneyLandingTransformer())
3940
transformerRegistry.register(new CategoryLandingTransformer())
4041
transformerRegistry.register(new DiscoveryLandingTransformer())
4142
transformerRegistry.register(new SearchPageTransformer())
43+
transformerRegistry.register(new ReleaseNotesTransformer())
4244
// ArticleTransformer is the catch-all — must be registered last.
4345
transformerRegistry.register(new ArticleTransformer())
4446

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import type { Context, Page, GHESReleasePatch } from '@/types'
2+
import type { PageTransformer } from './types'
3+
import { getReleaseNotes } from '@/release-notes/middleware/get-release-notes'
4+
import { formatReleases } from '@/release-notes/lib/release-notes-utils'
5+
import { renderContent } from '@/content-render/index'
6+
7+
/**
8+
* Transformer for GHES enterprise-server release notes pages.
9+
*
10+
* The release notes content comes from YAML data files (not the markdown body),
11+
* so the generic ArticleTransformer would return an empty body. This transformer
12+
* fetches the release notes data directly and renders it as markdown.
13+
*/
14+
export class ReleaseNotesTransformer implements PageTransformer {
15+
canTransform(page: Page): boolean {
16+
return page.layout === 'release-notes'
17+
}
18+
19+
async transform(page: Page, _pathname: string, context: Context): Promise<string> {
20+
const currentVersion = context.currentVersion
21+
if (!currentVersion) {
22+
throw new Error('No currentVersion in context for release notes transformer')
23+
}
24+
25+
const [plan, release] = currentVersion.split('@')
26+
if (plan !== 'enterprise-server') {
27+
throw new Error(`Release notes transformer only supports enterprise-server, got: ${plan}`)
28+
}
29+
30+
const releaseNotes = getReleaseNotes('enterprise-server', 'en')
31+
const allReleases = formatReleases(releaseNotes)
32+
33+
const matchedRelease = allReleases.find((r) => r.version === release)
34+
if (!matchedRelease) {
35+
throw new Error(`No release notes found for enterprise-server@${release}`)
36+
}
37+
38+
const title = page.title
39+
const intro = page.intro ? await page.renderProp('intro', context, { textOnly: true }) : ''
40+
41+
return await renderReleaseNotesMarkdown(title, intro, matchedRelease.patches, context)
42+
}
43+
}
44+
45+
// Matches the labels used by the web renderer; keep in sync with
46+
// src/release-notes/components/PatchNotes.tsx.
47+
const SECTION_LABELS: Record<string, string> = {
48+
features: 'Features',
49+
bugs: 'Bug fixes',
50+
known_issues: 'Known issues',
51+
security_fixes: 'Security fixes',
52+
changes: 'Changes',
53+
deprecations: 'Deprecations',
54+
backups: 'Backups',
55+
errata: 'Errata',
56+
closing_down: 'Closing down',
57+
retired: 'Retired',
58+
}
59+
60+
async function renderNoteMarkdown(raw: string, context: Context): Promise<string> {
61+
return await renderContent(raw, { ...context, markdownRequested: true })
62+
}
63+
64+
// Format `text` as a list item at the given indent depth. The first line
65+
// gets the `- ` bullet; continuation lines are indented to align under it,
66+
// so multi-paragraph notes and fenced code blocks stay inside the list item
67+
// per CommonMark/GFM rules.
68+
function bulletize(text: string, depth = 0): string {
69+
const trimmed = text.replace(/\s+$/, '')
70+
if (!trimmed) return ''
71+
const indent = ' '.repeat(depth)
72+
const continuationIndent = `${indent} `
73+
const [first, ...rest] = trimmed.split('\n')
74+
if (rest.length === 0) return `${indent}- ${first}`
75+
const continuation = rest.map((line) => (line.length ? `${continuationIndent}${line}` : line))
76+
return `${indent}- ${first}\n${continuation.join('\n')}`
77+
}
78+
79+
export async function renderReleaseNotesMarkdown(
80+
title: string,
81+
intro: string,
82+
patches: GHESReleasePatch[],
83+
context: Context,
84+
): Promise<string> {
85+
const lines: string[] = [`# ${title}`]
86+
if (intro) lines.push('', intro)
87+
88+
for (const patch of patches) {
89+
lines.push('', `## ${patch.version}`)
90+
if (patch.date) lines.push('', `**Release date:** ${patch.date}`)
91+
if (patch.intro) {
92+
lines.push('', await renderNoteMarkdown(patch.intro, context))
93+
}
94+
95+
for (const [sectionKey, sectionArray] of Object.entries(patch.sections)) {
96+
if (!Array.isArray(sectionArray) || sectionArray.length === 0) continue
97+
const sectionLabel = SECTION_LABELS[sectionKey] || sectionKey
98+
lines.push('', `### ${sectionLabel}`)
99+
100+
for (const note of sectionArray) {
101+
if (typeof note === 'string') {
102+
const rendered = await renderNoteMarkdown(note, context)
103+
lines.push('', bulletize(rendered))
104+
} else if (
105+
note &&
106+
typeof note === 'object' &&
107+
'heading' in note &&
108+
'notes' in note &&
109+
Array.isArray((note as { notes: unknown }).notes)
110+
) {
111+
const heading = (note as { heading: string }).heading
112+
const subNotes = (note as { notes: string[] }).notes
113+
lines.push('', `- **${heading}**`)
114+
for (const subNote of subNotes) {
115+
const rendered = await renderNoteMarkdown(subNote, context)
116+
lines.push(bulletize(rendered, 1))
117+
}
118+
} else {
119+
throw new Error(`Unrecognized release note shape in section ${sectionKey}`)
120+
}
121+
}
122+
}
123+
}
124+
125+
return lines.join('\n')
126+
}

0 commit comments

Comments
 (0)