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.
- package/README.md +35 -7
- package/dist/cli.mjs +128 -17
- 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
|
-
|
|
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
|
-
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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
|
|
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
|
|
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 ? ` - ${details}` : "";
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 = /^(
|
|
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.
|
|
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
|
-
"
|
|
17
|
-
"changelog"
|
|
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
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
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"
|