From 6cb6c52770bad632cbf2df7eb4198f6cf64ebfa7 Mon Sep 17 00:00:00 2001 From: Julian Nymark Date: Thu, 22 Jun 2023 15:10:03 +0200 Subject: [PATCH] Changelog generator script (#2046) --- .github/workflows/release.yml | 4 + package.json | 3 +- scripts/deno/createMainChangelog.ts | 233 ++++++++++++++++++++++++++++ scripts/deno/utils.ts | 23 +++ 4 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 scripts/deno/createMainChangelog.ts create mode 100644 scripts/deno/utils.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7094ed9c12..eb6f8a3ab5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,10 @@ jobs: node-version: 16.13.0 cache: yarn + - uses: denoland/setup-deno@v1 + with: + deno-version: v1.x + - name: Restore cache uses: actions/cache@v3 with: diff --git a/package.json b/package.json index bf2258e865..df5f65f308 100644 --- a/package.json +++ b/package.json @@ -11,13 +11,14 @@ "storybook:aksel": "concurrently \"yarn watch:tw\" \"cross-env STORYBOOK_STORIES=all storybook dev -p 6006\"", "dev": "yarn workspace aksel.nav.no dev", "docgen": "yarn workspaces foreach -p run docgen", + "changelog": "deno run --allow-read --allow-write --no-config scripts/deno/createMainChangelog.ts", "chromatic": "npx chromatic --project-token x3xqdfgkujg --build-script-name build:storybook", "build:storybook": "storybook build", "test": "yarn workspaces foreach -p run test", "lint": "yarn eslint . && yarn stylelint @navikt/**/*.css", "lint:css": "yarn stylelint @navikt/**/*.css", "changeset": "changeset", - "version": "changeset version", + "version": "changeset version && yarn changelog", "release": "yarn boot && yarn docgen && changeset publish" }, "workspaces": [ diff --git a/scripts/deno/createMainChangelog.ts b/scripts/deno/createMainChangelog.ts new file mode 100644 index 0000000000..6d173588d2 --- /dev/null +++ b/scripts/deno/createMainChangelog.ts @@ -0,0 +1,233 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { + List, + ListItem, + Root, + Text, + Link, + Paragraph, + PhrasingContent, +} from "npm:@types/mdast"; +import { Node } from "npm:@types/unist"; +import { heading, root, text } from "npm:mdast-builder"; +import remarkParse from "npm:remark-parse"; +import remarkStringify from "npm:remark-stringify"; +import { unified } from "npm:unified"; +import { EXIT, SKIP, CONTINUE, visit } from "npm:unist-util-visit"; +import { visitParents } from "npm:unist-util-visit-parents"; +import { getChangelogs } from "./utils.ts"; + +/** + * Small diagram of the process: + + original + markdown files + | + V + custom JSON + representation + of changelog + | + V + final markdown + */ + +type PackageName = string; +type Version = string; +type VersionEntry = Record; +type Changelog = Record; + +const upsertEntry = ( + changelog: Changelog, + { + lastSeenPackage, + lastSeenVersion, + }: { + lastSeenPackage: string; + lastSeenVersion: string; + }, + value: Node +) => { + changelog[lastSeenVersion] ??= {}; + + const raw_ast_nodes = changelog[lastSeenVersion][lastSeenPackage]; + + changelog[lastSeenVersion][lastSeenPackage] = raw_ast_nodes + ? [...raw_ast_nodes, value] + : [value]; + + const updatedVersion = changelog[lastSeenVersion] || []; + changelog[lastSeenVersion] = updatedVersion; +}; + +const PROrCommitHashLink = (node: Root): Link | null => { + let infoLink: Link | null = null; + visit(node, (someChildNode) => { + if (someChildNode.type === "link") { + infoLink = structuredClone(someChildNode); + return EXIT; + } + }); + + return infoLink; +}; + +const processNode = (node: Root) => { + const infoLink = PROrCommitHashLink(node) || text(""); + visitParents(node, (childNode, ancestors) => { + if (childNode.type === "text" && childNode.value.startsWith("! -")) { + const parent = ancestors.findLast( + (ancestor) => ancestor.type === "paragraph" + ) as Paragraph; + visit(parent, (node, index, parent) => { + if ( + node.type === "link" || + (node.type === "text" && node.value === " Thanks ") + ) { + if (parent && index !== null) { + parent.children.splice(index, 1); + return [CONTINUE, index]; + } + } + if (node.type === "text" && node.value.startsWith("! -")) { + node.value = node.value.replace(/! (- )+/, ""); + return; + } + }); + parent.children.push(text(" ") as PhrasingContent); + parent.children.push(infoLink as PhrasingContent); + } + }); +}; + +const parseMarkdownFiles = async (filePaths: string[]): Promise => { + const changelog: Changelog = {}; + + for (const filePath of filePaths) { + const fileContent = readFileSync(filePath, { encoding: "utf-8" }); + const fileAST = await unified().use(remarkParse).parse(fileContent); + + /////////////////// + // filtering passes + /////////////////// + + // filter out all the 'Updated dependencies' nodes (at their relevant parent) + visitParents(fileAST, "paragraph", (node, ancestors) => { + if ( + node.children[0].type === "text" && + node.children[0].value.startsWith("Updated dependencies") + ) { + const listIndex = ancestors.findLastIndex( + (ancestor) => ancestor.type === "list" + ); + const parent = ancestors[listIndex] as List; + + // we traversed up from child match to parent + // so we need to traverse down from parent to child + // again to find index of child within parent... yeah 😂 + const indexWithinList = parent.children.findIndex((child: ListItem) => { + return child.children.some((grandchild) => { + let found = false; + visit(grandchild, (node) => { + if ( + node.type === "text" && + node.value.startsWith("Updated dependencies") + ) { + found = true; + return EXIT; + } + }); + return found; + }); + }); + if (parent && indexWithinList !== -1) { + parent.children.splice(indexWithinList, 1); + return [SKIP, indexWithinList]; + } + } + }); + + // filter all empty list nodes + visit(fileAST, (node, index, parent) => { + if (node.type === "list" && node.children.length === 0) { + if (parent && index !== null) { + parent.children.splice(index, 1); + return [SKIP, index]; + } + } + }); + + /////////////////// + // upsert into custom JS object + /////////////////// + + let lastSeenPackage = ""; + let lastSeenVersion = ""; + + visit(fileAST, (node) => { + if (node.type === "root") { + return; + } + if (node.type === "heading" && node.depth === 1) { + const childNode = node.children[0] as Text | undefined; + if (childNode && childNode.type === "text") { + const packageName = childNode.value; + if (packageName.startsWith("@navikt/")) { + lastSeenPackage = packageName; + } + } + } else if (node.type === "heading" && node.depth === 2) { + const childNode = node.children[0] as Text | undefined; + if (childNode && childNode.type === "text") { + const version = childNode.value; + if (version.match(/^\d+\.\d+\.\d+$/)) { + lastSeenVersion = version; + } + } + } else if (node.type === "heading" && node.depth === 3) { + const childNode = node.children[0] as Text | undefined; + if (childNode && childNode.type === "text") { + // ignore semver heading ('Major', 'Minor', 'Patch') + } + } else { + upsertEntry( + changelog, + { lastSeenPackage, lastSeenVersion }, + structuredClone(node) + ); + } + return SKIP; + }); + } + return changelog; +}; + +const createMainChangelog = async (changelog: Changelog): Promise => { + const headings = []; + Object.entries(changelog).forEach(([version, versionEntry]) => { + headings.push(heading(2, [text(version)])); + for (const [packageName, changes] of Object.entries(versionEntry)) { + headings.push(heading(3, [text(packageName)])); + for (const change of changes) { + processNode(change as Root); + headings.push(change); + } + } + }); + + headings.unshift(heading(1, [text("Changelog")])); + + const changelog_node_tree = root(headings) as Root; + const processed = await unified() + .use(remarkStringify, { bullet: "-" }) + .stringify(changelog_node_tree); + + return processed; +}; + +const changelogFiles = getChangelogs("./@navikt"); +console.log("processing the following markdown files:", changelogFiles); +const changelogJSON = await parseMarkdownFiles(changelogFiles); +const changelog = await createMainChangelog(changelogJSON); +writeFileSync("CHANGELOG.md", changelog); +console.log("wrote to CHANGELOG.md"); diff --git a/scripts/deno/utils.ts b/scripts/deno/utils.ts new file mode 100644 index 0000000000..ea5f22a08a --- /dev/null +++ b/scripts/deno/utils.ts @@ -0,0 +1,23 @@ +import { readdirSync, statSync } from "node:fs"; + +export const getChangelogs = (path: string) => { + const changelogs: string[] = []; + const walkFiles = (dirPath: string) => { + const files = readdirSync(dirPath); + files.forEach((file) => { + const filePath = `${dirPath}/${file}`; + if ( + statSync(filePath).isDirectory() && + !file.startsWith("node_modules") + ) { + walkFiles(filePath); + } else { + if (file.match(/^CHANGELOG\.md$/)) { + changelogs.push(filePath); + } + } + }); + }; + walkFiles(path); + return changelogs; +};