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.
- package/README.md +35 -7
- package/dist/cli.mjs +115 -15
- 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,8 +68,7 @@ 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] || "";
|
|
@@ -79,14 +79,29 @@ function renderSection(commits, title, repository) {
|
|
|
79
79
|
reference
|
|
80
80
|
].filter(Boolean).join(" ");
|
|
81
81
|
const suffix = details ? ` - ${details}` : "";
|
|
82
|
-
return
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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 = /^(
|
|
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.
|
|
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"
|