From 0a915e22d692bc29967bd2eb3545b18aef876dc6 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 20:26:00 -0700 Subject: [PATCH 01/12] fix skill symlink --- .agents/skills/beachball-change-file/SKILL.md | 140 +++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) mode change 120000 => 100644 .agents/skills/beachball-change-file/SKILL.md diff --git a/.agents/skills/beachball-change-file/SKILL.md b/.agents/skills/beachball-change-file/SKILL.md deleted file mode 120000 index dad310b1e..000000000 --- a/.agents/skills/beachball-change-file/SKILL.md +++ /dev/null @@ -1 +0,0 @@ -skills/beachball-change-file/SKILL.md \ No newline at end of file diff --git a/.agents/skills/beachball-change-file/SKILL.md b/.agents/skills/beachball-change-file/SKILL.md new file mode 100644 index 000000000..665b0b70e --- /dev/null +++ b/.agents/skills/beachball-change-file/SKILL.md @@ -0,0 +1,139 @@ +--- +name: beachball-change-file +description: How to create a Beachball change file. ONLY use this skill when the user asks to generate change files, before pushing a branch, or before creating a PR. +metadata: + version: 1.0.3 + source: https://github.com/microsoft/beachball/blob/main/skills/beachball-change-file/SKILL.md +--- + +[Beachball](https://microsoft.github.io/beachball/) is a tool used for managing versioning and changelogs for JS/TS codebases. Every pull request must include a Beachball change file. Change files include the list of packages with public-facing changes in the branch, with the description and semver change type for each package. After the PR is checked in and a release is run, the change files are used to determine version bumps and update changelogs. + +Beachball normally uses a CLI with an interactive prompt to create change files, but they can also be created manually using the standardized JSON format detailed below. + +## Prerequisites + +- Deterine the root directory: this is almost always the git root, but the user might specify a different folder. (The root usually contains `beachball.config.*` or `.beachballrc.*` or has a `"beachball"` key in `package.json`.) +- Determine the package manager for the repo (`npm`, `yarn`, `pnpm`). The example commands below assume `yarn`, but substitute the appropriate command runner syntax for a different package manager. +- Check the root `package.json` `scripts` for scripts that run `beachball change` and `beachball check`. + - The examples below assume `scripts` called `change` and `checkchange` respectively, but substitute the appropriate script names if found. + - Using `scripts` if defined is preferred since they may add extra arguments, but it's possible to run the commands directly: `yarn beachball change` and `yarn beachball check` (substituting appropriate command runner) +- Use `beachball config get` to check the following settings (note: `beachball config get` only exists in versions `>= 2.64.0`) + - `yarn beachball config get changeDir`: where to put the change files + - `yarn beachball config get branch`: target branch name + - `yarn beachball config get groupChanges`: whether grouped change files are enabled (true/false/undefined) + +## Creating and validating a change file + +Usually, an AI agent should create a change file manually following the standardized format detailed below. + +### 1. Validate repo state + +Beachball only considers staged and committed files, so you should check for unstaged or untracked changes before proceeding: + +1. Get file paths with unstaged changes (`git ls-files -m`) and untracked changes (`git ls-files -o --exclude-standard`) +2. If there are any unstaged or untracked changes, ask the user whether they would like to stage all files or continue without staging. If they choose to stage, run `git add .` before proceeding. + +### 2. Get changed packages + +Run `yarn checkchange --verbose` to get the list of changed packages and files considered by `beachball`: + +- The list of changed packages is under "Found changes in the following packages" -- you must ONLY include these packages in the change file! (beachball has various settings to ignore packages or files) +- The list of changed files is under "changed files in current branch". IGNORE any files with `~~` strikethrough formatting. + +### 3. Create the change file(s) + +Change files are located under ``. There are two possible structures for change files, determined by the `groupChanges` setting. + +#### Case 1: Non-grouped format (`groupChanges` is `false` or unset) + +If `groupChanges` is `false` or unset, you should create a separate change file for each package. + +For each changed package **as listed by beachball**: + +1. Generate a random GUID: `node -e "console.log(crypto.randomUUID())"` +2. Create a change file under `/-.json` with the following format. See [Change entry values](#change-entry-values) below for the proper values of each field. + +```json +{ + "packageName": "", + "type": "", + "dependentChangeType": "", + "comment": "", + "email": "" +} +``` + +#### Case 2: Grouped format (`groupChanges: true`) + +If `groupChanges` is `true`, you should create a single change file. + +1. Generate a random GUID: `node -e "console.log(crypto.randomUUID())"` +2. Create a single change file under `/change-.json` with the following format. The `changes` array should have an entry for each changed package **as listed by beachball**. See [Change entry values](#change-entry-values) below for the proper values of each field. + +```json +{ + "changes": [ + { + "packageName": "", + "type": "", + "dependentChangeType": "", + "comment": "", + "email": "" + } + ] +} +``` + +### 4. Validate the change file(s) + +Run `git add `, then re-run `yarn checkchange` to verify. + +## Change entry values + +Each package's entry has the following values: + +- `packageName`: The name of the changed package, e.g. `just-task` +- `type`: The semantic versioning change type for the package. See [Determining a package's change type](#determining-a-packages-change-type) below. +- `dependentChangeType`: Change type for packages that depend on this package. If `type` is `"none"`, this should be `"none"`. Otherwise, this should be `"patch"` (beachball internally handles this for the special case of prerelease packages). +- `comment` (`--message` CLI arg): A concise description of the changes made to the package. Tips: + - This will go in the changelog, so it should focus on user-facing changes (especially any API changes) rather than implementation details. + - Markdown formatting is allowed, so any references to names from code should be wrapped with backticks. +- `email`: User's email from `git config user.email`, or `"email not defined"` if not available. Do NOT invent an email. + +### Determining a package's change type + +The `type` field is the semantic versioning change type for the package, determined based on the diff content of changed files in that package. There are different options depending on whether the package's current version contains a prerelease suffix or not, and the `disallowedChangeTypes` setting may modify which change types are allowed. + +If you're still uncertain about the change type after following the instructions below, ask the user to choose. + +For each package, start by checking: + +- The current `version` in `package.json` +- `disallowedChangeTypes` for the specific package: `yarn beachball config get disallowedChangeTypes --package ` +- Whether the package has a file `/etc/*.api.md`. If so, the diff of this file will show whether any public API signatures changed. + +#### Case 1: Version is 1.0.0 or greater and NOT prerelease + +If the package's current version is 1.0.0 or greater and does NOT have a prerelease suffix, the typical options are `` (but you MUST respect `disallowedChangeTypes`): + +- `"patch"`: Bug fixes or other changes that don't impact exported API signatures. +- `"minor"`: New exported APIs, non-breaking signature changes to exported APIs, or more significant changes to internal logic. (If the package has a `/etc/*.api.md` file, checking its diff is the easiest way to see exported API changes.) +- `"major"`: Breaking changes to exported APIs (removals or breaking signature changes), critical dependency updates, or behavior changes that might be breaking for the consumer. You MUST confirm with the user before choosing `"major"`. +- `"none"`: None of the changes will impact consumers of the package (e.g. the changes are only to non-exported test-specific files or documentation). If you're not certain, prefer `"patch"`. +- There are additional options `prerelease|premajor|preminor|prepatch`, but you should only use one of these if explicitly requested by the user. + +#### Case 2: Version is 0.x.y and NOT prerelease + +If the package's major version is 0 and does NOT have a prerelease suffix, this is similar to case 1. However, version 0 packages follow different conventions for semantic versioning (you MUST still respect `disallowedChangeTypes`): + +- Use `"minor"` for breaking changes (do NOT use `"major"` unless specifically requested) +- Use `"patch"` for any other changes that impact consumers of the package +- Use `"none"` in the same circumstances as case 1 + +#### Case 3: Version IS prerelease + +ONLY if the package's current version includes a prerelease suffix, the typical options are `` (but you MUST respect `disallowedChangeTypes`): + +- `"prerelease"`: Any changes that impact consumers of the package +- `"none"`: None of the changes will impact consumers of the package (e.g. the changes are only to non-exported test-specific files or documentation). If you're not certain, prefer `"prerelease"`. +- There are additional options `premajor|preminor|prepatch`, but you should only use one of these if explicitly requested by the user or all other change types are disallowed. From 144e97b3b86de03788986c37de828d22a955252c Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 20:26:16 -0700 Subject: [PATCH 02/12] initial package --- packages/proper-changelog/.depcheckrc.yml | 7 + packages/proper-changelog/README.md | 44 ++++++ .../proper-changelog/bin/proper-changelog.js | 4 + packages/proper-changelog/eslint.config.js | 4 + packages/proper-changelog/jest.config.js | 4 + packages/proper-changelog/package.json | 39 +++++ .../src/__fixtures__/makeRelease.ts | 49 ++++++ .../src/__tests__/cli.test.ts | 65 ++++++++ .../src/__tests__/fetchReleases.test.ts | 79 ++++++++++ .../src/__tests__/renderChangelog.test.ts | 141 ++++++++++++++++++ .../src/__tests__/resolveToken.test.ts | 57 +++++++ packages/proper-changelog/src/cli.ts | 111 ++++++++++++++ .../proper-changelog/src/fetchReleases.ts | 57 +++++++ packages/proper-changelog/src/index.ts | 4 + .../proper-changelog/src/renderChangelog.ts | 126 ++++++++++++++++ packages/proper-changelog/src/resolveToken.ts | 31 ++++ packages/proper-changelog/src/types.ts | 26 ++++ packages/proper-changelog/tsconfig.json | 10 ++ scripts/config/tsconfig.base.json | 2 + yarn.lock | 28 ++++ 20 files changed, 888 insertions(+) create mode 100644 packages/proper-changelog/.depcheckrc.yml create mode 100644 packages/proper-changelog/README.md create mode 100755 packages/proper-changelog/bin/proper-changelog.js create mode 100644 packages/proper-changelog/eslint.config.js create mode 100644 packages/proper-changelog/jest.config.js create mode 100644 packages/proper-changelog/package.json create mode 100644 packages/proper-changelog/src/__fixtures__/makeRelease.ts create mode 100644 packages/proper-changelog/src/__tests__/cli.test.ts create mode 100644 packages/proper-changelog/src/__tests__/fetchReleases.test.ts create mode 100644 packages/proper-changelog/src/__tests__/renderChangelog.test.ts create mode 100644 packages/proper-changelog/src/__tests__/resolveToken.test.ts create mode 100644 packages/proper-changelog/src/cli.ts create mode 100644 packages/proper-changelog/src/fetchReleases.ts create mode 100644 packages/proper-changelog/src/index.ts create mode 100644 packages/proper-changelog/src/renderChangelog.ts create mode 100644 packages/proper-changelog/src/resolveToken.ts create mode 100644 packages/proper-changelog/src/types.ts create mode 100644 packages/proper-changelog/tsconfig.json diff --git a/packages/proper-changelog/.depcheckrc.yml b/packages/proper-changelog/.depcheckrc.yml new file mode 100644 index 000000000..fe2dd70f0 --- /dev/null +++ b/packages/proper-changelog/.depcheckrc.yml @@ -0,0 +1,7 @@ +ignore-path: ../../.gitignore + +ignores: + # used in scripts + - cross-env + # specified at root + - '@jest/globals' diff --git a/packages/proper-changelog/README.md b/packages/proper-changelog/README.md new file mode 100644 index 000000000..45e08a913 --- /dev/null +++ b/packages/proper-changelog/README.md @@ -0,0 +1,44 @@ +# proper-changelog + +GitHub releases are useful in some ways, but they're horrible as changelogs if you need to look at changes across multiple versions or figure out when a specific change was introduced. This tool reads GitHub releases and generates a single markdown changelog. + +## Usage + +```bash +npx proper-changelog --repo / +``` + +By default this writes the changelog to `-changelog.md` in the current directory. Use `--stdout` to print it instead, or `--out` to choose a different file name. + +```bash +# Write to a custom file +npx proper-changelog --repo microsoft/beachball --out CHANGELOG.md + +# Print to stdout +npx proper-changelog --repo microsoft/beachball --stdout +``` + +## Authentication + +The GitHub API is rate-limited for unauthenticated requests. To use a token, the tool checks the following in order: + +1. The `--token` option +2. The `GITHUB_TOKEN` or `GH_TOKEN` environment variables +3. The output of `gh auth token` (if the [GitHub CLI](https://cli.github.com/) is installed and authenticated) + +If no token is found, the tool prints a warning and continues unauthenticated. + +## Options + +| Option | Description | +| ----------------------- | ---------------------------------------------------------------------------------- | +| `--repo ` | **Required.** GitHub repository to read releases from. | +| `-o, --out ` | Output file name (default: `-changelog.md`). Cannot be used with `--stdout`. | +| `--stdout` | Write the changelog to stdout instead of a file. Cannot be used with `--out`. | +| `--token ` | GitHub token (see [Authentication](#authentication)). | +| `--include-prereleases` | Include prerelease releases. Draft releases are always excluded. | +| `--from ` | Include releases up to and including this tag. | +| `--to ` | Include releases down to and including this tag. | +| `--limit ` | Maximum number of releases to include. | + +Releases are listed newest-first by published date. Draft releases are always excluded, and prereleases are excluded unless `--include-prereleases` is passed. diff --git a/packages/proper-changelog/bin/proper-changelog.js b/packages/proper-changelog/bin/proper-changelog.js new file mode 100755 index 000000000..c37e885bc --- /dev/null +++ b/packages/proper-changelog/bin/proper-changelog.js @@ -0,0 +1,4 @@ +#!/usr/bin/env node +import { cli } from '../lib/cli.js'; + +cli(); diff --git a/packages/proper-changelog/eslint.config.js b/packages/proper-changelog/eslint.config.js new file mode 100644 index 000000000..755cf0b92 --- /dev/null +++ b/packages/proper-changelog/eslint.config.js @@ -0,0 +1,4 @@ +// @ts-check +import { getConfig } from '@microsoft/beachball-scripts/config/eslint.ts'; + +export default getConfig(import.meta.dirname); diff --git a/packages/proper-changelog/jest.config.js b/packages/proper-changelog/jest.config.js new file mode 100644 index 000000000..af6b95d72 --- /dev/null +++ b/packages/proper-changelog/jest.config.js @@ -0,0 +1,4 @@ +// @ts-check +import { getESMConfig } from '@microsoft/beachball-scripts/config/jest.cjs'; + +export default getESMConfig(); diff --git a/packages/proper-changelog/package.json b/packages/proper-changelog/package.json new file mode 100644 index 000000000..1563b3166 --- /dev/null +++ b/packages/proper-changelog/package.json @@ -0,0 +1,39 @@ +{ + "name": "proper-changelog", + "version": "0.1.0", + "description": "Make a readable changelog from GitHub releases", + "type": "module", + "repository": { + "type": "git", + "url": "https://github.com/microsoft/beachball", + "directory": "packages/proper-changelog" + }, + "license": "MIT", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "bin": "./bin/proper-changelog.js", + "engines": { + "node": ">=22.18.0" + }, + "files": [ + "bin", + "lib/!(__*)", + "lib/!(__*)/**/*" + ], + "scripts": { + "build": "yarn run -T tsc --pretty", + "depcheck": "yarn run -T depcheck .", + "lint": "yarn run -T eslint --color --max-warnings=0 src", + "test": "cross-env NODE_OPTIONS='--experimental-vm-modules' yarn run -T jest", + "update-snapshots": "yarn test -u" + }, + "devDependencies": { + "@microsoft/beachball-scripts": "workspace:^", + "@octokit/openapi-types": "^27.0.0", + "cross-env": "^10.1.0" + }, + "dependencies": { + "commander": "^15.0.0", + "nano-spawn": "^2.1.0" + } +} diff --git a/packages/proper-changelog/src/__fixtures__/makeRelease.ts b/packages/proper-changelog/src/__fixtures__/makeRelease.ts new file mode 100644 index 000000000..1319a8c53 --- /dev/null +++ b/packages/proper-changelog/src/__fixtures__/makeRelease.ts @@ -0,0 +1,49 @@ +import type { GitHubRelease } from '../types.ts'; + +/** + * Create a {@link GitHubRelease} fixture with sensible defaults, overriding only the fields + * relevant to a given test. + */ +export function makeRelease(overrides: Partial = {}): GitHubRelease { + const tag = overrides.tag_name ?? 'v1.0.0'; + return { + url: 'https://api.github.com/repos/o/r/releases/1', + html_url: `https://github.com/o/r/releases/tag/${tag}`, + assets_url: '', + upload_url: '', + tarball_url: null, + zipball_url: null, + id: 1, + node_id: 'node', + tag_name: tag, + target_commitish: 'main', + name: tag, + body: '', + draft: false, + prerelease: false, + created_at: '2024-01-01T00:00:00Z', + published_at: '2024-01-01T00:00:00Z', + author: { + login: 'octocat', + id: 1, + node_id: 'u', + avatar_url: '', + gravatar_id: null, + url: '', + html_url: '', + followers_url: '', + following_url: '', + gists_url: '', + starred_url: '', + subscriptions_url: '', + organizations_url: '', + repos_url: '', + events_url: '', + received_events_url: '', + type: 'User', + site_admin: false, + }, + assets: [], + ...overrides, + }; +} diff --git a/packages/proper-changelog/src/__tests__/cli.test.ts b/packages/proper-changelog/src/__tests__/cli.test.ts new file mode 100644 index 000000000..e2995a6ee --- /dev/null +++ b/packages/proper-changelog/src/__tests__/cli.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from '@jest/globals'; +import { createProgram, parseRepo } from '../cli.ts'; + +describe('parseRepo', () => { + it('parses an owner/repo string', () => { + expect(parseRepo('microsoft/beachball')).toEqual({ owner: 'microsoft', repo: 'beachball' }); + }); + + it.each(['beachball', 'a/b/c', 'owner/', '/repo', 'owner repo'])('rejects invalid input %p', input => { + expect(() => parseRepo(input)).toThrow(); + }); +}); + +describe('createProgram', () => { + function parse(args: string[]): Record { + const program = createProgram().exitOverride(); + program.parse(args, { from: 'user' }); + return program.opts(); + } + + it('parses --repo into a RepoId and applies defaults', () => { + const opts = parse(['--repo', 'o/r']); + expect(opts.repo).toEqual({ owner: 'o', repo: 'r' }); + expect(opts.includePrereleases).toBeUndefined(); + }); + + it('parses all options', () => { + const opts = parse([ + '--repo', + 'o/r', + '--out', + 'changes.md', + '--token', + 't', + '--include-prereleases', + '--from', + 'v2.0.0', + '--to', + 'v1.0.0', + '--limit', + '5', + ]); + expect(opts).toMatchObject({ + repo: { owner: 'o', repo: 'r' }, + out: 'changes.md', + token: 't', + includePrereleases: true, + from: 'v2.0.0', + to: 'v1.0.0', + limit: 5, + }); + }); + + it('requires --repo', () => { + expect(() => parse([])).toThrow(); + }); + + it('rejects a non-integer --limit', () => { + expect(() => parse(['--repo', 'o/r', '--limit', 'abc'])).toThrow(); + }); + + it('rejects using --out and --stdout together', () => { + expect(() => parse(['--repo', 'o/r', '--out', 'x.md', '--stdout'])).toThrow(); + }); +}); diff --git a/packages/proper-changelog/src/__tests__/fetchReleases.test.ts b/packages/proper-changelog/src/__tests__/fetchReleases.test.ts new file mode 100644 index 000000000..b676b9155 --- /dev/null +++ b/packages/proper-changelog/src/__tests__/fetchReleases.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { fetchReleases } from '../fetchReleases.ts'; +import { makeRelease } from '../__fixtures__/makeRelease.ts'; + +const repo = { owner: 'o', repo: 'r' }; + +describe('fetchReleases', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn() as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + function mockResponse(body: unknown, init: { link?: string; ok?: boolean; status?: number } = {}): Response { + const headers = new Headers(); + if (init.link) { + headers.set('link', init.link); + } + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: 'OK', + headers, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as Response; + } + + it('requests the releases endpoint without an Authorization header when no token is given', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock.mockResolvedValueOnce(mockResponse([makeRelease({ tag_name: 'v1.0.0' })])); + + const releases = await fetchReleases(repo); + + expect(releases.map(r => r.tag_name)).toEqual(['v1.0.0']); + const [url, requestInit] = fetchMock.mock.calls[0]; + expect(url).toBe('https://api.github.com/repos/o/r/releases?per_page=100'); + const headers = (requestInit as RequestInit).headers as Record; + expect(headers.Authorization).toBeUndefined(); + }); + + it('sends a bearer token when one is provided', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock.mockResolvedValueOnce(mockResponse([])); + + await fetchReleases(repo, 'secret-token'); + + const headers = (fetchMock.mock.calls[0][1] as RequestInit).headers as Record; + expect(headers.Authorization).toBe('Bearer secret-token'); + }); + + it('follows pagination via the Link header', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock + .mockResolvedValueOnce( + mockResponse([makeRelease({ tag_name: 'v2.0.0' })], { + link: '; rel="next"', + }) + ) + .mockResolvedValueOnce(mockResponse([makeRelease({ tag_name: 'v1.0.0' })])); + + const releases = await fetchReleases(repo); + + expect(releases.map(r => r.tag_name)).toEqual(['v2.0.0', 'v1.0.0']); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock.mock.calls[1][0]).toBe('https://api.github.com/repos/o/r/releases?per_page=100&page=2'); + }); + + it('throws a descriptive error on a non-OK response', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock.mockResolvedValueOnce(mockResponse({ message: 'Not Found' }, { ok: false, status: 404 })); + + await expect(fetchReleases(repo)).rejects.toThrow('Failed to fetch releases for o/r: 404'); + }); +}); diff --git a/packages/proper-changelog/src/__tests__/renderChangelog.test.ts b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts new file mode 100644 index 000000000..0dabcb9e2 --- /dev/null +++ b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts @@ -0,0 +1,141 @@ +import { describe, it, expect } from '@jest/globals'; +import { renderChangelog, selectReleases } from '../renderChangelog.ts'; +import { makeRelease } from '../__fixtures__/makeRelease.ts'; +import type { ProperChangelogOptions } from '../types.ts'; + +const repo = { owner: 'o', repo: 'r' }; + +function options(overrides: Partial = {}): ProperChangelogOptions { + return { repo, ...overrides }; +} + +describe('selectReleases', () => { + it('always excludes draft releases', () => { + const releases = [makeRelease({ tag_name: 'v2.0.0' }), makeRelease({ tag_name: 'v1.0.0-draft', draft: true })]; + expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); + }); + + it('excludes prereleases by default', () => { + const releases = [ + makeRelease({ tag_name: 'v2.0.0' }), + makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true }), + ]; + expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); + }); + + it('includes prereleases when includePrereleases is set', () => { + const releases = [ + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true, published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ includePrereleases: true })).map(r => r.tag_name)).toEqual([ + 'v2.0.0', + 'v2.0.0-beta.1', + ]); + }); + + it('sorts releases newest-first by published date', () => { + const releases = [ + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0', 'v1.0.0']); + }); + + it('applies the limit after sorting', () => { + const releases = [ + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ limit: 2 })).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0']); + }); + + it('applies an inclusive from/to tag range regardless of bound order', () => { + const releases = [ + makeRelease({ tag_name: 'v4.0.0', published_at: '2024-04-01T00:00:00Z' }), + makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ from: 'v3.0.0', to: 'v2.0.0' })).map(r => r.tag_name)).toEqual([ + 'v3.0.0', + 'v2.0.0', + ]); + }); + + it('throws a helpful error when a from/to tag is not found', () => { + const releases = [makeRelease({ tag_name: 'v1.0.0' })]; + expect(() => selectReleases(releases, options({ from: 'v9.9.9' }))).toThrow('No release found with tag "v9.9.9".'); + }); +}); + +describe('renderChangelog', () => { + it('renders a heading and per-release sections', () => { + const releases = [ + makeRelease({ + tag_name: 'v2.0.0', + name: 'Version 2.0.0', + published_at: '2024-02-01T00:00:00Z', + body: 'Second release.', + }), + makeRelease({ + tag_name: 'v1.0.0', + name: 'v1.0.0', + published_at: '2024-01-01T00:00:00Z', + body: 'First release.', + }), + ]; + expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` +"# r changelog + +## Version 2.0.0 + +_Tag [\`v2.0.0\`](https://github.com/o/r/releases/tag/v2.0.0) · released 2024-02-01_ + +Second release. + +## v1.0.0 + +_[\`v1.0.0\`](https://github.com/o/r/releases/tag/v1.0.0) · released 2024-01-01_ + +First release. +" +`); + }); + + it('demotes headings in release bodies but leaves fenced code untouched', () => { + const releases = [ + makeRelease({ + tag_name: 'v1.0.0', + body: '# Features\r\n\r\n```sh\n# not a heading\n```\r\n\r\n## Details', + }), + ]; + expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` +"# r changelog + +## v1.0.0 + +_[\`v1.0.0\`](https://github.com/o/r/releases/tag/v1.0.0) · released 2024-01-01_ + +### Features + +\`\`\`sh +# not a heading +\`\`\` + +#### Details +" +`); + }); + + it('renders a placeholder when there are no releases', () => { + expect(renderChangelog([], options())).toMatchInlineSnapshot(` +"# r changelog + +No releases found. +" +`); + }); +}); diff --git a/packages/proper-changelog/src/__tests__/resolveToken.test.ts b/packages/proper-changelog/src/__tests__/resolveToken.test.ts new file mode 100644 index 000000000..5e8578440 --- /dev/null +++ b/packages/proper-changelog/src/__tests__/resolveToken.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, jest, beforeEach } from '@jest/globals'; + +type SpawnFn = (file: string, args: string[]) => Promise<{ stdout: string }>; + +const mockSpawn = jest.fn(); + +jest.unstable_mockModule('nano-spawn', () => ({ + default: mockSpawn, +})); + +const { resolveToken } = await import('../resolveToken.ts'); + +/** Configure the mocked spawn to succeed with the given stdout. */ +function mockGhSuccess(stdout: string): void { + mockSpawn.mockResolvedValue({ stdout }); +} + +/** Configure the mocked spawn to fail (e.g. gh not installed). */ +function mockGhFailure(): void { + mockSpawn.mockRejectedValue(new Error('gh: command not found')); +} + +describe('resolveToken', () => { + beforeEach(() => { + mockSpawn.mockReset(); + }); + + it('returns the explicit token without consulting env or gh', async () => { + expect(await resolveToken('explicit', { GITHUB_TOKEN: 'env-token' })).toBe('explicit'); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('falls back to GITHUB_TOKEN', async () => { + expect(await resolveToken(undefined, { GITHUB_TOKEN: 'env-token' })).toBe('env-token'); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('falls back to GH_TOKEN when GITHUB_TOKEN is absent', async () => { + expect(await resolveToken(undefined, { GH_TOKEN: 'gh-env-token' })).toBe('gh-env-token'); + }); + + it('falls back to `gh auth token` when no env token is set', async () => { + mockGhSuccess('gh-cli-token\n'); + expect(await resolveToken(undefined, {})).toBe('gh-cli-token'); + expect(mockSpawn).toHaveBeenCalledWith('gh', ['auth', 'token']); + }); + + it('returns undefined when gh is unavailable', async () => { + mockGhFailure(); + expect(await resolveToken(undefined, {})).toBeUndefined(); + }); + + it('returns undefined when gh outputs an empty token', async () => { + mockGhSuccess(' \n'); + expect(await resolveToken(undefined, {})).toBeUndefined(); + }); +}); diff --git a/packages/proper-changelog/src/cli.ts b/packages/proper-changelog/src/cli.ts new file mode 100644 index 000000000..b13a3d011 --- /dev/null +++ b/packages/proper-changelog/src/cli.ts @@ -0,0 +1,111 @@ +import { writeFile } from 'fs/promises'; +import { Command, Option, InvalidArgumentError } from 'commander'; +import { fetchReleases } from './fetchReleases.ts'; +import { renderChangelog } from './renderChangelog.ts'; +import { resolveToken } from './resolveToken.ts'; +import type { ProperChangelogOptions, RepoId } from './types.ts'; + +/** Parse an `owner/repo` string into a {@link RepoId}. */ +export function parseRepo(value: string): RepoId { + const match = value.match(/^([^/\s]+)\/([^/\s]+)$/); + if (!match) { + throw new InvalidArgumentError(`Expected "owner/repo" but got "${value}".`); + } + return { owner: match[1], repo: match[2] }; +} + +/** Parse a non-negative integer option value. */ +function parsePositiveInt(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new InvalidArgumentError(`Expected a non-negative integer but got "${value}".`); + } + return parsed; +} + +interface RawCliOptions { + repo: RepoId; + out?: string; + stdout?: boolean; + token?: string; + includePrereleases?: boolean; + from?: string; + to?: string; + limit?: number; +} + +/** Build the commander program. Exported for testing. */ +export function createProgram(): Command { + const program = new Command(); + program + .name('proper-changelog') + .description('Generate a single markdown changelog from a GitHub repository\u2019s releases.') + .requiredOption('--repo ', 'GitHub repository to read releases from', parseRepo) + .addOption(new Option('-o, --out ', 'output file name (default: -changelog.md)').conflicts('stdout')) + .addOption(new Option('--stdout', 'write the changelog to stdout instead of a file').conflicts('out')) + .option('--token ', 'GitHub token (falls back to GITHUB_TOKEN/GH_TOKEN, then `gh auth token`)') + .option('--include-prereleases', 'include prerelease releases (drafts are always excluded)') + .option('--from ', 'include releases up to and including this tag') + .option('--to ', 'include releases down to and including this tag') + .option('--limit ', 'maximum number of releases to include', parsePositiveInt) + .allowExcessArguments(false); + return program; +} + +/** Generate the changelog and write it to a file or stdout based on the parsed options. */ +export async function run( + raw: RawCliOptions, + deps: { + log?: (message: string) => void; + warn?: (message: string) => void; + write?: (file: string, content: string) => Promise; + } = {} +): Promise { + const log = deps.log ?? ((message: string) => console.log(message)); + const warn = deps.warn ?? ((message: string) => console.warn(message)); + const write = deps.write ?? ((file: string, content: string) => writeFile(file, content, 'utf8')); + + const token = await resolveToken(raw.token); + if (!token) { + warn( + 'Warning: no GitHub token found (checked --token, GITHUB_TOKEN/GH_TOKEN, and `gh auth token`). ' + + 'Requests will be unauthenticated and may be rate-limited.' + ); + } + + const options: ProperChangelogOptions = { + repo: raw.repo, + token, + includePrereleases: raw.includePrereleases, + from: raw.from, + to: raw.to, + limit: raw.limit, + }; + + const releases = await fetchReleases(raw.repo, token); + const changelog = renderChangelog(releases, options); + + if (raw.stdout) { + log(changelog); + return; + } + + const outFile = raw.out ?? `${raw.repo.repo}-changelog.md`; + await write(outFile, changelog); + warn(`Wrote changelog to ${outFile}`); +} + +/** CLI entry point: parse argv and run, setting a non-zero exit code on failure. */ +export async function main(argv: string[]): Promise { + const program = createProgram(); + program.parse(argv); + await run(program.opts()); +} + +/** Run the CLI and handle top-level errors. Intended to be called from the bin script. */ +export function cli(argv: string[] = process.argv): void { + main(argv).catch((error: unknown) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/packages/proper-changelog/src/fetchReleases.ts b/packages/proper-changelog/src/fetchReleases.ts new file mode 100644 index 000000000..ce37c14c6 --- /dev/null +++ b/packages/proper-changelog/src/fetchReleases.ts @@ -0,0 +1,57 @@ +import type { GitHubRelease, RepoId } from './types.ts'; + +const apiBase = 'https://api.github.com'; +const perPage = 100; + +/** Parse the `Link` response header and return the URL with `rel="next"`, if any. */ +function getNextLink(linkHeader: string | null): string | undefined { + if (!linkHeader) { + return undefined; + } + + for (const part of linkHeader.split(',')) { + const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/); + if (match && match[2] === 'next') { + return match[1]; + } + } + return undefined; +} + +/** + * Fetch all releases for a repository from the GitHub REST API, following pagination. + * + * If `token` is provided, it is sent as a bearer token; otherwise requests are made + * unauthenticated (and are subject to stricter rate limits). + */ +export async function fetchReleases(repo: RepoId, token?: string): Promise { + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + 'User-Agent': 'proper-changelog', + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + + const releases: GitHubRelease[] = []; + let url: string | undefined = `${apiBase}/repos/${repo.owner}/${repo.repo}/releases?per_page=${perPage}`; + + while (url) { + const response: Response = await fetch(url, { headers }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + throw new Error( + `Failed to fetch releases for ${repo.owner}/${repo.repo}: ${response.status} ${response.statusText}` + + (body ? `\n${body}` : '') + ); + } + + const page = (await response.json()) as GitHubRelease[]; + releases.push(...page); + + url = getNextLink(response.headers.get('link')); + } + + return releases; +} diff --git a/packages/proper-changelog/src/index.ts b/packages/proper-changelog/src/index.ts new file mode 100644 index 000000000..f6fae396a --- /dev/null +++ b/packages/proper-changelog/src/index.ts @@ -0,0 +1,4 @@ +export { fetchReleases } from './fetchReleases.ts'; +export { renderChangelog, selectReleases } from './renderChangelog.ts'; +export { resolveToken } from './resolveToken.ts'; +export type { GitHubRelease, ProperChangelogOptions, RepoId } from './types.ts'; diff --git a/packages/proper-changelog/src/renderChangelog.ts b/packages/proper-changelog/src/renderChangelog.ts new file mode 100644 index 000000000..69dcb73c1 --- /dev/null +++ b/packages/proper-changelog/src/renderChangelog.ts @@ -0,0 +1,126 @@ +import type { GitHubRelease, ProperChangelogOptions } from './types.ts'; + +const maxHeadingLevel = 6; +const headingDemotion = 2; + +/** Format an ISO timestamp as `YYYY-MM-DD`, or return undefined if missing/invalid. */ +function formatDate(published: string | null): string | undefined { + if (!published) { + return undefined; + } + const date = new Date(published); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString().slice(0, 10); +} + +/** + * Demote ATX markdown headings (lines starting with `#`) in a release body so they nest + * under the release's `##` section heading. Headings inside fenced code blocks are left alone. + */ +function demoteHeadings(body: string): string { + let inFence = false; + return body + .split(/\r?\n/) + .map(line => { + const fenceMatch = line.match(/^\s*(```|~~~)/); + if (fenceMatch) { + inFence = !inFence; + return line; + } + if (inFence) { + return line; + } + const headingMatch = line.match(/^(#{1,6})(\s.*)$/); + if (headingMatch) { + const level = Math.min(headingMatch[1].length + headingDemotion, maxHeadingLevel); + return '#'.repeat(level) + headingMatch[2]; + } + return line; + }) + .join('\n'); +} + +/** Find the index of a release by tag name, throwing a helpful error if not found. */ +function indexOfTag(releases: GitHubRelease[], tag: string): number { + const index = releases.findIndex(release => release.tag_name === tag); + if (index === -1) { + throw new Error(`No release found with tag "${tag}".`); + } + return index; +} + +/** + * Filter, sort, and slice releases according to the provided options. + * Draft releases are always excluded; prereleases are excluded unless `includePrereleases`. + * Releases are returned newest-first by published date. + */ +export function selectReleases(releases: GitHubRelease[], options: ProperChangelogOptions): GitHubRelease[] { + let selected = releases.filter(release => !release.draft); + + if (!options.includePrereleases) { + selected = selected.filter(release => !release.prerelease); + } + + selected.sort((a, b) => { + const aTime = a.published_at ? Date.parse(a.published_at) : 0; + const bTime = b.published_at ? Date.parse(b.published_at) : 0; + return bTime - aTime; + }); + + // Apply the `from`/`to` tag range (inclusive, order-independent). + if (options.from || options.to) { + const bounds = [ + options.from !== undefined ? indexOfTag(selected, options.from) : 0, + options.to !== undefined ? indexOfTag(selected, options.to) : selected.length - 1, + ]; + const start = Math.min(...bounds); + const end = Math.max(...bounds); + selected = selected.slice(start, end + 1); + } + + if (options.limit !== undefined && options.limit >= 0) { + selected = selected.slice(0, options.limit); + } + + return selected; +} + +/** Render a single release as a markdown section. */ +function renderRelease(release: GitHubRelease): string { + const title = release.name?.trim() || release.tag_name; + const date = formatDate(release.published_at); + + const lines = [`## ${title}`, '']; + + const meta: string[] = []; + if (release.name?.trim() && release.name.trim() !== release.tag_name) { + meta.push(`Tag [\`${release.tag_name}\`](${release.html_url})`); + } else { + meta.push(`[\`${release.tag_name}\`](${release.html_url})`); + } + if (date) { + meta.push(`released ${date}`); + } + lines.push(`_${meta.join(' · ')}_`, ''); + + const body = release.body?.trim(); + if (body) { + lines.push(demoteHeadings(body), ''); + } + + return lines.join('\n').trimEnd(); +} + +/** + * Render a full markdown changelog from GitHub releases, applying the given options. + */ +export function renderChangelog(releases: GitHubRelease[], options: ProperChangelogOptions): string { + const selected = selectReleases(releases, options); + const heading = `# ${options.repo.repo} changelog`; + + if (selected.length === 0) { + return `${heading}\n\nNo releases found.\n`; + } + + const sections = selected.map(renderRelease); + return `${heading}\n\n${sections.join('\n\n')}\n`; +} diff --git a/packages/proper-changelog/src/resolveToken.ts b/packages/proper-changelog/src/resolveToken.ts new file mode 100644 index 000000000..3627920bc --- /dev/null +++ b/packages/proper-changelog/src/resolveToken.ts @@ -0,0 +1,31 @@ +import spawn from 'nano-spawn'; + +/** + * Resolve a GitHub auth token, in priority order: + * 1. The explicitly provided token (e.g. from `--token`). + * 2. The `GITHUB_TOKEN` or `GH_TOKEN` environment variables. + * 3. The output of `gh auth token` (if the GitHub CLI is installed and authenticated). + * + * Returns `undefined` if no token could be resolved. + */ +export async function resolveToken( + explicitToken?: string, + env: NodeJS.ProcessEnv = process.env +): Promise { + if (explicitToken) { + return explicitToken; + } + + const envToken = env.GITHUB_TOKEN || env.GH_TOKEN; + if (envToken) { + return envToken; + } + + try { + const { stdout } = await spawn('gh', ['auth', 'token']); + return stdout.trim() || undefined; + } catch { + // gh not installed or not authenticated; fall through to unauthenticated. + return undefined; + } +} diff --git a/packages/proper-changelog/src/types.ts b/packages/proper-changelog/src/types.ts new file mode 100644 index 000000000..e6e2fd8e3 --- /dev/null +++ b/packages/proper-changelog/src/types.ts @@ -0,0 +1,26 @@ +import type { components } from '@octokit/openapi-types'; + +/** A GitHub release as returned by the REST API (`GET /repos/{owner}/{repo}/releases`). */ +export type GitHubRelease = components['schemas']['release']; + +/** Parsed `owner/repo` identifier. */ +export interface RepoId { + owner: string; + repo: string; +} + +/** Options controlling changelog generation. */ +export interface ProperChangelogOptions { + /** Repository to read releases from, as `owner/repo`. */ + repo: RepoId; + /** Auth token for the GitHub API (optional; requests are rate-limited without one). */ + token?: string; + /** Include prerelease releases (default: false). Draft releases are always excluded. */ + includePrereleases?: boolean; + /** Only include releases up to and including this tag (most recent bound). */ + from?: string; + /** Only include releases down to and including this tag (oldest bound). */ + to?: string; + /** Maximum number of releases to include. */ + limit?: number; +} diff --git a/packages/proper-changelog/tsconfig.json b/packages/proper-changelog/tsconfig.json new file mode 100644 index 000000000..210aff21e --- /dev/null +++ b/packages/proper-changelog/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@microsoft/beachball-scripts/config/tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "lib", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/scripts/config/tsconfig.base.json b/scripts/config/tsconfig.base.json index a8522ea5b..add63f7f4 100644 --- a/scripts/config/tsconfig.base.json +++ b/scripts/config/tsconfig.base.json @@ -5,6 +5,8 @@ // Use CJS output for now "module": "node20", "moduleResolution": "node16", + "rewriteRelativeImportExtensions": true, + "allowImportingTsExtensions": true, "declaration": true, "declarationMap": true, "sourceMap": true, diff --git a/yarn.lock b/yarn.lock index 58dae2ac8..b9a15b7c5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4200,6 +4200,13 @@ __metadata: languageName: node linkType: hard +"commander@npm:^15.0.0": + version: 15.0.0 + resolution: "commander@npm:15.0.0" + checksum: 10c0/539229c171914ea1ccd45ee5f10d924289a12a684ea3a7a44147abe54003c35ed6de9ae4ad198d88c29fdf403dca0428451a388234e6dc01b6faa978fb207206 + languageName: node + linkType: hard + "compressible@npm:~2.0.18": version: 2.0.18 resolution: "compressible@npm:2.0.18" @@ -7252,6 +7259,13 @@ __metadata: languageName: node linkType: hard +"nano-spawn@npm:^2.1.0": + version: 2.1.0 + resolution: "nano-spawn@npm:2.1.0" + checksum: 10c0/3becc67ed9ab630b6572feab69a4ef468891ad1f89d5c8643f14a2044cf32ba64533033506208039b1e3d9ddcb2f5f4f87ec360f13b3c4f0774304aedf0f0290 + languageName: node + linkType: hard + "nanoid@npm:^3.3.11": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -7860,6 +7874,20 @@ __metadata: languageName: node linkType: hard +"proper-changelog@workspace:packages/proper-changelog": + version: 0.0.0-use.local + resolution: "proper-changelog@workspace:packages/proper-changelog" + dependencies: + "@microsoft/beachball-scripts": "workspace:^" + "@octokit/openapi-types": "npm:^27.0.0" + commander: "npm:^15.0.0" + cross-env: "npm:^10.1.0" + nano-spawn: "npm:^2.1.0" + bin: + proper-changelog: ./bin/proper-changelog.js + languageName: unknown + linkType: soft + "protocols@npm:^2.0.0, protocols@npm:^2.0.1": version: 2.0.2 resolution: "protocols@npm:2.0.2" From f447d402f67efd2f011225a8d6dc5ed7c8a1ce44 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 21:18:37 -0700 Subject: [PATCH 03/12] add --package and some reorganization --- packages/proper-changelog/README.md | 36 +++-- packages/proper-changelog/package.json | 3 +- .../src/__fixtures__/makeRelease.ts | 25 +--- .../src/__tests__/cli.test.ts | 34 +++-- .../src/__tests__/fetchReleases.test.ts | 12 +- .../src/__tests__/renderChangelog.test.ts | 112 ++++------------ .../__tests__/resolveRepoFromPackage.test.ts | 95 ++++++++++++++ .../src/__tests__/selectReleases.test.ts | 72 ++++++++++ packages/proper-changelog/src/cli.ts | 47 ++++--- .../proper-changelog/src/fetchReleases.ts | 32 ++--- packages/proper-changelog/src/index.ts | 4 - .../proper-changelog/src/renderChangelog.ts | 123 ++++++------------ .../src/resolveRepoFromPackage.ts | 74 +++++++++++ .../proper-changelog/src/selectReleases.ts | 46 +++++++ 14 files changed, 459 insertions(+), 256 deletions(-) create mode 100644 packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts create mode 100644 packages/proper-changelog/src/__tests__/selectReleases.test.ts delete mode 100644 packages/proper-changelog/src/index.ts create mode 100644 packages/proper-changelog/src/resolveRepoFromPackage.ts create mode 100644 packages/proper-changelog/src/selectReleases.ts diff --git a/packages/proper-changelog/README.md b/packages/proper-changelog/README.md index 45e08a913..fabb24b72 100644 --- a/packages/proper-changelog/README.md +++ b/packages/proper-changelog/README.md @@ -5,9 +5,15 @@ GitHub releases are useful in some ways, but they're horrible as changelogs if y ## Usage ```bash +# By GitHub repository npx proper-changelog --repo / + +# By npm package name (the GitHub repository is read from the latest published version) +npx proper-changelog --package ``` +Exactly one of `--repo` or `--package` is required, and they cannot be used together. + By default this writes the changelog to `-changelog.md` in the current directory. Use `--stdout` to print it instead, or `--out` to choose a different file name. ```bash @@ -16,6 +22,9 @@ npx proper-changelog --repo microsoft/beachball --out CHANGELOG.md # Print to stdout npx proper-changelog --repo microsoft/beachball --stdout + +# Resolve the repository from an npm package +npx proper-changelog --package @fluentui/react --stdout ``` ## Authentication @@ -30,15 +39,22 @@ If no token is found, the tool prints a warning and continues unauthenticated. ## Options -| Option | Description | -| ----------------------- | ---------------------------------------------------------------------------------- | -| `--repo ` | **Required.** GitHub repository to read releases from. | -| `-o, --out ` | Output file name (default: `-changelog.md`). Cannot be used with `--stdout`. | -| `--stdout` | Write the changelog to stdout instead of a file. Cannot be used with `--out`. | -| `--token ` | GitHub token (see [Authentication](#authentication)). | -| `--include-prereleases` | Include prerelease releases. Draft releases are always excluded. | -| `--from ` | Include releases up to and including this tag. | -| `--to ` | Include releases down to and including this tag. | -| `--limit ` | Maximum number of releases to include. | +| Option | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `--repo ` | GitHub repository to read releases from. Required unless `--package` is given; cannot be used with it. | +| `--package ` | npm package whose GitHub repository should be used (read from the latest published version's manifest). Required unless `--repo` is given; cannot be used with it. | +| `-o, --out ` | Output file name (default: `-changelog.md`). Cannot be used with `--stdout`. | +| `--stdout` | Write the changelog to stdout instead of a file. Cannot be used with `--out`. | +| `--token ` | GitHub token (see [Authentication](#authentication)). | +| `--include-prereleases` | Include prerelease releases. Draft releases are always excluded. | +| `--from ` | Include releases up to and including this tag. | +| `--to ` | Include releases down to and including this tag. | +| `--limit ` | Maximum number of releases to include. | Releases are listed newest-first by published date. Draft releases are always excluded, and prereleases are excluded unless `--include-prereleases` is passed. + +When using `--package`, only packages whose repository is on github.com are supported. + +## API + +The package currently does not have an importable API. If you want this, please open a feature request describing your use case. diff --git a/packages/proper-changelog/package.json b/packages/proper-changelog/package.json index 1563b3166..c1bd8ce95 100644 --- a/packages/proper-changelog/package.json +++ b/packages/proper-changelog/package.json @@ -9,8 +9,7 @@ "directory": "packages/proper-changelog" }, "license": "MIT", - "main": "lib/index.js", - "types": "lib/index.d.ts", + "exports": null, "bin": "./bin/proper-changelog.js", "engines": { "node": ">=22.18.0" diff --git a/packages/proper-changelog/src/__fixtures__/makeRelease.ts b/packages/proper-changelog/src/__fixtures__/makeRelease.ts index 1319a8c53..f8058afa3 100644 --- a/packages/proper-changelog/src/__fixtures__/makeRelease.ts +++ b/packages/proper-changelog/src/__fixtures__/makeRelease.ts @@ -7,8 +7,8 @@ import type { GitHubRelease } from '../types.ts'; export function makeRelease(overrides: Partial = {}): GitHubRelease { const tag = overrides.tag_name ?? 'v1.0.0'; return { - url: 'https://api.github.com/repos/o/r/releases/1', - html_url: `https://github.com/o/r/releases/tag/${tag}`, + url: 'https://api.github.com/repos/microsoft/some-repo/releases/1', + html_url: `https://github.com/microsoft/some-repo/releases/tag/${tag}`, assets_url: '', upload_url: '', tarball_url: null, @@ -23,26 +23,7 @@ export function makeRelease(overrides: Partial = {}): GitHubRelea prerelease: false, created_at: '2024-01-01T00:00:00Z', published_at: '2024-01-01T00:00:00Z', - author: { - login: 'octocat', - id: 1, - node_id: 'u', - avatar_url: '', - gravatar_id: null, - url: '', - html_url: '', - followers_url: '', - following_url: '', - gists_url: '', - starred_url: '', - subscriptions_url: '', - organizations_url: '', - repos_url: '', - events_url: '', - received_events_url: '', - type: 'User', - site_admin: false, - }, + author: {} as GitHubRelease['author'], // not used assets: [], ...overrides, }; diff --git a/packages/proper-changelog/src/__tests__/cli.test.ts b/packages/proper-changelog/src/__tests__/cli.test.ts index e2995a6ee..a1c8871be 100644 --- a/packages/proper-changelog/src/__tests__/cli.test.ts +++ b/packages/proper-changelog/src/__tests__/cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from '@jest/globals'; -import { createProgram, parseRepo } from '../cli.ts'; +import { createProgram, parseRepo, run } from '../cli.ts'; describe('parseRepo', () => { it('parses an owner/repo string', () => { @@ -19,15 +19,15 @@ describe('createProgram', () => { } it('parses --repo into a RepoId and applies defaults', () => { - const opts = parse(['--repo', 'o/r']); - expect(opts.repo).toEqual({ owner: 'o', repo: 'r' }); + const opts = parse(['--repo', 'microsoft/some-repo']); + expect(opts.repo).toEqual({ owner: 'microsoft', repo: 'some-repo' }); expect(opts.includePrereleases).toBeUndefined(); }); it('parses all options', () => { const opts = parse([ '--repo', - 'o/r', + 'microsoft/some-repo', '--out', 'changes.md', '--token', @@ -41,7 +41,7 @@ describe('createProgram', () => { '5', ]); expect(opts).toMatchObject({ - repo: { owner: 'o', repo: 'r' }, + repo: { owner: 'microsoft', repo: 'some-repo' }, out: 'changes.md', token: 't', includePrereleases: true, @@ -51,15 +51,31 @@ describe('createProgram', () => { }); }); - it('requires --repo', () => { - expect(() => parse([])).toThrow(); + it('parses --package', () => { + const opts = parse(['--package', '@scope/pkg']); + expect(opts.package).toBe('@scope/pkg'); + expect(opts.repo).toBeUndefined(); }); it('rejects a non-integer --limit', () => { - expect(() => parse(['--repo', 'o/r', '--limit', 'abc'])).toThrow(); + expect(() => parse(['--repo', 'microsoft/some-repo', '--limit', 'abc'])).toThrow( + 'Expected a non-negative integer but got \"abc\"' + ); }); it('rejects using --out and --stdout together', () => { - expect(() => parse(['--repo', 'o/r', '--out', 'x.md', '--stdout'])).toThrow(); + expect(() => parse(['--repo', 'microsoft/some-repo', '--out', 'x.md', '--stdout'])).toThrow(/--out.*?--stdout/); + }); + + it('rejects using --repo and --package together', () => { + expect(() => parse(['--repo', 'microsoft/some-repo', '--package', 'pkg'])).toThrow(/--repo.*?--package/); + }); +}); + +describe('run', () => { + it('throws when neither --repo nor --package is provided', async () => { + await expect(run({}, { log() {}, warn() {}, write: () => Promise.resolve() })).rejects.toThrow( + 'Exactly one of --repo or --package is required.' + ); }); }); diff --git a/packages/proper-changelog/src/__tests__/fetchReleases.test.ts b/packages/proper-changelog/src/__tests__/fetchReleases.test.ts index b676b9155..1f0670b89 100644 --- a/packages/proper-changelog/src/__tests__/fetchReleases.test.ts +++ b/packages/proper-changelog/src/__tests__/fetchReleases.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals import { fetchReleases } from '../fetchReleases.ts'; import { makeRelease } from '../__fixtures__/makeRelease.ts'; -const repo = { owner: 'o', repo: 'r' }; +const repo = { owner: 'microsoft', repo: 'some-repo' }; describe('fetchReleases', () => { const originalFetch = global.fetch; @@ -38,7 +38,7 @@ describe('fetchReleases', () => { expect(releases.map(r => r.tag_name)).toEqual(['v1.0.0']); const [url, requestInit] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.github.com/repos/o/r/releases?per_page=100'); + expect(url).toBe('https://api.github.com/repos/microsoft/some-repo/releases?per_page=100'); const headers = (requestInit as RequestInit).headers as Record; expect(headers.Authorization).toBeUndefined(); }); @@ -58,7 +58,7 @@ describe('fetchReleases', () => { fetchMock .mockResolvedValueOnce( mockResponse([makeRelease({ tag_name: 'v2.0.0' })], { - link: '; rel="next"', + link: '; rel="next"', }) ) .mockResolvedValueOnce(mockResponse([makeRelease({ tag_name: 'v1.0.0' })])); @@ -67,13 +67,15 @@ describe('fetchReleases', () => { expect(releases.map(r => r.tag_name)).toEqual(['v2.0.0', 'v1.0.0']); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[1][0]).toBe('https://api.github.com/repos/o/r/releases?per_page=100&page=2'); + expect(fetchMock.mock.calls[1][0]).toBe( + 'https://api.github.com/repos/microsoft/some-repo/releases?per_page=100&page=2' + ); }); it('throws a descriptive error on a non-OK response', async () => { const fetchMock = global.fetch as jest.MockedFunction; fetchMock.mockResolvedValueOnce(mockResponse({ message: 'Not Found' }, { ok: false, status: 404 })); - await expect(fetchReleases(repo)).rejects.toThrow('Failed to fetch releases for o/r: 404'); + await expect(fetchReleases(repo)).rejects.toThrow('Failed to fetch releases for microsoft/some-repo: 404'); }); }); diff --git a/packages/proper-changelog/src/__tests__/renderChangelog.test.ts b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts index 0dabcb9e2..caeff07d6 100644 --- a/packages/proper-changelog/src/__tests__/renderChangelog.test.ts +++ b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts @@ -1,76 +1,14 @@ import { describe, it, expect } from '@jest/globals'; -import { renderChangelog, selectReleases } from '../renderChangelog.ts'; +import { renderChangelog } from '../renderChangelog.ts'; import { makeRelease } from '../__fixtures__/makeRelease.ts'; import type { ProperChangelogOptions } from '../types.ts'; -const repo = { owner: 'o', repo: 'r' }; +const repo = { owner: 'microsoft', repo: 'some-repo' }; function options(overrides: Partial = {}): ProperChangelogOptions { return { repo, ...overrides }; } -describe('selectReleases', () => { - it('always excludes draft releases', () => { - const releases = [makeRelease({ tag_name: 'v2.0.0' }), makeRelease({ tag_name: 'v1.0.0-draft', draft: true })]; - expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); - }); - - it('excludes prereleases by default', () => { - const releases = [ - makeRelease({ tag_name: 'v2.0.0' }), - makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true }), - ]; - expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); - }); - - it('includes prereleases when includePrereleases is set', () => { - const releases = [ - makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), - makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true, published_at: '2024-01-01T00:00:00Z' }), - ]; - expect(selectReleases(releases, options({ includePrereleases: true })).map(r => r.tag_name)).toEqual([ - 'v2.0.0', - 'v2.0.0-beta.1', - ]); - }); - - it('sorts releases newest-first by published date', () => { - const releases = [ - makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), - makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), - makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), - ]; - expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0', 'v1.0.0']); - }); - - it('applies the limit after sorting', () => { - const releases = [ - makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), - makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), - makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), - ]; - expect(selectReleases(releases, options({ limit: 2 })).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0']); - }); - - it('applies an inclusive from/to tag range regardless of bound order', () => { - const releases = [ - makeRelease({ tag_name: 'v4.0.0', published_at: '2024-04-01T00:00:00Z' }), - makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), - makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), - makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), - ]; - expect(selectReleases(releases, options({ from: 'v3.0.0', to: 'v2.0.0' })).map(r => r.tag_name)).toEqual([ - 'v3.0.0', - 'v2.0.0', - ]); - }); - - it('throws a helpful error when a from/to tag is not found', () => { - const releases = [makeRelease({ tag_name: 'v1.0.0' })]; - expect(() => selectReleases(releases, options({ from: 'v9.9.9' }))).toThrow('No release found with tag "v9.9.9".'); - }); -}); - describe('renderChangelog', () => { it('renders a heading and per-release sections', () => { const releases = [ @@ -88,21 +26,21 @@ describe('renderChangelog', () => { }), ]; expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` -"# r changelog + "# some-repo changelog -## Version 2.0.0 + ## Version 2.0.0 -_Tag [\`v2.0.0\`](https://github.com/o/r/releases/tag/v2.0.0) · released 2024-02-01_ + _Tag [\`v2.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v2.0.0) • released 2024-02-01_ -Second release. + Second release. -## v1.0.0 + ## v1.0.0 -_[\`v1.0.0\`](https://github.com/o/r/releases/tag/v1.0.0) · released 2024-01-01_ + _Tag [\`v1.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v1.0.0) • released 2024-01-01_ -First release. -" -`); + First release. + " + `); }); it('demotes headings in release bodies but leaves fenced code untouched', () => { @@ -113,29 +51,29 @@ First release. }), ]; expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` -"# r changelog + "# some-repo changelog -## v1.0.0 + ## v1.0.0 -_[\`v1.0.0\`](https://github.com/o/r/releases/tag/v1.0.0) · released 2024-01-01_ + _Tag [\`v1.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v1.0.0) • released 2024-01-01_ -### Features + ### Features -\`\`\`sh -# not a heading -\`\`\` + \`\`\`sh + # not a heading + \`\`\` -#### Details -" -`); + #### Details + " + `); }); it('renders a placeholder when there are no releases', () => { expect(renderChangelog([], options())).toMatchInlineSnapshot(` -"# r changelog + "# some-repo changelog -No releases found. -" -`); + No releases found. + " + `); }); }); diff --git a/packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts b/packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts new file mode 100644 index 000000000..a144514af --- /dev/null +++ b/packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; +import { parseGitHubRepo, resolveRepoFromPackage } from '../resolveRepoFromPackage.ts'; + +describe('parseGitHubRepo', () => { + const expected = { owner: 'microsoft', repo: 'beachball' }; + + it.each([ + 'git+https://github.com/microsoft/beachball.git', + 'https://github.com/microsoft/beachball', + 'https://github.com/microsoft/beachball.git', + 'git://github.com/microsoft/beachball.git', + 'git@github.com:microsoft/beachball.git', + 'github:microsoft/beachball', + 'microsoft/beachball', + 'https://github.com/microsoft/beachball.git#main', + ])('parses %p', url => { + expect(parseGitHubRepo(url, 'beachball')).toEqual(expected); + }); + + it('parses the object form with a url', () => { + expect( + parseGitHubRepo({ type: 'git', url: 'git+https://github.com/microsoft/beachball.git' }, 'beachball') + ).toEqual(expected); + }); + + it('throws when no repository is specified', () => { + expect(() => parseGitHubRepo(undefined, 'beachball')).toThrow( + 'npm package "beachball" does not specify a repository.' + ); + }); + + it.each(['gitlab:owner/repo', 'https://gitlab.com/owner/repo.git', 'https://bitbucket.org/owner/repo.git'])( + 'throws for non-github.com repository %p', + url => { + expect(() => parseGitHubRepo(url, 'pkg')).toThrow('is not on github.com'); + } + ); +}); + +describe('resolveRepoFromPackage', () => { + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = jest.fn() as typeof fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + function mockResponse(body: unknown, init: { ok?: boolean; status?: number } = {}): Response { + return { + ok: init.ok ?? true, + status: init.status ?? 200, + statusText: init.ok === false ? 'Not Found' : 'OK', + headers: new Headers(), + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + } as Response; + } + + it('resolves the repository from the latest manifest', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock.mockResolvedValueOnce( + mockResponse({ repository: { type: 'git', url: 'git+https://github.com/microsoft/beachball.git' } }) + ); + + expect(await resolveRepoFromPackage('beachball')).toEqual({ owner: 'microsoft', repo: 'beachball' }); + expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/beachball/latest'); + }); + + it('encodes the slash in a scoped package name', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock.mockResolvedValueOnce(mockResponse({ repository: 'github:microsoft/fluentui' })); + + await resolveRepoFromPackage('@fluentui/react'); + expect(fetchMock.mock.calls[0][0]).toBe('https://registry.npmjs.org/@fluentui%2Freact/latest'); + }); + + it('throws when the package is not found', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock.mockResolvedValueOnce(mockResponse({}, { ok: false, status: 404 })); + + await expect(resolveRepoFromPackage('does-not-exist')).rejects.toThrow( + 'Failed to look up npm package "does-not-exist": 404' + ); + }); + + it('throws when the package has no repository', async () => { + const fetchMock = global.fetch as jest.MockedFunction; + fetchMock.mockResolvedValueOnce(mockResponse({ name: 'no-repo' })); + + await expect(resolveRepoFromPackage('no-repo')).rejects.toThrow('does not specify a repository'); + }); +}); diff --git a/packages/proper-changelog/src/__tests__/selectReleases.test.ts b/packages/proper-changelog/src/__tests__/selectReleases.test.ts new file mode 100644 index 000000000..7d48863c4 --- /dev/null +++ b/packages/proper-changelog/src/__tests__/selectReleases.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from '@jest/globals'; +import { selectReleases } from '../selectReleases.ts'; +import { makeRelease } from '../__fixtures__/makeRelease.ts'; +import type { ProperChangelogOptions } from '../types.ts'; + +const repo = { owner: 'microsoft', repo: 'some-repo' }; + +function options(overrides: Partial = {}): ProperChangelogOptions { + return { repo, ...overrides }; +} + +describe('selectReleases', () => { + it('always excludes draft releases', () => { + const releases = [makeRelease({ tag_name: 'v2.0.0' }), makeRelease({ tag_name: 'v1.0.0-draft', draft: true })]; + expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); + }); + + it('excludes prereleases by default', () => { + const releases = [ + makeRelease({ tag_name: 'v2.0.0' }), + makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true }), + ]; + expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); + }); + + it('includes prereleases when includePrereleases is set', () => { + const releases = [ + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true, published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ includePrereleases: true })).map(r => r.tag_name)).toEqual([ + 'v2.0.0', + 'v2.0.0-beta.1', + ]); + }); + + it('sorts releases newest-first by published date', () => { + const releases = [ + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0', 'v1.0.0']); + }); + + it('applies the limit after sorting', () => { + const releases = [ + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ limit: 2 })).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0']); + }); + + it('applies an inclusive from/to tag range regardless of bound order', () => { + const releases = [ + makeRelease({ tag_name: 'v4.0.0', published_at: '2024-04-01T00:00:00Z' }), + makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ from: 'v3.0.0', to: 'v2.0.0' })).map(r => r.tag_name)).toEqual([ + 'v3.0.0', + 'v2.0.0', + ]); + }); + + it('throws a helpful error when a from/to tag is not found', () => { + const releases = [makeRelease({ tag_name: 'v1.0.0' })]; + expect(() => selectReleases(releases, options({ from: 'v9.9.9' }))).toThrow('No release found with tag "v9.9.9".'); + }); +}); diff --git a/packages/proper-changelog/src/cli.ts b/packages/proper-changelog/src/cli.ts index b13a3d011..3a4f07451 100644 --- a/packages/proper-changelog/src/cli.ts +++ b/packages/proper-changelog/src/cli.ts @@ -2,6 +2,7 @@ import { writeFile } from 'fs/promises'; import { Command, Option, InvalidArgumentError } from 'commander'; import { fetchReleases } from './fetchReleases.ts'; import { renderChangelog } from './renderChangelog.ts'; +import { resolveRepoFromPackage } from './resolveRepoFromPackage.ts'; import { resolveToken } from './resolveToken.ts'; import type { ProperChangelogOptions, RepoId } from './types.ts'; @@ -24,7 +25,8 @@ function parsePositiveInt(value: string): number { } interface RawCliOptions { - repo: RepoId; + repo?: RepoId; + package?: string; out?: string; stdout?: boolean; token?: string; @@ -40,18 +42,34 @@ export function createProgram(): Command { program .name('proper-changelog') .description('Generate a single markdown changelog from a GitHub repository\u2019s releases.') - .requiredOption('--repo ', 'GitHub repository to read releases from', parseRepo) + .addOption( + new Option('--repo ', 'GitHub repository to read releases from') + .argParser(parseRepo) + .conflicts('package') + ) + .addOption(new Option('--package ', 'npm package whose GitHub repository should be used').conflicts('repo')) .addOption(new Option('-o, --out ', 'output file name (default: -changelog.md)').conflicts('stdout')) .addOption(new Option('--stdout', 'write the changelog to stdout instead of a file').conflicts('out')) .option('--token ', 'GitHub token (falls back to GITHUB_TOKEN/GH_TOKEN, then `gh auth token`)') .option('--include-prereleases', 'include prerelease releases (drafts are always excluded)') - .option('--from ', 'include releases up to and including this tag') - .option('--to ', 'include releases down to and including this tag') + .option('--from ', 'include releases up to and including this tag (based on date, not semver)') + .option('--to ', 'include releases down to and including this tag (based on date, not semver)') .option('--limit ', 'maximum number of releases to include', parsePositiveInt) .allowExcessArguments(false); return program; } +/** Resolve the target repository from either `--repo` or `--package`. */ +async function resolveRepo(raw: RawCliOptions): Promise { + if (raw.repo) { + return raw.repo; + } + if (raw.package) { + return resolveRepoFromPackage(raw.package); + } + throw new Error('Exactly one of --repo or --package is required.'); +} + /** Generate the changelog and write it to a file or stdout based on the parsed options. */ export async function run( raw: RawCliOptions, @@ -65,6 +83,8 @@ export async function run( const warn = deps.warn ?? ((message: string) => console.warn(message)); const write = deps.write ?? ((file: string, content: string) => writeFile(file, content, 'utf8')); + const repo = await resolveRepo(raw); + const token = await resolveToken(raw.token); if (!token) { warn( @@ -74,7 +94,7 @@ export async function run( } const options: ProperChangelogOptions = { - repo: raw.repo, + repo, token, includePrereleases: raw.includePrereleases, from: raw.from, @@ -82,7 +102,7 @@ export async function run( limit: raw.limit, }; - const releases = await fetchReleases(raw.repo, token); + const releases = await fetchReleases(repo, token); const changelog = renderChangelog(releases, options); if (raw.stdout) { @@ -90,21 +110,18 @@ export async function run( return; } - const outFile = raw.out ?? `${raw.repo.repo}-changelog.md`; + const outFile = raw.out ?? `${repo.repo}-changelog.md`; await write(outFile, changelog); warn(`Wrote changelog to ${outFile}`); } -/** CLI entry point: parse argv and run, setting a non-zero exit code on failure. */ -export async function main(argv: string[]): Promise { - const program = createProgram(); - program.parse(argv); - await run(program.opts()); -} - /** Run the CLI and handle top-level errors. Intended to be called from the bin script. */ export function cli(argv: string[] = process.argv): void { - main(argv).catch((error: unknown) => { + (async () => { + const program = createProgram(); + program.parse(argv); + await run(program.opts()); + })().catch((error: unknown) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }); diff --git a/packages/proper-changelog/src/fetchReleases.ts b/packages/proper-changelog/src/fetchReleases.ts index ce37c14c6..6f2ab28ea 100644 --- a/packages/proper-changelog/src/fetchReleases.ts +++ b/packages/proper-changelog/src/fetchReleases.ts @@ -3,21 +3,6 @@ import type { GitHubRelease, RepoId } from './types.ts'; const apiBase = 'https://api.github.com'; const perPage = 100; -/** Parse the `Link` response header and return the URL with `rel="next"`, if any. */ -function getNextLink(linkHeader: string | null): string | undefined { - if (!linkHeader) { - return undefined; - } - - for (const part of linkHeader.split(',')) { - const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/); - if (match && match[2] === 'next') { - return match[1]; - } - } - return undefined; -} - /** * Fetch all releases for a repository from the GitHub REST API, following pagination. * @@ -27,7 +12,7 @@ function getNextLink(linkHeader: string | null): string | undefined { export async function fetchReleases(repo: RepoId, token?: string): Promise { const headers: Record = { Accept: 'application/vnd.github+json', - 'X-GitHub-Api-Version': '2022-11-28', + 'X-GitHub-Api-Version': '2026-03-10', 'User-Agent': 'proper-changelog', }; if (token) { @@ -55,3 +40,18 @@ export async function fetchReleases(repo: RepoId, token?: string): Promise]+)>;\s*rel="([^"]+)"/); + if (match?.[2] === 'next') { + return match[1]; + } + } + return undefined; +} diff --git a/packages/proper-changelog/src/index.ts b/packages/proper-changelog/src/index.ts deleted file mode 100644 index f6fae396a..000000000 --- a/packages/proper-changelog/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { fetchReleases } from './fetchReleases.ts'; -export { renderChangelog, selectReleases } from './renderChangelog.ts'; -export { resolveToken } from './resolveToken.ts'; -export type { GitHubRelease, ProperChangelogOptions, RepoId } from './types.ts'; diff --git a/packages/proper-changelog/src/renderChangelog.ts b/packages/proper-changelog/src/renderChangelog.ts index 69dcb73c1..a42151b5a 100644 --- a/packages/proper-changelog/src/renderChangelog.ts +++ b/packages/proper-changelog/src/renderChangelog.ts @@ -1,8 +1,45 @@ +import { selectReleases } from './selectReleases.ts'; import type { GitHubRelease, ProperChangelogOptions } from './types.ts'; const maxHeadingLevel = 6; const headingDemotion = 2; +/** + * Render a full markdown changelog from GitHub releases, applying the given options. + */ +export function renderChangelog(releases: GitHubRelease[], options: ProperChangelogOptions): string { + const selected = selectReleases(releases, options); + const heading = `# ${options.repo.repo} changelog`; + + if (selected.length === 0) { + return `${heading}\n\nNo releases found.\n`; + } + + const sections = selected.map(renderRelease); + return `${heading}\n\n${sections.join('\n\n')}\n`; +} + +/** Render a single release as a markdown section. */ +function renderRelease(release: GitHubRelease): string { + const title = release.name?.trim() || release.tag_name; + const date = formatDate(release.published_at); + + const lines = [`## ${title}`, '']; + + const meta = [`Tag [\`${release.tag_name}\`](${release.html_url})`]; + if (date) { + meta.push(`released ${date}`); + } + lines.push(`_${meta.join(' • ')}_`, ''); + + const body = release.body?.trim(); + if (body) { + lines.push(demoteHeadings(body), ''); + } + + return lines.join('\n').trimEnd(); +} + /** Format an ISO timestamp as `YYYY-MM-DD`, or return undefined if missing/invalid. */ function formatDate(published: string | null): string | undefined { if (!published) { @@ -38,89 +75,3 @@ function demoteHeadings(body: string): string { }) .join('\n'); } - -/** Find the index of a release by tag name, throwing a helpful error if not found. */ -function indexOfTag(releases: GitHubRelease[], tag: string): number { - const index = releases.findIndex(release => release.tag_name === tag); - if (index === -1) { - throw new Error(`No release found with tag "${tag}".`); - } - return index; -} - -/** - * Filter, sort, and slice releases according to the provided options. - * Draft releases are always excluded; prereleases are excluded unless `includePrereleases`. - * Releases are returned newest-first by published date. - */ -export function selectReleases(releases: GitHubRelease[], options: ProperChangelogOptions): GitHubRelease[] { - let selected = releases.filter(release => !release.draft); - - if (!options.includePrereleases) { - selected = selected.filter(release => !release.prerelease); - } - - selected.sort((a, b) => { - const aTime = a.published_at ? Date.parse(a.published_at) : 0; - const bTime = b.published_at ? Date.parse(b.published_at) : 0; - return bTime - aTime; - }); - - // Apply the `from`/`to` tag range (inclusive, order-independent). - if (options.from || options.to) { - const bounds = [ - options.from !== undefined ? indexOfTag(selected, options.from) : 0, - options.to !== undefined ? indexOfTag(selected, options.to) : selected.length - 1, - ]; - const start = Math.min(...bounds); - const end = Math.max(...bounds); - selected = selected.slice(start, end + 1); - } - - if (options.limit !== undefined && options.limit >= 0) { - selected = selected.slice(0, options.limit); - } - - return selected; -} - -/** Render a single release as a markdown section. */ -function renderRelease(release: GitHubRelease): string { - const title = release.name?.trim() || release.tag_name; - const date = formatDate(release.published_at); - - const lines = [`## ${title}`, '']; - - const meta: string[] = []; - if (release.name?.trim() && release.name.trim() !== release.tag_name) { - meta.push(`Tag [\`${release.tag_name}\`](${release.html_url})`); - } else { - meta.push(`[\`${release.tag_name}\`](${release.html_url})`); - } - if (date) { - meta.push(`released ${date}`); - } - lines.push(`_${meta.join(' · ')}_`, ''); - - const body = release.body?.trim(); - if (body) { - lines.push(demoteHeadings(body), ''); - } - - return lines.join('\n').trimEnd(); -} - -/** - * Render a full markdown changelog from GitHub releases, applying the given options. - */ -export function renderChangelog(releases: GitHubRelease[], options: ProperChangelogOptions): string { - const selected = selectReleases(releases, options); - const heading = `# ${options.repo.repo} changelog`; - - if (selected.length === 0) { - return `${heading}\n\nNo releases found.\n`; - } - - const sections = selected.map(renderRelease); - return `${heading}\n\n${sections.join('\n\n')}\n`; -} diff --git a/packages/proper-changelog/src/resolveRepoFromPackage.ts b/packages/proper-changelog/src/resolveRepoFromPackage.ts new file mode 100644 index 000000000..777fd8488 --- /dev/null +++ b/packages/proper-changelog/src/resolveRepoFromPackage.ts @@ -0,0 +1,74 @@ +import type { RepoId } from './types.ts'; + +/** The `repository` field of an npm package manifest, in object form. */ +interface NpmRepository { + type?: string; + url?: string; + directory?: string; +} + +/** Minimal npm registry manifest shape (only the fields we read). */ +interface NpmManifest { + repository?: NpmRepository | string; +} + +const registryBase = 'https://registry.npmjs.org'; + +/** + * Parse a GitHub `owner/repo` from an npm `repository` field. Only github.com is supported: + * github.com URLs (`git+https`, `https`, `git://`, `git@github.com:`) and the `github:` + * shorthand. Throws if the repository refers to any other host or can't be parsed. + */ +export function parseGitHubRepo(repository: NpmRepository | string | undefined, packageName: string): RepoId { + const raw = typeof repository === 'string' ? repository : repository?.url; + if (!raw) { + throw new Error(`npm package "${packageName}" does not specify a repository.`); + } + + // `github:owner/repo` shorthand always refers to github.com. + const shorthandMatch = raw.match(/^github:([^/#]+)\/([^/#]+?)(?:\.git)?(?:#.*)?$/); + if (shorthandMatch) { + return { owner: shorthandMatch[1], repo: shorthandMatch[2] }; + } + + // A bare `owner/repo` string shorthand also defaults to github.com. + const bareMatch = raw.match(/^([^/#:]+)\/([^/#]+?)(?:\.git)?(?:#.*)?$/); + if (bareMatch) { + return { owner: bareMatch[1], repo: bareMatch[2] }; + } + + // Any other host shorthand (e.g. `gitlab:`/`bitbucket:`) is unsupported. + if (/^[a-z]+:[^/]+\/[^/]+$/i.test(raw) && !raw.startsWith('github:')) { + throw new Error(`npm package "${packageName}" repository is not on github.com: ${raw}`); + } + + // URL forms: https, git+https, git://, ssh (git@github.com:owner/repo). + const urlMatch = raw.match(/github\.com[/:]([^/#]+)\/([^/#]+?)(?:\.git)?(?:#.*)?$/); + if (urlMatch) { + return { owner: urlMatch[1], repo: urlMatch[2] }; + } + + throw new Error(`npm package "${packageName}" repository is not on github.com: ${raw}`); +} + +/** Fetch the latest published manifest for an npm package. */ +export async function fetchPackageManifest(packageName: string): Promise { + // Encode the package name for the URL path. The leading `@` in scoped names is allowed + // unencoded, but the `/` separator must be encoded. + const encodedName = packageName.startsWith('@') + ? `@${encodeURIComponent(packageName.slice(1))}` + : encodeURIComponent(packageName); + + const url = `${registryBase}/${encodedName}/latest`; + const response = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + throw new Error(`Failed to look up npm package "${packageName}": ${response.status} ${response.statusText}`); + } + return (await response.json()) as NpmManifest; +} + +/** Resolve the GitHub repository for an npm package from its latest published version. */ +export async function resolveRepoFromPackage(packageName: string): Promise { + const manifest = await fetchPackageManifest(packageName); + return parseGitHubRepo(manifest.repository, packageName); +} diff --git a/packages/proper-changelog/src/selectReleases.ts b/packages/proper-changelog/src/selectReleases.ts new file mode 100644 index 000000000..03c254875 --- /dev/null +++ b/packages/proper-changelog/src/selectReleases.ts @@ -0,0 +1,46 @@ +import type { GitHubRelease, ProperChangelogOptions } from './types.ts'; + +/** + * Filter, sort, and slice releases according to the provided options. + * Draft releases are always excluded; prereleases are excluded unless `includePrereleases`. + * Releases are returned newest-first by published date. + */ +export function selectReleases(releases: GitHubRelease[], options: ProperChangelogOptions): GitHubRelease[] { + let selected = releases.filter(release => !release.draft); + + if (!options.includePrereleases) { + selected = selected.filter(release => !release.prerelease); + } + + selected.sort((a, b) => { + const aTime = (a.published_at && Date.parse(a.published_at)) || 0; + const bTime = (b.published_at && Date.parse(b.published_at)) || 0; + return bTime - aTime; + }); + + // Apply the `from`/`to` tag range (inclusive, order-independent). + if (options.from || options.to) { + const bounds = [ + options.from !== undefined ? indexOfTag(selected, options.from) : 0, + options.to !== undefined ? indexOfTag(selected, options.to) : selected.length - 1, + ]; + const start = Math.min(...bounds); + const end = Math.max(...bounds); + selected = selected.slice(start, end + 1); + } + + if (options.limit !== undefined && options.limit >= 0) { + selected = selected.slice(0, options.limit); + } + + return selected; +} + +/** Find the index of a release by tag name, throwing a helpful error if not found. */ +function indexOfTag(releases: GitHubRelease[], tag: string): number { + const index = releases.findIndex(release => release.tag_name === tag); + if (index === -1) { + throw new Error(`No release found with tag "${tag}".`); + } + return index; +} From 3166a2febc33f53c012d108435856d2911be2dd1 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 21:35:53 -0700 Subject: [PATCH 04/12] improve formatting --- packages/proper-changelog/README.md | 25 ++-- .../src/__tests__/cli.test.ts | 20 ++- .../src/__tests__/renderChangelog.test.ts | 103 +++++++++++++-- packages/proper-changelog/src/cli.ts | 14 +- .../proper-changelog/src/renderChangelog.ts | 123 ++++++++++++++---- packages/proper-changelog/src/types.ts | 2 + 6 files changed, 232 insertions(+), 55 deletions(-) diff --git a/packages/proper-changelog/README.md b/packages/proper-changelog/README.md index fabb24b72..da4d3f893 100644 --- a/packages/proper-changelog/README.md +++ b/packages/proper-changelog/README.md @@ -14,7 +14,7 @@ npx proper-changelog --package Exactly one of `--repo` or `--package` is required, and they cannot be used together. -By default this writes the changelog to `-changelog.md` in the current directory. Use `--stdout` to print it instead, or `--out` to choose a different file name. +By default this writes the changelog to `CHANGELOG-.md` in the current directory (using the package name when `--package` is given, otherwise the repo name). Use `--stdout` to print it instead, or `--out` to choose a different file name. ```bash # Write to a custom file @@ -39,17 +39,18 @@ If no token is found, the tool prints a warning and continues unauthenticated. ## Options -| Option | Description | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `--repo ` | GitHub repository to read releases from. Required unless `--package` is given; cannot be used with it. | -| `--package ` | npm package whose GitHub repository should be used (read from the latest published version's manifest). Required unless `--repo` is given; cannot be used with it. | -| `-o, --out ` | Output file name (default: `-changelog.md`). Cannot be used with `--stdout`. | -| `--stdout` | Write the changelog to stdout instead of a file. Cannot be used with `--out`. | -| `--token ` | GitHub token (see [Authentication](#authentication)). | -| `--include-prereleases` | Include prerelease releases. Draft releases are always excluded. | -| `--from ` | Include releases up to and including this tag. | -| `--to ` | Include releases down to and including this tag. | -| `--limit ` | Maximum number of releases to include. | + +| Option | Description | +| ------ | ----------- | +| `--repo ` | GitHub repository to read releases from. Required unless `--package` is given; cannot be used with it. | +| `--package ` | npm package whose GitHub repository should be used (read from the latest published version's manifest). Required unless `--repo` is given; cannot be used with it. Note that for a monorepo, this does **not** do any filtering of releases by package. | +| `-o, --out ` | Output file name (default: `CHANGELOG-.md`). Cannot be used with `--stdout`. | +| `--stdout` | Write the changelog to stdout instead of a file. Cannot be used with `--out`. | +| `--token ` | GitHub token (see [Authentication](#authentication)). | +| `--include-prereleases` | Include prerelease releases. Draft releases are always excluded. | +| `--from ` | Include releases up to and including this tag. | +| `--to ` | Include releases down to and including this tag. | +| `--limit ` | Maximum number of releases to include. | Releases are listed newest-first by published date. Draft releases are always excluded, and prereleases are excluded unless `--include-prereleases` is passed. diff --git a/packages/proper-changelog/src/__tests__/cli.test.ts b/packages/proper-changelog/src/__tests__/cli.test.ts index a1c8871be..3b7dd5bf6 100644 --- a/packages/proper-changelog/src/__tests__/cli.test.ts +++ b/packages/proper-changelog/src/__tests__/cli.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from '@jest/globals'; -import { createProgram, parseRepo, run } from '../cli.ts'; +import { createProgram, defaultBaseName, parseRepo, run } from '../cli.ts'; describe('parseRepo', () => { it('parses an owner/repo string', () => { @@ -59,7 +59,7 @@ describe('createProgram', () => { it('rejects a non-integer --limit', () => { expect(() => parse(['--repo', 'microsoft/some-repo', '--limit', 'abc'])).toThrow( - 'Expected a non-negative integer but got \"abc\"' + 'Expected a non-negative integer but got "abc"' ); }); @@ -72,6 +72,22 @@ describe('createProgram', () => { }); }); +describe('defaultBaseName', () => { + const repo = { owner: 'microsoft', repo: 'beachball' }; + + it('uses the repo name when no package is given', () => { + expect(defaultBaseName(undefined, repo)).toBe('beachball'); + }); + + it('uses the package name when given', () => { + expect(defaultBaseName('lodash', repo)).toBe('lodash'); + }); + + it('sanitizes a scoped package name into a safe filename', () => { + expect(defaultBaseName('@fluentui/react', repo)).toBe('fluentui-react'); + }); +}); + describe('run', () => { it('throws when neither --repo nor --package is provided', async () => { await expect(run({}, { log() {}, warn() {}, write: () => Promise.resolve() })).rejects.toThrow( diff --git a/packages/proper-changelog/src/__tests__/renderChangelog.test.ts b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts index caeff07d6..605ef2545 100644 --- a/packages/proper-changelog/src/__tests__/renderChangelog.test.ts +++ b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts @@ -10,11 +10,11 @@ function options(overrides: Partial = {}): ProperChangel } describe('renderChangelog', () => { - it('renders a heading and per-release sections', () => { + it('uses the release name and does not demote when there are no h2 headings', () => { const releases = [ makeRelease({ tag_name: 'v2.0.0', - name: 'Version 2.0.0', + name: 'The big rewrite', published_at: '2024-02-01T00:00:00Z', body: 'Second release.', }), @@ -22,13 +22,13 @@ describe('renderChangelog', () => { tag_name: 'v1.0.0', name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z', - body: 'First release.', + body: 'First release.\n\n### Details\n\nSome details.', }), ]; expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` - "# some-repo changelog + "# Changelog - some-repo - ## Version 2.0.0 + ## v2.0.0 - The big rewrite _Tag [\`v2.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v2.0.0) • released 2024-02-01_ @@ -39,38 +39,119 @@ describe('renderChangelog', () => { _Tag [\`v1.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v1.0.0) • released 2024-01-01_ First release. + + ### Details + + Some details. " `); }); - it('demotes headings in release bodies but leaves fenced code untouched', () => { + it('uses a single h2 as the section heading, prefixing the tag when needed', () => { const releases = [ makeRelease({ tag_name: 'v1.0.0', - body: '# Features\r\n\r\n```sh\n# not a heading\n```\r\n\r\n## Details', + name: 'v1.0.0', + body: "## What's Changed\n\n- Did a thing\n\n### Subsection\n\nMore.", }), ]; expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` - "# some-repo changelog + "# Changelog - some-repo - ## v1.0.0 + ## v1.0.0 - What's Changed + + _Tag [\`v1.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v1.0.0) • released 2024-01-01_ + + - Did a thing + + ### Subsection + + More. + " + `); + }); + + it('uses a single h2 unmodified when it already references the version', () => { + const releases = [ + makeRelease({ + tag_name: 'v1.2.3', + name: 'v1.2.3', + body: '## Release 1.2.3\n\nNotes.', + }), + ]; + expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` + "# Changelog - some-repo + + ## Release 1.2.3 + + _Tag [\`v1.2.3\`](https://github.com/microsoft/some-repo/releases/tag/v1.2.3) • released 2024-01-01_ + + Notes. + " + `); + }); + + it('uses the release name and demotes all headings when there are multiple h2 headings', () => { + const releases = [ + makeRelease({ + tag_name: 'v1.0.0', + name: 'Big release', + body: '## Features\n\n- A feature\n\n## Fixes\n\n- A fix', + }), + ]; + expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` + "# Changelog - some-repo + + ## v1.0.0 - Big release _Tag [\`v1.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v1.0.0) • released 2024-01-01_ ### Features + - A feature + + ### Fixes + + - A fix + " + `); + }); + + it('demotes everything by one level when the body has an h1 (becoming the single h2)', () => { + const releases = [ + makeRelease({ + tag_name: 'v1.0.0', + body: '# Features\r\n\r\n```sh\n# not a heading\n```\r\n\r\n## Details', + }), + ]; + expect(renderChangelog(releases, options())).toMatchInlineSnapshot(` + "# Changelog - some-repo + + ## v1.0.0 - Features + + _Tag [\`v1.0.0\`](https://github.com/microsoft/some-repo/releases/tag/v1.0.0) • released 2024-01-01_ + \`\`\`sh # not a heading \`\`\` - #### Details + ### Details " `); }); it('renders a placeholder when there are no releases', () => { expect(renderChangelog([], options())).toMatchInlineSnapshot(` - "# some-repo changelog + "# Changelog - some-repo + + No releases found. + " + `); + }); + + it('uses the package name in the heading when provided', () => { + expect(renderChangelog([], options({ packageName: '@scope/some-pkg' }))).toMatchInlineSnapshot(` + "# Changelog - @scope/some-pkg No releases found. " diff --git a/packages/proper-changelog/src/cli.ts b/packages/proper-changelog/src/cli.ts index 3a4f07451..38d76eeda 100644 --- a/packages/proper-changelog/src/cli.ts +++ b/packages/proper-changelog/src/cli.ts @@ -48,7 +48,9 @@ export function createProgram(): Command { .conflicts('package') ) .addOption(new Option('--package ', 'npm package whose GitHub repository should be used').conflicts('repo')) - .addOption(new Option('-o, --out ', 'output file name (default: -changelog.md)').conflicts('stdout')) + .addOption( + new Option('-o, --out ', 'output file name (default: CHANGELOG-.md)').conflicts('stdout') + ) .addOption(new Option('--stdout', 'write the changelog to stdout instead of a file').conflicts('out')) .option('--token ', 'GitHub token (falls back to GITHUB_TOKEN/GH_TOKEN, then `gh auth token`)') .option('--include-prereleases', 'include prerelease releases (drafts are always excluded)') @@ -95,6 +97,7 @@ export async function run( const options: ProperChangelogOptions = { repo, + packageName: raw.package, token, includePrereleases: raw.includePrereleases, from: raw.from, @@ -110,11 +113,18 @@ export async function run( return; } - const outFile = raw.out ?? `${repo.repo}-changelog.md`; + const outFile = raw.out ?? `CHANGELOG-${defaultBaseName(raw.package, repo)}.md`; await write(outFile, changelog); warn(`Wrote changelog to ${outFile}`); } +/** Derive the default changelog file base name from the package name (if given) or repo name. */ +export function defaultBaseName(packageName: string | undefined, repo: RepoId): string { + const base = packageName ?? repo.repo; + // Strip a leading npm scope and replace path separators so the result is a safe single filename. + return base.replace(/^@/, '').replace(/\//g, '-'); +} + /** Run the CLI and handle top-level errors. Intended to be called from the bin script. */ export function cli(argv: string[] = process.argv): void { (async () => { diff --git a/packages/proper-changelog/src/renderChangelog.ts b/packages/proper-changelog/src/renderChangelog.ts index a42151b5a..391388dc8 100644 --- a/packages/proper-changelog/src/renderChangelog.ts +++ b/packages/proper-changelog/src/renderChangelog.ts @@ -2,14 +2,23 @@ import { selectReleases } from './selectReleases.ts'; import type { GitHubRelease, ProperChangelogOptions } from './types.ts'; const maxHeadingLevel = 6; -const headingDemotion = 2; + +/** An ATX markdown heading found in a release body. */ +interface BodyHeading { + /** Index of the heading line within the body's lines. */ + lineIndex: number; + /** Heading level (number of leading `#` characters). */ + level: number; + /** Heading text (without the leading `#`s or surrounding whitespace). */ + text: string; +} /** * Render a full markdown changelog from GitHub releases, applying the given options. */ export function renderChangelog(releases: GitHubRelease[], options: ProperChangelogOptions): string { const selected = selectReleases(releases, options); - const heading = `# ${options.repo.repo} changelog`; + const heading = `# Changelog - ${options.packageName || options.repo.repo}`; if (selected.length === 0) { return `${heading}\n\nNo releases found.\n`; @@ -21,25 +30,58 @@ export function renderChangelog(releases: GitHubRelease[], options: ProperChange /** Render a single release as a markdown section. */ function renderRelease(release: GitHubRelease): string { - const title = release.name?.trim() || release.tag_name; - const date = formatDate(release.published_at); + const releaseName = release.name?.trim() || release.tag_name; + const bodyLines = (release.body ?? '').replace(/\r\n/g, '\n').split('\n'); - const lines = [`## ${title}`, '']; + const headings = parseHeadings(bodyLines); + // If the body has any h1, demote every heading by one level so the highest is h2. + const baseDemotion = headings.some(heading => heading.level === 1) ? 1 : 0; + // Headings that sit at level 2 after applying the base demotion. + const h2Headings = headings.filter(heading => heading.level + baseDemotion === 2); + let sectionHeading: string; + let demotion: number; + let promotedLineIndex: number | undefined; + + if (h2Headings.length === 1) { + // Use the single h2 as the section heading (don't demote any other headings). + sectionHeading = `## ${formatSectionHeading(h2Headings[0].text, release.tag_name)}`; + demotion = baseDemotion; + promotedLineIndex = h2Headings[0].lineIndex; + } else { + // No h2 (don't demote) or multiple h2s (demote everything one more level): use the + // release name as the section heading. + sectionHeading = `## ${formatSectionHeading(releaseName, release.tag_name)}`; + demotion = h2Headings.length > 1 ? baseDemotion + 1 : baseDemotion; + } + + const lines = [sectionHeading, '']; + + const date = formatDate(release.published_at); const meta = [`Tag [\`${release.tag_name}\`](${release.html_url})`]; if (date) { meta.push(`released ${date}`); } lines.push(`_${meta.join(' • ')}_`, ''); - const body = release.body?.trim(); + const body = transformBody(bodyLines, demotion, promotedLineIndex); if (body) { - lines.push(demoteHeadings(body), ''); + lines.push(body, ''); } return lines.join('\n').trimEnd(); } +/** + * Build the section heading text from a candidate title (either a single h2 heading or the release + * name). If the title already references the version (the tag without a leading `v`), use it as-is; + * otherwise prefix it with the tag. + */ +function formatSectionHeading(title: string, tag: string): string { + const tagWithoutV = tag.replace(/^v/, ''); + return title.includes(tagWithoutV) ? title : `${tag} - ${title}`; +} + /** Format an ISO timestamp as `YYYY-MM-DD`, or return undefined if missing/invalid. */ function formatDate(published: string | null): string | undefined { if (!published) { @@ -49,29 +91,54 @@ function formatDate(published: string | null): string | undefined { return Number.isNaN(date.getTime()) ? undefined : date.toISOString().slice(0, 10); } +/** Collect the ATX headings from body lines, ignoring headings inside fenced code blocks. */ +function parseHeadings(lines: string[]): BodyHeading[] { + const headings: BodyHeading[] = []; + let inFence = false; + lines.forEach((line, lineIndex) => { + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + return; + } + if (inFence) { + return; + } + const match = line.match(/^(#{1,6})\s+(.*\S)\s*$/); + if (match) { + headings.push({ lineIndex, level: match[1].length, text: match[2] }); + } + }); + return headings; +} + /** - * Demote ATX markdown headings (lines starting with `#`) in a release body so they nest - * under the release's `##` section heading. Headings inside fenced code blocks are left alone. + * Rebuild a release body, demoting ATX headings by `demotion` levels (headings inside fenced + * code blocks are left alone) and optionally removing the heading promoted to the section heading. */ -function demoteHeadings(body: string): string { +function transformBody(lines: string[], demotion: number, promotedLineIndex?: number): string { let inFence = false; - return body - .split(/\r?\n/) - .map(line => { - const fenceMatch = line.match(/^\s*(```|~~~)/); - if (fenceMatch) { - inFence = !inFence; - return line; - } - if (inFence) { - return line; - } - const headingMatch = line.match(/^(#{1,6})(\s.*)$/); - if (headingMatch) { - const level = Math.min(headingMatch[1].length + headingDemotion, maxHeadingLevel); - return '#'.repeat(level) + headingMatch[2]; + const out: string[] = []; + lines.forEach((line, lineIndex) => { + if (lineIndex === promotedLineIndex) { + return; + } + if (/^\s*(```|~~~)/.test(line)) { + inFence = !inFence; + out.push(line); + return; + } + if (!inFence && demotion > 0) { + const match = line.match(/^(#{1,6})(\s.*)$/); + if (match) { + const level = Math.min(match[1].length + demotion, maxHeadingLevel); + out.push('#'.repeat(level) + match[2]); + return; } - return line; - }) - .join('\n'); + } + out.push(line); + }); + return out + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); } diff --git a/packages/proper-changelog/src/types.ts b/packages/proper-changelog/src/types.ts index e6e2fd8e3..06e0cdde2 100644 --- a/packages/proper-changelog/src/types.ts +++ b/packages/proper-changelog/src/types.ts @@ -13,6 +13,8 @@ export interface RepoId { export interface ProperChangelogOptions { /** Repository to read releases from, as `owner/repo`. */ repo: RepoId; + /** npm package name the repo was resolved from, if any (used for the changelog heading/filename). */ + packageName?: string; /** Auth token for the GitHub API (optional; requests are rate-limited without one). */ token?: string; /** Include prerelease releases (default: false). Draft releases are always excluded. */ From b663037a706272767bbe95bb0c316b75ce774b2a Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 21:47:59 -0700 Subject: [PATCH 05/12] add filter and since --- packages/proper-changelog/README.md | 40 ++++++++-------- .../src/__tests__/cli.test.ts | 10 ++++ .../src/__tests__/selectReleases.test.ts | 47 +++++++++++++++++++ packages/proper-changelog/src/cli.ts | 15 ++++++ .../proper-changelog/src/selectReleases.ts | 34 ++++++++++++++ packages/proper-changelog/src/types.ts | 7 +++ 6 files changed, 134 insertions(+), 19 deletions(-) diff --git a/packages/proper-changelog/README.md b/packages/proper-changelog/README.md index da4d3f893..e27ff1d0c 100644 --- a/packages/proper-changelog/README.md +++ b/packages/proper-changelog/README.md @@ -12,7 +12,9 @@ npx proper-changelog --repo / npx proper-changelog --package ``` -Exactly one of `--repo` or `--package` is required, and they cannot be used together. +Exactly one of `--repo` or `--package` is required. + +Releases are listed newest-first by published date. Draft releases are always excluded, and prereleases are excluded unless `--include-prereleases` is passed. By default this writes the changelog to `CHANGELOG-.md` in the current directory (using the package name when `--package` is given, otherwise the repo name). Use `--stdout` to print it instead, or `--out` to choose a different file name. @@ -27,34 +29,34 @@ npx proper-changelog --repo microsoft/beachball --stdout npx proper-changelog --package @fluentui/react --stdout ``` -## Authentication - -The GitHub API is rate-limited for unauthenticated requests. To use a token, the tool checks the following in order: - -1. The `--token` option -2. The `GITHUB_TOKEN` or `GH_TOKEN` environment variables -3. The output of `gh auth token` (if the [GitHub CLI](https://cli.github.com/) is installed and authenticated) - -If no token is found, the tool prints a warning and continues unauthenticated. - ## Options +Either `--package` or `--repo` is required, and they're mutually exclusive. + | Option | Description | | ------ | ----------- | -| `--repo ` | GitHub repository to read releases from. Required unless `--package` is given; cannot be used with it. | -| `--package ` | npm package whose GitHub repository should be used (read from the latest published version's manifest). Required unless `--repo` is given; cannot be used with it. Note that for a monorepo, this does **not** do any filtering of releases by package. | -| `-o, --out ` | Output file name (default: `CHANGELOG-.md`). Cannot be used with `--stdout`. | -| `--stdout` | Write the changelog to stdout instead of a file. Cannot be used with `--out`. | +| `--repo ` | GitHub repository to read releases from. | +| `--package ` | npm package whose GitHub repository should be used (read from the latest published version on npmjs.com; only supports github.com repos). Note that for a monorepo, this does **not** do any filtering of releases by package. | +| `-o, --out ` | Output file name (default: `CHANGELOG-.md`). Mutually exclusive with `--stdout`. | +| `--stdout` | Write the changelog to stdout instead of a file. Mutually exclusive with `--out`. | | `--token ` | GitHub token (see [Authentication](#authentication)). | | `--include-prereleases` | Include prerelease releases. Draft releases are always excluded. | -| `--from ` | Include releases up to and including this tag. | -| `--to ` | Include releases down to and including this tag. | +| `--from ` | Include releases up to and including this tag (based on date, **not** semver). | +| `--to ` | Include releases down to and including this tag (based on date, **not** semver). | | `--limit ` | Maximum number of releases to include. | +| `--filter ` | Only include releases whose **tag** matches ``. A plain string matches tags containing it (case-insensitive); wrap the value in slashes (e.g. `/^v1\./i`) to match with a regular expression. Useful for monorepos that tag releases per package. | +| `--since ` | Only include releases published after this date. Accepts any value parseable by `new Date()`, such as `2024-01-01`. | -Releases are listed newest-first by published date. Draft releases are always excluded, and prereleases are excluded unless `--include-prereleases` is passed. +## Authentication + +The GitHub API is rate-limited for unauthenticated requests. To use a token, the tool checks the following in order: -When using `--package`, only packages whose repository is on github.com are supported. +1. The `--token` option +2. The `GITHUB_TOKEN` or `GH_TOKEN` environment variables +3. The output of `gh auth token` (if the [GitHub CLI](https://cli.github.com/) is installed and authenticated) + +If no token is found, the tool prints a warning and continues unauthenticated. ## API diff --git a/packages/proper-changelog/src/__tests__/cli.test.ts b/packages/proper-changelog/src/__tests__/cli.test.ts index 3b7dd5bf6..18a77829e 100644 --- a/packages/proper-changelog/src/__tests__/cli.test.ts +++ b/packages/proper-changelog/src/__tests__/cli.test.ts @@ -39,6 +39,10 @@ describe('createProgram', () => { 'v1.0.0', '--limit', '5', + '--filter', + '/^v2\\./', + '--since', + '2024-01-01', ]); expect(opts).toMatchObject({ repo: { owner: 'microsoft', repo: 'some-repo' }, @@ -48,6 +52,8 @@ describe('createProgram', () => { from: 'v2.0.0', to: 'v1.0.0', limit: 5, + filter: '/^v2\\./', + since: new Date('2024-01-01'), }); }); @@ -63,6 +69,10 @@ describe('createProgram', () => { ); }); + it('rejects an invalid --since date', () => { + expect(() => parse(['--repo', 'microsoft/some-repo', '--since', 'nope'])).toThrow('Expected a date but got "nope"'); + }); + it('rejects using --out and --stdout together', () => { expect(() => parse(['--repo', 'microsoft/some-repo', '--out', 'x.md', '--stdout'])).toThrow(/--out.*?--stdout/); }); diff --git a/packages/proper-changelog/src/__tests__/selectReleases.test.ts b/packages/proper-changelog/src/__tests__/selectReleases.test.ts index 7d48863c4..948c3514b 100644 --- a/packages/proper-changelog/src/__tests__/selectReleases.test.ts +++ b/packages/proper-changelog/src/__tests__/selectReleases.test.ts @@ -69,4 +69,51 @@ describe('selectReleases', () => { const releases = [makeRelease({ tag_name: 'v1.0.0' })]; expect(() => selectReleases(releases, options({ from: 'v9.9.9' }))).toThrow('No release found with tag "v9.9.9".'); }); + + it('filters by a case-insensitive substring of the tag', () => { + const releases = [ + makeRelease({ tag_name: 'app_v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'lib_v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ filter: 'APP' })).map(r => r.tag_name)).toEqual(['app_v2.0.0']); + }); + + it('filters by a /regex/ when the value is wrapped in slashes', () => { + const releases = [ + makeRelease({ tag_name: 'v2.1.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ filter: '/^v2\\./' })).map(r => r.tag_name)).toEqual([ + 'v2.1.0', + 'v2.0.0', + ]); + }); + + it('supports regex flags such as case-insensitivity', () => { + const releases = [ + makeRelease({ tag_name: 'Release-A', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'release-b', published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ filter: '/^release-/i' })).map(r => r.tag_name)).toEqual([ + 'Release-A', + 'release-b', + ]); + }); + + it('throws a helpful error for an invalid /regex/ filter', () => { + const releases = [makeRelease({ tag_name: 'v1.0.0' })]; + expect(() => selectReleases(releases, options({ filter: '/[/' }))).toThrow(/Invalid --filter regular expression/); + }); + + it('includes only releases published after the --since date', () => { + const releases = [ + makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), + makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), + makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), + ]; + expect(selectReleases(releases, options({ since: new Date('2024-02-01') })).map(r => r.tag_name)).toEqual([ + 'v3.0.0', + ]); + }); }); diff --git a/packages/proper-changelog/src/cli.ts b/packages/proper-changelog/src/cli.ts index 38d76eeda..c685e9f60 100644 --- a/packages/proper-changelog/src/cli.ts +++ b/packages/proper-changelog/src/cli.ts @@ -24,6 +24,15 @@ function parsePositiveInt(value: string): number { return parsed; } +/** Validate a date option value (any format parseable by `new Date()`). */ +function parseDate(value: string): Date { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new InvalidArgumentError(`Expected a date but got "${value}".`); + } + return date; +} + interface RawCliOptions { repo?: RepoId; package?: string; @@ -34,6 +43,8 @@ interface RawCliOptions { from?: string; to?: string; limit?: number; + filter?: string; + since?: Date; } /** Build the commander program. Exported for testing. */ @@ -57,6 +68,8 @@ export function createProgram(): Command { .option('--from ', 'include releases up to and including this tag (based on date, not semver)') .option('--to ', 'include releases down to and including this tag (based on date, not semver)') .option('--limit ', 'maximum number of releases to include', parsePositiveInt) + .option('--filter ', 'only include releases whose tag matches this substring or /regex/') + .option('--since ', 'only include releases published after this date (e.g. 2024-01-01)', parseDate) .allowExcessArguments(false); return program; } @@ -103,6 +116,8 @@ export async function run( from: raw.from, to: raw.to, limit: raw.limit, + filter: raw.filter, + since: raw.since, }; const releases = await fetchReleases(repo, token); diff --git a/packages/proper-changelog/src/selectReleases.ts b/packages/proper-changelog/src/selectReleases.ts index 03c254875..c72fe5306 100644 --- a/packages/proper-changelog/src/selectReleases.ts +++ b/packages/proper-changelog/src/selectReleases.ts @@ -12,6 +12,19 @@ export function selectReleases(releases: GitHubRelease[], options: ProperChangel selected = selected.filter(release => !release.prerelease); } + if (options.filter) { + const matchesTag = makeTagMatcher(options.filter); + selected = selected.filter(release => matchesTag(release.tag_name)); + } + + if (options.since) { + const sinceTime = options.since.getTime(); + selected = selected.filter(release => { + const time = release.published_at ? new Date(release.published_at).getTime() : NaN; + return !Number.isNaN(time) && time > sinceTime; + }); + } + selected.sort((a, b) => { const aTime = (a.published_at && Date.parse(a.published_at)) || 0; const bTime = (b.published_at && Date.parse(b.published_at)) || 0; @@ -44,3 +57,24 @@ function indexOfTag(releases: GitHubRelease[], tag: string): number { } return index; } + +/** + * Build a tag-matching predicate from a filter string. A value wrapped in slashes (optionally with + * trailing regex flags, e.g. `/^v1\./i`) is treated as a regular expression; otherwise it is a + * case-insensitive substring match. + */ +function makeTagMatcher(filter: string): (tag: string) => boolean { + const regexMatch = filter.match(/^\/(.*)\/([a-z]*)$/s); + if (regexMatch) { + let regex: RegExp; + try { + regex = new RegExp(regexMatch[1], regexMatch[2]); + } catch (error) { + throw new Error(`Invalid --filter regular expression "${filter}": ${(error as Error).message}`); + } + return tag => regex.test(tag); + } + + const needle = filter.toLowerCase(); + return tag => tag.toLowerCase().includes(needle); +} diff --git a/packages/proper-changelog/src/types.ts b/packages/proper-changelog/src/types.ts index 06e0cdde2..20fd8fc21 100644 --- a/packages/proper-changelog/src/types.ts +++ b/packages/proper-changelog/src/types.ts @@ -25,4 +25,11 @@ export interface ProperChangelogOptions { to?: string; /** Maximum number of releases to include. */ limit?: number; + /** + * Filter releases by tag name. A plain string matches tags that contain it (case-insensitive); + * a value wrapped in slashes (e.g. `/^v1\./i`) is treated as a regular expression. + */ + filter?: string; + /** Only include releases published after this date. */ + since?: Date; } From 6c02df883f61dc63df733d52837bc060220a356f Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 21:48:47 -0700 Subject: [PATCH 06/12] change --- ...per-changelog-09b60638-6897-444b-8606-da737bda3a24.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/proper-changelog-09b60638-6897-444b-8606-da737bda3a24.json diff --git a/change/proper-changelog-09b60638-6897-444b-8606-da737bda3a24.json b/change/proper-changelog-09b60638-6897-444b-8606-da737bda3a24.json new file mode 100644 index 000000000..78bb36feb --- /dev/null +++ b/change/proper-changelog-09b60638-6897-444b-8606-da737bda3a24.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Initial release", + "packageName": "proper-changelog", + "email": "elcraig@microsoft.com", + "dependentChangeType": "patch" +} From c88e2468bb8b0b2745ec52f5bdfdeff8f62714e3 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 21:50:41 -0700 Subject: [PATCH 07/12] release fix --- .github/workflows/release.yml | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a483012b4..63928774c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,14 +31,6 @@ jobs: # Don't save creds in the git config (so it's easier to override later) persist-credentials: false - # Prereleases from main are disabled until the canary/prerelease flow is fixed - - name: Verify v2 branch - run: | - if [[ "${GITHUB_REF}" != "refs/heads/v2" ]]; then - echo "Releases can only be triggered from the v2 branch." - exit 1 - fi - - name: Install Node.js ${{ env.nodeVersion }} uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: @@ -54,7 +46,8 @@ jobs: - run: yarn test --verbose # TODO (release): switch back to regular release - - name: Publish packages + # (temporarily add yarn release:canary when ready) + - name: Publish packages (non-prerelease) run: | git config user.email "kchau@microsoft.com" git config user.name "Ken Chau" @@ -66,7 +59,6 @@ jobs: # Add a token to the remote URL for auth during release git remote set-url origin "https://$REPO_PAT@github.com/$GITHUB_REPOSITORY" - yarn release:canary yarn release:others env: REPO_PAT: ${{ secrets.REPO_PAT }} From 7000b7f598f31b5562731768b2c0787769cf2e7f Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 21:52:24 -0700 Subject: [PATCH 08/12] really fix skill --- .agents/skills/beachball-change-file/SKILL.md | 140 +----------------- 1 file changed, 1 insertion(+), 139 deletions(-) mode change 100644 => 120000 .agents/skills/beachball-change-file/SKILL.md diff --git a/.agents/skills/beachball-change-file/SKILL.md b/.agents/skills/beachball-change-file/SKILL.md deleted file mode 100644 index 665b0b70e..000000000 --- a/.agents/skills/beachball-change-file/SKILL.md +++ /dev/null @@ -1,139 +0,0 @@ ---- -name: beachball-change-file -description: How to create a Beachball change file. ONLY use this skill when the user asks to generate change files, before pushing a branch, or before creating a PR. -metadata: - version: 1.0.3 - source: https://github.com/microsoft/beachball/blob/main/skills/beachball-change-file/SKILL.md ---- - -[Beachball](https://microsoft.github.io/beachball/) is a tool used for managing versioning and changelogs for JS/TS codebases. Every pull request must include a Beachball change file. Change files include the list of packages with public-facing changes in the branch, with the description and semver change type for each package. After the PR is checked in and a release is run, the change files are used to determine version bumps and update changelogs. - -Beachball normally uses a CLI with an interactive prompt to create change files, but they can also be created manually using the standardized JSON format detailed below. - -## Prerequisites - -- Deterine the root directory: this is almost always the git root, but the user might specify a different folder. (The root usually contains `beachball.config.*` or `.beachballrc.*` or has a `"beachball"` key in `package.json`.) -- Determine the package manager for the repo (`npm`, `yarn`, `pnpm`). The example commands below assume `yarn`, but substitute the appropriate command runner syntax for a different package manager. -- Check the root `package.json` `scripts` for scripts that run `beachball change` and `beachball check`. - - The examples below assume `scripts` called `change` and `checkchange` respectively, but substitute the appropriate script names if found. - - Using `scripts` if defined is preferred since they may add extra arguments, but it's possible to run the commands directly: `yarn beachball change` and `yarn beachball check` (substituting appropriate command runner) -- Use `beachball config get` to check the following settings (note: `beachball config get` only exists in versions `>= 2.64.0`) - - `yarn beachball config get changeDir`: where to put the change files - - `yarn beachball config get branch`: target branch name - - `yarn beachball config get groupChanges`: whether grouped change files are enabled (true/false/undefined) - -## Creating and validating a change file - -Usually, an AI agent should create a change file manually following the standardized format detailed below. - -### 1. Validate repo state - -Beachball only considers staged and committed files, so you should check for unstaged or untracked changes before proceeding: - -1. Get file paths with unstaged changes (`git ls-files -m`) and untracked changes (`git ls-files -o --exclude-standard`) -2. If there are any unstaged or untracked changes, ask the user whether they would like to stage all files or continue without staging. If they choose to stage, run `git add .` before proceeding. - -### 2. Get changed packages - -Run `yarn checkchange --verbose` to get the list of changed packages and files considered by `beachball`: - -- The list of changed packages is under "Found changes in the following packages" -- you must ONLY include these packages in the change file! (beachball has various settings to ignore packages or files) -- The list of changed files is under "changed files in current branch". IGNORE any files with `~~` strikethrough formatting. - -### 3. Create the change file(s) - -Change files are located under ``. There are two possible structures for change files, determined by the `groupChanges` setting. - -#### Case 1: Non-grouped format (`groupChanges` is `false` or unset) - -If `groupChanges` is `false` or unset, you should create a separate change file for each package. - -For each changed package **as listed by beachball**: - -1. Generate a random GUID: `node -e "console.log(crypto.randomUUID())"` -2. Create a change file under `/-.json` with the following format. See [Change entry values](#change-entry-values) below for the proper values of each field. - -```json -{ - "packageName": "", - "type": "", - "dependentChangeType": "", - "comment": "", - "email": "" -} -``` - -#### Case 2: Grouped format (`groupChanges: true`) - -If `groupChanges` is `true`, you should create a single change file. - -1. Generate a random GUID: `node -e "console.log(crypto.randomUUID())"` -2. Create a single change file under `/change-.json` with the following format. The `changes` array should have an entry for each changed package **as listed by beachball**. See [Change entry values](#change-entry-values) below for the proper values of each field. - -```json -{ - "changes": [ - { - "packageName": "", - "type": "", - "dependentChangeType": "", - "comment": "", - "email": "" - } - ] -} -``` - -### 4. Validate the change file(s) - -Run `git add `, then re-run `yarn checkchange` to verify. - -## Change entry values - -Each package's entry has the following values: - -- `packageName`: The name of the changed package, e.g. `just-task` -- `type`: The semantic versioning change type for the package. See [Determining a package's change type](#determining-a-packages-change-type) below. -- `dependentChangeType`: Change type for packages that depend on this package. If `type` is `"none"`, this should be `"none"`. Otherwise, this should be `"patch"` (beachball internally handles this for the special case of prerelease packages). -- `comment` (`--message` CLI arg): A concise description of the changes made to the package. Tips: - - This will go in the changelog, so it should focus on user-facing changes (especially any API changes) rather than implementation details. - - Markdown formatting is allowed, so any references to names from code should be wrapped with backticks. -- `email`: User's email from `git config user.email`, or `"email not defined"` if not available. Do NOT invent an email. - -### Determining a package's change type - -The `type` field is the semantic versioning change type for the package, determined based on the diff content of changed files in that package. There are different options depending on whether the package's current version contains a prerelease suffix or not, and the `disallowedChangeTypes` setting may modify which change types are allowed. - -If you're still uncertain about the change type after following the instructions below, ask the user to choose. - -For each package, start by checking: - -- The current `version` in `package.json` -- `disallowedChangeTypes` for the specific package: `yarn beachball config get disallowedChangeTypes --package ` -- Whether the package has a file `/etc/*.api.md`. If so, the diff of this file will show whether any public API signatures changed. - -#### Case 1: Version is 1.0.0 or greater and NOT prerelease - -If the package's current version is 1.0.0 or greater and does NOT have a prerelease suffix, the typical options are `` (but you MUST respect `disallowedChangeTypes`): - -- `"patch"`: Bug fixes or other changes that don't impact exported API signatures. -- `"minor"`: New exported APIs, non-breaking signature changes to exported APIs, or more significant changes to internal logic. (If the package has a `/etc/*.api.md` file, checking its diff is the easiest way to see exported API changes.) -- `"major"`: Breaking changes to exported APIs (removals or breaking signature changes), critical dependency updates, or behavior changes that might be breaking for the consumer. You MUST confirm with the user before choosing `"major"`. -- `"none"`: None of the changes will impact consumers of the package (e.g. the changes are only to non-exported test-specific files or documentation). If you're not certain, prefer `"patch"`. -- There are additional options `prerelease|premajor|preminor|prepatch`, but you should only use one of these if explicitly requested by the user. - -#### Case 2: Version is 0.x.y and NOT prerelease - -If the package's major version is 0 and does NOT have a prerelease suffix, this is similar to case 1. However, version 0 packages follow different conventions for semantic versioning (you MUST still respect `disallowedChangeTypes`): - -- Use `"minor"` for breaking changes (do NOT use `"major"` unless specifically requested) -- Use `"patch"` for any other changes that impact consumers of the package -- Use `"none"` in the same circumstances as case 1 - -#### Case 3: Version IS prerelease - -ONLY if the package's current version includes a prerelease suffix, the typical options are `` (but you MUST respect `disallowedChangeTypes`): - -- `"prerelease"`: Any changes that impact consumers of the package -- `"none"`: None of the changes will impact consumers of the package (e.g. the changes are only to non-exported test-specific files or documentation). If you're not certain, prefer `"prerelease"`. -- There are additional options `premajor|preminor|prepatch`, but you should only use one of these if explicitly requested by the user or all other change types are disallowed. diff --git a/.agents/skills/beachball-change-file/SKILL.md b/.agents/skills/beachball-change-file/SKILL.md new file mode 120000 index 000000000..59c34408c --- /dev/null +++ b/.agents/skills/beachball-change-file/SKILL.md @@ -0,0 +1 @@ +../../../skills/beachball-change-file/SKILL.md \ No newline at end of file From fd1d44018f14101f795a92f88a7e03eb8a010285 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 22:00:56 -0700 Subject: [PATCH 09/12] sigh --- packages/proper-changelog/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/proper-changelog/README.md b/packages/proper-changelog/README.md index e27ff1d0c..94237bc4f 100644 --- a/packages/proper-changelog/README.md +++ b/packages/proper-changelog/README.md @@ -45,7 +45,7 @@ Either `--package` or `--repo` is required, and they're mutually exclusive. | `--from ` | Include releases up to and including this tag (based on date, **not** semver). | | `--to ` | Include releases down to and including this tag (based on date, **not** semver). | | `--limit ` | Maximum number of releases to include. | -| `--filter ` | Only include releases whose **tag** matches ``. A plain string matches tags containing it (case-insensitive); wrap the value in slashes (e.g. `/^v1\./i`) to match with a regular expression. Useful for monorepos that tag releases per package. | +| `--filter ` | Only include releases whose **tag** matches ``. A plain string matches tags containing it (case-insensitive); wrap the value in slashes (e.g. `/^v1\./i`) to match with a regular expression. Useful for monorepos that tag releases per package. (Warning: this is _not_ sanitized, so ReDOS yourself at will.) | | `--since ` | Only include releases published after this date. Accepts any value parseable by `new Date()`, such as `2024-01-01`. | ## Authentication From 921be47a724c460efe106f5de68ab352056ade21 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Thu, 11 Jun 2026 22:05:42 -0700 Subject: [PATCH 10/12] fixes --- packages/proper-changelog/package.json | 4 +++- packages/proper-changelog/src/__tests__/cli.test.ts | 2 +- packages/proper-changelog/src/cli.ts | 4 ++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/proper-changelog/package.json b/packages/proper-changelog/package.json index c1bd8ce95..7312424ba 100644 --- a/packages/proper-changelog/package.json +++ b/packages/proper-changelog/package.json @@ -9,7 +9,9 @@ "directory": "packages/proper-changelog" }, "license": "MIT", - "exports": null, + "exports": { + ".": null + }, "bin": "./bin/proper-changelog.js", "engines": { "node": ">=22.18.0" diff --git a/packages/proper-changelog/src/__tests__/cli.test.ts b/packages/proper-changelog/src/__tests__/cli.test.ts index 18a77829e..8e3fb8da9 100644 --- a/packages/proper-changelog/src/__tests__/cli.test.ts +++ b/packages/proper-changelog/src/__tests__/cli.test.ts @@ -65,7 +65,7 @@ describe('createProgram', () => { it('rejects a non-integer --limit', () => { expect(() => parse(['--repo', 'microsoft/some-repo', '--limit', 'abc'])).toThrow( - 'Expected a non-negative integer but got "abc"' + 'Expected a positive integer but got "abc"' ); }); diff --git a/packages/proper-changelog/src/cli.ts b/packages/proper-changelog/src/cli.ts index c685e9f60..0212d9ce0 100644 --- a/packages/proper-changelog/src/cli.ts +++ b/packages/proper-changelog/src/cli.ts @@ -18,8 +18,8 @@ export function parseRepo(value: string): RepoId { /** Parse a non-negative integer option value. */ function parsePositiveInt(value: string): number { const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed < 0) { - throw new InvalidArgumentError(`Expected a non-negative integer but got "${value}".`); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError(`Expected a positive integer but got "${value}".`); } return parsed; } From 7e291f4757054695a382cff065de0857be449acb Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Sat, 13 Jun 2026 02:01:15 -0700 Subject: [PATCH 11/12] restructuring --- .../src/__tests__/cli.test.ts | 235 +++++++++++++----- .../src/__tests__/renderChangelog.test.ts | 7 +- .../__tests__/resolveRepoFromPackage.test.ts | 14 +- .../src/__tests__/resolveToken.test.ts | 20 +- .../src/__tests__/selectReleases.test.ts | 36 +-- packages/proper-changelog/src/cli.ts | 208 ++++++++-------- .../proper-changelog/src/renderChangelog.ts | 8 +- .../src/resolveRepoFromPackage.ts | 59 ++--- packages/proper-changelog/src/resolveToken.ts | 4 +- .../proper-changelog/src/selectReleases.ts | 7 +- packages/proper-changelog/src/types.ts | 22 +- scripts/config/eslint.ts | 20 +- 12 files changed, 364 insertions(+), 276 deletions(-) diff --git a/packages/proper-changelog/src/__tests__/cli.test.ts b/packages/proper-changelog/src/__tests__/cli.test.ts index 8e3fb8da9..6da87e680 100644 --- a/packages/proper-changelog/src/__tests__/cli.test.ts +++ b/packages/proper-changelog/src/__tests__/cli.test.ts @@ -1,50 +1,83 @@ -import { describe, it, expect } from '@jest/globals'; -import { createProgram, defaultBaseName, parseRepo, run } from '../cli.ts'; +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import { makeRelease } from '../__fixtures__/makeRelease.ts'; +import type { CliContext } from '../cli.ts'; +import type * as fetchReleasesModule from '../fetchReleases.ts'; +import type * as resolveRepoModule from '../resolveRepoFromPackage.ts'; -describe('parseRepo', () => { - it('parses an owner/repo string', () => { - expect(parseRepo('microsoft/beachball')).toEqual({ owner: 'microsoft', repo: 'beachball' }); - }); +jest.unstable_mockModule('../fetchReleases.ts', () => ({ + fetchReleases: jest.fn(() => Promise.resolve([])), +})); +const mockFetchReleases = (await import('../fetchReleases.ts')).fetchReleases as jest.MockedFunction< + typeof fetchReleasesModule.fetchReleases +>; + +jest.unstable_mockModule('../resolveRepoFromPackage.ts', () => ({ + resolveRepoFromPackage: jest.fn(packageName => + Promise.resolve({ owner: 'microsoft', repo: packageName.replace(/^.*?\//, '') }) + ), +})); +const mockResolveRepoFromPackage = (await import('../resolveRepoFromPackage.ts')) + .resolveRepoFromPackage as jest.MockedFunction; + +// Mock nano-spawn which is used for `gh auth token` +let mockGhAuthToken = ''; +jest.unstable_mockModule('nano-spawn', () => ({ default: () => Promise.resolve({ stdout: mockGhAuthToken }) })); + +const { _parseArgs, _generateChangelog } = await import('../cli.ts'); - it.each(['beachball', 'a/b/c', 'owner/', '/repo', 'owner repo'])('rejects invalid input %p', input => { - expect(() => parseRepo(input)).toThrow(); +/** Get a context which mocks all functions and throws on `exitOverride` */ +function getContext(args: string[], env: NodeJS.ProcessEnv = {}) { + return jest.mocked>({ + argv: ['node', 'proper-changelog.js', ...args], + env, + log: jest.fn(), + warn: jest.fn(), + writeFile: jest.fn(), + writeErr: jest.fn(), + exitOverride: err => { + throw err; + }, }); +} + +afterEach(() => { + mockGhAuthToken = ''; + mockFetchReleases.mockResolvedValue([]); }); -describe('createProgram', () => { - function parse(args: string[]): Record { - const program = createProgram().exitOverride(); - program.parse(args, { from: 'user' }); - return program.opts(); - } - - it('parses --repo into a RepoId and applies defaults', () => { - const opts = parse(['--repo', 'microsoft/some-repo']); - expect(opts.repo).toEqual({ owner: 'microsoft', repo: 'some-repo' }); - expect(opts.includePrereleases).toBeUndefined(); - }); - - it('parses all options', () => { - const opts = parse([ - '--repo', - 'microsoft/some-repo', - '--out', - 'changes.md', - '--token', - 't', - '--include-prereleases', - '--from', - 'v2.0.0', - '--to', - 'v1.0.0', - '--limit', - '5', - '--filter', - '/^v2\\./', - '--since', - '2024-01-01', - ]); - expect(opts).toMatchObject({ +describe('_parseArgs', () => { + it('parses --repo into a RepoId and applies defaults', async () => { + const context = getContext(['--repo', 'microsoft/some-repo']); + const opts = await _parseArgs(context); + expect(opts).toEqual({ repo: { owner: 'microsoft', repo: 'some-repo' } }); + // no token, so a warning is logged + expect(context.warn).toHaveBeenCalledWith(expect.stringContaining('no GitHub token found')); + }); + + it('parses all options', async () => { + const context = getContext( + [ + '--repo', + 'microsoft/some-repo', + '--out', + 'changes.md', + '--token', + 't', + '--include-prereleases', + '--from', + 'v2.0.0', + '--to', + 'v1.0.0', + '--limit', + '5', + '--filter', + '/^v2\\./', + '--since', + '2024-01-01', + ], + { GITHUB_TOKEN: 'env-token' } // not used due to --token + ); + expect(await _parseArgs(context)).toEqual({ repo: { owner: 'microsoft', repo: 'some-repo' }, out: 'changes.md', token: 't', @@ -57,51 +90,115 @@ describe('createProgram', () => { }); }); - it('parses --package', () => { - const opts = parse(['--package', '@scope/pkg']); - expect(opts.package).toBe('@scope/pkg'); - expect(opts.repo).toBeUndefined(); + it('parses and fetches --package', async () => { + const context = getContext(['--package', '@scope/pkg']); + const opts = await _parseArgs(context); + // per the resolve mock + expect(opts).toEqual({ repo: { owner: 'microsoft', repo: 'pkg' }, package: '@scope/pkg' }); + expect(await mockResolveRepoFromPackage.mock.results[0].value).toEqual({ owner: 'microsoft', repo: 'pkg' }); }); - it('rejects a non-integer --limit', () => { - expect(() => parse(['--repo', 'microsoft/some-repo', '--limit', 'abc'])).toThrow( - 'Expected a positive integer but got "abc"' - ); + it('gets the token from the environment', async () => { + const context = getContext(['--repo', 'microsoft/some-repo'], { GITHUB_TOKEN: 'token' }); + const opts = await _parseArgs(context); + expect(opts.token).toBe('token'); + }); + + it('gets the token from `gh auth token` as fallack', async () => { + mockGhAuthToken = 'gh-token'; + const context = getContext(['--repo', 'microsoft/some-repo']); + const opts = await _parseArgs(context); + expect(opts.token).toBe('gh-token'); + }); + + it.each(['beachball', 'a/b/c', 'owner/', '/repo', 'owner repo'])('rejects invalid --repo %p', async input => { + const context = getContext(['--repo', input]); + // this message comes from commander but will contain the invalid input + await expect(_parseArgs(context)).rejects.toThrow(input); + }); + + it('rejects a non-integer --limit', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--limit', 'abc']); + await expect(_parseArgs(context)).rejects.toThrow('Expected a positive integer but got "abc"'); }); - it('rejects an invalid --since date', () => { - expect(() => parse(['--repo', 'microsoft/some-repo', '--since', 'nope'])).toThrow('Expected a date but got "nope"'); + it('rejects an invalid --since date', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--since', 'nope']); + await expect(_parseArgs(context)).rejects.toThrow('Expected a date but got "nope"'); }); - it('rejects using --out and --stdout together', () => { - expect(() => parse(['--repo', 'microsoft/some-repo', '--out', 'x.md', '--stdout'])).toThrow(/--out.*?--stdout/); + it('rejects using --out and --stdout together', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--out', 'x.md', '--stdout']); + await expect(_parseArgs(context)).rejects.toThrow(/--out.*?--stdout/); }); - it('rejects using --repo and --package together', () => { - expect(() => parse(['--repo', 'microsoft/some-repo', '--package', 'pkg'])).toThrow(/--repo.*?--package/); + it('rejects using --repo and --package together', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--package', 'pkg']); + await expect(_parseArgs(context)).rejects.toThrow(/--repo.*?--package/); }); }); -describe('defaultBaseName', () => { +describe('_generateChangelog', () => { const repo = { owner: 'microsoft', repo: 'beachball' }; - it('uses the repo name when no package is given', () => { - expect(defaultBaseName(undefined, repo)).toBe('beachball'); - }); + it('warns and writes nothing when there are no releases', async () => { + mockFetchReleases.mockResolvedValue([]); + const context = getContext([]); + await _generateChangelog({ repo }, context); - it('uses the package name when given', () => { - expect(defaultBaseName('lodash', repo)).toBe('lodash'); + expect(mockFetchReleases).toHaveBeenCalledWith(repo, undefined); + expect(context.warn).toHaveBeenCalledWith('No releases found for microsoft/beachball'); + expect(context.writeFile).not.toHaveBeenCalled(); + expect(context.log).not.toHaveBeenCalled(); }); - it('sanitizes a scoped package name into a safe filename', () => { - expect(defaultBaseName('@fluentui/react', repo)).toBe('fluentui-react'); + it('writes to the default CHANGELOG-.md file and logs the path', async () => { + mockFetchReleases.mockResolvedValue([makeRelease({ tag_name: 'v1.0.0' })]); + const context = getContext([]); + await _generateChangelog({ repo }, context); + + expect(context.writeFile).toHaveBeenCalledWith( + 'CHANGELOG-beachball.md', + expect.stringContaining('# Changelog - beachball') + ); + expect(context.log).toHaveBeenCalledWith('Wrote changelog to CHANGELOG-beachball.md'); }); -}); -describe('run', () => { - it('throws when neither --repo nor --package is provided', async () => { - await expect(run({}, { log() {}, warn() {}, write: () => Promise.resolve() })).rejects.toThrow( - 'Exactly one of --repo or --package is required.' + it('derives the default file name from the package name when given', async () => { + mockFetchReleases.mockResolvedValue([makeRelease({ tag_name: 'v1.0.0' })]); + const context = getContext([]); + await _generateChangelog({ repo, package: '@fluentui/react' }, context); + + expect(context.writeFile).toHaveBeenCalledWith( + 'CHANGELOG-fluentui-react.md', + expect.stringContaining('# Changelog -') ); + expect(context.log).toHaveBeenCalledWith('Wrote changelog to CHANGELOG-fluentui-react.md'); + }); + + it('writes to a custom file name when --out is given', async () => { + mockFetchReleases.mockResolvedValue([makeRelease({ tag_name: 'v1.0.0' })]); + const context = getContext([]); + await _generateChangelog({ repo, out: 'CUSTOM.md' }, context); + + expect(context.writeFile).toHaveBeenCalledWith('CUSTOM.md', expect.stringContaining('# Changelog -')); + expect(context.log).toHaveBeenCalledWith('Wrote changelog to CUSTOM.md'); + }); + + it('writes to stdout instead of a file when stdout is set', async () => { + mockFetchReleases.mockResolvedValue([makeRelease({ tag_name: 'v1.0.0' })]); + const context = getContext([]); + await _generateChangelog({ repo, stdout: true }, context); + + expect(context.writeFile).not.toHaveBeenCalled(); + expect(context.log).toHaveBeenCalledWith(expect.stringContaining('# Changelog -')); + }); + + it('passes the token to fetchReleases', async () => { + mockFetchReleases.mockResolvedValue([makeRelease({ tag_name: 'v1.0.0' })]); + const context = getContext([]); + await _generateChangelog({ repo, token: 'secret-token' }, context); + + expect(mockFetchReleases).toHaveBeenCalledWith(repo, 'secret-token'); }); }); diff --git a/packages/proper-changelog/src/__tests__/renderChangelog.test.ts b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts index 605ef2545..7972c70b6 100644 --- a/packages/proper-changelog/src/__tests__/renderChangelog.test.ts +++ b/packages/proper-changelog/src/__tests__/renderChangelog.test.ts @@ -1,11 +1,10 @@ import { describe, it, expect } from '@jest/globals'; -import { renderChangelog } from '../renderChangelog.ts'; +import { renderChangelog, type RenderChangelogOptions } from '../renderChangelog.ts'; import { makeRelease } from '../__fixtures__/makeRelease.ts'; -import type { ProperChangelogOptions } from '../types.ts'; const repo = { owner: 'microsoft', repo: 'some-repo' }; -function options(overrides: Partial = {}): ProperChangelogOptions { +function options(overrides: Partial = {}): RenderChangelogOptions { return { repo, ...overrides }; } @@ -150,7 +149,7 @@ describe('renderChangelog', () => { }); it('uses the package name in the heading when provided', () => { - expect(renderChangelog([], options({ packageName: '@scope/some-pkg' }))).toMatchInlineSnapshot(` + expect(renderChangelog([], options({ package: '@scope/some-pkg' }))).toMatchInlineSnapshot(` "# Changelog - @scope/some-pkg No releases found. diff --git a/packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts b/packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts index a144514af..642919304 100644 --- a/packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts +++ b/packages/proper-changelog/src/__tests__/resolveRepoFromPackage.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals'; -import { parseGitHubRepo, resolveRepoFromPackage } from '../resolveRepoFromPackage.ts'; +import { _parseGitHubRepo, resolveRepoFromPackage } from '../resolveRepoFromPackage.ts'; -describe('parseGitHubRepo', () => { +describe('_parseGitHubRepo', () => { const expected = { owner: 'microsoft', repo: 'beachball' }; it.each([ @@ -14,17 +14,17 @@ describe('parseGitHubRepo', () => { 'microsoft/beachball', 'https://github.com/microsoft/beachball.git#main', ])('parses %p', url => { - expect(parseGitHubRepo(url, 'beachball')).toEqual(expected); + expect(_parseGitHubRepo(url, 'beachball')).toEqual(expected); }); it('parses the object form with a url', () => { expect( - parseGitHubRepo({ type: 'git', url: 'git+https://github.com/microsoft/beachball.git' }, 'beachball') + _parseGitHubRepo({ type: 'git', url: 'git+https://github.com/microsoft/beachball.git' }, 'beachball') ).toEqual(expected); }); it('throws when no repository is specified', () => { - expect(() => parseGitHubRepo(undefined, 'beachball')).toThrow( + expect(() => _parseGitHubRepo(undefined, 'beachball')).toThrow( 'npm package "beachball" does not specify a repository.' ); }); @@ -32,7 +32,9 @@ describe('parseGitHubRepo', () => { it.each(['gitlab:owner/repo', 'https://gitlab.com/owner/repo.git', 'https://bitbucket.org/owner/repo.git'])( 'throws for non-github.com repository %p', url => { - expect(() => parseGitHubRepo(url, 'pkg')).toThrow('is not on github.com'); + expect(() => _parseGitHubRepo(url, 'pkg')).toThrow( + `npm package "pkg" repository is "${url}" which does not appear to be on github.com` + ); } ); }); diff --git a/packages/proper-changelog/src/__tests__/resolveToken.test.ts b/packages/proper-changelog/src/__tests__/resolveToken.test.ts index 5e8578440..7a6c0ecaf 100644 --- a/packages/proper-changelog/src/__tests__/resolveToken.test.ts +++ b/packages/proper-changelog/src/__tests__/resolveToken.test.ts @@ -1,26 +1,24 @@ import { describe, it, expect, jest, beforeEach } from '@jest/globals'; type SpawnFn = (file: string, args: string[]) => Promise<{ stdout: string }>; - const mockSpawn = jest.fn(); - jest.unstable_mockModule('nano-spawn', () => ({ default: mockSpawn, })); const { resolveToken } = await import('../resolveToken.ts'); -/** Configure the mocked spawn to succeed with the given stdout. */ -function mockGhSuccess(stdout: string): void { - mockSpawn.mockResolvedValue({ stdout }); -} +describe('resolveToken', () => { + /** Configure the mocked spawn to succeed with the given stdout. */ + function mockGhSuccess(stdout: string): void { + mockSpawn.mockResolvedValue({ stdout }); + } -/** Configure the mocked spawn to fail (e.g. gh not installed). */ -function mockGhFailure(): void { - mockSpawn.mockRejectedValue(new Error('gh: command not found')); -} + /** Configure the mocked spawn to fail (e.g. gh not installed). */ + function mockGhFailure(): void { + mockSpawn.mockRejectedValue(new Error('gh: command not found')); + } -describe('resolveToken', () => { beforeEach(() => { mockSpawn.mockReset(); }); diff --git a/packages/proper-changelog/src/__tests__/selectReleases.test.ts b/packages/proper-changelog/src/__tests__/selectReleases.test.ts index 948c3514b..16f38e9ab 100644 --- a/packages/proper-changelog/src/__tests__/selectReleases.test.ts +++ b/packages/proper-changelog/src/__tests__/selectReleases.test.ts @@ -1,18 +1,11 @@ import { describe, it, expect } from '@jest/globals'; import { selectReleases } from '../selectReleases.ts'; import { makeRelease } from '../__fixtures__/makeRelease.ts'; -import type { ProperChangelogOptions } from '../types.ts'; - -const repo = { owner: 'microsoft', repo: 'some-repo' }; - -function options(overrides: Partial = {}): ProperChangelogOptions { - return { repo, ...overrides }; -} describe('selectReleases', () => { it('always excludes draft releases', () => { const releases = [makeRelease({ tag_name: 'v2.0.0' }), makeRelease({ tag_name: 'v1.0.0-draft', draft: true })]; - expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); + expect(selectReleases(releases, {}).map(r => r.tag_name)).toEqual(['v2.0.0']); }); it('excludes prereleases by default', () => { @@ -20,7 +13,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'v2.0.0' }), makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true }), ]; - expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v2.0.0']); + expect(selectReleases(releases, {}).map(r => r.tag_name)).toEqual(['v2.0.0']); }); it('includes prereleases when includePrereleases is set', () => { @@ -28,7 +21,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), makeRelease({ tag_name: 'v2.0.0-beta.1', prerelease: true, published_at: '2024-01-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options({ includePrereleases: true })).map(r => r.tag_name)).toEqual([ + expect(selectReleases(releases, { includePrereleases: true }).map(r => r.tag_name)).toEqual([ 'v2.0.0', 'v2.0.0-beta.1', ]); @@ -40,7 +33,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options()).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0', 'v1.0.0']); + expect(selectReleases(releases, {}).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0', 'v1.0.0']); }); it('applies the limit after sorting', () => { @@ -49,7 +42,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'v3.0.0', published_at: '2024-03-01T00:00:00Z' }), makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options({ limit: 2 })).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0']); + expect(selectReleases(releases, { limit: 2 }).map(r => r.tag_name)).toEqual(['v3.0.0', 'v2.0.0']); }); it('applies an inclusive from/to tag range regardless of bound order', () => { @@ -59,7 +52,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options({ from: 'v3.0.0', to: 'v2.0.0' })).map(r => r.tag_name)).toEqual([ + expect(selectReleases(releases, { from: 'v3.0.0', to: 'v2.0.0' }).map(r => r.tag_name)).toEqual([ 'v3.0.0', 'v2.0.0', ]); @@ -67,7 +60,7 @@ describe('selectReleases', () => { it('throws a helpful error when a from/to tag is not found', () => { const releases = [makeRelease({ tag_name: 'v1.0.0' })]; - expect(() => selectReleases(releases, options({ from: 'v9.9.9' }))).toThrow('No release found with tag "v9.9.9".'); + expect(() => selectReleases(releases, { from: 'v9.9.9' })).toThrow('No release found with tag "v9.9.9".'); }); it('filters by a case-insensitive substring of the tag', () => { @@ -75,7 +68,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'app_v2.0.0', published_at: '2024-02-01T00:00:00Z' }), makeRelease({ tag_name: 'lib_v1.0.0', published_at: '2024-01-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options({ filter: 'APP' })).map(r => r.tag_name)).toEqual(['app_v2.0.0']); + expect(selectReleases(releases, { filter: 'APP' }).map(r => r.tag_name)).toEqual(['app_v2.0.0']); }); it('filters by a /regex/ when the value is wrapped in slashes', () => { @@ -84,10 +77,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options({ filter: '/^v2\\./' })).map(r => r.tag_name)).toEqual([ - 'v2.1.0', - 'v2.0.0', - ]); + expect(selectReleases(releases, { filter: '/^v2\\./' }).map(r => r.tag_name)).toEqual(['v2.1.0', 'v2.0.0']); }); it('supports regex flags such as case-insensitivity', () => { @@ -95,7 +85,7 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'Release-A', published_at: '2024-02-01T00:00:00Z' }), makeRelease({ tag_name: 'release-b', published_at: '2024-01-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options({ filter: '/^release-/i' })).map(r => r.tag_name)).toEqual([ + expect(selectReleases(releases, { filter: '/^release-/i' }).map(r => r.tag_name)).toEqual([ 'Release-A', 'release-b', ]); @@ -103,7 +93,7 @@ describe('selectReleases', () => { it('throws a helpful error for an invalid /regex/ filter', () => { const releases = [makeRelease({ tag_name: 'v1.0.0' })]; - expect(() => selectReleases(releases, options({ filter: '/[/' }))).toThrow(/Invalid --filter regular expression/); + expect(() => selectReleases(releases, { filter: '/[/' })).toThrow(/Invalid --filter regular expression/); }); it('includes only releases published after the --since date', () => { @@ -112,8 +102,6 @@ describe('selectReleases', () => { makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), ]; - expect(selectReleases(releases, options({ since: new Date('2024-02-01') })).map(r => r.tag_name)).toEqual([ - 'v3.0.0', - ]); + expect(selectReleases(releases, { since: new Date('2024-02-01') }).map(r => r.tag_name)).toEqual(['v3.0.0']); }); }); diff --git a/packages/proper-changelog/src/cli.ts b/packages/proper-changelog/src/cli.ts index 0212d9ce0..82c6f5c09 100644 --- a/packages/proper-changelog/src/cli.ts +++ b/packages/proper-changelog/src/cli.ts @@ -1,64 +1,52 @@ -import { writeFile } from 'fs/promises'; -import { Command, Option, InvalidArgumentError } from 'commander'; +import fs from 'fs'; +import { Command, CommanderError, Option, InvalidArgumentError, type OutputConfiguration } from 'commander'; import { fetchReleases } from './fetchReleases.ts'; import { renderChangelog } from './renderChangelog.ts'; import { resolveRepoFromPackage } from './resolveRepoFromPackage.ts'; import { resolveToken } from './resolveToken.ts'; -import type { ProperChangelogOptions, RepoId } from './types.ts'; +import { ChangelogError, type RawCliOptions, type ProperChangelogOptions, type RepoId } from './types.ts'; -/** Parse an `owner/repo` string into a {@link RepoId}. */ -export function parseRepo(value: string): RepoId { - const match = value.match(/^([^/\s]+)\/([^/\s]+)$/); - if (!match) { - throw new InvalidArgumentError(`Expected "owner/repo" but got "${value}".`); - } - return { owner: match[1], repo: match[2] }; -} - -/** Parse a non-negative integer option value. */ -function parsePositiveInt(value: string): number { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new InvalidArgumentError(`Expected a positive integer but got "${value}".`); - } - return parsed; -} - -/** Validate a date option value (any format parseable by `new Date()`). */ -function parseDate(value: string): Date { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - throw new InvalidArgumentError(`Expected a date but got "${value}".`); - } - return date; -} - -interface RawCliOptions { - repo?: RepoId; - package?: string; - out?: string; - stdout?: boolean; - token?: string; - includePrereleases?: boolean; - from?: string; - to?: string; - limit?: number; - filter?: string; - since?: Date; +export interface CliContext { + argv: string[]; + env: NodeJS.ProcessEnv; + /** Commander error handler */ + exitOverride?: (err: CommanderError) => never | void; + /** Commander error logging handler */ + writeErr?: OutputConfiguration['writeErr']; + log: (message: string) => void; + warn: (message: string) => void; + writeFile: (file: string, content: string) => void; } -/** Build the commander program. Exported for testing. */ -export function createProgram(): Command { - const program = new Command(); - program +/** + * Parse the CLI arguments (`process.argv` by default), fetch the repo from `--package` if needed, + * and get the default token if needed. + * + * By default this will exit the program if an argument is invalid. + */ +export async function _parseArgs( + context: Pick +): Promise { + const program = new Command() .name('proper-changelog') - .description('Generate a single markdown changelog from a GitHub repository\u2019s releases.') + .description("Generate a single markdown changelog from a GitHub repository's releases.") .addOption( - new Option('--repo ', 'GitHub repository to read releases from') - .argParser(parseRepo) + new Option('--repo ', 'GitHub repository to read releases from (use this OR --package)') + .argParser((value): RepoId => { + const match = value.match(/^([^/\s]+)\/([^/\s]+)$/); + if (!match) { + throw new InvalidArgumentError(`Expected "owner/repo" but got "${value}".`); + } + return { owner: match[1], repo: match[2] }; + }) .conflicts('package') ) - .addOption(new Option('--package ', 'npm package whose GitHub repository should be used').conflicts('repo')) + .addOption( + new Option( + '--package ', + 'npm package whose GitHub repository should be used (use this OR --repo)' + ).conflicts('repo') + ) .addOption( new Option('-o, --out ', 'output file name (default: CHANGELOG-.md)').conflicts('stdout') ) @@ -67,87 +55,89 @@ export function createProgram(): Command { .option('--include-prereleases', 'include prerelease releases (drafts are always excluded)') .option('--from ', 'include releases up to and including this tag (based on date, not semver)') .option('--to ', 'include releases down to and including this tag (based on date, not semver)') - .option('--limit ', 'maximum number of releases to include', parsePositiveInt) + .option('--limit ', 'maximum number of releases to include', value => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError(`Expected a positive integer but got "${value}".`); + } + return parsed; + }) .option('--filter ', 'only include releases whose tag matches this substring or /regex/') - .option('--since ', 'only include releases published after this date (e.g. 2024-01-01)', parseDate) + .option('--since ', 'only include releases published after this date (e.g. 2024-01-01)', value => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new InvalidArgumentError(`Expected a date but got "${value}".`); + } + return date; + }) .allowExcessArguments(false); - return program; -} -/** Resolve the target repository from either `--repo` or `--package`. */ -async function resolveRepo(raw: RawCliOptions): Promise { - if (raw.repo) { - return raw.repo; - } - if (raw.package) { - return resolveRepoFromPackage(raw.package); - } - throw new Error('Exactly one of --repo or --package is required.'); -} + context.exitOverride && program.exitOverride(context.exitOverride); + context.writeErr && program.configureOutput({ writeErr: context.writeErr }); -/** Generate the changelog and write it to a file or stdout based on the parsed options. */ -export async function run( - raw: RawCliOptions, - deps: { - log?: (message: string) => void; - warn?: (message: string) => void; - write?: (file: string, content: string) => Promise; - } = {} -): Promise { - const log = deps.log ?? ((message: string) => console.log(message)); - const warn = deps.warn ?? ((message: string) => console.warn(message)); - const write = deps.write ?? ((file: string, content: string) => writeFile(file, content, 'utf8')); + const rawOptions = program.parse(context.argv ?? process.argv).opts(); - const repo = await resolveRepo(raw); + let repo = rawOptions.repo; + if (rawOptions.package) { + repo = await resolveRepoFromPackage(rawOptions.package); + } else if (!repo) { + throw new ChangelogError('Exactly one of --repo or --package is required.'); + } - const token = await resolveToken(raw.token); + const token = await resolveToken(rawOptions.token, context.env); if (!token) { - warn( + context.warn( 'Warning: no GitHub token found (checked --token, GITHUB_TOKEN/GH_TOKEN, and `gh auth token`). ' + 'Requests will be unauthenticated and may be rate-limited.' ); } - const options: ProperChangelogOptions = { - repo, - packageName: raw.package, - token, - includePrereleases: raw.includePrereleases, - from: raw.from, - to: raw.to, - limit: raw.limit, - filter: raw.filter, - since: raw.since, - }; + return { ...rawOptions, repo, token }; +} - const releases = await fetchReleases(repo, token); - const changelog = renderChangelog(releases, options); +/** Generate the changelog and write it to a file or stdout. */ +export async function _generateChangelog(options: ProperChangelogOptions, context: CliContext): Promise { + const { repo } = options; - if (raw.stdout) { - log(changelog); + const releases = await fetchReleases(repo, options.token); + if (!releases.length) { + context.warn(`No releases found for ${repo.owner}/${repo.repo}`); return; } - const outFile = raw.out ?? `CHANGELOG-${defaultBaseName(raw.package, repo)}.md`; - await write(outFile, changelog); - warn(`Wrote changelog to ${outFile}`); -} + const changelog = renderChangelog(releases, options); + + if (options.stdout) { + context.log(changelog); + return; + } -/** Derive the default changelog file base name from the package name (if given) or repo name. */ -export function defaultBaseName(packageName: string | undefined, repo: RepoId): string { - const base = packageName ?? repo.repo; // Strip a leading npm scope and replace path separators so the result is a safe single filename. - return base.replace(/^@/, '').replace(/\//g, '-'); + const changelogName = (options.package ?? repo.repo).replace(/^@/, '').replace(/\//g, '-'); + const outFile = options.out ?? `CHANGELOG-${changelogName}.md`; + context.writeFile(outFile, changelog); + context.log(`Wrote changelog to ${outFile}`); } /** Run the CLI and handle top-level errors. Intended to be called from the bin script. */ -export function cli(argv: string[] = process.argv): void { +export function cli(): void { (async () => { - const program = createProgram(); - program.parse(argv); - await run(program.opts()); - })().catch((error: unknown) => { - console.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; + const context: CliContext = { + argv: process.argv, + env: process.env, + log: message => console.log(message), + warn: message => console.warn(message), + writeFile: (file, content) => fs.writeFileSync(file, content, 'utf8'), + }; + const options = await _parseArgs(context); + await _generateChangelog(options, context); + })().catch((err: unknown) => { + if (err instanceof CommanderError || err instanceof ChangelogError) { + console.error(err.message); + } else { + console.error(err instanceof Error ? err.stack || err.message : String(err)); + } + // eslint-disable-next-line no-restricted-properties -- central handler + process.exit(1); }); } diff --git a/packages/proper-changelog/src/renderChangelog.ts b/packages/proper-changelog/src/renderChangelog.ts index 391388dc8..27b0331c2 100644 --- a/packages/proper-changelog/src/renderChangelog.ts +++ b/packages/proper-changelog/src/renderChangelog.ts @@ -1,4 +1,4 @@ -import { selectReleases } from './selectReleases.ts'; +import { selectReleases, type SelectReleasesOptions } from './selectReleases.ts'; import type { GitHubRelease, ProperChangelogOptions } from './types.ts'; const maxHeadingLevel = 6; @@ -13,12 +13,14 @@ interface BodyHeading { text: string; } +export type RenderChangelogOptions = SelectReleasesOptions & Pick; + /** * Render a full markdown changelog from GitHub releases, applying the given options. */ -export function renderChangelog(releases: GitHubRelease[], options: ProperChangelogOptions): string { +export function renderChangelog(releases: GitHubRelease[], options: RenderChangelogOptions): string { const selected = selectReleases(releases, options); - const heading = `# Changelog - ${options.packageName || options.repo.repo}`; + const heading = `# Changelog - ${options.package || options.repo.repo}`; if (selected.length === 0) { return `${heading}\n\nNo releases found.\n`; diff --git a/packages/proper-changelog/src/resolveRepoFromPackage.ts b/packages/proper-changelog/src/resolveRepoFromPackage.ts index 777fd8488..9456419b6 100644 --- a/packages/proper-changelog/src/resolveRepoFromPackage.ts +++ b/packages/proper-changelog/src/resolveRepoFromPackage.ts @@ -1,4 +1,4 @@ -import type { RepoId } from './types.ts'; +import { ChangelogError, type RepoId } from './types.ts'; /** The `repository` field of an npm package manifest, in object form. */ interface NpmRepository { @@ -14,15 +14,36 @@ interface NpmManifest { const registryBase = 'https://registry.npmjs.org'; +/** Resolve the GitHub repository for an npm package from its latest published version. */ +export async function resolveRepoFromPackage(packageName: string): Promise { + // Encode the package name for the URL path. The leading `@` in scoped names is allowed + // unencoded, but the `/` separator must be encoded. + const encodedName = packageName.startsWith('@') + ? `@${encodeURIComponent(packageName.slice(1))}` + : encodeURIComponent(packageName); + + const url = `${registryBase}/${encodedName}/latest`; + const response = await fetch(url, { headers: { Accept: 'application/json' } }); + if (!response.ok) { + throw new ChangelogError( + `Failed to look up npm package "${packageName}": ${response.status} ${response.statusText}` + ); + } + + const manifest = (await response.json()) as NpmManifest; + return _parseGitHubRepo(manifest.repository, packageName); +} + /** * Parse a GitHub `owner/repo` from an npm `repository` field. Only github.com is supported: * github.com URLs (`git+https`, `https`, `git://`, `git@github.com:`) and the `github:` * shorthand. Throws if the repository refers to any other host or can't be parsed. + * @internal Exported for testing */ -export function parseGitHubRepo(repository: NpmRepository | string | undefined, packageName: string): RepoId { +export function _parseGitHubRepo(repository: NpmRepository | string | undefined, packageName: string): RepoId { const raw = typeof repository === 'string' ? repository : repository?.url; if (!raw) { - throw new Error(`npm package "${packageName}" does not specify a repository.`); + throw new ChangelogError(`npm package "${packageName}" does not specify a repository.`); } // `github:owner/repo` shorthand always refers to github.com. @@ -38,8 +59,10 @@ export function parseGitHubRepo(repository: NpmRepository | string | undefined, } // Any other host shorthand (e.g. `gitlab:`/`bitbucket:`) is unsupported. - if (/^[a-z]+:[^/]+\/[^/]+$/i.test(raw) && !raw.startsWith('github:')) { - throw new Error(`npm package "${packageName}" repository is not on github.com: ${raw}`); + if (/^\w+:[^/]+\/[^/]+$/i.test(raw) && !raw.startsWith('github:')) { + throw new ChangelogError( + `npm package "${packageName}" repository is "${raw}" which does not appear to be on github.com` + ); } // URL forms: https, git+https, git://, ssh (git@github.com:owner/repo). @@ -48,27 +71,7 @@ export function parseGitHubRepo(repository: NpmRepository | string | undefined, return { owner: urlMatch[1], repo: urlMatch[2] }; } - throw new Error(`npm package "${packageName}" repository is not on github.com: ${raw}`); -} - -/** Fetch the latest published manifest for an npm package. */ -export async function fetchPackageManifest(packageName: string): Promise { - // Encode the package name for the URL path. The leading `@` in scoped names is allowed - // unencoded, but the `/` separator must be encoded. - const encodedName = packageName.startsWith('@') - ? `@${encodeURIComponent(packageName.slice(1))}` - : encodeURIComponent(packageName); - - const url = `${registryBase}/${encodedName}/latest`; - const response = await fetch(url, { headers: { Accept: 'application/json' } }); - if (!response.ok) { - throw new Error(`Failed to look up npm package "${packageName}": ${response.status} ${response.statusText}`); - } - return (await response.json()) as NpmManifest; -} - -/** Resolve the GitHub repository for an npm package from its latest published version. */ -export async function resolveRepoFromPackage(packageName: string): Promise { - const manifest = await fetchPackageManifest(packageName); - return parseGitHubRepo(manifest.repository, packageName); + throw new ChangelogError( + `npm package "${packageName}" repository is "${raw}" which does not appear to be on github.com` + ); } diff --git a/packages/proper-changelog/src/resolveToken.ts b/packages/proper-changelog/src/resolveToken.ts index 3627920bc..d68f916ed 100644 --- a/packages/proper-changelog/src/resolveToken.ts +++ b/packages/proper-changelog/src/resolveToken.ts @@ -9,8 +9,8 @@ import spawn from 'nano-spawn'; * Returns `undefined` if no token could be resolved. */ export async function resolveToken( - explicitToken?: string, - env: NodeJS.ProcessEnv = process.env + explicitToken: string | undefined, + env: NodeJS.ProcessEnv ): Promise { if (explicitToken) { return explicitToken; diff --git a/packages/proper-changelog/src/selectReleases.ts b/packages/proper-changelog/src/selectReleases.ts index c72fe5306..4478f2f32 100644 --- a/packages/proper-changelog/src/selectReleases.ts +++ b/packages/proper-changelog/src/selectReleases.ts @@ -1,11 +1,16 @@ import type { GitHubRelease, ProperChangelogOptions } from './types.ts'; +export type SelectReleasesOptions = Pick< + ProperChangelogOptions, + 'includePrereleases' | 'filter' | 'since' | 'from' | 'to' | 'limit' +>; + /** * Filter, sort, and slice releases according to the provided options. * Draft releases are always excluded; prereleases are excluded unless `includePrereleases`. * Releases are returned newest-first by published date. */ -export function selectReleases(releases: GitHubRelease[], options: ProperChangelogOptions): GitHubRelease[] { +export function selectReleases(releases: GitHubRelease[], options: SelectReleasesOptions): GitHubRelease[] { let selected = releases.filter(release => !release.draft); if (!options.includePrereleases) { diff --git a/packages/proper-changelog/src/types.ts b/packages/proper-changelog/src/types.ts index 20fd8fc21..30c69eb84 100644 --- a/packages/proper-changelog/src/types.ts +++ b/packages/proper-changelog/src/types.ts @@ -9,12 +9,12 @@ export interface RepoId { repo: string; } -/** Options controlling changelog generation. */ -export interface ProperChangelogOptions { - /** Repository to read releases from, as `owner/repo`. */ - repo: RepoId; +/** Options as returned by `program.parse().opts()`. */ +export interface RawCliOptions { + /** Repository to read releases from. */ + repo?: RepoId; /** npm package name the repo was resolved from, if any (used for the changelog heading/filename). */ - packageName?: string; + package?: string; /** Auth token for the GitHub API (optional; requests are rate-limited without one). */ token?: string; /** Include prerelease releases (default: false). Draft releases are always excluded. */ @@ -32,4 +32,16 @@ export interface ProperChangelogOptions { filter?: string; /** Only include releases published after this date. */ since?: Date; + /** Write output to this file */ + out?: string; + /** If true, write to stdout instead of a file */ + stdout?: boolean; +} + +/** Options controlling changelog generation. */ +export type ProperChangelogOptions = Required> & RawCliOptions; + +/** Throw this to indicate an expected error (stack won't be logged) */ +export class ChangelogError extends Error { + name = 'ChangelogError'; } diff --git a/scripts/config/eslint.ts b/scripts/config/eslint.ts index 985d5b87e..04cfc0179 100644 --- a/scripts/config/eslint.ts +++ b/scripts/config/eslint.ts @@ -63,7 +63,7 @@ export function getConfig(dirname: string, ...configs: eslint.Config[]) { }, ], '@typescript-eslint/prefer-for-of': 'error', - '@typescript-eslint/no-restricted-imports': [ + 'no-restricted-imports': [ 'error', { paths: [ @@ -71,6 +71,11 @@ export function getConfig(dirname: string, ...configs: eslint.Config[]) { name: 'node:test', message: 'You probably meant to import from "@jest/globals"', }, + { + name: '@jest/globals', + importNames: ['xdescribe', 'xit', 'xtest'], + message: 'Do not commit disabled tests (disable this rule if needed)', + }, ], }, ], @@ -162,19 +167,6 @@ export function getConfig(dirname: string, ...configs: eslint.Config[]) { ]) .flat(), ], - // Use the ESLint version of the rule to avoid overriding the restricted imports from the base config - 'no-restricted-imports': [ - 'error', - { - paths: [ - { - name: '@jest/globals', - importNames: ['xdescribe', 'xit', 'xtest'], - message: 'Do not commit disabled tests (disable this rule if needed)', - }, - ], - }, - ], }, }, { From e5eab52fefe782b3fc153f173d966798844fc953 Mon Sep 17 00:00:00 2001 From: Elizabeth Craig Date: Sat, 13 Jun 2026 02:24:17 -0700 Subject: [PATCH 12/12] refactoring --- .../src/__fixtures__/getContext.ts | 17 +++ .../src/__tests__/cli.test.ts | 134 +----------------- .../src/__tests__/fetchReleases.test.ts | 9 +- .../src/__tests__/parseArgs.test.ts | 131 +++++++++++++++++ .../src/__tests__/resolveToken.test.ts | 7 +- .../src/__tests__/selectReleases.test.ts | 20 +-- packages/proper-changelog/src/cli.ts | 98 +------------ .../proper-changelog/src/fetchReleases.ts | 4 +- packages/proper-changelog/src/parseArgs.ts | 107 ++++++++++++++ .../proper-changelog/src/selectReleases.ts | 22 +-- packages/proper-changelog/src/types.ts | 24 +++- 11 files changed, 300 insertions(+), 273 deletions(-) create mode 100644 packages/proper-changelog/src/__fixtures__/getContext.ts create mode 100644 packages/proper-changelog/src/__tests__/parseArgs.test.ts create mode 100644 packages/proper-changelog/src/parseArgs.ts diff --git a/packages/proper-changelog/src/__fixtures__/getContext.ts b/packages/proper-changelog/src/__fixtures__/getContext.ts new file mode 100644 index 000000000..ca202d567 --- /dev/null +++ b/packages/proper-changelog/src/__fixtures__/getContext.ts @@ -0,0 +1,17 @@ +import { jest } from '@jest/globals'; +import type { CliContext } from '../types.ts'; + +/** Get a context which mocks all functions and throws on `exitOverride` */ +export function getContext(args: string[], env: NodeJS.ProcessEnv = {}): jest.Mocked> { + return { + argv: ['node', 'proper-changelog.js', ...args], + env, + log: jest.fn(), + warn: jest.fn(), + writeFile: jest.fn(), + writeErr: jest.fn(), + exitOverride: jest.fn(err => { + throw err; + }), + }; +} diff --git a/packages/proper-changelog/src/__tests__/cli.test.ts b/packages/proper-changelog/src/__tests__/cli.test.ts index 6da87e680..707c4e4c2 100644 --- a/packages/proper-changelog/src/__tests__/cli.test.ts +++ b/packages/proper-changelog/src/__tests__/cli.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, it, jest } from '@jest/globals'; import { makeRelease } from '../__fixtures__/makeRelease.ts'; -import type { CliContext } from '../cli.ts'; import type * as fetchReleasesModule from '../fetchReleases.ts'; -import type * as resolveRepoModule from '../resolveRepoFromPackage.ts'; +import { getContext } from '../__fixtures__/getContext.ts'; jest.unstable_mockModule('../fetchReleases.ts', () => ({ fetchReleases: jest.fn(() => Promise.resolve([])), @@ -11,135 +10,14 @@ const mockFetchReleases = (await import('../fetchReleases.ts')).fetchReleases as typeof fetchReleasesModule.fetchReleases >; -jest.unstable_mockModule('../resolveRepoFromPackage.ts', () => ({ - resolveRepoFromPackage: jest.fn(packageName => - Promise.resolve({ owner: 'microsoft', repo: packageName.replace(/^.*?\//, '') }) - ), -})); -const mockResolveRepoFromPackage = (await import('../resolveRepoFromPackage.ts')) - .resolveRepoFromPackage as jest.MockedFunction; - -// Mock nano-spawn which is used for `gh auth token` -let mockGhAuthToken = ''; -jest.unstable_mockModule('nano-spawn', () => ({ default: () => Promise.resolve({ stdout: mockGhAuthToken }) })); - -const { _parseArgs, _generateChangelog } = await import('../cli.ts'); - -/** Get a context which mocks all functions and throws on `exitOverride` */ -function getContext(args: string[], env: NodeJS.ProcessEnv = {}) { - return jest.mocked>({ - argv: ['node', 'proper-changelog.js', ...args], - env, - log: jest.fn(), - warn: jest.fn(), - writeFile: jest.fn(), - writeErr: jest.fn(), - exitOverride: err => { - throw err; - }, - }); -} - -afterEach(() => { - mockGhAuthToken = ''; - mockFetchReleases.mockResolvedValue([]); -}); - -describe('_parseArgs', () => { - it('parses --repo into a RepoId and applies defaults', async () => { - const context = getContext(['--repo', 'microsoft/some-repo']); - const opts = await _parseArgs(context); - expect(opts).toEqual({ repo: { owner: 'microsoft', repo: 'some-repo' } }); - // no token, so a warning is logged - expect(context.warn).toHaveBeenCalledWith(expect.stringContaining('no GitHub token found')); - }); - - it('parses all options', async () => { - const context = getContext( - [ - '--repo', - 'microsoft/some-repo', - '--out', - 'changes.md', - '--token', - 't', - '--include-prereleases', - '--from', - 'v2.0.0', - '--to', - 'v1.0.0', - '--limit', - '5', - '--filter', - '/^v2\\./', - '--since', - '2024-01-01', - ], - { GITHUB_TOKEN: 'env-token' } // not used due to --token - ); - expect(await _parseArgs(context)).toEqual({ - repo: { owner: 'microsoft', repo: 'some-repo' }, - out: 'changes.md', - token: 't', - includePrereleases: true, - from: 'v2.0.0', - to: 'v1.0.0', - limit: 5, - filter: '/^v2\\./', - since: new Date('2024-01-01'), - }); - }); - - it('parses and fetches --package', async () => { - const context = getContext(['--package', '@scope/pkg']); - const opts = await _parseArgs(context); - // per the resolve mock - expect(opts).toEqual({ repo: { owner: 'microsoft', repo: 'pkg' }, package: '@scope/pkg' }); - expect(await mockResolveRepoFromPackage.mock.results[0].value).toEqual({ owner: 'microsoft', repo: 'pkg' }); - }); +const { _generateChangelog } = await import('../cli.ts'); - it('gets the token from the environment', async () => { - const context = getContext(['--repo', 'microsoft/some-repo'], { GITHUB_TOKEN: 'token' }); - const opts = await _parseArgs(context); - expect(opts.token).toBe('token'); - }); - - it('gets the token from `gh auth token` as fallack', async () => { - mockGhAuthToken = 'gh-token'; - const context = getContext(['--repo', 'microsoft/some-repo']); - const opts = await _parseArgs(context); - expect(opts.token).toBe('gh-token'); - }); - - it.each(['beachball', 'a/b/c', 'owner/', '/repo', 'owner repo'])('rejects invalid --repo %p', async input => { - const context = getContext(['--repo', input]); - // this message comes from commander but will contain the invalid input - await expect(_parseArgs(context)).rejects.toThrow(input); - }); - - it('rejects a non-integer --limit', async () => { - const context = getContext(['--repo', 'microsoft/some-repo', '--limit', 'abc']); - await expect(_parseArgs(context)).rejects.toThrow('Expected a positive integer but got "abc"'); - }); - - it('rejects an invalid --since date', async () => { - const context = getContext(['--repo', 'microsoft/some-repo', '--since', 'nope']); - await expect(_parseArgs(context)).rejects.toThrow('Expected a date but got "nope"'); - }); - - it('rejects using --out and --stdout together', async () => { - const context = getContext(['--repo', 'microsoft/some-repo', '--out', 'x.md', '--stdout']); - await expect(_parseArgs(context)).rejects.toThrow(/--out.*?--stdout/); - }); +describe('cli _generateChangelog', () => { + const repo = { owner: 'microsoft', repo: 'beachball' }; - it('rejects using --repo and --package together', async () => { - const context = getContext(['--repo', 'microsoft/some-repo', '--package', 'pkg']); - await expect(_parseArgs(context)).rejects.toThrow(/--repo.*?--package/); + afterEach(() => { + mockFetchReleases.mockResolvedValue([]); }); -}); - -describe('_generateChangelog', () => { - const repo = { owner: 'microsoft', repo: 'beachball' }; it('warns and writes nothing when there are no releases', async () => { mockFetchReleases.mockResolvedValue([]); diff --git a/packages/proper-changelog/src/__tests__/fetchReleases.test.ts b/packages/proper-changelog/src/__tests__/fetchReleases.test.ts index 1f0670b89..59d28af96 100644 --- a/packages/proper-changelog/src/__tests__/fetchReleases.test.ts +++ b/packages/proper-changelog/src/__tests__/fetchReleases.test.ts @@ -3,6 +3,7 @@ import { fetchReleases } from '../fetchReleases.ts'; import { makeRelease } from '../__fixtures__/makeRelease.ts'; const repo = { owner: 'microsoft', repo: 'some-repo' }; +const apiUrl = `https://api.github.com/repos/${repo.owner}/${repo.repo}/releases?per_page=100`; describe('fetchReleases', () => { const originalFetch = global.fetch; @@ -38,7 +39,7 @@ describe('fetchReleases', () => { expect(releases.map(r => r.tag_name)).toEqual(['v1.0.0']); const [url, requestInit] = fetchMock.mock.calls[0]; - expect(url).toBe('https://api.github.com/repos/microsoft/some-repo/releases?per_page=100'); + expect(url).toBe(apiUrl); const headers = (requestInit as RequestInit).headers as Record; expect(headers.Authorization).toBeUndefined(); }); @@ -58,7 +59,7 @@ describe('fetchReleases', () => { fetchMock .mockResolvedValueOnce( mockResponse([makeRelease({ tag_name: 'v2.0.0' })], { - link: '; rel="next"', + link: `<${apiUrl}&page=2>; rel="next"`, }) ) .mockResolvedValueOnce(mockResponse([makeRelease({ tag_name: 'v1.0.0' })])); @@ -67,9 +68,7 @@ describe('fetchReleases', () => { expect(releases.map(r => r.tag_name)).toEqual(['v2.0.0', 'v1.0.0']); expect(fetchMock).toHaveBeenCalledTimes(2); - expect(fetchMock.mock.calls[1][0]).toBe( - 'https://api.github.com/repos/microsoft/some-repo/releases?per_page=100&page=2' - ); + expect(fetchMock.mock.calls[1][0]).toBe(`${apiUrl}&page=2`); }); it('throws a descriptive error on a non-OK response', async () => { diff --git a/packages/proper-changelog/src/__tests__/parseArgs.test.ts b/packages/proper-changelog/src/__tests__/parseArgs.test.ts new file mode 100644 index 000000000..58279e637 --- /dev/null +++ b/packages/proper-changelog/src/__tests__/parseArgs.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, jest } from '@jest/globals'; +import type * as resolveRepoModule from '../resolveRepoFromPackage.ts'; +import { getContext } from '../__fixtures__/getContext.ts'; + +jest.unstable_mockModule('../resolveRepoFromPackage.ts', () => ({ + resolveRepoFromPackage: jest.fn(packageName => + Promise.resolve({ owner: 'microsoft', repo: packageName.replace(/^.*?\//, '') }) + ), +})); +const mockResolveRepoFromPackage = (await import('../resolveRepoFromPackage.ts')) + .resolveRepoFromPackage as jest.MockedFunction; + +// Mock nano-spawn which is used for `gh auth token` +let mockGhAuthToken = ''; +jest.unstable_mockModule('nano-spawn', () => ({ default: () => Promise.resolve({ stdout: mockGhAuthToken }) })); + +const { parseArgs } = await import('../parseArgs.ts'); + +describe('parseArgs', () => { + afterEach(() => { + mockGhAuthToken = ''; + }); + + it('parses --repo into a RepoId and applies defaults', async () => { + const context = getContext(['--repo', 'microsoft/some-repo']); + const opts = await parseArgs(context); + expect(opts).toEqual({ repo: { owner: 'microsoft', repo: 'some-repo' } }); + // no token, so a warning is logged + expect(context.warn).toHaveBeenCalledWith(expect.stringContaining('no GitHub token found')); + }); + + it('parses all options', async () => { + const context = getContext( + [ + '--repo', + 'microsoft/some-repo', + '--out', + 'changes.md', + '--token', + 't', + '--include-prereleases', + '--from', + 'v2.0.0', + '--to', + 'v1.0.0', + '--limit', + '5', + '--filter', + '/^v2\\./', + '--since', + '2024-01-01', + ], + { GITHUB_TOKEN: 'env-token' } // not used due to --token + ); + expect(await parseArgs(context)).toEqual({ + repo: { owner: 'microsoft', repo: 'some-repo' }, + out: 'changes.md', + token: 't', + includePrereleases: true, + from: 'v2.0.0', + to: 'v1.0.0', + limit: 5, + filter: /^v2\./, + since: new Date('2024-01-01'), + }); + }); + + it('parses and fetches --package', async () => { + const context = getContext(['--package', '@scope/pkg']); + const opts = await parseArgs(context); + // per the resolve mock + expect(opts).toEqual({ repo: { owner: 'microsoft', repo: 'pkg' }, package: '@scope/pkg' }); + expect(await mockResolveRepoFromPackage.mock.results[0].value).toEqual({ owner: 'microsoft', repo: 'pkg' }); + }); + + it('gets the token from the environment', async () => { + const context = getContext(['--repo', 'microsoft/some-repo'], { GITHUB_TOKEN: 'token' }); + const opts = await parseArgs(context); + expect(opts.token).toBe('token'); + }); + + it('gets the token from `gh auth token` as fallback', async () => { + mockGhAuthToken = 'gh-token'; + const context = getContext(['--repo', 'microsoft/some-repo']); + const opts = await parseArgs(context); + expect(opts.token).toBe('gh-token'); + }); + + it.each(['beachball', 'a/b/c', 'owner/', '/repo', 'owner repo'])('rejects invalid --repo %p', async input => { + const context = getContext(['--repo', input]); + // this message comes from commander but will contain the invalid input + await expect(parseArgs(context)).rejects.toThrow(input); + }); + + it('rejects a non-integer --limit', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--limit', 'abc']); + await expect(parseArgs(context)).rejects.toThrow('Expected a positive integer but got "abc"'); + }); + + it('rejects an invalid --since date', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--since', 'nope']); + await expect(parseArgs(context)).rejects.toThrow('Expected a date but got "nope"'); + }); + + it('keeps a plain --filter as a string', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--filter', 'v2']); + const opts = await parseArgs(context); + expect(opts.filter).toBe('v2'); + }); + + it('converts a slash-wrapped --filter into a RegExp', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--filter', '/^v2\\./i']); + const opts = await parseArgs(context); + expect(opts.filter).toEqual(/^v2\./i); + }); + + it('rejects an invalid --filter regex', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--filter', '/[/']); + await expect(parseArgs(context)).rejects.toThrow('Invalid regular expression "/[/"'); + }); + + it('rejects using --out and --stdout together', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--out', 'x.md', '--stdout']); + await expect(parseArgs(context)).rejects.toThrow(/--out.*?--stdout/); + }); + + it('rejects using --repo and --package together', async () => { + const context = getContext(['--repo', 'microsoft/some-repo', '--package', 'pkg']); + await expect(parseArgs(context)).rejects.toThrow(/--repo.*?--package/); + }); +}); diff --git a/packages/proper-changelog/src/__tests__/resolveToken.test.ts b/packages/proper-changelog/src/__tests__/resolveToken.test.ts index 7a6c0ecaf..deb684822 100644 --- a/packages/proper-changelog/src/__tests__/resolveToken.test.ts +++ b/packages/proper-changelog/src/__tests__/resolveToken.test.ts @@ -14,11 +14,6 @@ describe('resolveToken', () => { mockSpawn.mockResolvedValue({ stdout }); } - /** Configure the mocked spawn to fail (e.g. gh not installed). */ - function mockGhFailure(): void { - mockSpawn.mockRejectedValue(new Error('gh: command not found')); - } - beforeEach(() => { mockSpawn.mockReset(); }); @@ -44,7 +39,7 @@ describe('resolveToken', () => { }); it('returns undefined when gh is unavailable', async () => { - mockGhFailure(); + mockSpawn.mockRejectedValue(new Error('gh: command not found')); expect(await resolveToken(undefined, {})).toBeUndefined(); }); diff --git a/packages/proper-changelog/src/__tests__/selectReleases.test.ts b/packages/proper-changelog/src/__tests__/selectReleases.test.ts index 16f38e9ab..44f5700ce 100644 --- a/packages/proper-changelog/src/__tests__/selectReleases.test.ts +++ b/packages/proper-changelog/src/__tests__/selectReleases.test.ts @@ -71,29 +71,13 @@ describe('selectReleases', () => { expect(selectReleases(releases, { filter: 'APP' }).map(r => r.tag_name)).toEqual(['app_v2.0.0']); }); - it('filters by a /regex/ when the value is wrapped in slashes', () => { + it('filters by a RegExp', () => { const releases = [ makeRelease({ tag_name: 'v2.1.0', published_at: '2024-03-01T00:00:00Z' }), makeRelease({ tag_name: 'v2.0.0', published_at: '2024-02-01T00:00:00Z' }), makeRelease({ tag_name: 'v1.0.0', published_at: '2024-01-01T00:00:00Z' }), ]; - expect(selectReleases(releases, { filter: '/^v2\\./' }).map(r => r.tag_name)).toEqual(['v2.1.0', 'v2.0.0']); - }); - - it('supports regex flags such as case-insensitivity', () => { - const releases = [ - makeRelease({ tag_name: 'Release-A', published_at: '2024-02-01T00:00:00Z' }), - makeRelease({ tag_name: 'release-b', published_at: '2024-01-01T00:00:00Z' }), - ]; - expect(selectReleases(releases, { filter: '/^release-/i' }).map(r => r.tag_name)).toEqual([ - 'Release-A', - 'release-b', - ]); - }); - - it('throws a helpful error for an invalid /regex/ filter', () => { - const releases = [makeRelease({ tag_name: 'v1.0.0' })]; - expect(() => selectReleases(releases, { filter: '/[/' })).toThrow(/Invalid --filter regular expression/); + expect(selectReleases(releases, { filter: /^v2\./ }).map(r => r.tag_name)).toEqual(['v2.1.0', 'v2.0.0']); }); it('includes only releases published after the --since date', () => { diff --git a/packages/proper-changelog/src/cli.ts b/packages/proper-changelog/src/cli.ts index 82c6f5c09..c6ce3824f 100644 --- a/packages/proper-changelog/src/cli.ts +++ b/packages/proper-changelog/src/cli.ts @@ -1,99 +1,9 @@ import fs from 'fs'; -import { Command, CommanderError, Option, InvalidArgumentError, type OutputConfiguration } from 'commander'; +import { CommanderError } from 'commander'; import { fetchReleases } from './fetchReleases.ts'; import { renderChangelog } from './renderChangelog.ts'; -import { resolveRepoFromPackage } from './resolveRepoFromPackage.ts'; -import { resolveToken } from './resolveToken.ts'; -import { ChangelogError, type RawCliOptions, type ProperChangelogOptions, type RepoId } from './types.ts'; - -export interface CliContext { - argv: string[]; - env: NodeJS.ProcessEnv; - /** Commander error handler */ - exitOverride?: (err: CommanderError) => never | void; - /** Commander error logging handler */ - writeErr?: OutputConfiguration['writeErr']; - log: (message: string) => void; - warn: (message: string) => void; - writeFile: (file: string, content: string) => void; -} - -/** - * Parse the CLI arguments (`process.argv` by default), fetch the repo from `--package` if needed, - * and get the default token if needed. - * - * By default this will exit the program if an argument is invalid. - */ -export async function _parseArgs( - context: Pick -): Promise { - const program = new Command() - .name('proper-changelog') - .description("Generate a single markdown changelog from a GitHub repository's releases.") - .addOption( - new Option('--repo ', 'GitHub repository to read releases from (use this OR --package)') - .argParser((value): RepoId => { - const match = value.match(/^([^/\s]+)\/([^/\s]+)$/); - if (!match) { - throw new InvalidArgumentError(`Expected "owner/repo" but got "${value}".`); - } - return { owner: match[1], repo: match[2] }; - }) - .conflicts('package') - ) - .addOption( - new Option( - '--package ', - 'npm package whose GitHub repository should be used (use this OR --repo)' - ).conflicts('repo') - ) - .addOption( - new Option('-o, --out ', 'output file name (default: CHANGELOG-.md)').conflicts('stdout') - ) - .addOption(new Option('--stdout', 'write the changelog to stdout instead of a file').conflicts('out')) - .option('--token ', 'GitHub token (falls back to GITHUB_TOKEN/GH_TOKEN, then `gh auth token`)') - .option('--include-prereleases', 'include prerelease releases (drafts are always excluded)') - .option('--from ', 'include releases up to and including this tag (based on date, not semver)') - .option('--to ', 'include releases down to and including this tag (based on date, not semver)') - .option('--limit ', 'maximum number of releases to include', value => { - const parsed = Number(value); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new InvalidArgumentError(`Expected a positive integer but got "${value}".`); - } - return parsed; - }) - .option('--filter ', 'only include releases whose tag matches this substring or /regex/') - .option('--since ', 'only include releases published after this date (e.g. 2024-01-01)', value => { - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - throw new InvalidArgumentError(`Expected a date but got "${value}".`); - } - return date; - }) - .allowExcessArguments(false); - - context.exitOverride && program.exitOverride(context.exitOverride); - context.writeErr && program.configureOutput({ writeErr: context.writeErr }); - - const rawOptions = program.parse(context.argv ?? process.argv).opts(); - - let repo = rawOptions.repo; - if (rawOptions.package) { - repo = await resolveRepoFromPackage(rawOptions.package); - } else if (!repo) { - throw new ChangelogError('Exactly one of --repo or --package is required.'); - } - - const token = await resolveToken(rawOptions.token, context.env); - if (!token) { - context.warn( - 'Warning: no GitHub token found (checked --token, GITHUB_TOKEN/GH_TOKEN, and `gh auth token`). ' + - 'Requests will be unauthenticated and may be rate-limited.' - ); - } - - return { ...rawOptions, repo, token }; -} +import { ChangelogError, type CliContext, type ProperChangelogOptions } from './types.ts'; +import { parseArgs } from './parseArgs.ts'; /** Generate the changelog and write it to a file or stdout. */ export async function _generateChangelog(options: ProperChangelogOptions, context: CliContext): Promise { @@ -129,7 +39,7 @@ export function cli(): void { warn: message => console.warn(message), writeFile: (file, content) => fs.writeFileSync(file, content, 'utf8'), }; - const options = await _parseArgs(context); + const options = await parseArgs(context); await _generateChangelog(options, context); })().catch((err: unknown) => { if (err instanceof CommanderError || err instanceof ChangelogError) { diff --git a/packages/proper-changelog/src/fetchReleases.ts b/packages/proper-changelog/src/fetchReleases.ts index 6f2ab28ea..25301fea0 100644 --- a/packages/proper-changelog/src/fetchReleases.ts +++ b/packages/proper-changelog/src/fetchReleases.ts @@ -1,4 +1,4 @@ -import type { GitHubRelease, RepoId } from './types.ts'; +import { ChangelogError, type GitHubRelease, type RepoId } from './types.ts'; const apiBase = 'https://api.github.com'; const perPage = 100; @@ -26,7 +26,7 @@ export async function fetchReleases(repo: RepoId, token?: string): Promise ''); - throw new Error( + throw new ChangelogError( `Failed to fetch releases for ${repo.owner}/${repo.repo}: ${response.status} ${response.statusText}` + (body ? `\n${body}` : '') ); diff --git a/packages/proper-changelog/src/parseArgs.ts b/packages/proper-changelog/src/parseArgs.ts new file mode 100644 index 000000000..592853d0f --- /dev/null +++ b/packages/proper-changelog/src/parseArgs.ts @@ -0,0 +1,107 @@ +import { Command, InvalidArgumentError, Option } from 'commander'; +import { resolveRepoFromPackage } from './resolveRepoFromPackage.ts'; +import { resolveToken } from './resolveToken.ts'; +import { type CliContext, type CliOptions, type ProperChangelogOptions, type RepoId, ChangelogError } from './types.ts'; + +/** + * Parse the CLI arguments (`process.argv` by default), fetch the repo from `--package` if needed, + * and get the default token if needed. + * + * By default this will exit the program if an argument is invalid. + */ +export async function parseArgs( + context: Pick +): Promise { + const program = new Command() + .name('proper-changelog') + .description("Generate a single markdown changelog from a GitHub repository's releases.") + .addOption( + new Option('--repo ', 'GitHub repository to read releases from (use this OR --package)') + .argParser(parseRepo) + .conflicts('package') + ) + .addOption( + new Option( + '--package ', + 'npm package whose GitHub repository should be used (use this OR --repo)' + ).conflicts('repo') + ) + .addOption( + new Option('-o, --out ', 'output file name (default: CHANGELOG-.md)').conflicts('stdout') + ) + .addOption(new Option('--stdout', 'write the changelog to stdout instead of a file').conflicts('out')) + .option('--token ', 'GitHub token (falls back to GITHUB_TOKEN/GH_TOKEN, then `gh auth token`)') + .option('--include-prereleases', 'include prerelease releases (drafts are always excluded)') + .option('--from ', 'include releases up to and including this tag (based on date, not semver)') + .option('--to ', 'include releases down to and including this tag (based on date, not semver)') + .option('--limit ', 'maximum number of releases to include', parseLimit) + .option('--filter ', 'only include releases whose tag matches this substring or /regex/', parseFilter) + .option('--since ', 'only include releases published after this date (e.g. 2024-01-01)', parseSince) + .allowExcessArguments(false); + + context.exitOverride && program.exitOverride(context.exitOverride); + context.writeErr && program.configureOutput({ writeErr: context.writeErr }); + + const rawOptions = program.parse(context.argv ?? process.argv).opts(); + + let repo = rawOptions.repo; + if (rawOptions.package) { + repo = await resolveRepoFromPackage(rawOptions.package); + } else if (!repo) { + throw new ChangelogError('Exactly one of --repo or --package is required.'); + } + + const token = await resolveToken(rawOptions.token, context.env); + if (!token) { + context.warn( + 'Warning: no GitHub token found (checked --token, GITHUB_TOKEN/GH_TOKEN, and `gh auth token`). ' + + 'Requests will be unauthenticated and may be rate-limited.' + ); + } + + return { ...rawOptions, repo, token }; +} + +/** Parse a `--repo` value in `owner/repo` form. */ +function parseRepo(value: string): RepoId { + const match = value.match(/^([^/\s]+)\/([^/\s]+)$/); + if (!match) { + throw new InvalidArgumentError(`Expected "owner/repo" but got "${value}".`); + } + return { owner: match[1], repo: match[2] }; +} + +/** Parse a `--limit` value as a positive integer. */ +function parseLimit(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new InvalidArgumentError(`Expected a positive integer but got "${value}".`); + } + return parsed; +} + +/** Parse a `--since` value as a date. */ +function parseSince(value: string): Date { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + throw new InvalidArgumentError(`Expected a date but got "${value}".`); + } + return date; +} + +/** + * Parse a `--filter` value. A value wrapped in slashes (optionally with trailing regex flags, + * e.g. `/^v1\./i`) is converted to a `RegExp`; any other value is returned as-is for a + * case-insensitive substring match. + */ +function parseFilter(value: string): string | RegExp { + const regexMatch = value.match(/^\/(.*)\/([a-z]*)$/s); + if (!regexMatch) { + return value; + } + try { + return new RegExp(regexMatch[1], regexMatch[2]); + } catch (error) { + throw new InvalidArgumentError(`Invalid regular expression "${value}": ${(error as Error).message}`); + } +} diff --git a/packages/proper-changelog/src/selectReleases.ts b/packages/proper-changelog/src/selectReleases.ts index 4478f2f32..3cafede66 100644 --- a/packages/proper-changelog/src/selectReleases.ts +++ b/packages/proper-changelog/src/selectReleases.ts @@ -1,4 +1,4 @@ -import type { GitHubRelease, ProperChangelogOptions } from './types.ts'; +import { ChangelogError, type GitHubRelease, type ProperChangelogOptions } from './types.ts'; export type SelectReleasesOptions = Pick< ProperChangelogOptions, @@ -58,26 +58,18 @@ export function selectReleases(releases: GitHubRelease[], options: SelectRelease function indexOfTag(releases: GitHubRelease[], tag: string): number { const index = releases.findIndex(release => release.tag_name === tag); if (index === -1) { - throw new Error(`No release found with tag "${tag}".`); + throw new ChangelogError(`No release found with tag "${tag}".`); } return index; } /** - * Build a tag-matching predicate from a filter string. A value wrapped in slashes (optionally with - * trailing regex flags, e.g. `/^v1\./i`) is treated as a regular expression; otherwise it is a - * case-insensitive substring match. + * Build a tag-matching predicate from a filter. A `RegExp` matches tags that satisfy it; + * a string matches tags that contain it (case-insensitive). */ -function makeTagMatcher(filter: string): (tag: string) => boolean { - const regexMatch = filter.match(/^\/(.*)\/([a-z]*)$/s); - if (regexMatch) { - let regex: RegExp; - try { - regex = new RegExp(regexMatch[1], regexMatch[2]); - } catch (error) { - throw new Error(`Invalid --filter regular expression "${filter}": ${(error as Error).message}`); - } - return tag => regex.test(tag); +function makeTagMatcher(filter: string | RegExp): (tag: string) => boolean { + if (filter instanceof RegExp) { + return tag => filter.test(tag); } const needle = filter.toLowerCase(); diff --git a/packages/proper-changelog/src/types.ts b/packages/proper-changelog/src/types.ts index 30c69eb84..f2a4d462c 100644 --- a/packages/proper-changelog/src/types.ts +++ b/packages/proper-changelog/src/types.ts @@ -1,4 +1,5 @@ import type { components } from '@octokit/openapi-types'; +import type { CommanderError, OutputConfiguration } from 'commander'; /** A GitHub release as returned by the REST API (`GET /repos/{owner}/{repo}/releases`). */ export type GitHubRelease = components['schemas']['release']; @@ -10,7 +11,7 @@ export interface RepoId { } /** Options as returned by `program.parse().opts()`. */ -export interface RawCliOptions { +export interface CliOptions { /** Repository to read releases from. */ repo?: RepoId; /** npm package name the repo was resolved from, if any (used for the changelog heading/filename). */ @@ -26,10 +27,11 @@ export interface RawCliOptions { /** Maximum number of releases to include. */ limit?: number; /** - * Filter releases by tag name. A plain string matches tags that contain it (case-insensitive); - * a value wrapped in slashes (e.g. `/^v1\./i`) is treated as a regular expression. + * Filter releases by tag name. A string matches tags that contain it (case-insensitive); + * a `RegExp` matches tags that satisfy it. (The CLI converts a value wrapped in slashes, + * e.g. `/^v1\./i`, into a `RegExp`.) */ - filter?: string; + filter?: string | RegExp; /** Only include releases published after this date. */ since?: Date; /** Write output to this file */ @@ -39,7 +41,19 @@ export interface RawCliOptions { } /** Options controlling changelog generation. */ -export type ProperChangelogOptions = Required> & RawCliOptions; +export type ProperChangelogOptions = Required> & CliOptions; + +export interface CliContext { + argv: string[]; + env: NodeJS.ProcessEnv; + /** Commander error handler */ + exitOverride?: (err: CommanderError) => never | void; + /** Commander error logging handler */ + writeErr?: OutputConfiguration['writeErr']; + log: (message: string) => void; + warn: (message: string) => void; + writeFile: (file: string, content: string) => void; +} /** Throw this to indicate an expected error (stack won't be logged) */ export class ChangelogError extends Error {