keepchanges 0.0.1 → 0.0.3

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.
Files changed (3) hide show
  1. package/README.md +35 -7
  2. package/dist/cli.mjs +128 -17
  3. package/package.json +20 -16
package/README.md CHANGED
@@ -98,10 +98,24 @@ npx keepchanges 1.1.0 --release --dry
98
98
  ### 生成和发布行为
99
99
 
100
100
  仓库地址优先读取 `package.json#repository`,不存在时读取 Git 的 `origin`。
101
- 当前支持 GitHub。识别到仓库后,每条记录会包含 commit 链接,末尾会包含版本对比链接。
101
+ GitHub 地址会被自动识别。自托管 Gitea 需要在 `package.json` 中显式声明:
102
+
103
+ ```json
104
+ {
105
+ "repository": {
106
+ "type": "git",
107
+ "provider": "gitea",
108
+ "url": "http://10.102.248.21/edram/keepchanges.git"
109
+ }
110
+ }
111
+ ```
112
+
113
+ 识别到仓库后,每条记录会包含 commit 和 PR 链接,末尾会包含版本对比链接。
114
+ GitHub 和 Gitea 均支持作者解析和 Release 发布;Gitea 使用 `GITEA_TOKEN`
115
+ 解析 commit 主作者并发布 Release。
102
116
 
103
117
  默认使用 Git 提交中的作者名,并将 `Co-Authored-By` 参与者一起写入记录,bot
104
- 账号会被忽略。提供 token 后,会通过 GitHub 尝试将邮箱解析为用户名。
118
+ 账号会被忽略。提供对应平台的 token 后,会尝试将邮箱解析为用户名。
105
119
 
106
120
  `--commit` 只提交 changelog 和检测到的版本文件。其他已暂存或未暂存的改动会
107
121
  保持原状。
@@ -223,13 +237,27 @@ npx keepchanges 1.1.0 --release --dry
223
237
  ### Generation and release behavior
224
238
 
225
239
  The repository URL is read from `package.json#repository`, then from the Git
226
- `origin`. GitHub is currently supported. When a repository is detected, each
227
- entry includes a commit link and the release ends with a version comparison
228
- link.
240
+ `origin`. GitHub URLs are detected automatically. A self-hosted Gitea repository
241
+ must be declared explicitly in `package.json`:
242
+
243
+ ```json
244
+ {
245
+ "repository": {
246
+ "type": "git",
247
+ "provider": "gitea",
248
+ "url": "http://10.102.248.21/edram/keepchanges.git"
249
+ }
250
+ }
251
+ ```
252
+
253
+ When a repository is detected, entries include commit and pull request links,
254
+ and the release ends with a version comparison link. GitHub and Gitea both
255
+ support author resolution and Release publishing. Gitea uses `GITEA_TOKEN` to
256
+ resolve primary commit authors and publish Releases.
229
257
 
230
258
  Entries use Git author names by default and include `Co-Authored-By`
231
- participants. Bot accounts are omitted. With a token, the CLI asks GitHub to
232
- resolve email addresses to usernames.
259
+ participants. Bot accounts are omitted. With the corresponding provider token,
260
+ the CLI attempts to resolve email addresses to usernames.
233
261
 
234
262
  `--commit` commits only the changelog and detected version file. Other staged
235
263
  and unstaged changes remain untouched.
package/dist/cli.mjs CHANGED
@@ -9,7 +9,7 @@ import { readFile, writeFile } from "node:fs/promises";
9
9
  import { normalizeFull } from "verkit";
10
10
  import { x } from "tinyexec";
11
11
  //#region package.json
12
- var version = "0.0.1";
12
+ var version = "0.0.3";
13
13
  //#endregion
14
14
  //#region src/changelog.ts
15
15
  function createChangelog(version, commits, repository, comparisonFrom) {
@@ -17,6 +17,7 @@ function createChangelog(version, commits, repository, comparisonFrom) {
17
17
  ...renderSection(commits.filter((commit) => commit.isBreaking), "🚨 Breaking Changes", repository),
18
18
  ...renderSection(commits.filter((commit) => !commit.isBreaking && commit.type === "feat"), "🚀 Features", repository),
19
19
  ...renderSection(commits.filter((commit) => !commit.isBreaking && commit.type === "fix"), "🐞 Bug Fixes", repository),
20
+ ...renderSection(commits.filter((commit) => !commit.isBreaking && commit.type === "perf"), "🏎 Performance", repository),
20
21
  ...repository && comparisonFrom ? ["", `#####     [View changes on ${repository.provider.name}](${repository.provider.compareUrl(repository, comparisonFrom, `v${version}`)})`] : []
21
22
  ].join("\n").trim();
22
23
  return {
@@ -58,7 +59,7 @@ function insertRelease(changelog, release) {
58
59
  return `${preamble}\n\n${release.trim()}\n\n${history}\n`;
59
60
  }
60
61
  function releaseHeadings(changelog) {
61
- return [...changelog.matchAll(/^##\s+(\S+).*$/gm)].flatMap((heading) => {
62
+ return [...changelog.matchAll(/^##[^\S\r\n]+(\S+)/gm)].flatMap((heading) => {
62
63
  const version = normalizeFull(heading[1]);
63
64
  return version ? [{
64
65
  index: heading.index,
@@ -67,21 +68,40 @@ function releaseHeadings(changelog) {
67
68
  });
68
69
  }
69
70
  function renderSection(commits, title, repository) {
70
- const lines = commits.map((commit) => {
71
- const scope = commit.scope ? `**${escapeHtml(commit.scope)}**: ` : "";
71
+ const renderedCommits = commits.map((commit) => {
72
72
  const reference = repository ? `[<samp>(${commit.hash.slice(0, 5)})</samp>](${repository.provider.commitUrl(repository, commit.hash)})` : "";
73
73
  const authorNames = commit.authors.map((author) => author.login ? `@${author.login}` : `**${escapeHtml(author.name)}**`);
74
74
  const authors = authorNames.length > 1 ? `${authorNames.slice(0, -1).join(", ")} and ${authorNames.at(-1)}` : authorNames[0] || "";
75
- const details = [authors ? `by ${authors}` : "", reference].filter(Boolean).join(" ");
75
+ const pullRequests = commit.pullRequests.map((pullRequest) => repository ? `[${pullRequest}](${repository.provider.pullRequestUrl(repository, pullRequest)})` : pullRequest).join(", ");
76
+ const details = [
77
+ authors ? `by ${authors}` : "",
78
+ pullRequests ? `in ${pullRequests}` : "",
79
+ reference
80
+ ].filter(Boolean).join(" ");
76
81
  const suffix = details ? ` &nbsp;-&nbsp; ${details}` : "";
77
- return `${scope}${escapeHtml(capitalize$1(commit.description))}${suffix}`;
78
- }).reverse();
79
- if (!lines.length) return [];
82
+ return {
83
+ scope: commit.scope,
84
+ line: `${escapeHtml(capitalize$1(commit.description))}${suffix}`
85
+ };
86
+ });
87
+ if (!renderedCommits.length) return [];
88
+ const commitsByScope = /* @__PURE__ */ new Map();
89
+ for (const { scope, line } of renderedCommits) {
90
+ const lines = commitsByScope.get(scope) || [];
91
+ lines.push(line);
92
+ commitsByScope.set(scope, lines);
93
+ }
94
+ const lines = [...commitsByScope].some(([scope, lines]) => Boolean(scope) && lines.length > 1) ? [...commitsByScope.keys()].sort().flatMap((scope) => {
95
+ const scopedLines = (commitsByScope.get(scope) || []).reverse().map((line) => `${scope ? " " : ""}- ${line}`);
96
+ return scope ? [`- **${escapeHtml(scope)}**:`, ...scopedLines] : scopedLines;
97
+ }) : renderedCommits.reverse().map(({ scope, line }) => {
98
+ return `- ${scope ? `**${escapeHtml(scope)}**: ` : ""}${line}`;
99
+ });
80
100
  return [
81
101
  "",
82
102
  `### ${title}`,
83
103
  "",
84
- ...lines.map((line) => `- ${line}`)
104
+ ...lines
85
105
  ];
86
106
  }
87
107
  function capitalize$1(value) {
@@ -148,8 +168,10 @@ function parseGitLog(log) {
148
168
  function parseCommit(hash, subject, body, author) {
149
169
  const match = /^(?<type>[a-z]+)(?:\((?<scope>[^()\r\n]+)\))?(?<breaking>!)?: (?<description>.+)$/i.exec(subject);
150
170
  if (!match?.groups) return null;
171
+ const pullRequestPattern = /\([ a-z]*(#\d+)\s*\)/gi;
172
+ const pullRequests = [...new Set([...match.groups.description.matchAll(pullRequestPattern)].map((reference) => reference[1]))];
151
173
  const authors = [author];
152
- for (const coAuthor of body.matchAll(/^Co-Authored-By:\s*(.+?)\s*<([^>]+)>$/gim)) {
174
+ for (const coAuthor of body.matchAll(/^Co-Authored-By:([^<\r\n]+)<([^>\r\n]+)>[^\S\r\n]*$/gim)) {
153
175
  const email = coAuthor[2].trim();
154
176
  if (!authors.some((author) => author.email === email)) authors.push({
155
177
  name: coAuthor[1].trim(),
@@ -161,11 +183,91 @@ function parseCommit(hash, subject, body, author) {
161
183
  authors: authors.filter((author) => !/\[bot\]|dependabot|\(bot\)/i.test(author.name)),
162
184
  type: match.groups.type.toLowerCase(),
163
185
  scope: match.groups.scope || "",
164
- description: match.groups.description,
186
+ description: match.groups.description.replace(pullRequestPattern, "").trim(),
187
+ pullRequests,
165
188
  isBreaking: Boolean(match.groups.breaking) || /^BREAKING(?: |-)CHANGE:/im.test(body)
166
189
  };
167
190
  }
168
191
  //#endregion
192
+ //#region src/providers/gitea.ts
193
+ const giteaProvider = {
194
+ name: "Gitea",
195
+ parse(source) {
196
+ try {
197
+ const url = new URL(source.replace(/^git\+/, ""));
198
+ if (!/^https?:$/.test(url.protocol)) return;
199
+ const path = url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, "");
200
+ if (!path.includes("/")) return;
201
+ return {
202
+ provider: giteaProvider,
203
+ path,
204
+ webUrl: `${url.origin}/${path}`
205
+ };
206
+ } catch {}
207
+ },
208
+ token(explicit, env) {
209
+ return explicit || env.GITEA_TOKEN;
210
+ },
211
+ commitUrl(repository, hash) {
212
+ return `${repository.webUrl}/commit/${hash}`;
213
+ },
214
+ pullRequestUrl(repository, reference) {
215
+ return `${repository.webUrl}/pulls/${reference.replace(/^#/, "")}`;
216
+ },
217
+ compareUrl(repository, from, to) {
218
+ return `${repository.webUrl}/compare/${from}...${to}`;
219
+ },
220
+ async resolveAuthors(commits, repository, token, fetch) {
221
+ const headers = {
222
+ accept: "application/json",
223
+ authorization: `token ${token}`
224
+ };
225
+ const commitsByEmail = new Map(commits.flatMap((commit) => commit.authors[0] ? [[commit.authors[0].email, commit]] : []));
226
+ const loginsByEmail = /* @__PURE__ */ new Map();
227
+ await Promise.all([...commitsByEmail].map(async ([email, commit]) => {
228
+ try {
229
+ const response = await fetch(`${new URL(repository.webUrl).origin}/api/v1/repos/${repository.path}/git/commits/${commit.hash}`, { headers });
230
+ if (!response.ok) return;
231
+ const data = await response.json();
232
+ if (data.author?.login) loginsByEmail.set(email, data.author.login);
233
+ } catch {}
234
+ }));
235
+ for (const commit of commits) for (const author of commit.authors) author.login = loginsByEmail.get(author.email);
236
+ },
237
+ async publishRelease(repository, release, token, fetch) {
238
+ const headers = {
239
+ "accept": "application/json",
240
+ "authorization": `token ${token}`,
241
+ "content-type": "application/json"
242
+ };
243
+ const releasesUrl = `${new URL(repository.webUrl).origin}/api/v1/repos/${repository.path}/releases`;
244
+ const existing = await fetch(`${releasesUrl}/tags/${encodeURIComponent(release.tag)}`, { headers });
245
+ let url = releasesUrl;
246
+ let method = "POST";
247
+ let action = "created";
248
+ if (existing.ok) {
249
+ url = `${releasesUrl}/${(await existing.json()).id}`;
250
+ method = "PATCH";
251
+ action = "updated";
252
+ } else if (existing.status !== 404) throw new Error(`Gitea release lookup failed (${existing.status})`);
253
+ const response = await fetch(url, {
254
+ method,
255
+ headers,
256
+ body: JSON.stringify({
257
+ tag_name: release.tag,
258
+ name: release.name,
259
+ body: release.body,
260
+ prerelease: release.prerelease
261
+ })
262
+ });
263
+ if (!response.ok) throw new Error(`Gitea release publishing failed (${response.status})`);
264
+ return {
265
+ url: (await response.json()).html_url,
266
+ action
267
+ };
268
+ }
269
+ };
270
+ //#endregion
169
271
  //#region src/providers/github.ts
170
272
  const githubProvider = {
171
273
  name: "GitHub",
@@ -184,6 +286,9 @@ const githubProvider = {
184
286
  commitUrl(repository, hash) {
185
287
  return `${repository.webUrl}/commit/${hash}`;
186
288
  },
289
+ pullRequestUrl(repository, reference) {
290
+ return `${repository.webUrl}/pull/${reference.replace(/^#/, "")}`;
291
+ },
187
292
  compareUrl(repository, from, to) {
188
293
  return `${repository.webUrl}/compare/${from}...${to}`;
189
294
  },
@@ -214,8 +319,8 @@ const githubProvider = {
214
319
  },
215
320
  async publishRelease(repository, release, token, fetch) {
216
321
  const headers = {
217
- accept: "application/vnd.github+json",
218
- authorization: `Bearer ${token}`,
322
+ "accept": "application/vnd.github+json",
323
+ "authorization": `Bearer ${token}`,
219
324
  "content-type": "application/json"
220
325
  };
221
326
  const releasesUrl = `https://api.github.com/repos/${repository.path}/releases`;
@@ -247,10 +352,15 @@ const githubProvider = {
247
352
  };
248
353
  //#endregion
249
354
  //#region src/repository.ts
250
- const providers$1 = [githubProvider];
355
+ const providers$1 = {
356
+ gitea: giteaProvider,
357
+ github: githubProvider
358
+ };
251
359
  async function resolveRepository(cwd) {
360
+ let providerName = "";
252
361
  let source = await readFile(resolve(cwd, "package.json"), "utf8").then((contents) => {
253
362
  const repository = JSON.parse(contents).repository;
363
+ if (typeof repository === "object") providerName = repository?.provider?.toLowerCase() || "";
254
364
  return typeof repository === "string" ? repository : repository?.url || "";
255
365
  }).catch(() => "");
256
366
  if (!source) source = (await x("git", [
@@ -258,7 +368,8 @@ async function resolveRepository(cwd) {
258
368
  "get-url",
259
369
  "origin"
260
370
  ], { nodeOptions: { cwd } })).stdout.trim();
261
- for (const provider of providers$1) {
371
+ if (providerName) return providers$1[providerName]?.parse(source);
372
+ for (const provider of [githubProvider]) {
262
373
  const repository = provider.parse(source);
263
374
  if (repository) return repository;
264
375
  }
@@ -364,7 +475,7 @@ async function runChangelog(options, environment) {
364
475
  printReleasePreview();
365
476
  stdout(`${colors.yellow("Dry run. Release skipped.")}\n\n`);
366
477
  printManualReleaseUrl();
367
- } else stdout(changelog);
478
+ } else stdout(release);
368
479
  return;
369
480
  }
370
481
  if (options.release && taggedCommit) {
@@ -393,7 +504,7 @@ function capitalize(value) {
393
504
  return value.charAt(0).toUpperCase() + value.slice(1);
394
505
  }
395
506
  function resolveGitIdentity(author = defaultAuthor) {
396
- const match = /^(.+?)\s*<([^<>]+)>$/.exec(author);
507
+ const match = /^([^<>\r\n]+)<([^<>\r\n]+)>$/.exec(author);
397
508
  if (!match) throw new Error("Author must use the \"Name <email>\" format");
398
509
  return [
399
510
  "-c",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "keepchanges",
3
3
  "type": "module",
4
- "version": "0.0.1",
4
+ "version": "0.0.3",
5
5
  "description": "Generate and maintain CHANGELOG.md from Conventional Commits.",
6
6
  "author": "edram",
7
7
  "license": "MIT",
@@ -13,42 +13,46 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/edram/keepchanges/issues"
15
15
  },
16
- "bin": {
17
- "changelog": "./dist/cli.mjs"
18
- },
16
+ "keywords": [
17
+ "changelog",
18
+ "cli",
19
+ "conventional-commits",
20
+ "git"
21
+ ],
19
22
  "exports": {
20
23
  ".": "./dist/cli.mjs",
21
24
  "./cli": "./dist/cli.mjs",
22
25
  "./package.json": "./package.json"
23
26
  },
27
+ "bin": {
28
+ "changelog": "./dist/cli.mjs"
29
+ },
24
30
  "files": [
25
31
  "dist"
26
32
  ],
27
33
  "engines": {
28
34
  "node": ">=20.19.0"
29
35
  },
30
- "keywords": [
31
- "changelog",
32
- "cli",
33
- "conventional-commits",
34
- "git"
35
- ],
36
+ "dependencies": {
37
+ "ansis": "^4.3.1",
38
+ "cac": "^7.0.0",
39
+ "tinyexec": "^1.2.4",
40
+ "verkit": "^0.3.0"
41
+ },
36
42
  "devDependencies": {
43
+ "@antfu/eslint-config": "^9.2.0",
37
44
  "@types/node": "^26.1.1",
38
45
  "bumpp": "^11.1.0",
46
+ "eslint": "^10.7.0",
39
47
  "tsdown": "^0.22.5",
40
48
  "typescript": "^6.0.3",
41
49
  "vitest": "^4.1.10"
42
50
  },
43
- "dependencies": {
44
- "ansis": "^4.3.1",
45
- "cac": "^7.0.0",
46
- "tinyexec": "^1.2.4",
47
- "verkit": "^0.3.0"
48
- },
49
51
  "scripts": {
50
52
  "build": "tsdown",
51
53
  "dev": "tsdown --watch",
54
+ "lint": "eslint",
55
+ "lint:fix": "eslint --fix",
52
56
  "test": "pnpm run build && vitest run",
53
57
  "typecheck": "tsc --noEmit",
54
58
  "release": "bumpp"