From 87b5ae62ab7c12f809e0260d3c81839934313f97 Mon Sep 17 00:00:00 2001 From: Peter Bengtsson Date: Fri, 14 Jun 2024 13:20:53 -0400 Subject: [PATCH] Port `ghes-release-notes.js` to TypeScript (#51196) --- .../release-notes/enterprise-server/README.md | 2 +- src/frame/middleware/index.ts | 2 +- ...-notes-utils.js => release-notes-utils.ts} | 33 ++++++++----- ...-release-notes.js => get-release-notes.ts} | 9 ++-- ...release-notes.js => ghes-release-notes.ts} | 38 ++++++++++----- ...{release-notes-1.js => release-notes-1.ts} | 4 +- .../{release-notes.js => release-notes.ts} | 9 ++-- src/types.ts | 47 +++++++++++++++++++ 8 files changed, 106 insertions(+), 38 deletions(-) rename src/release-notes/lib/{release-notes-utils.js => release-notes-utils.ts} (78%) rename src/release-notes/middleware/{get-release-notes.js => get-release-notes.ts} (90%) rename src/release-notes/middleware/{ghes-release-notes.js => ghes-release-notes.ts} (74%) rename src/release-notes/tests/{release-notes-1.js => release-notes-1.ts} (94%) rename src/release-notes/tests/{release-notes.js => release-notes.ts} (80%) diff --git a/data/release-notes/enterprise-server/README.md b/data/release-notes/enterprise-server/README.md index b8d0cca276..8add320023 100644 --- a/data/release-notes/enterprise-server/README.md +++ b/data/release-notes/enterprise-server/README.md @@ -31,7 +31,7 @@ Note that patch files can be deprecated individually (i.e., hidden on the docs s ### Middleware processing -The YAML data is processed and sorted by `src/release-notes/middleware/context/ghes-release-notes.js` and added to the `context` object. +The YAML data is processed and sorted by `src/release-notes/middleware/context/ghes-release-notes.ts` and added to the `context` object. ### Layouts diff --git a/src/frame/middleware/index.ts b/src/frame/middleware/index.ts index a60d3a2617..1c3f1206c8 100644 --- a/src/frame/middleware/index.ts +++ b/src/frame/middleware/index.ts @@ -37,7 +37,7 @@ import earlyAccessLinks from '@/early-access/middleware/early-access-links' import categoriesForSupport from './categories-for-support' import triggerError from '@/observability/middleware/trigger-error' import secretScanning from '@/secret-scanning/middleware/secret-scanning' -import ghesReleaseNotes from '@/release-notes/middleware/ghes-release-notes.js' +import ghesReleaseNotes from '@/release-notes/middleware/ghes-release-notes' import whatsNewChangelog from './context/whats-new-changelog.js' import layout from './context/layout.js' import currentProductTree from './context/current-product-tree.js' diff --git a/src/release-notes/lib/release-notes-utils.js b/src/release-notes/lib/release-notes-utils.ts similarity index 78% rename from src/release-notes/lib/release-notes-utils.js rename to src/release-notes/lib/release-notes-utils.ts index 2e30916a8e..3bb94aed01 100644 --- a/src/release-notes/lib/release-notes-utils.js +++ b/src/release-notes/lib/release-notes-utils.ts @@ -1,13 +1,14 @@ import semver from 'semver' -import { supported, latestStable, latest } from '#src/versions/lib/enterprise-server-releases.js' -import { renderContent } from '#src/content-render/index.js' +import { supported, latestStable, latest } from '@/versions/lib/enterprise-server-releases.js' +import { renderContent } from '@/content-render/index.js' +import type { Context, GHESReleasePatch, ReleaseNotes } from '@/types' /** * Create an array of release note objects and sort them by number. * Turn { [key]: { notes, intro, date, sections... } } * Into [{ version, patches: [ {notes, intro, date, sections... }] }] */ -export function formatReleases(releaseNotes) { +export function formatReleases(releaseNotes: ReleaseNotes) { // Get release note numbers in dot notation and sort from highest to lowest. const sortedReleaseNumbers = Object.keys(releaseNotes) .map((r) => r.replace(/-/g, '.')) @@ -21,11 +22,11 @@ export function formatReleases(releaseNotes) { // Change version-rc1 to version-rc.1 to make these proper semver RC versions. const patchNumberSemver = patchNumber.replace(/rc/, 'rc.') return { + ...notesPerVersion[patchNumber], version: `${releaseNumber}.${patchNumberSemver}`, patchVersion: patchNumberSemver, downloadVersion: `${releaseNumber}.${patchNumber.replace(/-rc\d*$/, '')}`, // Remove RC release: releaseNumber, - ...notesPerVersion[patchNumber], } }) .sort((a, b) => semver.compare(b.version, a.version)) @@ -50,11 +51,15 @@ export function formatReleases(releaseNotes) { * case of a sub-section. * Returns [{version, patchVersion, intro, date, sections: { features: [], bugs: []...}}] */ -export async function renderPatchNotes(patches, ctx) { +export async function renderPatchNotes( + patches: GHESReleasePatch[], + ctx: Context, +): Promise { return await Promise.all( patches.map(async (patch) => { // Clone the patch object but drop 'sections' so we can render them below without mutations - const { sections, ...renderedPatch } = patch + // const { sections } = patch + const renderedPatch: GHESReleasePatch = { ...patch, sections: {} } renderedPatch.intro = await renderContent(patch.intro, ctx) // Now render the sections... @@ -69,17 +74,19 @@ export async function renderPatchNotes(patches, ctx) { // where `note` may be a string or an object like { heading, notes: []} if (typeof note === 'string') { return renderContent(note, ctx) + } else if (typeof note === 'object' && 'heading' in note && 'notes' in note) { + return { + heading: note.heading, + notes: await Promise.all( + note.notes.map(async (noteStr) => renderContent(noteStr, ctx)), + ), + } } else { - const renderedNoteObj = {} - renderedNoteObj.heading = note.heading - renderedNoteObj.notes = await Promise.all( - note.notes.map(async (noteStr) => renderContent(noteStr, ctx)), - ) - - return renderedNoteObj + throw new Error('Unrecognized note type') } }), ) + return [sectionType, renderedSectionArray] }), ), diff --git a/src/release-notes/middleware/get-release-notes.js b/src/release-notes/middleware/get-release-notes.ts similarity index 90% rename from src/release-notes/middleware/get-release-notes.js rename to src/release-notes/middleware/get-release-notes.ts index 224ad7216c..e32931d0df 100644 --- a/src/release-notes/middleware/get-release-notes.js +++ b/src/release-notes/middleware/get-release-notes.ts @@ -1,11 +1,12 @@ -import { getDataByLanguage, getDeepDataByLanguage } from '#src/data-directory/lib/get-data.js' +import { getDataByLanguage, getDeepDataByLanguage } from '@/data-directory/lib/get-data.js' +import type { ReleaseNotes } from '@/types' // If we one day support release-notes for other products, add it here. // Checking against this is only really to make sure there's no typos // since we don't have TypeScript to make sure the argument is valid. const VALID_PREFIXES = new Set(['enterprise-server', 'github-ae']) -export function getReleaseNotes(prefix, langCode) { +export function getReleaseNotes(prefix: string, langCode: string) { if (!VALID_PREFIXES.has(prefix)) { throw new Error( `'${prefix}' is not a valid prefix for this function. Must be one of ${Array.from( @@ -16,7 +17,7 @@ export function getReleaseNotes(prefix, langCode) { // Use English as the foundation, then we'll try to load each individual // data/release-notes/**/*.yml file from the translation. // If the language is 'en', don't even bother merging. - const releaseNotes = getDeepDataByLanguage(`release-notes.${prefix}`, 'en') + const releaseNotes = getDeepDataByLanguage(`release-notes.${prefix}`, 'en') as ReleaseNotes if (langCode === 'en') { // Exit early because nothing special needs to be done. return releaseNotes @@ -34,7 +35,7 @@ export function getReleaseNotes(prefix, langCode) { // use the English ones. // The output of `getDeepDataByLanguage()` is a mutable object // from a memoize cache, so don't mutate it to avoid confusing bugs. - const translatedReleaseNotes = {} + const translatedReleaseNotes: ReleaseNotes = {} // Now, let's iterated over all nested keys and for each one load in the // translated releases. diff --git a/src/release-notes/middleware/ghes-release-notes.js b/src/release-notes/middleware/ghes-release-notes.ts similarity index 74% rename from src/release-notes/middleware/ghes-release-notes.js rename to src/release-notes/middleware/ghes-release-notes.ts index 5e14db2597..5d403a854a 100644 --- a/src/release-notes/middleware/ghes-release-notes.js +++ b/src/release-notes/middleware/ghes-release-notes.ts @@ -1,9 +1,18 @@ -import { formatReleases, renderPatchNotes } from '#src/release-notes/lib/release-notes-utils.js' -import { all } from '#src/versions/lib/enterprise-server-releases.js' -import { executeWithFallback } from '#src/languages/lib/render-with-fallback.js' -import { getReleaseNotes } from './get-release-notes.js' +import type { NextFunction, Response } from 'express' -export default async function ghesReleaseNotesContext(req, res, next) { +import { formatReleases, renderPatchNotes } from '@/release-notes/lib/release-notes-utils' +import { all } from '@/versions/lib/enterprise-server-releases.js' +import { executeWithFallback } from '@/languages/lib/render-with-fallback.js' +import { getReleaseNotes } from './get-release-notes' +import type { Context, ExtendedRequest } from '@/types' + +export default async function ghesReleaseNotesContext( + req: ExtendedRequest, + res: Response, + next: NextFunction, +) { + if (!req.pagePath || !req.context || !req.context.currentVersion) + throw new Error('request not contextualized') if (!(req.pagePath.endsWith('/release-notes') || req.pagePath.endsWith('/admin'))) return next() const [requestedPlan, requestedRelease] = req.context.currentVersion.split('@') if (requestedPlan !== 'enterprise-server') return next() @@ -38,9 +47,9 @@ export default async function ghesReleaseNotesContext(req, res, next) { req.context.ghesReleases = formatReleases(ghesReleaseNotes) // Find the notes for the current release only - const currentReleaseNotes = req.context.ghesReleases.find( - (r) => r.version === requestedRelease, - ).patches + const matchedReleaseNotes = req.context.ghesReleases.find((r) => r.version === requestedRelease) + if (!matchedReleaseNotes) throw new Error('Release notes not found') + const currentReleaseNotes = matchedReleaseNotes.patches // This means the AUTOTITLE links are in the current language, but // since we're already force the source of the release notes from English @@ -55,15 +64,18 @@ export default async function ghesReleaseNotesContext(req, res, next) { // Returns the current release's patches array: [{version, patchVersion, intro, date, sections}] req.context.ghesReleaseNotes = await executeWithFallback( req.context, - () => renderPatchNotes(currentReleaseNotes, req.context), - (enContext) => { + () => renderPatchNotes(currentReleaseNotes, req.context!), + (enContext: Context) => { // Something in the release notes ultimately caused a Liquid // rendering error. Let's start over and gather the English release // notes instead. enContext.ghesReleases = formatReleases(ghesReleaseNotes) - const currentReleaseNotes = enContext.ghesReleases.find( + + const matchedReleaseNotes = enContext.ghesReleases!.find( (r) => r.version === requestedRelease, - ).patches + ) + if (!matchedReleaseNotes) throw new Error('Release notes not found') + const currentReleaseNotes = matchedReleaseNotes.patches return renderPatchNotes(currentReleaseNotes, enContext) }, ) @@ -74,7 +86,7 @@ export default async function ghesReleaseNotesContext(req, res, next) { // GHES release notes on docs started with 2.20 but older release notes exist on enterprise.github.com. // So we want to use _all_ GHES versions when calculating next and previous releases. - req.context.latestPatch = req.context.ghesReleaseNotes[0].version + req.context.latestPatch = req.context.ghesReleaseNotes![0].version req.context.latestRelease = all[0] // Add convenience props for "Supported releases" section on GHES Admin landing page (NOT release notes). diff --git a/src/release-notes/tests/release-notes-1.js b/src/release-notes/tests/release-notes-1.ts similarity index 94% rename from src/release-notes/tests/release-notes-1.js rename to src/release-notes/tests/release-notes-1.ts index 9d447e9963..85f7f43661 100644 --- a/src/release-notes/tests/release-notes-1.js +++ b/src/release-notes/tests/release-notes-1.ts @@ -1,8 +1,8 @@ import { afterAll, beforeAll, describe, expect, test, vi } from 'vitest' import nock from 'nock' -import { get, getDOM } from '#src/tests/helpers/e2etest.js' -import enterpriseServerReleases from '#src/versions/lib/enterprise-server-releases.js' +import { get, getDOM } from '@/tests/helpers/e2etest.js' +import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases.js' describe('release notes', () => { vi.setConfig({ testTimeout: 60 * 1000 }) diff --git a/src/release-notes/tests/release-notes.js b/src/release-notes/tests/release-notes.ts similarity index 80% rename from src/release-notes/tests/release-notes.js rename to src/release-notes/tests/release-notes.ts index 10b42179f4..42ea044641 100644 --- a/src/release-notes/tests/release-notes.js +++ b/src/release-notes/tests/release-notes.ts @@ -1,8 +1,8 @@ import { describe, expect, test, vi } from 'vitest' -import enterpriseServerReleases from '#src/versions/lib/enterprise-server-releases.js' -import { get } from '#src/tests/helpers/e2etest.js' -import Page from '#src/frame/lib/page.js' +import enterpriseServerReleases from '@/versions/lib/enterprise-server-releases.js' +import { get } from '@/tests/helpers/e2etest.js' +import Page from '@/frame/lib/page.js' // The English content page's `versions:` frontmatter is the source // of (convenient) truth about which versions of this page is available. @@ -11,6 +11,7 @@ const page = await Page.init({ relativePath: 'admin/release-notes.md', languageCode: 'en', }) +if (!page) throw new Error('Page not found') describe('server', () => { vi.setConfig({ testTimeout: 60 * 1000 }) @@ -28,7 +29,7 @@ describe('server', () => { expect(res.statusCode).toBe(200) }) - const { applicableVersions } = page + const applicableVersions = page.applicableVersions test.each(applicableVersions)('version %s that has release-notes', async (version) => { const url = `/en/${version}/admin/release-notes` diff --git a/src/types.ts b/src/types.ts index 20110fed01..e2975a0aa4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -75,6 +75,52 @@ export type Context = { redirectNotFound?: string earlyAccessPageLinks?: string secretScanningData?: SecretScanningData[] + ghesReleases?: GHESRelease[] + ghesReleaseNotes?: GHESReleasePatch[] + autotitleLanguage?: string + latestPatch?: string + latestRelease?: string +} + +export type GHESRelease = { + version: string + patches: GHESReleasePatch[] + isReleaseCandidate: boolean + firstPreviousRelease?: string + secondPreviousRelease?: string +} + +type ReleasePatchSectionNote = { + heading: string + notes: string[] +} + +type ReleasePatchSection = { + security_fixes?: string[] | ReleasePatchSectionNote[] + known_issues?: string[] | ReleasePatchSectionNote[] + features?: string[] | ReleasePatchSectionNote[] + deprecations?: string[] | ReleasePatchSectionNote[] + bugs?: string[] | ReleasePatchSectionNote[] + errata?: string[] | ReleasePatchSectionNote[] + backups?: string[] | ReleasePatchSectionNote[] +} + +export type GHESReleasePatch = { + version: string + patchVersion: string + downloadVersion: string + release: string + date: string + release_candidate?: boolean + deprecated?: boolean + intro?: string + sections: ReleasePatchSection +} + +export type ReleaseNotes = { + [majorVersion: string]: { + [minorVersion: string]: GHESReleasePatch + } } export type SecretScanningData = { @@ -129,6 +175,7 @@ export type Page = { renderProp: (prop: string, context: any, opts: any) => Promise markdown: string versions: FrontmatterVersions + applicableVersions: string[] } export type Tree = {