From acf06a037dbd2502eff3a9df3b891299d3b9c8d9 Mon Sep 17 00:00:00 2001 From: pooya parsa Date: Fri, 3 Mar 2023 16:42:23 +0100 Subject: [PATCH] feat: github release integration (#67) --- .eslintrc | 7 +- README.md | 25 +++++-- package.json | 4 ++ pnpm-lock.yaml | 26 +++++-- src/cli.ts | 106 ++++------------------------- src/commands/default.ts | 114 +++++++++++++++++++++++++++++++ src/commands/github.ts | 108 +++++++++++++++++++++++++++++ src/config.ts | 62 +++++++++-------- src/github.ts | 136 +++++++++++++++++++++++++++++++++++++ src/index.ts | 1 + src/markdown.ts | 31 +++++++++ src/repo.ts | 1 + test/fixtures/CHANGELOG.md | 44 ++++++++++++ test/markdown.test.ts | 59 ++++++++++++++++ 14 files changed, 588 insertions(+), 136 deletions(-) create mode 100644 src/commands/default.ts create mode 100644 src/commands/github.ts create mode 100644 src/github.ts create mode 100644 test/fixtures/CHANGELOG.md create mode 100644 test/markdown.test.ts diff --git a/.eslintrc b/.eslintrc index 309317b..c5bdc8b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,10 +1,9 @@ { - "extends": [ - "eslint-config-unjs" - ], + "extends": ["eslint-config-unjs"], "rules": { "unicorn/no-null": 0, "unicorn/prefer-top-level-await": 0, - "unicorn/template-indent": 0 + "unicorn/template-indent": 0, + "unicorn/no-process-exit": 0 } } diff --git a/README.md b/README.md index e67c819..2fd5761 100644 --- a/README.md +++ b/README.md @@ -30,26 +30,39 @@ npx changelogen@latest --release ## CLI Usage ```sh -npx changelogen@latest [...args] [] +npx changelogen@latest [...args] [--dir ] ``` **Arguments:** - `--from`: Start commit reference. When not provided, **latest git tag** will be used as default. - `--to`: End commit reference. When not provided, **latest commit in HEAD** will be used as default. -- `--rootDir`: Path to git repository. When not provided, **current working directory** will be used as as default. -- `--output`: Changelog file name to create or update. Defaults to `CHANGELOG.md` and resolved relative to rootDir. Use `--no-output` to write to console only. +- `--dir`: Path to git repository. When not provided, **current working directory** will be used as as default. +- `--output`: Changelog file name to create or update. Defaults to `CHANGELOG.md` and resolved relative to dir. Use `--no-output` to write to console only. - `--bump`: Determine semver change and update version in `package.json`. - `--release`. Bumps version in `package.json` and creates commit and git tags using local `git`. You can disable commit using `--no-commit` and tag using `--no-tag`. - `-r`: Release as specific version. +### `changelogen gh release` + +Changelogen has built-in functionality to sync with with Github releases! + +In order to manually sync a release, you can use `changelogen gh release [--dir] [--token]`. It will parse current `CHANGELOG.md` from current repository (local, then remote) and create or update releases. + +To enable this integration, make sure there is a valid `repository` field in `package.json` or `repo` is set in `.changelogenrc`. + +By default in unauthenticated mode, changelogen will open a browser link to make manual release. By providing github token, it can be automated. + +- Using environment variables or `.env`, use `CHANGELOGEN_TOKENS_GITHUB` or `GITHUB_TOKEN` or `GH_TOKEN` +- Using CLI args, use `--token ` +- Using global configuration, put `tokens.github=` inside `~/.changlogenrc` + ## Configuration Configuration is loaded by [unjs/c12](https://github.com/unjs/c12) from cwd. You can use either `changelog.json`, `changelog.{ts,js,mjs,cjs}`, `.changelogrc` or use the `changelog` field in `package.json`. See [./src/config.ts](./src/config.ts) for available options and defaults. - ## 💻 Development - Clone this repository @@ -64,14 +77,12 @@ Made with 💛 Published under [MIT License](./LICENSE). + [npm-version-src]: https://img.shields.io/npm/v/changelogen?style=flat-square [npm-version-href]: https://npmjs.com/package/changelogen - [npm-downloads-src]: https://img.shields.io/npm/dm/changelogen?style=flat-square [npm-downloads-href]: https://npmjs.com/package/changelogen - [github-actions-src]: https://img.shields.io/github/workflow/status/unjs/changelogen/ci/main?style=flat-square [github-actions-href]: https://github.com/unjs/changelogen/actions?query=workflow%3Aci - [codecov-src]: https://img.shields.io/codecov/c/gh/unjs/changelogen/main?style=flat-square [codecov-href]: https://codecov.io/gh/unjs/changelogen diff --git a/package.json b/package.json index 6b0b8ec..312ba2c 100644 --- a/package.json +++ b/package.json @@ -33,11 +33,15 @@ }, "dependencies": { "c12": "^1.1.2", + "colorette": "^2.0.19", "consola": "^2.15.3", "convert-gitmoji": "^0.1.3", "execa": "^7.0.0", "mri": "^1.2.0", "node-fetch-native": "^1.0.2", + "ofetch": "^1.0.1", + "open": "^8.4.2", + "pathe": "^1.1.0", "pkg-types": "^1.0.2", "scule": "^1.0.0", "semver": "^7.3.8" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5a1e1a..4fa6d91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,7 @@ specifiers: '@types/semver': ^7.3.13 '@vitest/coverage-c8': ^0.29.2 c12: ^1.1.2 + colorette: ^2.0.19 consola: ^2.15.3 convert-gitmoji: ^0.1.3 eslint: ^8.35.0 @@ -13,6 +14,9 @@ specifiers: jiti: ^1.17.1 mri: ^1.2.0 node-fetch-native: ^1.0.2 + ofetch: ^1.0.1 + open: ^8.4.2 + pathe: ^1.1.0 pkg-types: ^1.0.2 prettier: ^2.8.4 scule: ^1.0.0 @@ -24,11 +28,15 @@ specifiers: dependencies: c12: 1.1.2 + colorette: 2.0.19 consola: 2.15.3 convert-gitmoji: 0.1.3 execa: 7.0.0 mri: 1.2.0 node-fetch-native: 1.0.2 + ofetch: 1.0.1 + open: 8.4.2 + pathe: 1.1.0 pkg-types: 1.0.2 scule: 1.0.0 semver: 7.3.8 @@ -772,7 +780,7 @@ packages: dependencies: cross-spawn: 7.0.3 is-glob: 4.0.3 - open: 8.4.0 + open: 8.4.2 picocolors: 1.0.0 tiny-glob: 0.2.9 tslib: 2.4.1 @@ -1725,7 +1733,6 @@ packages: /define-lazy-prop/2.0.0: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} - dev: true /define-properties/1.1.4: resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==} @@ -2900,7 +2907,6 @@ packages: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} hasBin: true - dev: true /is-extglob/2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} @@ -3028,7 +3034,6 @@ packages: engines: {node: '>=8'} dependencies: is-docker: 2.2.1 - dev: true /isarray/1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} @@ -3480,6 +3485,14 @@ packages: es-abstract: 1.21.1 dev: true + /ofetch/1.0.1: + resolution: {integrity: sha512-icBz2JYfEpt+wZz1FRoGcrMigjNKjzvufE26m9+yUiacRQRHwnNlGRPiDnW4op7WX/MR6aniwS8xw8jyVelF2g==} + dependencies: + destr: 1.2.2 + node-fetch-native: 1.0.2 + ufo: 1.1.1 + dev: false + /once/1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: @@ -3493,14 +3506,13 @@ packages: mimic-fn: 4.0.0 dev: false - /open/8.4.0: - resolution: {integrity: sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q==} + /open/8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} dependencies: define-lazy-prop: 2.0.0 is-docker: 2.2.1 is-wsl: 2.2.0 - dev: true /optionator/0.9.1: resolution: {integrity: sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==} diff --git a/src/cli.ts b/src/cli.ts index c5483ea..3c263c7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,104 +1,28 @@ #!/usr/bin/env node -import { resolve } from "node:path"; -import { existsSync, promises as fsp } from "node:fs"; import consola from "consola"; import mri from "mri"; -import { execa } from "execa"; -import { getGitDiff, parseCommits } from "./git"; -import { loadChangelogConfig } from "./config"; -import { generateMarkDown } from "./markdown"; -import { bumpVersion } from "./semver"; -async function main() { - const args = mri(process.argv.splice(2)); - const cwd = resolve(args._[0] || ""); - process.chdir(cwd); - - const config = await loadChangelogConfig(cwd, { - from: args.from, - to: args.to, - output: args.output, - newVersion: args.r, - }); - - const logger = consola.create({ stdout: process.stderr }); - logger.info(`Generating changelog for ${config.from}...${config.to}`); - - const rawCommits = await getGitDiff(config.from, config.to); - - // Parse commits as conventional commits - const commits = parseCommits(rawCommits, config).filter( - (c) => - config.types[c.type] && - !(c.type === "chore" && c.scope === "deps" && !c.isBreaking) - ); - - // Bump version optionally - if (args.bump || args.release) { - const newVersion = await bumpVersion(commits, config); - if (!newVersion) { - consola.error("Unable to bump version based on changes."); - process.exit(1); - } - config.newVersion = newVersion; - } +const subCommands = { + _default: () => import("./commands/default"), + gh: () => import("./commands/github"), + github: () => import("./commands/github"), +}; - // Generate markdown - const markdown = await generateMarkDown(commits, config); +async function main() { + let subCommand = process.argv[2]; - // Show changelog in CLI unless bumping or releasing - const displayOnly = !args.bump && !args.release; - if (displayOnly) { - consola.log("\n\n" + markdown + "\n\n"); + if (!subCommand || subCommand.startsWith("-")) { + subCommand = "_default"; } - // Update changelog file (only when bumping or releasing or when --output is specified as a file) - if (typeof config.output === "string" && (args.output || !displayOnly)) { - let changelogMD: string; - if (existsSync(config.output)) { - consola.info(`Updating ${config.output}`); - changelogMD = await fsp.readFile(config.output, "utf8"); - } else { - consola.info(`Creating ${config.output}`); - changelogMD = "# Changelog\n\n"; - } - - const lastEntry = changelogMD.match(/^###?\s+.*$/m); - - if (lastEntry) { - changelogMD = - changelogMD.slice(0, lastEntry.index) + - markdown + - "\n\n" + - changelogMD.slice(lastEntry.index); - } else { - changelogMD += "\n" + markdown + "\n\n"; - } - - await fsp.writeFile(config.output, changelogMD); + if (!(subCommand in subCommands)) { + consola.error(`Unknown command ${subCommand}`); + process.exit(1); } - // Commit and tag changes for release mode - if (args.release) { - if (args.commit !== false) { - const filesToAdd = [config.output, "package.json"].filter( - (f) => f && typeof f === "string" - ) as string[]; - await execa("git", ["add", ...filesToAdd], { cwd }); - await execa( - "git", - ["commit", "-m", `chore(release): v${config.newVersion}`], - { cwd } - ); - } - if (args.tag !== false) { - await execa( - "git", - ["tag", "-am", "v" + config.newVersion, "v" + config.newVersion], - { cwd } - ); - } - } + await subCommands[subCommand]().then((r) => + r.default(mri(process.argv.splice(3))) + ); } main().catch(consola.error); diff --git a/src/commands/default.ts b/src/commands/default.ts new file mode 100644 index 0000000..8210224 --- /dev/null +++ b/src/commands/default.ts @@ -0,0 +1,114 @@ +import { existsSync, promises as fsp } from "node:fs"; +import type { Argv } from "mri"; +import { resolve } from "pathe"; +import consola from "consola"; +import { execa } from "execa"; +import { + loadChangelogConfig, + getGitDiff, + parseCommits, + bumpVersion, + generateMarkDown, +} from ".."; +import { githubRelease } from "./github"; + +export default async function defaultMain(args: Argv) { + const cwd = resolve(args._[0] /* bw compat */ || args.dir || ""); + process.chdir(cwd); + consola.wrapConsole(); + + const config = await loadChangelogConfig(cwd, { + from: args.from, + to: args.to, + output: args.output, + newVersion: args.r, + }); + + const logger = consola.create({ stdout: process.stderr }); + logger.info(`Generating changelog for ${config.from}...${config.to}`); + + const rawCommits = await getGitDiff(config.from, config.to); + + // Parse commits as conventional commits + const commits = parseCommits(rawCommits, config).filter( + (c) => + config.types[c.type] && + !(c.type === "chore" && c.scope === "deps" && !c.isBreaking) + ); + + // Bump version optionally + if (args.bump || args.release) { + const newVersion = await bumpVersion(commits, config); + if (!newVersion) { + consola.error("Unable to bump version based on changes."); + process.exit(1); + } + config.newVersion = newVersion; + } + + // Generate markdown + const markdown = await generateMarkDown(commits, config); + + // Show changelog in CLI unless bumping or releasing + const displayOnly = !args.bump && !args.release; + if (displayOnly) { + consola.log("\n\n" + markdown + "\n\n"); + } + + // Update changelog file (only when bumping or releasing or when --output is specified as a file) + if (typeof config.output === "string" && (args.output || !displayOnly)) { + let changelogMD: string; + if (existsSync(config.output)) { + consola.info(`Updating ${config.output}`); + changelogMD = await fsp.readFile(config.output, "utf8"); + } else { + consola.info(`Creating ${config.output}`); + changelogMD = "# Changelog\n\n"; + } + + const lastEntry = changelogMD.match(/^###?\s+.*$/m); + + if (lastEntry) { + changelogMD = + changelogMD.slice(0, lastEntry.index) + + markdown + + "\n\n" + + changelogMD.slice(lastEntry.index); + } else { + changelogMD += "\n" + markdown + "\n\n"; + } + + await fsp.writeFile(config.output, changelogMD); + } + + // Commit and tag changes for release mode + if (args.release) { + if (args.commit !== false) { + const filesToAdd = [config.output, "package.json"].filter( + (f) => f && typeof f === "string" + ) as string[]; + await execa("git", ["add", ...filesToAdd], { cwd }); + await execa( + "git", + ["commit", "-m", `chore(release): v${config.newVersion}`], + { cwd } + ); + } + if (args.tag !== false) { + await execa( + "git", + ["tag", "-am", "v" + config.newVersion, "v" + config.newVersion], + { cwd } + ); + } + if (args.push === true) { + await execa("git", ["push", "--follow-tags"], { cwd }); + } + if (args.github !== false && config.repo.provider === "github") { + await githubRelease(config, { + version: config.newVersion, + body: markdown, + }); + } + } +} diff --git a/src/commands/github.ts b/src/commands/github.ts new file mode 100644 index 0000000..f80e16e --- /dev/null +++ b/src/commands/github.ts @@ -0,0 +1,108 @@ +import { promises as fsp } from "node:fs"; +import type { Argv } from "mri"; +import { resolve } from "pathe"; +import consola from "consola"; +import { underline, cyan } from "colorette"; +import open from "open"; +import { getGithubChangelog, syncGithubRelease } from "../github"; +import { + ChangelogConfig, + loadChangelogConfig, + parseChangelogMarkdown, +} from ".."; + +export default async function githubMain(args: Argv) { + const cwd = resolve(args.dir || ""); + process.chdir(cwd); + + const [action, ..._versions] = args._; + if (action !== "release" || _versions.length === 0) { + consola.log( + "Usage: changelogen gh release [--dir] [--token]" + ); + process.exit(1); + } + + let versions = [..._versions].map((v) => v.replace(/^v/, "")); + + const config = await loadChangelogConfig(cwd, {}); + + if (config.repo?.provider !== "github") { + consola.error( + "This command is only supported for github repository provider." + ); + process.exit(1); + } + + if (args.token) { + config.tokens.github = args.token; + } + + let changelogMd: string; + if (typeof config.output === "string") { + changelogMd = await fsp + .readFile(resolve(config.output), "utf8") + .catch(() => null); + } + if (!changelogMd) { + changelogMd = await getGithubChangelog(config).catch(() => null); + } + if (!changelogMd) { + consola.error(`Cannot resolve CHANGELOG.md`); + process.exit(1); + } + + const changelogReleases = parseChangelogMarkdown(changelogMd).releases; + + if (versions.length === 1 && versions[0] === "all") { + versions = changelogReleases.map((r) => r.version).sort(); + } + + for (const version of versions) { + const release = changelogReleases.find((r) => r.version === version); + if (!release) { + consola.warn( + `No matching changelog entry found for ${version} in CHANGELOG.md. Skipping!` + ); + continue; + } + if (!release.body || !release.version) { + consola.warn( + `Changelog entry for ${version} in CHANGELOG.md is missing body or version. Skipping!` + ); + continue; + } + await githubRelease(config, { + version: release.version, + body: release.version, + }); + } +} + +export async function githubRelease( + config: ChangelogConfig, + release: { version: string; body: string } +) { + const result = await syncGithubRelease(config, release); + if (result.status === "manual") { + if (result.error) { + consola.error(result.error); + process.exitCode = 1; + } + await open(result.url) + .then(() => { + consola.info(`Followup in the browser to manually create the release.`); + }) + .catch(() => { + consola.info( + `Open this link to manually create a release: \n` + + underline(cyan(result.url)) + + "\n" + ); + }); + } else { + consola.success( + `Synced ${cyan(`v${release.version}`)} to Github releases!` + ); + } +} diff --git a/src/config.ts b/src/config.ts index 99e6dcd..3d65302 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,8 @@ import { resolve } from "node:path"; -import { loadConfig } from "c12"; +import { loadConfig, setupDotenv } from "c12"; import { readPackageJSON } from "pkg-types"; import { getLastGitTag, getCurrentGitRef } from "./git"; -import { getRepoConfig } from "./repo"; +import { getRepoConfig, RepoProvider } from "./repo"; import type { SemverBumpType } from "./semver"; import type { RepoConfig } from "./repo"; @@ -11,42 +11,52 @@ export interface ChangelogConfig { types: Record; scopeMap: Record; repo?: RepoConfig; + tokens: Partial>; from: string; to: string; newVersion?: string; output: string | boolean; } -const ConfigDefaults: ChangelogConfig = { - types: { - feat: { title: "🚀 Enhancements", semver: "minor" }, - perf: { title: "🔥 Performance", semver: "patch" }, - fix: { title: "🩹 Fixes", semver: "patch" }, - refactor: { title: "💅 Refactors", semver: "patch" }, - docs: { title: "📖 Documentation", semver: "patch" }, - build: { title: "📦 Build", semver: "patch" }, - types: { title: "🌊 Types", semver: "patch" }, - chore: { title: "🏡 Chore" }, - examples: { title: "🏀 Examples" }, - test: { title: "✅ Tests" }, - style: { title: "🎨 Styles" }, - ci: { title: "🤖 CI" }, - }, - cwd: null, - from: "", - to: "", - output: "CHANGELOG.md", - scopeMap: {}, -}; +const getDefaultConfig = () => + { + types: { + feat: { title: "🚀 Enhancements", semver: "minor" }, + perf: { title: "🔥 Performance", semver: "patch" }, + fix: { title: "🩹 Fixes", semver: "patch" }, + refactor: { title: "💅 Refactors", semver: "patch" }, + docs: { title: "📖 Documentation", semver: "patch" }, + build: { title: "📦 Build", semver: "patch" }, + types: { title: "🌊 Types", semver: "patch" }, + chore: { title: "🏡 Chore" }, + examples: { title: "🏀 Examples" }, + test: { title: "✅ Tests" }, + style: { title: "🎨 Styles" }, + ci: { title: "🤖 CI" }, + }, + cwd: null, + from: "", + to: "", + output: "CHANGELOG.md", + scopeMap: {}, + tokens: { + github: + process.env.CHANGELOGEN_TOKENS_GITHUB || + process.env.GITHUB_TOKEN || + process.env.GH_TOKEN, + }, + }; export async function loadChangelogConfig( cwd: string, overrides?: Partial ): Promise { + await setupDotenv({ cwd }); + const defaults = getDefaultConfig(); const { config } = await loadConfig({ cwd, name: "changelog", - defaults: ConfigDefaults, + defaults, overrides: { cwd, ...(overrides as ChangelogConfig), @@ -65,9 +75,7 @@ export async function loadChangelogConfig( config.output = false; } else if (config.output) { config.output = - config.output === true - ? ConfigDefaults.output - : resolve(cwd, config.output); + config.output === true ? defaults.output : resolve(cwd, config.output); } if (!config.repo) { diff --git a/src/github.ts b/src/github.ts new file mode 100644 index 0000000..c5f29f9 --- /dev/null +++ b/src/github.ts @@ -0,0 +1,136 @@ +import { $fetch, FetchOptions } from "ofetch"; +import { ChangelogConfig } from "./config"; + +export interface GithubOptions { + repo: string; + token: string; +} + +export interface GithubRelease { + id?: string; + tag_name: string; + name?: string; + body?: string; + draft?: boolean; + prerelease?: boolean; +} + +export async function listGithubReleases( + config: ChangelogConfig +): Promise { + return await githubFetch(config, `/repos/${config.repo.repo}/releases`, { + query: { per_page: 100 }, + }); +} + +export async function getGithubReleaseByTag( + config: ChangelogConfig, + tag: string +): Promise { + return await githubFetch( + config, + `/repos/${config.repo.repo}/releases/tags/${tag}`, + {} + ); +} + +export async function getGithubChangelog(config: ChangelogConfig) { + return await githubFetch( + config, + `https://raw.githubusercontent.com/${config.repo.repo}/main/CHANGELOG.md` + ); +} + +export async function createGithubRelease( + config: ChangelogConfig, + body: GithubRelease +) { + return await githubFetch(config, `/repos/${config.repo.repo}/releases`, { + method: "POST", + body, + }); +} + +export async function updateGithubRelease( + config: ChangelogConfig, + id: string, + body: GithubRelease +) { + return await githubFetch( + config, + `/repos/${config.repo.repo}/releases/${id}`, + { + method: "PATCH", + body, + } + ); +} + +export async function syncGithubRelease( + config: ChangelogConfig, + release: { version: string; body: string } +) { + const currentGhRelease = await getGithubReleaseByTag( + config, + `v${release.version}` + ).catch(() => {}); + + const ghRelease: GithubRelease = { + tag_name: `v${release.version}`, + name: `v${release.version}`, + body: release.body, + }; + + if (!config.tokens.github) { + return { + status: "manual", + url: githubNewReleaseURL(config, release), + }; + } + + try { + const newGhRelease = await (currentGhRelease + ? updateGithubRelease(config, currentGhRelease.id, ghRelease) + : createGithubRelease(config, ghRelease)); + return { + status: currentGhRelease ? "updated" : "created", + id: newGhRelease.id, + }; + } catch (error) { + return { + status: "manual", + error, + url: githubNewReleaseURL(config, release), + }; + } +} + +export function githubNewReleaseURL( + config: ChangelogConfig, + release: { version: string; body: string } +) { + return `https://${config.repo.domain}/${config.repo.repo}/releases/new?tag=v${ + release.version + }&title=v${release.version}&body=${encodeURIComponent(release.body)}`; +} + +// --- Internal utils --- +async function githubFetch( + config: ChangelogConfig, + url: string, + opts: FetchOptions = {} +) { + return await $fetch(url, { + ...opts, + baseURL: + config.repo.domain === "github.com" + ? "https://api.github.com" + : `https://${config.repo.domain}/api/v3`, + headers: { + ...opts.headers, + authorization: config.tokens.github + ? `Token ${config.tokens.github}` + : undefined, + }, + }); +} diff --git a/src/index.ts b/src/index.ts index f51fb07..84ce912 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from "./git"; +export * from "./github"; export * from "./markdown"; export * from "./config"; export * from "./semver"; diff --git a/src/markdown.ts b/src/markdown.ts index da5c0f4..e2a45e3 100644 --- a/src/markdown.ts +++ b/src/markdown.ts @@ -98,6 +98,34 @@ export async function generateMarkDown( return convert(markdown.join("\n").trim(), true); } +export function parseChangelogMarkdown(contents: string) { + const headings = [...contents.matchAll(CHANGELOG_RELEASE_HEAD_RE)]; + const releases: { version?: string; body: string }[] = []; + + for (let i = 0; i < headings.length; i++) { + const heading = headings[i]; + const nextHeading = headings[i + 1]; + const [, title] = heading; + const version = title.match(VERSION_RE); + const release = { + version: version ? version[1] : undefined, + body: contents + .slice( + heading.index + heading[0].length, + nextHeading?.index ?? contents.length + ) + .trim(), + }; + releases.push(release); + } + + return { + releases, + }; +} + +// --- Internal utils --- + function formatCommit(commit: GitCommit, config: ChangelogConfig) { return ( " - " + @@ -145,3 +173,6 @@ function groupBy(items: any[], key: string) { } return groups; } + +const CHANGELOG_RELEASE_HEAD_RE = /^#{2,}\s+.*(v?(\d+\.\d+\.\d+)).*$/gm; +const VERSION_RE = /^v?(\d+\.\d+\.\d+)$/; diff --git a/src/repo.ts b/src/repo.ts index 8258abf..4a0fa19 100644 --- a/src/repo.ts +++ b/src/repo.ts @@ -7,6 +7,7 @@ export type RepoConfig = { domain?: string; repo?: string; provider?: RepoProvider; + token?: string; }; const providerToRefSpec: Record< diff --git a/test/fixtures/CHANGELOG.md b/test/fixtures/CHANGELOG.md new file mode 100644 index 0000000..21a8e29 --- /dev/null +++ b/test/fixtures/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. + +## v0.4.1 + +[compare changes](https://github.com/unjs/changelogen/compare/v0.4.0...v0.4.1) + +### 🩹 Fixes + +- Bump by patch by default ([7e38438](https://github.com/unjs/changelogen/commit/7e38438)) + +### 🏡 Chore + +- Update renovate config ([#54](https://github.com/unjs/changelogen/pull/54)) +- Update dependencies ([4216bc6](https://github.com/unjs/changelogen/commit/4216bc6)) +- Update repo ([83c349f](https://github.com/unjs/changelogen/commit/83c349f)) + +### ❤️ Contributors + +- Pooya Parsa +- Nozomu Ikuta + +## 0.4.0 + +[compare changes](https://github.com/unjs/changelogen/compare/v0.3.5...v0.4.0) + +### 🚀 Enhancements + +- ⚠️ Resolve github usernames using `ungh/ungh` ([#46](https://github.com/unjs/changelogen/pull/46)) + +### 🩹 Fixes + +- **markdown:** Avoid rendering `noreply.github.com` emails ([4871721](https://github.com/unjs/changelogen/commit/4871721)) +- Avoid rendering authors with `[bot]` in their name ([4f3f644](https://github.com/unjs/changelogen/commit/4f3f644)) +- Format name to avoid duplicates ([f74a988](https://github.com/unjs/changelogen/commit/f74a988)) + +#### ⚠️ Breaking Changes + +- ⚠️ Resolve github usernames using `ungh/ungh` ([#46](https://github.com/unjs/changelogen/pull/46)) + +### ❤️ Contributors + +- Pooya Parsa ([@pi0](http://github.com/pi0)) diff --git a/test/markdown.test.ts b/test/markdown.test.ts new file mode 100644 index 0000000..5624808 --- /dev/null +++ b/test/markdown.test.ts @@ -0,0 +1,59 @@ +import { promises as fsp } from "node:fs"; +import { describe, expect, test } from "vitest"; +import { parseChangelogMarkdown } from "../src"; + +describe("markdown", () => { + test("should parse markdown", async () => { + const contents = await fsp.readFile( + new URL("fixtures/CHANGELOG.md", import.meta.url), + "utf8" + ); + expect(parseChangelogMarkdown(contents)).toMatchInlineSnapshot(` + { + "releases": [ + { + "body": "[compare changes](https://github.com/unjs/changelogen/compare/v0.4.0...v0.4.1) + + ### 🩹 Fixes + + - Bump by patch by default ([7e38438](https://github.com/unjs/changelogen/commit/7e38438)) + + ### 🏡 Chore + + - Update renovate config ([#54](https://github.com/unjs/changelogen/pull/54)) + - Update dependencies ([4216bc6](https://github.com/unjs/changelogen/commit/4216bc6)) + - Update repo ([83c349f](https://github.com/unjs/changelogen/commit/83c349f)) + + ### ❤️ Contributors + + - Pooya Parsa + - Nozomu Ikuta ", + "version": "0.4.1", + }, + { + "body": "[compare changes](https://github.com/unjs/changelogen/compare/v0.3.5...v0.4.0) + + ### 🚀 Enhancements + + - ⚠️ Resolve github usernames using \`ungh/ungh\` ([#46](https://github.com/unjs/changelogen/pull/46)) + + ### 🩹 Fixes + + - **markdown:** Avoid rendering \`noreply.github.com\` emails ([4871721](https://github.com/unjs/changelogen/commit/4871721)) + - Avoid rendering authors with \`[bot]\` in their name ([4f3f644](https://github.com/unjs/changelogen/commit/4f3f644)) + - Format name to avoid duplicates ([f74a988](https://github.com/unjs/changelogen/commit/f74a988)) + + #### ⚠️ Breaking Changes + + - ⚠️ Resolve github usernames using \`ungh/ungh\` ([#46](https://github.com/unjs/changelogen/pull/46)) + + ### ❤️ Contributors + + - Pooya Parsa ([@pi0](http://github.com/pi0))", + "version": "0.4.0", + }, + ], + } + `); + }); +});