@wpelevator/wp-release 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -131,6 +131,16 @@ Add release notes in a feature PR with `npx wp-release changeset-add --package @
131
131
 
132
132
  On the release branch, preview with `changeset-version --dry-run`, then run `changeset-version` to update version targets and changelogs. By default, new Markdown entries go before the first version heading; `changelogInsertAfter` sets a literal insertion marker for `CHANGELOG.md` and, with `readmeChangelog`, the plugin readme. Put the marker in both files; a missing marker is an error. Without a marker, plugin readmes use the `== Changelog ==` heading. Failed updates are rolled back for safe retries.
133
133
 
134
+ Configure `changelogEntry( { package, changeset, commit } )` to format the changelog entry of each changeset, where `commit` is the `{ hash, shortHash, subject }` of the commit that added the changeset file, if it's committed. The formatted entries go into `CHANGELOG.md`, the `changelog` of each release in the `changeset-version` result, and so the release notes; plugin readmes keep the plain summaries. `getGitHubPullRequest( { cwd, commit } )` finds the `{ number, url, author }` of the pull request that merged a commit with the authenticated `gh` CLI, for example to credit it:
135
+
136
+ ```js
137
+ changelogEntry: async ( { changeset, commit } ) => {
138
+ const pullRequest = commit && ( await getGitHubPullRequest( { cwd: import.meta.dirname, commit: commit.hash } ) );
139
+
140
+ return pullRequest ? `${ changeset.summary } (#${ pullRequest.number } by @${ pullRequest.author })` : changeset.summary;
141
+ },
142
+ ```
143
+
134
144
  To release packages separately, for example with a release pull request for each, pass `--package <name>`, repeatable, to version only those packages. A changeset that also names other packages keeps only their entries and its summary, so it still applies to their releases with the same bump. Changesets without packages are left for a run without `--package`.
135
145
 
136
146
  The `changeset-version` result lists each release with its `package`, `bump`, `currentVersion`, `nextVersion` and `changelog` entries, a Markdown list like the one added to `CHANGELOG.md`. Use it to name and describe the release pull request in a repository script, or call `versionChangesets()` from the library and format the entries of each release with `formatChangelogEntries( release.changesets )`.
@@ -89,6 +89,8 @@ export interface ChangesetConfig {
89
89
  nextVersion?: (input: NextVersionInput) => string;
90
90
  /** Format the release notes of a tag, for example to add links. */
91
91
  releaseNotes?: (input: ReleaseNotesInput) => string | Promise<string>;
92
+ /** Format the CHANGELOG.md entry of a changeset, for example to credit its pull request. */
93
+ changelogEntry?: (input: ChangelogEntryInput) => string | Promise<string>;
92
94
  }
93
95
  /** Inputs supplied to a custom next-version strategy. */
94
96
  export interface NextVersionInput {
@@ -117,6 +119,13 @@ export interface ReleaseNotesInput {
117
119
  /** The default release notes: the changelog section, or the commit list without one. */
118
120
  notes: string;
119
121
  }
122
+ /** Inputs supplied to a custom changelog entry formatter. */
123
+ export interface ChangelogEntryInput {
124
+ package: ChangesetPackage;
125
+ changeset: Changeset;
126
+ /** Commit that added the changeset file, unless it isn't committed yet. */
127
+ commit?: ReleaseCommit;
128
+ }
120
129
  export type WpReleaseConfig = ChangesetConfig;
121
130
  export interface PackageJsonVersionOptions {
122
131
  file?: string;
@@ -194,6 +194,10 @@ function validateChangesetConfig(config, configFile) {
194
194
  'function' !== typeof candidate.nextVersion) {
195
195
  throw new ValidationError(`wp-release config ${configFile}.nextVersion must be a function.`);
196
196
  }
197
+ if (undefined !== candidate.changelogEntry &&
198
+ 'function' !== typeof candidate.changelogEntry) {
199
+ throw new ValidationError(`wp-release config ${configFile}.changelogEntry must be a function.`);
200
+ }
197
201
  if (undefined !== candidate.releaseNotes &&
198
202
  'function' !== typeof candidate.releaseNotes) {
199
203
  throw new ValidationError(`wp-release config ${configFile}.releaseNotes must be a function.`);
@@ -1,4 +1,4 @@
1
- import type { Changeset, ChangesetPackage, ChangesetReleasePlan, PackageRelease } from './changesets.ts';
1
+ import type { Changeset, ChangesetPackage, ChangesetReleasePlan, PackageRelease, VersionedRelease } from './changesets.ts';
2
2
  export interface ChangesetRepository {
3
3
  cwd: string;
4
4
  configFile?: string;
@@ -9,6 +9,13 @@ export interface ChangesetVersionInput extends ChangesetRepository {
9
9
  /** Version only these packages, leaving the other changesets pending. */
10
10
  packages?: string[];
11
11
  }
12
+ /** Versioned releases, the applied changesets and whether files were changed. */
13
+ export interface VersionChangesetsResult {
14
+ changesets: Changeset[];
15
+ releases: VersionedRelease[];
16
+ date: string;
17
+ changed: boolean;
18
+ }
12
19
  export interface ChangesetTagInput extends ChangesetRepository {
13
20
  dryRun?: boolean;
14
21
  untagged?: boolean;
@@ -64,10 +71,7 @@ export declare function addChangeset(input: ChangesetRepository & {
64
71
  * @param input Repository and versioning options.
65
72
  * @return Planned releases and whether files were changed.
66
73
  */
67
- export declare function versionChangesets(input: ChangesetVersionInput): Promise<ChangesetReleasePlan & {
68
- date: string;
69
- changed: boolean;
70
- }>;
74
+ export declare function versionChangesets(input: ChangesetVersionInput): Promise<VersionChangesetsResult>;
71
75
  /**
72
76
  * Create missing release tags, optionally pushing them to the configured remote.
73
77
  *
@@ -6,7 +6,7 @@ import { promisify } from 'node:util';
6
6
  import { loadChangesetConfig } from "./changeset-config.js";
7
7
  import { findMainPluginFile } from "./package-meta.js";
8
8
  import { findConstantValues } from "./version-check.js";
9
- import { formatChangelogEntries, formatChangeset, getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
9
+ import { formatChangelogList, formatChangeset, getChangesetBump, getNextVersion, parseChangeset, } from "./changesets.js";
10
10
  import { ValidationError } from "./errors.js";
11
11
  const execFileAsync = promisify(execFile);
12
12
  /**
@@ -127,10 +127,12 @@ export async function versionChangesets(input) {
127
127
  ? selectReleasePlan(context.plan, packages, input.packages)
128
128
  : context.plan;
129
129
  const date = input.date ?? new Date().toISOString().slice(0, 10);
130
+ const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
131
+ const releases = await getVersionedReleases(input.cwd, config, releasePlan.releases, changesetDirectory);
130
132
  if (!input.dryRun) {
131
133
  const journal = new Map();
132
134
  try {
133
- for (const release of releasePlan.releases) {
135
+ for (const release of releases) {
134
136
  const configured = packages.find((pkg) => pkg.name === release.package.name);
135
137
  if (!configured) {
136
138
  throw new ValidationError(`Unknown package "${release.package.name}".`);
@@ -141,7 +143,6 @@ export async function versionChangesets(input) {
141
143
  await verifyVersionTargets(input.cwd, configured, release.nextVersion);
142
144
  await prependChangelog(configured.directory, release, date, config.readmeChangelog ?? false, config.changelogInsertAfter, journal);
143
145
  }
144
- const changesetDirectory = path.resolve(input.cwd, config.changesets ?? '.changeset');
145
146
  for (const changeset of releasePlan.changesets) {
146
147
  await consumeChangeset(path.join(changesetDirectory, changeset.fileName), changeset, input.packages, journal);
147
148
  }
@@ -155,11 +156,76 @@ export async function versionChangesets(input) {
155
156
  }
156
157
  }
157
158
  return {
158
- ...releasePlan,
159
+ changesets: releasePlan.changesets,
160
+ releases,
159
161
  date,
160
162
  changed: !input.dryRun && releasePlan.changesets.length > 0,
161
163
  };
162
164
  }
165
+ /**
166
+ * Format the changelog entries of each release, with the configured
167
+ * formatter and the commit that added each changeset.
168
+ *
169
+ * @param cwd Repository root.
170
+ * @param config Repository release configuration.
171
+ * @param releases Package releases.
172
+ * @param changesetDirectory Directory of the changeset files.
173
+ * @return Releases with their changelog entries.
174
+ */
175
+ async function getVersionedReleases(cwd, config, releases, changesetDirectory) {
176
+ const commits = new Map();
177
+ const getCommit = (changeset) => {
178
+ const cached = commits.get(changeset.fileName) ??
179
+ getChangesetCommit(cwd, path.join(changesetDirectory, changeset.fileName));
180
+ commits.set(changeset.fileName, cached);
181
+ return cached;
182
+ };
183
+ return Promise.all(releases.map(async (release) => {
184
+ const entries = [];
185
+ // Format one at a time, so that formatters calling an API aren't rate limited.
186
+ for (const changeset of release.changesets) {
187
+ entries.push(await getChangelogEntry(config, release.package, changeset, getCommit));
188
+ }
189
+ return { ...release, changelog: formatChangelogList(entries) };
190
+ }));
191
+ }
192
+ async function getChangelogEntry(config, pkg, changeset, getCommit) {
193
+ const summary = changeset.summary.trim();
194
+ if (!summary || !config.changelogEntry) {
195
+ return summary;
196
+ }
197
+ const commit = await getCommit(changeset);
198
+ const entry = await config.changelogEntry({
199
+ package: pkg,
200
+ changeset,
201
+ ...(commit ? { commit } : {}),
202
+ });
203
+ if ('string' !== typeof entry) {
204
+ throw new ValidationError(`The changelogEntry formatter didn't return a string for changeset ${changeset.fileName}.`);
205
+ }
206
+ return entry.trim();
207
+ }
208
+ /**
209
+ * Find the commit that added a changeset file.
210
+ *
211
+ * @param cwd Repository root.
212
+ * @param file Changeset file.
213
+ * @return Commit, or undefined when the file isn't committed.
214
+ */
215
+ async function getChangesetCommit(cwd, file) {
216
+ const output = await git(cwd, [
217
+ 'log',
218
+ '--diff-filter=A',
219
+ '--format=%H%x1f%h%x1f%s',
220
+ '-1',
221
+ '--',
222
+ path.relative(cwd, file).split(path.sep).join('/'),
223
+ ]);
224
+ const [hash, shortHash = '', subject = ''] = output
225
+ .trim()
226
+ .split('\x1f');
227
+ return hash ? { hash, shortHash, subject } : undefined;
228
+ }
163
229
  /**
164
230
  * Limit a release plan to some packages and the changesets that name them.
165
231
  *
@@ -749,7 +815,7 @@ async function prependChangelog(directory, release, date, readmeChangelog, chang
749
815
  const summaries = release.changesets
750
816
  .map((item) => item.summary.trim())
751
817
  .filter(Boolean);
752
- const section = `## ${release.nextVersion} (${date})\n\n${formatChangelogEntries(release.changesets)}\n\n`;
818
+ const section = `## ${release.nextVersion} (${date})\n\n${release.changelog}\n\n`;
753
819
  const changelog = path.join(directory, 'CHANGELOG.md');
754
820
  let previous = '';
755
821
  try {
@@ -32,6 +32,11 @@ export interface PackageRelease {
32
32
  nextVersion: string;
33
33
  changesets: Changeset[];
34
34
  }
35
+ /** A package release with its formatted changelog entries, once versioned. */
36
+ export interface VersionedRelease extends PackageRelease {
37
+ /** Changelog entries as a Markdown list, as added to CHANGELOG.md. */
38
+ changelog: string;
39
+ }
35
40
  /** Deterministic release plan assembled from pending changesets. */
36
41
  export interface ChangesetReleasePlan {
37
42
  changesets: Changeset[];
@@ -60,6 +65,13 @@ export declare function formatChangeset(changeset: Omit<Changeset, 'fileName'>):
60
65
  * @return Markdown list, empty without summaries.
61
66
  */
62
67
  export declare function formatChangelogEntries(changesets: Changeset[]): string;
68
+ /**
69
+ * Format changelog entries as a Markdown list, indenting their extra lines.
70
+ *
71
+ * @param entries Changelog entries; empty ones are left out.
72
+ * @return Markdown list, empty without entries.
73
+ */
74
+ export declare function formatChangelogList(entries: string[]): string;
63
75
  /**
64
76
  * Return the strongest bump requested for a package across changesets.
65
77
  *
@@ -71,8 +71,16 @@ export function formatChangeset(changeset) {
71
71
  * @return Markdown list, empty without summaries.
72
72
  */
73
73
  export function formatChangelogEntries(changesets) {
74
- return changesets
75
- .map((item) => item.summary.trim())
74
+ return formatChangelogList(changesets.map((item) => item.summary.trim()));
75
+ }
76
+ /**
77
+ * Format changelog entries as a Markdown list, indenting their extra lines.
78
+ *
79
+ * @param entries Changelog entries; empty ones are left out.
80
+ * @return Markdown list, empty without entries.
81
+ */
82
+ export function formatChangelogList(entries) {
83
+ return entries
76
84
  .filter(Boolean)
77
85
  .map((item) => `- ${item.replaceAll(/\r?\n/g, '\n ')}`)
78
86
  .join('\n');
@@ -1,5 +1,4 @@
1
1
  import { versionChangesets } from "../changeset-release.js";
2
- import { formatChangelogEntries } from "../changesets.js";
3
2
  import { getChangesetVersionSummary } from "../summary.js";
4
3
  import { parseChangesetVersionArgs, changesetVersionArgs, } from "./changeset-args.js";
5
4
  export default {
@@ -38,7 +37,7 @@ changesets without packages are left for a run without --package.`,
38
37
  bump: item.bump,
39
38
  currentVersion: item.currentVersion,
40
39
  nextVersion: item.nextVersion,
41
- changelog: formatChangelogEntries(item.changesets),
40
+ changelog: item.changelog,
42
41
  })),
43
42
  },
44
43
  };
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A GitHub pull request of a commit.
3
+ */
4
+ export interface GitHubPullRequest {
5
+ number: number;
6
+ url: string;
7
+ /** GitHub handle of the pull request author, without the @. */
8
+ author: string;
9
+ }
10
+ export interface GetGitHubPullRequestInput {
11
+ /** Directory of the repository, whose GitHub remote `gh` uses. */
12
+ cwd: string;
13
+ /** Commit hash. */
14
+ commit: string;
15
+ }
16
+ /**
17
+ * Find the pull request that merged a commit, with the authenticated `gh`
18
+ * command line client.
19
+ *
20
+ * @param input Repository directory and commit.
21
+ * @return The first pull request of the commit, or undefined without one.
22
+ * @throws {ValidationError} If `gh` fails, like without authentication.
23
+ */
24
+ export declare function getGitHubPullRequest(input: GetGitHubPullRequestInput): Promise<GitHubPullRequest | undefined>;
package/dist/github.js ADDED
@@ -0,0 +1,30 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { ValidationError } from "./errors.js";
4
+ const execFileAsync = promisify(execFile);
5
+ /**
6
+ * Find the pull request that merged a commit, with the authenticated `gh`
7
+ * command line client.
8
+ *
9
+ * @param input Repository directory and commit.
10
+ * @return The first pull request of the commit, or undefined without one.
11
+ * @throws {ValidationError} If `gh` fails, like without authentication.
12
+ */
13
+ export async function getGitHubPullRequest(input) {
14
+ let stdout;
15
+ try {
16
+ ({ stdout } = await execFileAsync('gh', ['api', `repos/{owner}/{repo}/commits/${input.commit}/pulls`], { cwd: input.cwd }));
17
+ }
18
+ catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ throw new ValidationError(`Could not find the pull request of commit ${input.commit}: ${message}`);
21
+ }
22
+ const [pullRequest] = JSON.parse(stdout);
23
+ return pullRequest
24
+ ? {
25
+ number: pullRequest.number,
26
+ url: pullRequest.html_url,
27
+ author: pullRequest.user?.login ?? '',
28
+ }
29
+ : undefined;
30
+ }
package/dist/index.d.ts CHANGED
@@ -14,7 +14,7 @@ export { ZIP_MTIME, ZIP_FILE_MODE, createZip } from './zip.ts';
14
14
  export { isUrl, downloadFile } from './download.ts';
15
15
  export { readZipEntries, getZipEntryErrors, extractZip } from './unzip.ts';
16
16
  export { GIT_TAG_FORMAT, VersionMismatchError, getGitTagVersion, findConstantValues, findConstantDefinitions, findVersionMismatches, getVersionMismatchProblems, } from './version-check.ts';
17
- export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, getChangesetBump, getNextVersion, } from './changesets.ts';
17
+ export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, formatChangelogList, getChangesetBump, getNextVersion, } from './changesets.ts';
18
18
  export { addChangeset, getChangesetPackages, getChangesetReleasePlan, getChangesetStatus, versionChangesets, tagChangesets, } from './changeset-release.ts';
19
19
  export { defineConfig, packageJsonVersion, composerJsonVersion, wordpressPluginVersion, wordpressThemeVersion, wordpressStableTag, gitTagVersion, phpConstantVersion, loadChangesetConfig, } from './changeset-config.ts';
20
20
  export { Svn, SvnError, createSvnRunner, parseStatus, toSvnPath, } from './svn.ts';
@@ -40,12 +40,14 @@ export type { FindMainPluginFileInput, HeaderValues, PackageMeta, ParseFileHeade
40
40
  export type { CreateZipInput } from './zip.ts';
41
41
  export type { ExtractZipInput, ZipEntryInfo, ZipEntryType } from './unzip.ts';
42
42
  export type { FindVersionMismatchesInput, GitTagInput, VersionCheckResult, VersionMismatch, } from './version-check.ts';
43
- export type { Changeset, ChangesetBump, ChangesetPackages, ChangesetConfig, ChangesetPackageType, ChangesetPackage, PackageRelease, ChangesetReleasePlan, } from './changesets.ts';
44
- export type { ChangesetRepository, ChangesetVersionInput, ChangesetTagInput, ChangesetTagResult, } from './changeset-release.ts';
45
- export type { WpReleaseConfig, ChangesetPackageGroup, PackageVersionConfig, PackageVersionSource, PackageVersionTarget, PackageIdSource, NextVersionInput, ReleaseCommit, ReleaseNotesInput, PackageJsonVersionOptions, ComposerJsonVersionOptions, WordPressVersionFileOptions, GitTagVersionOptions, PhpConstantVersionOptions, LoadChangesetConfigInput, } from './changeset-config.ts';
43
+ export type { Changeset, ChangesetBump, ChangesetPackages, ChangesetConfig, ChangesetPackageType, ChangesetPackage, PackageRelease, VersionedRelease, ChangesetReleasePlan, } from './changesets.ts';
44
+ export type { ChangesetRepository, ChangesetVersionInput, VersionChangesetsResult, ChangesetTagInput, ChangesetTagResult, } from './changeset-release.ts';
45
+ export type { WpReleaseConfig, ChangesetPackageGroup, PackageVersionConfig, PackageVersionSource, PackageVersionTarget, PackageIdSource, NextVersionInput, ReleaseCommit, ReleaseNotesInput, ChangelogEntryInput, PackageJsonVersionOptions, ComposerJsonVersionOptions, WordPressVersionFileOptions, GitTagVersionOptions, PhpConstantVersionOptions, LoadChangesetConfigInput, } from './changeset-config.ts';
46
46
  export type { SvnCheckoutInput, SvnCommandInput, SvnCommandOutput, SvnCommitInput, SvnCopyInput, SvnDepth, SvnOptions, SvnPathsInput, SvnPropsetInput, SvnRunner, SvnStatusEntry, SvnStatusType, SvnUpdateInput, } from './svn.ts';
47
47
  export type { SyncDirectoryInput, SyncResult } from './sync.ts';
48
48
  export type { SummaryContext } from './summary.ts';
49
+ export { getGitHubPullRequest } from './github.ts';
50
+ export type { GitHubPullRequest, GetGitHubPullRequestInput } from './github.ts';
49
51
  export type { CheckoutPath, DeployResult } from './svn-deploy.ts';
50
52
  export type { GitCommandInput, GitCommandOutput, GitRunner } from './git.ts';
51
53
  export type { GitDeployInput, GitDeployOptions, GitDeployResult, } from './git-deploy.ts';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ export { ZIP_MTIME, ZIP_FILE_MODE, createZip } from "./zip.js";
14
14
  export { isUrl, downloadFile } from "./download.js";
15
15
  export { readZipEntries, getZipEntryErrors, extractZip } from "./unzip.js";
16
16
  export { GIT_TAG_FORMAT, VersionMismatchError, getGitTagVersion, findConstantValues, findConstantDefinitions, findVersionMismatches, getVersionMismatchProblems, } from "./version-check.js";
17
- export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, getChangesetBump, getNextVersion, } from "./changesets.js";
17
+ export { CHANGESET_BUMPS, parseChangeset, formatChangeset, formatChangelogEntries, formatChangelogList, getChangesetBump, getNextVersion, } from "./changesets.js";
18
18
  export { addChangeset, getChangesetPackages, getChangesetReleasePlan, getChangesetStatus, versionChangesets, tagChangesets, } from "./changeset-release.js";
19
19
  export { defineConfig, packageJsonVersion, composerJsonVersion, wordpressPluginVersion, wordpressThemeVersion, wordpressStableTag, gitTagVersion, phpConstantVersion, loadChangesetConfig, } from "./changeset-config.js";
20
20
  export { Svn, SvnError, createSvnRunner, parseStatus, toSvnPath, } from "./svn.js";
@@ -23,3 +23,4 @@ export { ASSET_MIME_TYPES, SvnDeploy } from "./svn-deploy.js";
23
23
  export { GitError, createGitRunner, redactUrls } from "./git.js";
24
24
  export { GitDeploy, isValidRefName, resolveGitDeployOptions, } from "./git-deploy.js";
25
25
  export { getGitDeployInputFromArgs } from "./commands/deploy-git.js";
26
+ export { getGitHubPullRequest } from "./github.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wpelevator/wp-release",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Package, checksum, sign, verify and version-check WordPress plugins and themes, and deploy plugins to WordPress.org SVN",
5
5
  "license": "MIT",
6
6
  "repository": {