@archznn/xavva 2.9.0 → 3.1.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 +188 -1
- package/package.json +1 -1
- package/src/commands/ChangelogCommand.ts +128 -0
- package/src/commands/ConfigCommand.ts +48 -0
- package/src/commands/DbCommand.ts +126 -0
- package/src/commands/DockerCommand.ts +122 -0
- package/src/commands/HelpCommand.ts +44 -0
- package/src/commands/HttpCommand.ts +134 -0
- package/src/commands/InitCommand.ts +70 -0
- package/src/commands/TestCommand.ts +63 -0
- package/src/di/container.ts +15 -0
- package/src/index.ts +14 -1
- package/src/services/DbService.ts +357 -0
- package/src/services/DockerService.ts +361 -0
- package/src/services/HttpService.ts +259 -0
- package/src/services/TestService.ts +326 -0
- package/src/types/args.ts +1 -0
- package/src/types/config.ts +38 -0
- package/src/utils/ChangelogGenerator.ts +255 -0
- package/src/utils/LoggerLevel.ts +138 -0
- package/src/utils/config.ts +38 -2
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serviço de execução de testes
|
|
3
|
+
* Suporta Maven e Gradle com modo watch
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Logger } from "../utils/ui";
|
|
7
|
+
import { spawn } from "child_process";
|
|
8
|
+
import { watch, type FSWatcher } from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { promisify } from "util";
|
|
11
|
+
|
|
12
|
+
export interface TestOptions {
|
|
13
|
+
watch?: boolean;
|
|
14
|
+
coverage?: boolean;
|
|
15
|
+
filter?: string;
|
|
16
|
+
verbose?: boolean;
|
|
17
|
+
failFast?: boolean;
|
|
18
|
+
parallel?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface TestResult {
|
|
22
|
+
success: boolean;
|
|
23
|
+
totalTests: number;
|
|
24
|
+
passed: number;
|
|
25
|
+
failed: number;
|
|
26
|
+
skipped: number;
|
|
27
|
+
duration: number;
|
|
28
|
+
failures: TestFailure[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TestFailure {
|
|
32
|
+
className: string;
|
|
33
|
+
methodName: string;
|
|
34
|
+
message: string;
|
|
35
|
+
stackTrace?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TestService {
|
|
39
|
+
private buildTool: "maven" | "gradle";
|
|
40
|
+
private watcher: FSWatcher | null = null;
|
|
41
|
+
private isRunning = false;
|
|
42
|
+
|
|
43
|
+
constructor(buildTool: "maven" | "gradle") {
|
|
44
|
+
this.buildTool = buildTool;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async runTests(options: TestOptions = {}): Promise<TestResult> {
|
|
48
|
+
if (this.isRunning) {
|
|
49
|
+
Logger.warn("Test execution already in progress...");
|
|
50
|
+
return this.createEmptyResult();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
this.isRunning = true;
|
|
54
|
+
const startTime = Date.now();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
Logger.section("Running Tests");
|
|
58
|
+
|
|
59
|
+
if (options.filter) {
|
|
60
|
+
Logger.info("Filter", options.filter);
|
|
61
|
+
}
|
|
62
|
+
if (options.coverage) {
|
|
63
|
+
Logger.info("Coverage", "enabled");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const command = this.buildCommand(options);
|
|
67
|
+
const result = await this.executeTests(command, options.verbose);
|
|
68
|
+
|
|
69
|
+
result.duration = Date.now() - startTime;
|
|
70
|
+
this.printResults(result);
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
} finally {
|
|
74
|
+
this.isRunning = false;
|
|
75
|
+
Logger.endSection();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
startWatch(options: TestOptions = {}): void {
|
|
80
|
+
if (this.watcher) {
|
|
81
|
+
Logger.warn("Test watcher already running");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
Logger.section("Test Watch Mode");
|
|
86
|
+
Logger.info("Watching", "src/test/**/*");
|
|
87
|
+
Logger.info("Press", "Ctrl+C to stop");
|
|
88
|
+
Logger.endSection();
|
|
89
|
+
|
|
90
|
+
// Run tests initially
|
|
91
|
+
this.runTests(options);
|
|
92
|
+
|
|
93
|
+
// Watch for changes
|
|
94
|
+
const testPath = path.join(process.cwd(), "src", "test");
|
|
95
|
+
const mainPath = path.join(process.cwd(), "src", "main");
|
|
96
|
+
|
|
97
|
+
this.watcher = watch(
|
|
98
|
+
[testPath, mainPath],
|
|
99
|
+
{ recursive: true },
|
|
100
|
+
(eventType, filename) => {
|
|
101
|
+
if (filename && this.isTestFile(filename)) {
|
|
102
|
+
Logger.watch(`Test file changed: ${filename}`);
|
|
103
|
+
this.debounceRun(options);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
stopWatch(): void {
|
|
110
|
+
if (this.watcher) {
|
|
111
|
+
this.watcher.close();
|
|
112
|
+
this.watcher = null;
|
|
113
|
+
Logger.info("Test watcher stopped");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private debounceRun(options: TestOptions): void {
|
|
118
|
+
// Simple debounce
|
|
119
|
+
if (this.debounceTimer) {
|
|
120
|
+
clearTimeout(this.debounceTimer);
|
|
121
|
+
}
|
|
122
|
+
this.debounceTimer = setTimeout(() => {
|
|
123
|
+
this.runTests(options);
|
|
124
|
+
}, 500);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private debounceTimer: Timer | null = null;
|
|
128
|
+
|
|
129
|
+
private buildCommand(options: TestOptions): string[] {
|
|
130
|
+
if (this.buildTool === "maven") {
|
|
131
|
+
return this.buildMavenCommand(options);
|
|
132
|
+
} else {
|
|
133
|
+
return this.buildGradleCommand(options);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private buildMavenCommand(options: TestOptions): string[] {
|
|
138
|
+
const cmd = process.platform === "win32" ? "mvn.cmd" : "mvn";
|
|
139
|
+
const args: string[] = [cmd];
|
|
140
|
+
|
|
141
|
+
if (options.coverage) {
|
|
142
|
+
args.push("jacoco:prepare-agent");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
args.push("test");
|
|
146
|
+
|
|
147
|
+
if (options.coverage) {
|
|
148
|
+
args.push("jacoco:report");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (options.filter) {
|
|
152
|
+
// Maven surefire plugin syntax
|
|
153
|
+
args.push(`-Dtest=${options.filter}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (options.failFast) {
|
|
157
|
+
args.push("-Dsurefire.failIfNoSpecifiedTests=false");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (options.parallel) {
|
|
161
|
+
args.push("-Dsurefire.parallel=methods");
|
|
162
|
+
args.push("-Dsurefire.threadCount=4");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!options.verbose) {
|
|
166
|
+
args.push("-q");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return args;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private buildGradleCommand(options: TestOptions): string[] {
|
|
173
|
+
const cmd = process.platform === "win32" ? "gradle.bat" : "gradle";
|
|
174
|
+
const args: string[] = [cmd, "test"];
|
|
175
|
+
|
|
176
|
+
if (options.coverage) {
|
|
177
|
+
args.push("jacocoTestReport");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (options.filter) {
|
|
181
|
+
args.push(`--tests`, options.filter);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (options.failFast) {
|
|
185
|
+
args.push("--fail-fast");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (options.parallel) {
|
|
189
|
+
args.push("--parallel");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!options.verbose) {
|
|
193
|
+
args.push("-q");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return args;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private executeTests(command: string[], verbose: boolean = false): Promise<TestResult> {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
const [cmd, ...args] = command;
|
|
202
|
+
const child = spawn(cmd, args, {
|
|
203
|
+
cwd: process.cwd(),
|
|
204
|
+
stdio: verbose ? "inherit" : "pipe",
|
|
205
|
+
shell: process.platform === "win32"
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
let stdout = "";
|
|
209
|
+
let stderr = "";
|
|
210
|
+
|
|
211
|
+
if (!verbose) {
|
|
212
|
+
child.stdout?.on("data", (data) => {
|
|
213
|
+
stdout += data.toString();
|
|
214
|
+
});
|
|
215
|
+
child.stderr?.on("data", (data) => {
|
|
216
|
+
stderr += data.toString();
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const spinner = verbose ? () => {} : Logger.spinner("Running tests");
|
|
221
|
+
|
|
222
|
+
child.on("close", (code) => {
|
|
223
|
+
spinner(code === 0);
|
|
224
|
+
const result = this.parseTestOutput(stdout + stderr, code === 0);
|
|
225
|
+
resolve(result);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
child.on("error", (error) => {
|
|
229
|
+
spinner(false);
|
|
230
|
+
Logger.error(`Failed to run tests: ${error.message}`);
|
|
231
|
+
resolve(this.createEmptyResult());
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private parseTestOutput(output: string, success: boolean): TestResult {
|
|
237
|
+
const result: TestResult = {
|
|
238
|
+
success,
|
|
239
|
+
totalTests: 0,
|
|
240
|
+
passed: 0,
|
|
241
|
+
failed: 0,
|
|
242
|
+
skipped: 0,
|
|
243
|
+
duration: 0,
|
|
244
|
+
failures: []
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
if (this.buildTool === "maven") {
|
|
248
|
+
// Parse Maven Surefire output
|
|
249
|
+
const testsRun = output.match(/Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)/);
|
|
250
|
+
if (testsRun) {
|
|
251
|
+
result.totalTests = parseInt(testsRun[1]);
|
|
252
|
+
result.failed = parseInt(testsRun[2]) + parseInt(testsRun[3]);
|
|
253
|
+
result.skipped = parseInt(testsRun[4]);
|
|
254
|
+
result.passed = result.totalTests - result.failed - result.skipped;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Parse failures
|
|
258
|
+
const failureMatches = output.matchAll(/\[ERROR\]\s+(\S+)\.(\S+)\s+\[(.+?)\]\s+(.+)/g);
|
|
259
|
+
for (const match of failureMatches) {
|
|
260
|
+
result.failures.push({
|
|
261
|
+
className: match[1],
|
|
262
|
+
methodName: match[2],
|
|
263
|
+
message: match[4]
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
// Parse Gradle output
|
|
268
|
+
const testsRun = output.match(/(\d+) tests completed,(\s*\d+ failed)?/);
|
|
269
|
+
if (testsRun) {
|
|
270
|
+
result.totalTests = parseInt(testsRun[1]);
|
|
271
|
+
const failed = testsRun[2] ? parseInt(testsRun[2].trim()) : 0;
|
|
272
|
+
result.failed = failed;
|
|
273
|
+
result.passed = result.totalTests - result.failed;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private printResults(result: TestResult): void {
|
|
281
|
+
Logger.divider();
|
|
282
|
+
|
|
283
|
+
if (result.success && result.failed === 0) {
|
|
284
|
+
Logger.success(`All tests passed! (${result.passed} tests)`);
|
|
285
|
+
} else if (result.failed > 0) {
|
|
286
|
+
Logger.error(`${result.failed} test(s) failed`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
Logger.info("Total", result.totalTests);
|
|
290
|
+
Logger.info("Passed", `${Logger.C.success}${result.passed}${Logger.C.reset}`);
|
|
291
|
+
Logger.info("Failed", result.failed > 0 ? `${Logger.C.error}${result.failed}${Logger.C.reset}` : "0");
|
|
292
|
+
Logger.info("Skipped", result.skipped);
|
|
293
|
+
Logger.info("Duration", `${(result.duration / 1000).toFixed(2)}s`);
|
|
294
|
+
|
|
295
|
+
if (result.failures.length > 0) {
|
|
296
|
+
Logger.divider();
|
|
297
|
+
Logger.section("Failures");
|
|
298
|
+
for (const failure of result.failures.slice(0, 5)) {
|
|
299
|
+
Logger.error(`${failure.className}.${failure.methodName}`);
|
|
300
|
+
Logger.dim(` ${failure.message}`);
|
|
301
|
+
}
|
|
302
|
+
if (result.failures.length > 5) {
|
|
303
|
+
Logger.dim(` ... and ${result.failures.length - 5} more`);
|
|
304
|
+
}
|
|
305
|
+
Logger.endSection();
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private isTestFile(filename: string): boolean {
|
|
310
|
+
return filename.endsWith("Test.java") ||
|
|
311
|
+
filename.endsWith("IT.java") ||
|
|
312
|
+
filename.endsWith("Tests.java");
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private createEmptyResult(): TestResult {
|
|
316
|
+
return {
|
|
317
|
+
success: false,
|
|
318
|
+
totalTests: 0,
|
|
319
|
+
passed: 0,
|
|
320
|
+
failed: 0,
|
|
321
|
+
skipped: 0,
|
|
322
|
+
duration: 0,
|
|
323
|
+
failures: []
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
package/src/types/args.ts
CHANGED
package/src/types/config.ts
CHANGED
|
@@ -1,3 +1,16 @@
|
|
|
1
|
+
export interface EnvironmentConfig {
|
|
2
|
+
port?: number;
|
|
3
|
+
profile?: string;
|
|
4
|
+
db?: {
|
|
5
|
+
url?: string;
|
|
6
|
+
username?: string;
|
|
7
|
+
password?: string;
|
|
8
|
+
driver?: string;
|
|
9
|
+
};
|
|
10
|
+
tomcat?: Partial<TomcatConfig>;
|
|
11
|
+
env?: Record<string, string>;
|
|
12
|
+
}
|
|
13
|
+
|
|
1
14
|
export interface TomcatConfig {
|
|
2
15
|
path: string;
|
|
3
16
|
port: number;
|
|
@@ -24,6 +37,8 @@ export interface ProjectConfig {
|
|
|
24
37
|
encoding?: string;
|
|
25
38
|
war?: boolean;
|
|
26
39
|
cache?: boolean;
|
|
40
|
+
environment?: string;
|
|
41
|
+
environments?: Record<string, EnvironmentConfig>;
|
|
27
42
|
}
|
|
28
43
|
|
|
29
44
|
export interface AppConfig {
|
|
@@ -68,6 +83,29 @@ export interface CLIArguments {
|
|
|
68
83
|
"dry-run"?: boolean;
|
|
69
84
|
force?: boolean;
|
|
70
85
|
src?: string;
|
|
86
|
+
// Multi-environment
|
|
87
|
+
env?: string;
|
|
88
|
+
environment?: string;
|
|
89
|
+
// Test runner
|
|
90
|
+
coverage?: boolean;
|
|
91
|
+
"fail-fast"?: boolean;
|
|
92
|
+
parallel?: boolean;
|
|
93
|
+
// HTTP client
|
|
94
|
+
interactive?: boolean;
|
|
95
|
+
"base-url"?: string;
|
|
96
|
+
body?: string;
|
|
97
|
+
file?: string;
|
|
98
|
+
header?: string | string[];
|
|
99
|
+
"content-type"?: string;
|
|
100
|
+
accept?: string;
|
|
101
|
+
param?: string | string[];
|
|
102
|
+
timeout?: string;
|
|
103
|
+
// Docker
|
|
104
|
+
tag?: string;
|
|
105
|
+
"java-version"?: string;
|
|
106
|
+
detached?: boolean;
|
|
107
|
+
registry?: string;
|
|
108
|
+
namespace?: string;
|
|
71
109
|
}
|
|
72
110
|
|
|
73
111
|
export interface CommandContext {
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { writeFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
interface Commit {
|
|
6
|
+
hash: string;
|
|
7
|
+
date: string;
|
|
8
|
+
message: string;
|
|
9
|
+
type: string;
|
|
10
|
+
scope?: string;
|
|
11
|
+
subject: string;
|
|
12
|
+
breaking: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Version {
|
|
16
|
+
version: string;
|
|
17
|
+
date: string;
|
|
18
|
+
commits: Commit[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class ChangelogGenerator {
|
|
22
|
+
private static readonly TYPES: Record<string, { title: string; emoji: string }> = {
|
|
23
|
+
feat: { title: "Features", emoji: "✨" },
|
|
24
|
+
fix: { title: "Bug Fixes", emoji: "🐛" },
|
|
25
|
+
docs: { title: "Documentation", emoji: "📚" },
|
|
26
|
+
style: { title: "Styles", emoji: "💎" },
|
|
27
|
+
refactor: { title: "Code Refactoring", emoji: "♻️" },
|
|
28
|
+
perf: { title: "Performance", emoji: "⚡" },
|
|
29
|
+
test: { title: "Tests", emoji: "🧪" },
|
|
30
|
+
build: { title: "Build System", emoji: "🏗️" },
|
|
31
|
+
ci: { title: "CI/CD", emoji: "🔄" },
|
|
32
|
+
chore: { title: "Chores", emoji: "🔧" },
|
|
33
|
+
revert: { title: "Reverts", emoji: "⏪" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
static generate(): string {
|
|
37
|
+
const commits = this.getCommits();
|
|
38
|
+
const versions = this.groupByVersion(commits);
|
|
39
|
+
return this.formatChangelog(versions);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
static generateAndSave(outputPath: string = "CHANGELOG.md"): void {
|
|
43
|
+
const changelog = this.generate();
|
|
44
|
+
writeFileSync(outputPath, changelog);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
private static getCommits(): Commit[] {
|
|
48
|
+
try {
|
|
49
|
+
// Get commits in format: hash|date|message
|
|
50
|
+
const log = execSync(
|
|
51
|
+
'git log --pretty=format:"%h|%ad|%s" --date=short --no-merges',
|
|
52
|
+
{ encoding: "utf-8", cwd: process.cwd() }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
return log
|
|
56
|
+
.trim()
|
|
57
|
+
.split("\n")
|
|
58
|
+
.map(line => this.parseCommit(line))
|
|
59
|
+
.filter((c): c is Commit => c !== null);
|
|
60
|
+
} catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private static parseCommit(line: string): Commit | null {
|
|
66
|
+
const match = line.match(/^([^|]+)\|([^|]+)\|(.+)$/);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
|
|
69
|
+
const [, hash, date, message] = match;
|
|
70
|
+
const parsed = this.parseConventionalCommit(message);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
hash,
|
|
74
|
+
date,
|
|
75
|
+
message,
|
|
76
|
+
...parsed,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private static parseConventionalCommit(message: string): Omit<Commit, "hash" | "date" | "message"> {
|
|
81
|
+
// Pattern: type(scope)!: subject
|
|
82
|
+
// or: type!: subject
|
|
83
|
+
// or: type(scope): subject
|
|
84
|
+
// or: type: subject
|
|
85
|
+
const pattern = /^(\w+)(?:\(([^)]+)\))?(!)?: (.+)$/;
|
|
86
|
+
const match = message.match(pattern);
|
|
87
|
+
|
|
88
|
+
if (match) {
|
|
89
|
+
const [, type, scope, breaking, subject] = match;
|
|
90
|
+
return {
|
|
91
|
+
type,
|
|
92
|
+
scope,
|
|
93
|
+
subject,
|
|
94
|
+
breaking: !!breaking || subject.includes("BREAKING CHANGE"),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Fallback: treat as chore if doesn't match conventional commit
|
|
99
|
+
return {
|
|
100
|
+
type: "chore",
|
|
101
|
+
subject: message,
|
|
102
|
+
breaking: message.includes("BREAKING CHANGE"),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private static groupByVersion(commits: Commit[]): Version[] {
|
|
107
|
+
// Group by version tags
|
|
108
|
+
const versions: Version[] = [];
|
|
109
|
+
let currentVersion = "Unreleased";
|
|
110
|
+
let currentDate = new Date().toISOString().split("T")[0];
|
|
111
|
+
let currentCommits: Commit[] = [];
|
|
112
|
+
|
|
113
|
+
// Try to get version tags
|
|
114
|
+
const tags = this.getVersionTags();
|
|
115
|
+
|
|
116
|
+
if (tags.length === 0) {
|
|
117
|
+
// No tags, all commits are unreleased
|
|
118
|
+
return [{
|
|
119
|
+
version: "Unreleased",
|
|
120
|
+
date: currentDate,
|
|
121
|
+
commits,
|
|
122
|
+
}];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Process commits and assign to versions
|
|
126
|
+
const versionMap = new Map<string, Commit[]>();
|
|
127
|
+
|
|
128
|
+
for (const commit of commits) {
|
|
129
|
+
// Find which version this commit belongs to
|
|
130
|
+
const version = this.findVersionForCommit(commit.hash, tags);
|
|
131
|
+
if (!versionMap.has(version)) {
|
|
132
|
+
versionMap.set(version, []);
|
|
133
|
+
}
|
|
134
|
+
versionMap.get(version)!.push(commit);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Convert to array
|
|
138
|
+
for (const [version, versionCommits] of versionMap) {
|
|
139
|
+
const tagDate = this.getTagDate(version === "Unreleased" ? null : version);
|
|
140
|
+
versions.push({
|
|
141
|
+
version,
|
|
142
|
+
date: tagDate || currentDate,
|
|
143
|
+
commits: versionCommits,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Sort by version (newest first)
|
|
148
|
+
return versions.sort((a, b) => this.compareVersions(b.version, a.version));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private static getVersionTags(): string[] {
|
|
152
|
+
try {
|
|
153
|
+
const tags = execSync("git tag -l 'v*' --sort=-v:refname", { encoding: "utf-8" });
|
|
154
|
+
return tags.trim().split("\n").filter(Boolean);
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private static findVersionForCommit(hash: string, tags: string[]): string {
|
|
161
|
+
try {
|
|
162
|
+
// Check if commit is after a specific tag
|
|
163
|
+
for (const tag of tags) {
|
|
164
|
+
const result = execSync(`git merge-base --is-ancestor ${hash} ${tag} && echo "in" || echo "out"`, {
|
|
165
|
+
encoding: "utf-8",
|
|
166
|
+
cwd: process.cwd(),
|
|
167
|
+
});
|
|
168
|
+
if (result.trim() === "in") {
|
|
169
|
+
return tag;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Ignore errors
|
|
174
|
+
}
|
|
175
|
+
return "Unreleased";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private static getTagDate(tag: string | null): string | null {
|
|
179
|
+
if (!tag) return null;
|
|
180
|
+
try {
|
|
181
|
+
const date = execSync(`git log -1 --format=%ad --date=short ${tag}`, { encoding: "utf-8" });
|
|
182
|
+
return date.trim();
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private static compareVersions(a: string, b: string): number {
|
|
189
|
+
if (a === "Unreleased") return -1;
|
|
190
|
+
if (b === "Unreleased") return 1;
|
|
191
|
+
|
|
192
|
+
const parse = (v: string) => v.replace(/^v/, "").split(".").map(Number);
|
|
193
|
+
const aParts = parse(a);
|
|
194
|
+
const bParts = parse(b);
|
|
195
|
+
|
|
196
|
+
for (let i = 0; i < Math.max(aParts.length, bParts.length); i++) {
|
|
197
|
+
const aPart = aParts[i] || 0;
|
|
198
|
+
const bPart = bParts[i] || 0;
|
|
199
|
+
if (aPart !== bPart) return aPart - bPart;
|
|
200
|
+
}
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private static formatChangelog(versions: Version[]): string {
|
|
205
|
+
const lines: string[] = [
|
|
206
|
+
"# Changelog\n",
|
|
207
|
+
"All notable changes to this project will be documented in this file.\n",
|
|
208
|
+
"The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),",
|
|
209
|
+
"and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n",
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
for (const version of versions) {
|
|
213
|
+
lines.push(this.formatVersion(version));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return lines.join("\n");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private static formatVersion(version: Version): string {
|
|
220
|
+
const lines: string[] = [
|
|
221
|
+
`## [${version.version}] - ${version.date}`,
|
|
222
|
+
"",
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
// Group commits by type
|
|
226
|
+
const byType = new Map<string, Commit[]>();
|
|
227
|
+
for (const commit of version.commits) {
|
|
228
|
+
if (!byType.has(commit.type)) {
|
|
229
|
+
byType.set(commit.type, []);
|
|
230
|
+
}
|
|
231
|
+
byType.get(commit.type)!.push(commit);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Output in conventional order
|
|
235
|
+
const typeOrder = Object.keys(this.TYPES);
|
|
236
|
+
|
|
237
|
+
for (const type of typeOrder) {
|
|
238
|
+
const commits = byType.get(type);
|
|
239
|
+
if (!commits || commits.length === 0) continue;
|
|
240
|
+
|
|
241
|
+
const { title, emoji } = this.TYPES[type];
|
|
242
|
+
lines.push(`### ${emoji} ${title}\n`);
|
|
243
|
+
|
|
244
|
+
for (const commit of commits) {
|
|
245
|
+
const scope = commit.scope ? `**${commit.scope}**: ` : "";
|
|
246
|
+
const breaking = commit.breaking ? " 💥 **BREAKING CHANGE**" : "";
|
|
247
|
+
lines.push(`- ${scope}${commit.subject} ([${commit.hash}])${breaking}`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
lines.push("");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return lines.join("\n");
|
|
254
|
+
}
|
|
255
|
+
}
|