pi-project-gate 1.0.0 → 1.2.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.
@@ -0,0 +1,328 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "../config";
4
+ import { resolveGitea, giteaApi } from "../helpers";
5
+ import { validateIssueTemplate } from "../validate";
6
+
7
+ // ─── Create Issue ───────────────────────────────────────────────────────────────
8
+
9
+ export const createTool = {
10
+ name: "project_create_issue" as const,
11
+ label: "Create Issue",
12
+ description:
13
+ "Create a new issue with structured body. Validates required template sections before creation.",
14
+ parameters: Type.Object({
15
+ title: Type.String({ description: "Issue title" }),
16
+ body: Type.String({ description: "Issue body in markdown (must include required sections)" }),
17
+ labels: Type.Optional(Type.Array(Type.String({}), { description: "Labels to apply" })),
18
+ milestone: Type.Optional(Type.String({ description: "Milestone title or ID" })),
19
+ assignee: Type.Optional(Type.String({ description: "Username to assign" })),
20
+ }),
21
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
22
+ const config = loadConfig(ctx.cwd);
23
+ const opts = resolveGitea(ctx.cwd);
24
+
25
+ // Validate template
26
+ const tpl = validateIssueTemplate(params.body, config);
27
+ if (!tpl.ok) {
28
+ return {
29
+ content: [
30
+ {
31
+ type: "text",
32
+ text: `❌ Issue body is missing required sections:\n - ${tpl.missingSections.join("\n - ")}`,
33
+ },
34
+ ],
35
+ isError: true,
36
+ details: { missingSections: tpl.missingSections },
37
+ };
38
+ }
39
+
40
+ // Detect complexity from body
41
+ const complexity = config.complexityLevels.find((l) =>
42
+ params.body.toLowerCase().includes(l.toLowerCase()),
43
+ );
44
+
45
+ // Build payload
46
+ const payload: Record<string, unknown> = {
47
+ title: params.title,
48
+ body: params.body,
49
+ };
50
+ if (params.labels && params.labels.length > 0) payload.labels = params.labels;
51
+ if (params.milestone) payload.milestone = params.milestone;
52
+ if (params.assignee) payload.assignee = params.assignee;
53
+
54
+ const r = giteaApi("/issues", "POST", payload, opts, ctx.cwd);
55
+ if (!r.ok || !r.data) {
56
+ return {
57
+ content: [{ type: "text", text: `❌ Failed to create issue: ${r.error || "unknown error"}` }],
58
+ isError: true,
59
+ details: {},
60
+ };
61
+ }
62
+
63
+ const issue = r.data as Record<string, unknown>;
64
+ const lines = [
65
+ `✅ Issue #${issue.number} created: "${issue.title}"`,
66
+ ` URL: ${issue.html_url || `http://127.0.0.1:3001/${opts.repo}/issues/${issue.number}`}`,
67
+ ];
68
+ if (complexity) lines.push(` Complexity: ${complexity}`);
69
+ if (params.labels?.length) lines.push(` Labels: ${params.labels.join(", ")}`);
70
+ if (params.milestone) lines.push(` Milestone: ${params.milestone}`);
71
+ if (params.assignee) lines.push(` Assignee: ${params.assignee}`);
72
+
73
+ return {
74
+ content: [{ type: "text", text: lines.join("\n") }],
75
+ details: { issueId: issue.number, url: issue.html_url },
76
+ };
77
+ },
78
+ };
79
+
80
+ // ─── Update Issue ───────────────────────────────────────────────────────────────
81
+
82
+ export const updateTool = {
83
+ name: "project_update_issue" as const,
84
+ label: "Update Issue",
85
+ description:
86
+ "Update an existing issue — title, body, state (open/closed), labels, milestone, or assignee.",
87
+ parameters: Type.Object({
88
+ issue_id: Type.String({ description: "Issue number or ID" }),
89
+ title: Type.Optional(Type.String({ description: "New title" })),
90
+ body: Type.Optional(Type.String({ description: "New body in markdown" })),
91
+ state: Type.Optional(
92
+ Type.String({ description: 'State: "open" or "closed"' }),
93
+ ),
94
+ labels: Type.Optional(Type.Array(Type.String({}), { description: "Replacement labels" })),
95
+ milestone: Type.Optional(Type.String({ description: "Milestone title or ID, or null to remove" })),
96
+ assignee: Type.Optional(Type.String({ description: "Username to assign, or empty string to unassign" })),
97
+ }),
98
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
99
+ const config = loadConfig(ctx.cwd);
100
+ const opts = resolveGitea(ctx.cwd);
101
+ const issueId = String(params.issue_id).replace(/^#/, "");
102
+
103
+ // Fetch current issue to verify it exists
104
+ const current = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
105
+ if (!current.ok || !current.data) {
106
+ return {
107
+ content: [{ type: "text", text: `❌ Issue #${issueId} not found.` }],
108
+ isError: true,
109
+ details: {},
110
+ };
111
+ }
112
+
113
+ // Validate body template if body is being updated
114
+ if (params.body) {
115
+ const tpl = validateIssueTemplate(params.body, config);
116
+ if (!tpl.ok) {
117
+ return {
118
+ content: [
119
+ {
120
+ type: "text",
121
+ text: `❌ Updated body is missing required sections:\n - ${tpl.missingSections.join("\n - ")}`,
122
+ },
123
+ ],
124
+ isError: true,
125
+ details: { missingSections: tpl.missingSections },
126
+ };
127
+ }
128
+ }
129
+
130
+ // Build patch payload — only include fields that were provided
131
+ const payload: Record<string, unknown> = {};
132
+ if (params.title !== undefined) payload.title = params.title;
133
+ if (params.body !== undefined) payload.body = params.body;
134
+ if (params.state !== undefined) payload.state = params.state;
135
+ if (params.labels !== undefined) payload.labels = params.labels;
136
+ if (params.milestone !== undefined) payload.milestone = params.milestone;
137
+ if (params.assignee !== undefined) payload.assignee = params.assignee;
138
+
139
+ if (Object.keys(payload).length === 0) {
140
+ return {
141
+ content: [{ type: "text", text: "⚠️ No fields to update." }],
142
+ isError: true,
143
+ details: {},
144
+ };
145
+ }
146
+
147
+ const r = giteaApi(`/issues/${issueId}`, "PATCH", payload, opts, ctx.cwd);
148
+ if (!r.ok || !r.data) {
149
+ return {
150
+ content: [{ type: "text", text: `❌ Failed to update issue: ${r.error || "unknown error"}` }],
151
+ isError: true,
152
+ details: {},
153
+ };
154
+ }
155
+
156
+ const issue = r.data as Record<string, unknown>;
157
+ const changes: string[] = [];
158
+ if (params.title !== undefined) changes.push("title");
159
+ if (params.body !== undefined) changes.push("body");
160
+ if (params.state !== undefined) changes.push(`state → ${params.state}`);
161
+ if (params.labels !== undefined) changes.push("labels");
162
+ if (params.milestone !== undefined) changes.push("milestone");
163
+ if (params.assignee !== undefined) changes.push("assignee");
164
+
165
+ return {
166
+ content: [
167
+ {
168
+ type: "text",
169
+ text: `✅ Issue #${issueId} updated: "${issue.title}"\n Changed: ${changes.join(", ")}`,
170
+ },
171
+ ],
172
+ details: { issueId, changed: changes },
173
+ };
174
+ },
175
+ };
176
+
177
+ // ─── List Issues ────────────────────────────────────────────────────────────────
178
+
179
+ export const listTool = {
180
+ name: "project_list_issues" as const,
181
+ label: "List Issues",
182
+ description:
183
+ "Search and list issues with optional filters: state, labels, milestone, assignee, keyword, and pagination.",
184
+ parameters: Type.Object({
185
+ state: Type.Optional(Type.String({ description: 'Filter by state: "open" or "closed" (default: open)' })),
186
+ labels: Type.Optional(Type.String({ description: "Comma-separated label names" })),
187
+ milestone: Type.Optional(Type.String({ description: "Filter by milestone title" })),
188
+ assignee: Type.Optional(Type.String({ description: "Filter by assignee username" })),
189
+ q: Type.Optional(Type.String({ description: "Full-text search query (searches title + body)" })),
190
+ limit: Type.Optional(Type.Number({ description: "Max issues to return (default: 20, max: 100)" })),
191
+ page: Type.Optional(Type.Number({ description: "Page number (default: 1)" })),
192
+ }),
193
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
194
+ const opts = resolveGitea(ctx.cwd);
195
+ const queryParts: string[] = [];
196
+ queryParts.push(`state=${params.state || "open"}`);
197
+ queryParts.push(`limit=${Math.min(params.limit || 20, 100)}`);
198
+ queryParts.push(`page=${params.page || 1}`);
199
+ if (params.labels) queryParts.push(`labels=${encodeURIComponent(params.labels)}`);
200
+ if (params.milestone) queryParts.push(`milestone=${encodeURIComponent(params.milestone)}`);
201
+ if (params.assignee) queryParts.push(`assignee=${encodeURIComponent(params.assignee)}`);
202
+ if (params.q) queryParts.push(`q=${encodeURIComponent(params.q)}`);
203
+
204
+ const r = giteaApi(`/issues?${queryParts.join("&")}`, "GET", null, opts, ctx.cwd);
205
+ if (!r.ok) {
206
+ return {
207
+ content: [{ type: "text", text: `❌ Failed to list issues: ${r.error || "unknown error"}` }],
208
+ isError: true,
209
+ details: {},
210
+ };
211
+ }
212
+
213
+ const issues = Array.isArray(r.data) ? r.data : [];
214
+ if (issues.length === 0) {
215
+ return {
216
+ content: [{ type: "text", text: "No issues found." }],
217
+ details: { count: 0 },
218
+ };
219
+ }
220
+
221
+ const lines = [`📋 Issues (${issues.length} found)`];
222
+ if (params.q) lines.push(` Search: "${params.q}"`);
223
+ lines.push("");
224
+
225
+ for (const issue of issues as any[]) {
226
+ const labels =
227
+ issue.labels && issue.labels.length > 0
228
+ ? ` [${issue.labels.map((l: any) => l.name).join(", ")}]`
229
+ : "";
230
+ const assignee = issue.assignee ? ` (👤 ${issue.assignee.login})` : "";
231
+ lines.push(
232
+ ` #${issue.number} ${issue.state === "closed" ? "🔒" : "🟢"} ${issue.title}${labels}${assignee}`,
233
+ );
234
+ }
235
+
236
+ return {
237
+ content: [{ type: "text", text: lines.join("\n") }],
238
+ details: { count: issues.length, issues: issues.map((i: any) => i.number) },
239
+ };
240
+ },
241
+ };
242
+
243
+ // ─── Get Issue ───────────────────────────────────────────────────────────────────
244
+
245
+ export const getTool = {
246
+ name: "project_get_issue" as const,
247
+ label: "Get Issue",
248
+ description:
249
+ "Get full details of an issue including its body, labels, milestone, assignee, and recent comments.",
250
+ parameters: Type.Object({
251
+ issue_id: Type.String({ description: "Issue number or ID" }),
252
+ include_comments: Type.Optional(
253
+ Type.Boolean({ description: "Include recent comments (default: true)" }),
254
+ ),
255
+ }),
256
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
257
+ const opts = resolveGitea(ctx.cwd);
258
+ const issueId = String(params.issue_id).replace(/^#/, "");
259
+
260
+ const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
261
+ if (!r.ok || !r.data) {
262
+ return {
263
+ content: [{ type: "text", text: `❌ Issue #${issueId} not found.` }],
264
+ isError: true,
265
+ details: {},
266
+ };
267
+ }
268
+
269
+ const issue = r.data as Record<string, unknown>;
270
+ const labels = Array.isArray(issue.labels)
271
+ ? (issue.labels as any[]).map((l) => l.name).join(", ")
272
+ : "(none)";
273
+
274
+ const lines = [
275
+ `📋 Issue #${issueId}`,
276
+ ` Title: ${issue.title}`,
277
+ ` State: ${issue.state} | Created: ${String(issue.created_at).slice(0, 10)}`,
278
+ ` Author: ${(issue.user as any)?.login || "?"}`,
279
+ ` Assignee: ${(issue.assignee as any)?.login || "(unassigned)"}`,
280
+ ` Milestone: ${(issue.milestone as any)?.title || "(none)"}`,
281
+ ` Labels: ${labels}`,
282
+ ` URL: ${issue.html_url || `http://127.0.0.1:3001/${opts.repo}/issues/${issueId}`}`,
283
+ "",
284
+ "─── Body ───",
285
+ issue.body || "(empty)",
286
+ "",
287
+ ];
288
+
289
+ // Fetch comments
290
+ const includeComments = params.include_comments !== false;
291
+ if (includeComments) {
292
+ const cr = giteaApi(
293
+ `/issues/${issueId}/comments?limit=20`,
294
+ "GET",
295
+ null,
296
+ opts,
297
+ ctx.cwd,
298
+ );
299
+ const comments = Array.isArray(cr.data) ? cr.data : [];
300
+ if (comments.length > 0) {
301
+ lines.push(`─── Comments (${comments.length}) ───`);
302
+ for (const c of comments as any[]) {
303
+ const date = String(c.created_at).slice(0, 10);
304
+ const user = c.user?.login || "?";
305
+ const body = (c.body || "").split("\n").slice(0, 5).join("\n");
306
+ lines.push(`\n [${date}] ${user}:`);
307
+ for (const bl of body.split("\n")) {
308
+ lines.push(` ${bl}`);
309
+ }
310
+ if ((c.body || "").split("\n").length > 5) lines.push(" ...");
311
+ }
312
+ } else {
313
+ lines.push("─── Comments ───");
314
+ lines.push(" (none)");
315
+ }
316
+ }
317
+
318
+ return {
319
+ content: [{ type: "text", text: lines.join("\n") }],
320
+ details: {
321
+ issueId,
322
+ state: issue.state,
323
+ title: issue.title,
324
+ url: issue.html_url,
325
+ },
326
+ };
327
+ },
328
+ };
@@ -0,0 +1,113 @@
1
+ import { Type } from "typebox";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "../config";
4
+ import { exec, currentBranch, resolveGitea, giteaApi } from "../helpers";
5
+ import { validateIssueTemplate, parseDependencies, parseConventionalCommits } from "../validate";
6
+
7
+ const LABELS: Record<string, string> = { feat: "🚀 Features", fix: "🐛 Bug Fixes", perf: "⚡ Performance", refactor: "♻️ Refactoring", chore: "🔧 Chores", docs: "📝 Documentation", test: "✅ Tests", ci: "👷 CI/CD", build: "📦 Build", other: "📌 Other" };
8
+
9
+ export const checkTool = {
10
+ name: "project_check" as const, label: "Check Issue Readiness",
11
+ description: "Validate that an issue is ready to be worked on.",
12
+ parameters: Type.Object({ issue_id: Type.String({}) }),
13
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
14
+ const config = loadConfig(ctx.cwd); const opts = resolveGitea(ctx.cwd);
15
+ const issueId = params.issue_id.replace(/^#/, "");
16
+ const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
17
+ if (!r.ok || !r.data) return { content: [{ type: "text", text: `Issue #${issueId} not found.` }], isError: true, details: {} };
18
+ const issue = r.data as Record<string, unknown>;
19
+ const lines: string[] = []; const issues: string[] = [];
20
+ lines.push(`📋 Issue #${issueId}: ${issue.title}`);
21
+ const body = (issue.body as string) || "";
22
+ const tpl = validateIssueTemplate(body, config);
23
+ if (!tpl.ok) issues.push(`❌ Missing sections: ${tpl.missingSections.join(", ")}`);
24
+ else lines.push(" Template: ✅");
25
+ const complexity = config.complexityLevels.find(l => body.toLowerCase().includes(l.toLowerCase()));
26
+ lines.push(` Complexity: ${complexity || "?"}`);
27
+ const deps = parseDependencies(body, config);
28
+ if (deps.length > 0) {
29
+ const blocked: string[] = [];
30
+ for (const dep of deps) { const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd); if (dr.ok && (dr.data as any)?.state === "open") blocked.push(dep); }
31
+ if (blocked.length > 0) issues.push(`🔒 Blocked by: #${blocked.join(", #")}`);
32
+ else lines.push(" Dependencies: ✅");
33
+ }
34
+ if (issue.assignee) issues.push(`⚠️ Assigned to ${(issue.assignee as any)?.login}`);
35
+ if (issues.length > 0) { lines.push(""); for (const i of issues) lines.push(` ${i}`); }
36
+ const blocked = issues.some(i => i.startsWith("❌") || i.startsWith("🔒"));
37
+ lines.push("", blocked ? "❌ Not ready." : issues.length === 0 ? "✅ Ready!" : "⚠️ Warnings but can start.");
38
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { ready: !blocked } };
39
+ },
40
+ };
41
+
42
+ export const startTool = {
43
+ name: "project_start" as const, label: "Start Work",
44
+ description: "Mark an issue as in-progress. Checks WIP limits, dependencies, template.",
45
+ parameters: Type.Object({ issue_id: Type.String({}) }),
46
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
47
+ const config = loadConfig(ctx.cwd); const opts = resolveGitea(ctx.cwd);
48
+ const issueId = params.issue_id.replace(/^#/, "");
49
+ const r = giteaApi(`/issues/${issueId}`, "GET", null, opts, ctx.cwd);
50
+ if (!r.ok || !r.data) return { content: [{ type: "text", text: `Issue #${issueId} not found.` }], isError: true, details: {} };
51
+ const issue = r.data as Record<string, unknown>;
52
+ const body = (issue.body as string) || "";
53
+ const tpl = validateIssueTemplate(body, config);
54
+ if (!tpl.ok) return { content: [{ type: "text", text: `❌ Missing sections: ${tpl.missingSections.join(", ")}` }], isError: true, details: {} };
55
+ const deps = parseDependencies(body, config);
56
+ if (deps.length > 0) {
57
+ const blocked: string[] = [];
58
+ for (const dep of deps) { const dr = giteaApi(`/issues/${dep}`, "GET", null, opts, ctx.cwd); if (dr.ok && (dr.data as any)?.state === "open") blocked.push(dep); }
59
+ if (blocked.length > 0) return { content: [{ type: "text", text: `🔒 Blocked: #${blocked.join(", #")}` }], isError: true, details: {} };
60
+ }
61
+ const wipR = giteaApi("/pulls?state=open&limit=100", "GET", null, opts, ctx.cwd);
62
+ const prs = Array.isArray(wipR.data) ? wipR.data : [];
63
+ const author = (issue.user as any)?.login || "factory";
64
+ const currentWip = prs.filter((p: any) => p.user?.login === author).length;
65
+ if (currentWip >= config.maxWip) return { content: [{ type: "text", text: `⚠️ WIP limit reached (${currentWip}/${config.maxWip}).` }], isError: true, details: {} };
66
+ (globalThis as any).__project_issueId = issueId;
67
+ return { content: [{ type: "text", text: `✅ Work started on #${issueId}: "${issue.title}" (WIP ${currentWip + 1}/${config.maxWip})` }], details: { issueId } };
68
+ },
69
+ };
70
+
71
+ export const statusTool = {
72
+ name: "project_status" as const, label: "Project Status",
73
+ description: "Show project board — active issues, WIP, blockers.",
74
+ parameters: Type.Object({}),
75
+ async execute(_id: string, _p: any, _s: any, _u: any, ctx: ExtensionContext) {
76
+ const config = loadConfig(ctx.cwd); const opts = resolveGitea(ctx.cwd);
77
+ const lines = ["📊 Project Status", ""];
78
+ const wipR = giteaApi("/pulls?state=open&limit=100", "GET", null, opts, ctx.cwd);
79
+ const prs = Array.isArray(wipR.data) ? wipR.data : [];
80
+ const byAuthor: Record<string, number> = {};
81
+ for (const pr of prs) { const a = (pr as any).user?.login || "?"; byAuthor[a] = (byAuthor[a] || 0) + 1; }
82
+ lines.push(`🏗 WIP: ${prs.length} open PRs (limit: ${config.maxWip})`);
83
+ for (const [a, c] of Object.entries(byAuthor).sort(([, a], [, b]) => b - a)) lines.push(` ${a}: ${c}/${config.maxWip} ${c >= config.maxWip ? "⚠️" : "✅"}`);
84
+ const issuesR = giteaApi("/issues?state=open&limit=10", "GET", null, opts, ctx.cwd);
85
+ if (issuesR.ok && Array.isArray(issuesR.data)) {
86
+ const assigned = (issuesR.data as any[]).filter((i: any) => i.assignee).slice(0, 5);
87
+ if (assigned.length > 0) { lines.push("", "In Progress:"); for (const i of assigned) lines.push(` - #${i.number} [${i.assignee?.login}] ${i.title}`); }
88
+ }
89
+ return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
90
+ },
91
+ };
92
+
93
+ export const releaseTool = {
94
+ name: "project_release_notes" as const, label: "Generate Release Notes",
95
+ description: "Generate release notes from conventional commits.",
96
+ parameters: Type.Object({ from: Type.Optional(Type.String({})), to: Type.Optional(Type.String({})) }),
97
+ async execute(_id: string, params: any, _s: any, _u: any, ctx: ExtensionContext) {
98
+ const config = loadConfig(ctx.cwd);
99
+ let from = params.from || exec("git describe --tags --abbrev=0 2>/dev/null || echo ''", ctx.cwd).stdout;
100
+ const to = params.to || "HEAD";
101
+ if (!from) from = exec("git rev-list --max-parents=0 HEAD", ctx.cwd).stdout;
102
+ const range = from ? `${from}..${to}` : to;
103
+ const log = exec(`git log ${range} --format="commit %H%n%B%n---" --no-merges`, ctx.cwd);
104
+ const commits = parseConventionalCommits(log.stdout || "");
105
+ const sections: Record<string, string[]> = {};
106
+ for (const g of config.releaseNoteGroups) sections[g] = [];
107
+ sections["other"] = [];
108
+ for (const c of commits) { const prefix = config.releaseNoteIncludeHashes ? `- ${c.hash} ` : "- "; const line = `${prefix}${c.scope ? `**${c.scope}**: ` : ""}${c.subject}`; (sections[c.type] || sections["other"]).push(line); }
109
+ const lines = [`# Release ${to} (${new Date().toISOString().split("T")[0]})`, ""];
110
+ for (const [group, entries] of Object.entries(sections)) { if (entries.length === 0) continue; lines.push(`### ${LABELS[group] || group}`, ""); for (const e of entries) lines.push(e); lines.push(""); }
111
+ return { content: [{ type: "text", text: lines.join("\n") }], details: {} };
112
+ },
113
+ };
@@ -0,0 +1,31 @@
1
+ import type { ProjectConfig } from "./config";
2
+
3
+ export function validateIssueTemplate(body: string, config: ProjectConfig): { ok: true } | { ok: false; missingSections: string[] } {
4
+ const missing = config.requiredSections.filter(s => !body.includes(s));
5
+ return missing.length > 0 ? { ok: false, missingSections: missing } : { ok: true };
6
+ }
7
+
8
+ export function parseDependencies(body: string, config: ProjectConfig): string[] {
9
+ const pattern = new RegExp(config.dependencyPattern, "gi");
10
+ const deps = new Set<string>();
11
+ let match;
12
+ while ((match = pattern.exec(body)) !== null) deps.add(match[1]);
13
+ return [...deps];
14
+ }
15
+
16
+ export interface CommitEntry { hash: string; type: string; scope: string; subject: string; }
17
+
18
+ export function parseConventionalCommits(log: string): CommitEntry[] {
19
+ const entries: CommitEntry[] = [];
20
+ const commits = log.split(/\n(?=commit )/);
21
+ for (const block of commits) {
22
+ const hashMatch = block.match(/^commit (\S+)/m);
23
+ if (!hashMatch) continue;
24
+ const subjectLine = block.split("\n").find(l => l.trim() && !l.startsWith("commit ") && !l.startsWith("Author:") && !l.startsWith("Date:"));
25
+ if (!subjectLine) continue;
26
+ const convMatch = subjectLine.trim().match(/^(feat|fix|perf|refactor|chore|docs|style|test|ci|build|revert)(?:\(([^)]+)\))?:\s(.+)$/);
27
+ if (!convMatch) continue;
28
+ entries.push({ hash: hashMatch[1].slice(0, 8), type: convMatch[1], scope: convMatch[2] || "", subject: convMatch[3].trim() });
29
+ }
30
+ return entries;
31
+ }