keepchanges 0.0.2 → 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 +115 -15
  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.2";
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,8 +68,7 @@ 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] || "";
@@ -79,14 +79,29 @@ function renderSection(commits, title, repository) {
79
79
  reference
80
80
  ].filter(Boolean).join(" ");
81
81
  const suffix = details ? ` &nbsp;-&nbsp; ${details}` : "";
82
- return `${scope}${escapeHtml(capitalize$1(commit.description))}${suffix}`;
83
- }).reverse();
84
- 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
+ });
85
100
  return [
86
101
  "",
87
102
  `### ${title}`,
88
103
  "",
89
- ...lines.map((line) => `- ${line}`)
104
+ ...lines
90
105
  ];
91
106
  }
92
107
  function capitalize$1(value) {
@@ -156,7 +171,7 @@ function parseCommit(hash, subject, body, author) {
156
171
  const pullRequestPattern = /\([ a-z]*(#\d+)\s*\)/gi;
157
172
  const pullRequests = [...new Set([...match.groups.description.matchAll(pullRequestPattern)].map((reference) => reference[1]))];
158
173
  const authors = [author];
159
- 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)) {
160
175
  const email = coAuthor[2].trim();
161
176
  if (!authors.some((author) => author.email === email)) authors.push({
162
177
  name: coAuthor[1].trim(),
@@ -174,6 +189,85 @@ function parseCommit(hash, subject, body, author) {
174
189
  };
175
190
  }
176
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
177
271
  //#region src/providers/github.ts
178
272
  const githubProvider = {
179
273
  name: "GitHub",
@@ -225,8 +319,8 @@ const githubProvider = {
225
319
  },
226
320
  async publishRelease(repository, release, token, fetch) {
227
321
  const headers = {
228
- accept: "application/vnd.github+json",
229
- authorization: `Bearer ${token}`,
322
+ "accept": "application/vnd.github+json",
323
+ "authorization": `Bearer ${token}`,
230
324
  "content-type": "application/json"
231
325
  };
232
326
  const releasesUrl = `https://api.github.com/repos/${repository.path}/releases`;
@@ -258,10 +352,15 @@ const githubProvider = {
258
352
  };
259
353
  //#endregion
260
354
  //#region src/repository.ts
261
- const providers$1 = [githubProvider];
355
+ const providers$1 = {
356
+ gitea: giteaProvider,
357
+ github: githubProvider
358
+ };
262
359
  async function resolveRepository(cwd) {
360
+ let providerName = "";
263
361
  let source = await readFile(resolve(cwd, "package.json"), "utf8").then((contents) => {
264
362
  const repository = JSON.parse(contents).repository;
363
+ if (typeof repository === "object") providerName = repository?.provider?.toLowerCase() || "";
265
364
  return typeof repository === "string" ? repository : repository?.url || "";
266
365
  }).catch(() => "");
267
366
  if (!source) source = (await x("git", [
@@ -269,7 +368,8 @@ async function resolveRepository(cwd) {
269
368
  "get-url",
270
369
  "origin"
271
370
  ], { nodeOptions: { cwd } })).stdout.trim();
272
- for (const provider of providers$1) {
371
+ if (providerName) return providers$1[providerName]?.parse(source);
372
+ for (const provider of [githubProvider]) {
273
373
  const repository = provider.parse(source);
274
374
  if (repository) return repository;
275
375
  }
@@ -375,7 +475,7 @@ async function runChangelog(options, environment) {
375
475
  printReleasePreview();
376
476
  stdout(`${colors.yellow("Dry run. Release skipped.")}\n\n`);
377
477
  printManualReleaseUrl();
378
- } else stdout(changelog);
478
+ } else stdout(release);
379
479
  return;
380
480
  }
381
481
  if (options.release && taggedCommit) {
@@ -404,7 +504,7 @@ function capitalize(value) {
404
504
  return value.charAt(0).toUpperCase() + value.slice(1);
405
505
  }
406
506
  function resolveGitIdentity(author = defaultAuthor) {
407
- const match = /^(.+?)\s*<([^<>]+)>$/.exec(author);
507
+ const match = /^([^<>\r\n]+)<([^<>\r\n]+)>$/.exec(author);
408
508
  if (!match) throw new Error("Author must use the \"Name <email>\" format");
409
509
  return [
410
510
  "-c",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "keepchanges",
3
3
  "type": "module",
4
- "version": "0.0.2",
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"