@cullit/core 0.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/dist/index.js ADDED
@@ -0,0 +1,1251 @@
1
+ // src/constants.ts
2
+ var VERSION = "0.1.0";
3
+ var DEFAULT_CATEGORIES = ["features", "fixes", "breaking", "improvements", "chores"];
4
+ var DEFAULT_MODELS = {
5
+ anthropic: "claude-sonnet-4-20250514",
6
+ openai: "gpt-4o",
7
+ gemini: "gemini-2.0-flash",
8
+ ollama: "llama3.1",
9
+ openclaw: "claude-sonnet-4-6"
10
+ };
11
+
12
+ // src/collectors/git.ts
13
+ import { execSync } from "child_process";
14
+ var GitCollector = class {
15
+ cwd;
16
+ constructor(cwd = process.cwd()) {
17
+ this.cwd = cwd;
18
+ }
19
+ async collect(from, to) {
20
+ const log = this.getLog(from, to);
21
+ const commits = this.parseLog(log);
22
+ return {
23
+ from,
24
+ to,
25
+ commits,
26
+ filesChanged: this.getFilesChanged(from, to)
27
+ };
28
+ }
29
+ getLog(from, to) {
30
+ const format = "%H|%h|%an|%aI|%s|%b";
31
+ const separator = "---CULLIT_COMMIT---";
32
+ try {
33
+ return execSync(
34
+ `git log ${from}..${to} --format="${format}${separator}" --no-merges`,
35
+ { cwd: this.cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
36
+ );
37
+ } catch (error) {
38
+ const stderr = error?.stderr?.toString?.() || "";
39
+ const hint = stderr.includes("unknown revision") ? 'Check that both refs exist (run "cullit tags" to see tags).' : stderr.includes("not a git repository") ? "Run this command inside a git repository." : `Make sure both refs exist and you're in a git repository.`;
40
+ throw new Error(
41
+ `Failed to read git log between ${from} and ${to}. ${hint}`
42
+ );
43
+ }
44
+ }
45
+ parseLog(log) {
46
+ if (!log.trim()) return [];
47
+ const separator = "---CULLIT_COMMIT---";
48
+ const entries = log.split(separator).filter((e) => e.trim());
49
+ return entries.map((entry) => {
50
+ const parts = entry.trim().split("|");
51
+ const [hash, shortHash, author, date, message, ...bodyParts] = parts;
52
+ const body = bodyParts.join("|").trim() || void 0;
53
+ const fullMessage = body ? `${message}
54
+ ${body}` : message;
55
+ return {
56
+ hash: hash.trim(),
57
+ shortHash: shortHash.trim(),
58
+ author: author.trim(),
59
+ date: date.trim(),
60
+ message: message.trim(),
61
+ body,
62
+ prNumber: this.extractPRNumber(fullMessage),
63
+ issueKeys: this.extractIssueKeys(fullMessage)
64
+ };
65
+ });
66
+ }
67
+ /**
68
+ * Extracts PR number from commit messages.
69
+ * Matches patterns like: (#123), Merge pull request #123, PR #123
70
+ */
71
+ extractPRNumber(message) {
72
+ const patterns = [
73
+ /\(#(\d+)\)/,
74
+ // (#123)
75
+ /Merge pull request #(\d+)/i,
76
+ // Merge pull request #123
77
+ /PR\s*#(\d+)/i
78
+ // PR #123
79
+ ];
80
+ for (const pattern of patterns) {
81
+ const match = message.match(pattern);
82
+ if (match) return parseInt(match[1], 10);
83
+ }
84
+ return void 0;
85
+ }
86
+ /**
87
+ * Extracts issue keys from commit messages.
88
+ * Matches patterns like: PROJ-123, FIX-456, LIN-789
89
+ */
90
+ extractIssueKeys(message) {
91
+ const pattern = /\b([A-Z][A-Z0-9]+-\d+)\b/g;
92
+ const matches = message.match(pattern);
93
+ return matches ? [...new Set(matches)] : [];
94
+ }
95
+ getFilesChanged(from, to) {
96
+ try {
97
+ const output = execSync(
98
+ `git diff --shortstat ${from}..${to}`,
99
+ { cwd: this.cwd, encoding: "utf-8" }
100
+ );
101
+ const match = output.match(/(\d+) files? changed/);
102
+ return match ? parseInt(match[1], 10) : 0;
103
+ } catch {
104
+ return 0;
105
+ }
106
+ }
107
+ };
108
+ function getRecentTags(cwd = process.cwd(), count = 10) {
109
+ try {
110
+ const output = execSync(
111
+ `git tag --sort=-v:refname`,
112
+ { cwd, encoding: "utf-8" }
113
+ );
114
+ return output.trim().split("\n").filter(Boolean).slice(0, count);
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+ function getLatestTag(cwd = process.cwd()) {
120
+ try {
121
+ return execSync("git describe --tags --abbrev=0", { cwd, encoding: "utf-8" }).trim();
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ // src/collectors/jira.ts
128
+ var JiraCollector = class {
129
+ config;
130
+ constructor(config) {
131
+ this.config = config;
132
+ }
133
+ async collect(from, to) {
134
+ const jql = this.buildJQL(from, to);
135
+ const issues = await this.fetchIssues(jql);
136
+ const commits = issues.map((issue) => ({
137
+ hash: issue.key,
138
+ shortHash: issue.key,
139
+ author: issue.assignee || "unassigned",
140
+ date: issue.resolved || issue.updated || (/* @__PURE__ */ new Date()).toISOString(),
141
+ message: `${issue.type ? `[${issue.type}] ` : ""}${issue.summary}`,
142
+ body: issue.description?.substring(0, 500),
143
+ issueKeys: [issue.key]
144
+ }));
145
+ return {
146
+ from: `jira:${from}`,
147
+ to: to === "HEAD" ? `jira:${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}` : `jira:${to}`,
148
+ commits,
149
+ filesChanged: 0
150
+ };
151
+ }
152
+ buildJQL(from, to) {
153
+ if (from.includes("=") || from.includes("AND") || from.includes("OR")) {
154
+ const statusFilter = " AND status in (Done, Closed, Resolved)";
155
+ return from.includes("status") ? from : from + statusFilter;
156
+ }
157
+ if (to === "HEAD") {
158
+ return `project = ${from} AND status in (Done, Closed, Resolved) AND resolved >= -30d ORDER BY resolved DESC`;
159
+ }
160
+ return `project = ${from} AND fixVersion = "${to}" AND status in (Done, Closed, Resolved) ORDER BY resolved DESC`;
161
+ }
162
+ async fetchIssues(jql) {
163
+ const { domain, email, apiToken } = this.config;
164
+ const resolvedEmail = email || process.env.JIRA_EMAIL;
165
+ const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
166
+ if (!resolvedEmail || !resolvedToken) {
167
+ throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
168
+ }
169
+ const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
170
+ const issues = [];
171
+ let startAt = 0;
172
+ const maxResults = 50;
173
+ while (true) {
174
+ const url = new URL(`https://${domain}/rest/api/3/search`);
175
+ url.searchParams.set("jql", jql);
176
+ url.searchParams.set("startAt", String(startAt));
177
+ url.searchParams.set("maxResults", String(maxResults));
178
+ url.searchParams.set("fields", "summary,issuetype,assignee,status,resolution,resolutiondate,updated,labels,priority,description,fixVersions");
179
+ const response = await fetch(url.toString(), {
180
+ headers: {
181
+ "Authorization": `Basic ${auth}`,
182
+ "Accept": "application/json"
183
+ }
184
+ });
185
+ if (!response.ok) {
186
+ const error = await response.text();
187
+ throw new Error(`Jira API error (${response.status}): ${error}`);
188
+ }
189
+ const data = await response.json();
190
+ const batch = (data.issues || []).map((issue) => ({
191
+ key: issue.key,
192
+ summary: issue.fields.summary,
193
+ type: issue.fields.issuetype?.name?.toLowerCase(),
194
+ assignee: issue.fields.assignee?.displayName,
195
+ status: issue.fields.status?.name,
196
+ resolved: issue.fields.resolutiondate,
197
+ updated: issue.fields.updated,
198
+ description: issue.fields.description?.content?.[0]?.content?.[0]?.text,
199
+ labels: issue.fields.labels || [],
200
+ priority: issue.fields.priority?.name
201
+ }));
202
+ issues.push(...batch);
203
+ if (issues.length >= data.total || batch.length < maxResults) break;
204
+ startAt += maxResults;
205
+ }
206
+ return issues;
207
+ }
208
+ };
209
+
210
+ // src/collectors/linear.ts
211
+ var LinearCollector = class {
212
+ apiKey;
213
+ constructor(apiKey) {
214
+ const resolved = apiKey || process.env.LINEAR_API_KEY;
215
+ if (!resolved) {
216
+ throw new Error("Linear API key not configured. Set LINEAR_API_KEY.");
217
+ }
218
+ this.apiKey = resolved;
219
+ }
220
+ async collect(from, to) {
221
+ const filter = this.parseFilter(from);
222
+ const issues = await this.fetchIssues(filter);
223
+ const commits = issues.map((issue) => ({
224
+ hash: issue.identifier,
225
+ shortHash: issue.identifier,
226
+ author: issue.assignee || "unassigned",
227
+ date: issue.completedAt || issue.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
228
+ message: `${issue.type ? `[${issue.type}] ` : ""}${issue.title}`,
229
+ body: issue.description?.substring(0, 500),
230
+ issueKeys: [issue.identifier]
231
+ }));
232
+ return {
233
+ from: `linear:${from}`,
234
+ to: to === "HEAD" ? `linear:${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}` : `linear:${to}`,
235
+ commits,
236
+ filesChanged: 0
237
+ };
238
+ }
239
+ parseFilter(from) {
240
+ const [type, ...valueParts] = from.split(":");
241
+ const value = valueParts.join(":") || type;
242
+ switch (type.toLowerCase()) {
243
+ case "team":
244
+ return { type: "team", value };
245
+ case "project":
246
+ return { type: "project", value };
247
+ case "cycle":
248
+ return { type: "cycle", value };
249
+ case "label":
250
+ return { type: "label", value };
251
+ default:
252
+ return { type: "team", value: from };
253
+ }
254
+ }
255
+ async fetchIssues(filter) {
256
+ const filterClause = this.buildFilterClause(filter);
257
+ const query = `
258
+ query CompletedIssues {
259
+ issues(
260
+ filter: {
261
+ state: { type: { in: ["completed", "canceled"] } }
262
+ ${filterClause}
263
+ }
264
+ first: 100
265
+ orderBy: completedAt
266
+ ) {
267
+ nodes {
268
+ identifier
269
+ title
270
+ description
271
+ priority
272
+ completedAt
273
+ updatedAt
274
+ assignee { displayName }
275
+ state { name type }
276
+ labels { nodes { name } }
277
+ project { name }
278
+ }
279
+ }
280
+ }
281
+ `;
282
+ const response = await fetch("https://api.linear.app/graphql", {
283
+ method: "POST",
284
+ headers: {
285
+ "Content-Type": "application/json",
286
+ "Authorization": this.apiKey
287
+ },
288
+ body: JSON.stringify({ query })
289
+ });
290
+ if (!response.ok) {
291
+ const error = await response.text();
292
+ throw new Error(`Linear API error (${response.status}): ${error}`);
293
+ }
294
+ const data = await response.json();
295
+ const nodes = data.data?.issues?.nodes || [];
296
+ const priorityMap = {
297
+ 0: "none",
298
+ 1: "urgent",
299
+ 2: "high",
300
+ 3: "medium",
301
+ 4: "low"
302
+ };
303
+ return nodes.map((issue) => ({
304
+ identifier: issue.identifier,
305
+ title: issue.title,
306
+ description: issue.description?.substring(0, 500),
307
+ type: issue.labels?.nodes?.[0]?.name?.toLowerCase(),
308
+ assignee: issue.assignee?.displayName,
309
+ status: issue.state?.name,
310
+ completedAt: issue.completedAt,
311
+ updatedAt: issue.updatedAt,
312
+ labels: issue.labels?.nodes?.map((l) => l.name) || [],
313
+ priority: priorityMap[issue.priority]
314
+ }));
315
+ }
316
+ buildFilterClause(filter) {
317
+ switch (filter.type) {
318
+ case "team":
319
+ return `team: { key: { eq: "${filter.value}" } }`;
320
+ case "project":
321
+ return `project: { name: { containsIgnoreCase: "${filter.value}" } }`;
322
+ case "cycle":
323
+ if (filter.value === "current") {
324
+ return `cycle: { isActive: { eq: true } }`;
325
+ }
326
+ return `cycle: { name: { containsIgnoreCase: "${filter.value}" } }`;
327
+ case "label":
328
+ return `labels: { name: { eq: "${filter.value}" } }`;
329
+ default:
330
+ return "";
331
+ }
332
+ }
333
+ };
334
+
335
+ // src/generators/ai.ts
336
+ var AIGenerator = class {
337
+ openclawConfig;
338
+ timeoutMs;
339
+ constructor(openclawConfig, timeoutMs = 6e4) {
340
+ this.openclawConfig = openclawConfig;
341
+ this.timeoutMs = timeoutMs;
342
+ }
343
+ async fetchWithTimeout(url, init) {
344
+ const controller = new AbortController();
345
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
346
+ try {
347
+ return await fetch(url, { ...init, signal: controller.signal });
348
+ } catch (err) {
349
+ if (err.name === "AbortError") {
350
+ throw new Error(`Request to ${new URL(url).hostname} timed out after ${this.timeoutMs / 1e3}s`);
351
+ }
352
+ throw err;
353
+ } finally {
354
+ clearTimeout(timer);
355
+ }
356
+ }
357
+ async generate(context, config) {
358
+ const prompt = this.buildPrompt(context, config);
359
+ const apiKey = this.resolveApiKey(config);
360
+ let rawResponse;
361
+ if (config.provider === "anthropic") {
362
+ rawResponse = await this.callAnthropic(prompt, apiKey, config.model);
363
+ } else if (config.provider === "openai") {
364
+ rawResponse = await this.callOpenAI(prompt, apiKey, config.model);
365
+ } else if (config.provider === "gemini") {
366
+ rawResponse = await this.callGemini(prompt, apiKey, config.model);
367
+ } else if (config.provider === "ollama") {
368
+ rawResponse = await this.callOllama(prompt, config.model);
369
+ } else if (config.provider === "openclaw") {
370
+ rawResponse = await this.callOpenClaw(prompt, config.model);
371
+ } else {
372
+ throw new Error(`Unsupported AI provider: ${config.provider}`);
373
+ }
374
+ return this.parseResponse(rawResponse, context);
375
+ }
376
+ resolveApiKey(config) {
377
+ if (config.apiKey) return config.apiKey;
378
+ if (config.provider === "ollama" || config.provider === "openclaw") return "";
379
+ const envVarMap = {
380
+ anthropic: "ANTHROPIC_API_KEY",
381
+ openai: "OPENAI_API_KEY",
382
+ gemini: "GOOGLE_API_KEY"
383
+ };
384
+ const envVar = envVarMap[config.provider];
385
+ if (!envVar) throw new Error(`Unknown provider: ${config.provider}`);
386
+ const key = process.env[envVar];
387
+ if (!key) {
388
+ throw new Error(
389
+ `No API key found. Set ${envVar} in your environment or provide it in .cullit.yml under ai.apiKey`
390
+ );
391
+ }
392
+ return key;
393
+ }
394
+ buildPrompt(context, config) {
395
+ const { diff, tickets } = context;
396
+ const commitList = diff.commits.map((c) => {
397
+ let line = `- ${c.shortHash}: ${c.message}`;
398
+ if (c.issueKeys?.length) line += ` [${c.issueKeys.join(", ")}]`;
399
+ return line;
400
+ }).join("\n");
401
+ const ticketList = tickets.length > 0 ? tickets.map(
402
+ (t) => `- ${t.key}: ${t.title}${t.type ? ` (${t.type})` : ""}${t.labels?.length ? ` [${t.labels.join(", ")}]` : ""}`
403
+ ).join("\n") : "No enrichment data available.";
404
+ const audienceInstructions = {
405
+ "developer": "Write for developers. Include technical details, API changes, and migration notes.",
406
+ "end-user": "Write for end users. Use plain language. Focus on benefits and behavior changes. No jargon.",
407
+ "executive": "Write a brief executive summary. Focus on business impact, key metrics, and strategic changes."
408
+ };
409
+ const toneInstructions = {
410
+ "professional": "Tone: professional and clear.",
411
+ "casual": "Tone: conversational and approachable, but still informative.",
412
+ "terse": "Tone: minimal and direct. Short bullet points only."
413
+ };
414
+ const categories = config.categories.join(", ");
415
+ return `You are a release notes generator. Analyze the following git commits and related tickets, then produce structured release notes.
416
+
417
+ ## Input
418
+
419
+ ### Commits (${diff.from} \u2192 ${diff.to})
420
+ ${commitList}
421
+
422
+ ### Related Tickets
423
+ ${ticketList}
424
+
425
+ ## Instructions
426
+
427
+ ${audienceInstructions[config.audience]}
428
+ ${toneInstructions[config.tone]}
429
+
430
+ Categorize each change into one of: ${categories}
431
+
432
+ ## Output Format
433
+
434
+ Respond with ONLY valid JSON (no markdown, no backticks, no preamble):
435
+ {
436
+ "summary": "One paragraph summarizing this release",
437
+ "changes": [
438
+ {
439
+ "description": "Human-readable description of the change",
440
+ "category": "features|fixes|breaking|improvements|chores",
441
+ "ticketKey": "PROJ-123 or null"
442
+ }
443
+ ]
444
+ }
445
+
446
+ Rules:
447
+ - Combine related commits into single change entries
448
+ - Skip trivial commits (merge commits, formatting, typos) unless they fix bugs
449
+ - Each description should be one clear sentence
450
+ - Include ticket keys when available
451
+ - Group by category
452
+ - Maximum 20 change entries
453
+ - If a commit message mentions a breaking change, categorize it as "breaking"`;
454
+ }
455
+ async callAnthropic(prompt, apiKey, model) {
456
+ const response = await this.fetchWithTimeout("https://api.anthropic.com/v1/messages", {
457
+ method: "POST",
458
+ headers: {
459
+ "Content-Type": "application/json",
460
+ "x-api-key": apiKey,
461
+ "anthropic-version": "2023-06-01"
462
+ },
463
+ body: JSON.stringify({
464
+ model: model || "claude-sonnet-4-20250514",
465
+ max_tokens: 4096,
466
+ messages: [{ role: "user", content: prompt }]
467
+ })
468
+ });
469
+ if (!response.ok) {
470
+ const error = await response.text();
471
+ throw new Error(`Anthropic API error (${response.status}): ${error}`);
472
+ }
473
+ const data = await response.json();
474
+ return data.content[0]?.text || "";
475
+ }
476
+ async callOpenAI(prompt, apiKey, model) {
477
+ const response = await this.fetchWithTimeout("https://api.openai.com/v1/chat/completions", {
478
+ method: "POST",
479
+ headers: {
480
+ "Content-Type": "application/json",
481
+ "Authorization": `Bearer ${apiKey}`
482
+ },
483
+ body: JSON.stringify({
484
+ model: model || "gpt-4o",
485
+ messages: [{ role: "user", content: prompt }],
486
+ max_tokens: 4096,
487
+ temperature: 0.3
488
+ })
489
+ });
490
+ if (!response.ok) {
491
+ const error = await response.text();
492
+ throw new Error(`OpenAI API error (${response.status}): ${error}`);
493
+ }
494
+ const data = await response.json();
495
+ return data.choices[0]?.message?.content || "";
496
+ }
497
+ async callGemini(prompt, apiKey, model) {
498
+ const modelId = model || "gemini-2.0-flash";
499
+ const response = await this.fetchWithTimeout(
500
+ `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`,
501
+ {
502
+ method: "POST",
503
+ headers: { "Content-Type": "application/json" },
504
+ body: JSON.stringify({
505
+ contents: [{ parts: [{ text: prompt }] }],
506
+ generationConfig: { temperature: 0.3, maxOutputTokens: 4096 }
507
+ })
508
+ }
509
+ );
510
+ if (!response.ok) {
511
+ const error = await response.text();
512
+ throw new Error(`Gemini API error (${response.status}): ${error}`);
513
+ }
514
+ const data = await response.json();
515
+ return data.candidates?.[0]?.content?.parts?.[0]?.text || "";
516
+ }
517
+ async callOllama(prompt, model) {
518
+ const baseUrl = process.env.OLLAMA_HOST || "http://localhost:11434";
519
+ const response = await this.fetchWithTimeout(`${baseUrl}/api/chat`, {
520
+ method: "POST",
521
+ headers: { "Content-Type": "application/json" },
522
+ body: JSON.stringify({
523
+ model: model || "llama3.1",
524
+ messages: [{ role: "user", content: prompt }],
525
+ stream: false,
526
+ options: { temperature: 0.3 }
527
+ })
528
+ });
529
+ if (!response.ok) {
530
+ const error = await response.text();
531
+ throw new Error(`Ollama API error (${response.status}): ${error}`);
532
+ }
533
+ const data = await response.json();
534
+ return data.message?.content || "";
535
+ }
536
+ async callOpenClaw(prompt, model) {
537
+ const baseUrl = this.openclawConfig?.baseUrl || process.env.OPENCLAW_URL || "http://localhost:18789";
538
+ const token = this.openclawConfig?.token || process.env.OPENCLAW_TOKEN || "";
539
+ const headers = { "Content-Type": "application/json" };
540
+ if (token) headers["Authorization"] = `Bearer ${token}`;
541
+ const response = await this.fetchWithTimeout(`${baseUrl}/v1/chat/completions`, {
542
+ method: "POST",
543
+ headers,
544
+ body: JSON.stringify({
545
+ model: model || "anthropic/claude-sonnet-4-6",
546
+ messages: [{ role: "user", content: prompt }],
547
+ max_tokens: 4096,
548
+ temperature: 0.3
549
+ })
550
+ });
551
+ if (!response.ok) {
552
+ const error = await response.text();
553
+ throw new Error(`OpenClaw API error (${response.status}): ${error}`);
554
+ }
555
+ const data = await response.json();
556
+ return data.choices?.[0]?.message?.content || "";
557
+ }
558
+ parseResponse(raw, context) {
559
+ const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
560
+ let parsed;
561
+ try {
562
+ parsed = JSON.parse(cleaned);
563
+ } catch {
564
+ throw new Error(`Failed to parse AI response as JSON. Raw response:
565
+ ${raw.substring(0, 500)}`);
566
+ }
567
+ const validCategories = /* @__PURE__ */ new Set(["features", "fixes", "breaking", "improvements", "chores", "other"]);
568
+ const changes = (parsed.changes || []).map((c) => ({
569
+ description: c.description,
570
+ category: validCategories.has(c.category) ? c.category : "other",
571
+ ticketKey: c.ticketKey || void 0
572
+ }));
573
+ const contributors = [...new Set(context.diff.commits.map((c) => c.author))];
574
+ return {
575
+ version: context.diff.to,
576
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
577
+ summary: parsed.summary,
578
+ changes,
579
+ contributors,
580
+ metadata: {
581
+ commitCount: context.diff.commits.length,
582
+ prCount: context.diff.commits.filter((c) => c.prNumber).length,
583
+ ticketCount: context.tickets.length,
584
+ generatedBy: "cull",
585
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
586
+ }
587
+ };
588
+ }
589
+ };
590
+
591
+ // src/generators/template.ts
592
+ var TemplateGenerator = class {
593
+ async generate(context, config) {
594
+ const { diff, tickets } = context;
595
+ const changes = [];
596
+ const ticketMap = new Map(tickets.map((t) => [t.key, t]));
597
+ for (const commit of diff.commits) {
598
+ const category = this.categorize(commit.message, commit.issueKeys, ticketMap);
599
+ const description = this.cleanMessage(commit.message);
600
+ if (this.isTrivial(description)) continue;
601
+ changes.push({
602
+ description,
603
+ category,
604
+ ticketKey: commit.issueKeys?.[0],
605
+ commits: [commit.shortHash]
606
+ });
607
+ }
608
+ const deduped = this.deduplicateByTicket(changes);
609
+ const categoryOrder = ["breaking", "features", "improvements", "fixes", "chores", "other"];
610
+ deduped.sort((a, b) => categoryOrder.indexOf(a.category) - categoryOrder.indexOf(b.category));
611
+ const contributors = [...new Set(diff.commits.map((c) => c.author))];
612
+ return {
613
+ version: diff.to,
614
+ date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
615
+ summary: this.buildSummary(deduped, diff.commits.length),
616
+ changes: deduped.slice(0, 20),
617
+ contributors,
618
+ metadata: {
619
+ commitCount: diff.commits.length,
620
+ prCount: diff.commits.filter((c) => c.prNumber).length,
621
+ ticketCount: tickets.length,
622
+ generatedBy: "cull-template",
623
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString()
624
+ }
625
+ };
626
+ }
627
+ categorize(message, issueKeys, ticketMap) {
628
+ const lower = message.toLowerCase();
629
+ if (/^(feat|feature)[(!:]/.test(lower)) return "features";
630
+ if (/^fix[(!:]/.test(lower)) return "fixes";
631
+ if (/^breaking[ -]?change/i.test(lower) || lower.includes("!:")) return "breaking";
632
+ if (/^(refactor|perf|improve)[(!:]/.test(lower)) return "improvements";
633
+ if (/^(chore|ci|build|docs|style|test)[(!:]/.test(lower)) return "chores";
634
+ if (issueKeys?.length) {
635
+ for (const key of issueKeys) {
636
+ const ticket = ticketMap.get(key);
637
+ if (ticket?.type) {
638
+ if (["bug", "bugfix", "defect"].includes(ticket.type)) return "fixes";
639
+ if (["feature", "story", "enhancement"].includes(ticket.type)) return "features";
640
+ if (["improvement", "refactor"].includes(ticket.type)) return "improvements";
641
+ if (["task", "chore", "sub-task"].includes(ticket.type)) return "chores";
642
+ }
643
+ }
644
+ }
645
+ if (/\b(add|new|implement|introduce|create)\b/i.test(lower)) return "features";
646
+ if (/\b(fix|resolve|patch|correct|repair)\b/i.test(lower)) return "fixes";
647
+ if (/\b(update|improve|optimize|enhance|refactor)\b/i.test(lower)) return "improvements";
648
+ if (/\b(remove|delete|deprecate|drop)\b/i.test(lower)) return "chores";
649
+ return "other";
650
+ }
651
+ cleanMessage(message) {
652
+ let cleaned = message.replace(/^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|breaking change)(\(.+?\))?[!]?:\s*/i, "");
653
+ cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
654
+ cleaned = cleaned.replace(/\.$/, "");
655
+ return cleaned;
656
+ }
657
+ isTrivial(description) {
658
+ const trivialPatterns = [
659
+ /^merge\b/i,
660
+ /^wip\b/i,
661
+ /^typo\b/i,
662
+ /^formatting\b/i,
663
+ /^lint\b/i,
664
+ /^whitespace\b/i,
665
+ /^bump version/i
666
+ ];
667
+ return trivialPatterns.some((p) => p.test(description));
668
+ }
669
+ deduplicateByTicket(changes) {
670
+ const seen = /* @__PURE__ */ new Map();
671
+ const result = [];
672
+ for (const change of changes) {
673
+ if (change.ticketKey && seen.has(change.ticketKey)) {
674
+ const existing = seen.get(change.ticketKey);
675
+ if (change.commits) {
676
+ existing.commits = [...existing.commits || [], ...change.commits];
677
+ }
678
+ } else {
679
+ if (change.ticketKey) seen.set(change.ticketKey, change);
680
+ result.push(change);
681
+ }
682
+ }
683
+ return result;
684
+ }
685
+ buildSummary(changes, commitCount) {
686
+ const counts = {};
687
+ for (const c of changes) {
688
+ counts[c.category] = (counts[c.category] || 0) + 1;
689
+ }
690
+ const parts = [];
691
+ if (counts["breaking"]) parts.push(`${counts["breaking"]} breaking change${counts["breaking"] > 1 ? "s" : ""}`);
692
+ if (counts["features"]) parts.push(`${counts["features"]} feature${counts["features"] > 1 ? "s" : ""}`);
693
+ if (counts["fixes"]) parts.push(`${counts["fixes"]} fix${counts["fixes"] > 1 ? "es" : ""}`);
694
+ if (counts["improvements"]) parts.push(`${counts["improvements"]} improvement${counts["improvements"] > 1 ? "s" : ""}`);
695
+ const summary = parts.length > 0 ? `This release includes ${parts.join(", ")} across ${commitCount} commits.` : `This release includes ${commitCount} commits.`;
696
+ return summary;
697
+ }
698
+ };
699
+
700
+ // src/formatter.ts
701
+ var CATEGORY_LABELS = {
702
+ features: "\u2728 Features",
703
+ fixes: "\u{1F41B} Bug Fixes",
704
+ breaking: "\u26A0\uFE0F Breaking Changes",
705
+ improvements: "\u{1F527} Improvements",
706
+ chores: "\u{1F9F9} Chores",
707
+ other: "\u{1F4DD} Other"
708
+ };
709
+ var CATEGORY_ORDER = [
710
+ "breaking",
711
+ "features",
712
+ "improvements",
713
+ "fixes",
714
+ "chores",
715
+ "other"
716
+ ];
717
+ function formatNotes(notes, format) {
718
+ switch (format) {
719
+ case "markdown":
720
+ return formatMarkdown(notes);
721
+ case "html":
722
+ return formatHTML(notes);
723
+ case "json":
724
+ return JSON.stringify(notes, null, 2);
725
+ default:
726
+ return formatMarkdown(notes);
727
+ }
728
+ }
729
+ function formatMarkdown(notes) {
730
+ const lines = [];
731
+ lines.push(`## ${notes.version} \u2014 ${notes.date}`);
732
+ lines.push("");
733
+ if (notes.summary) {
734
+ lines.push(notes.summary);
735
+ lines.push("");
736
+ }
737
+ const grouped = groupByCategory(notes);
738
+ for (const category of CATEGORY_ORDER) {
739
+ const entries = grouped.get(category);
740
+ if (!entries?.length) continue;
741
+ lines.push(`### ${CATEGORY_LABELS[category]}`);
742
+ lines.push("");
743
+ for (const entry of entries) {
744
+ let line = `- ${entry.description}`;
745
+ if (entry.ticketKey) line += ` (${entry.ticketKey})`;
746
+ lines.push(line);
747
+ }
748
+ lines.push("");
749
+ }
750
+ if (notes.contributors?.length) {
751
+ lines.push("### Contributors");
752
+ lines.push("");
753
+ lines.push(notes.contributors.map((c) => `@${c}`).join(", "));
754
+ lines.push("");
755
+ }
756
+ if (notes.metadata) {
757
+ lines.push("---");
758
+ lines.push(`*Generated by [Cull](https://cullit.io) \u2022 ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs*`);
759
+ }
760
+ return lines.join("\n");
761
+ }
762
+ function escapeHtml(str) {
763
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
764
+ }
765
+ function formatHTML(notes) {
766
+ const grouped = groupByCategory(notes);
767
+ let html = `<div class="cull-release">`;
768
+ html += `<h2>${escapeHtml(notes.version)} \u2014 ${escapeHtml(notes.date)}</h2>`;
769
+ if (notes.summary) {
770
+ html += `<p class="summary">${escapeHtml(notes.summary)}</p>`;
771
+ }
772
+ for (const category of CATEGORY_ORDER) {
773
+ const entries = grouped.get(category);
774
+ if (!entries?.length) continue;
775
+ html += `<h3>${CATEGORY_LABELS[category]}</h3><ul>`;
776
+ for (const entry of entries) {
777
+ html += `<li>${escapeHtml(entry.description)}`;
778
+ if (entry.ticketKey) html += ` <code>${escapeHtml(entry.ticketKey)}</code>`;
779
+ html += `</li>`;
780
+ }
781
+ html += `</ul>`;
782
+ }
783
+ if (notes.metadata) {
784
+ html += `<footer><small>Generated by <a href="https://cullit.io">Cull</a> &bull; ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs</small></footer>`;
785
+ }
786
+ html += `</div>`;
787
+ return html;
788
+ }
789
+ function groupByCategory(notes) {
790
+ const grouped = /* @__PURE__ */ new Map();
791
+ for (const entry of notes.changes) {
792
+ const existing = grouped.get(entry.category) || [];
793
+ existing.push(entry);
794
+ grouped.set(entry.category, existing);
795
+ }
796
+ return grouped;
797
+ }
798
+
799
+ // src/publishers/index.ts
800
+ import { writeFileSync } from "fs";
801
+ var StdoutPublisher = class {
802
+ async publish(notes, format) {
803
+ const formatted = formatNotes(notes, format);
804
+ console.log(formatted);
805
+ }
806
+ };
807
+ var FilePublisher = class {
808
+ constructor(path) {
809
+ this.path = path;
810
+ }
811
+ async publish(notes, format) {
812
+ const formatted = formatNotes(notes, format);
813
+ writeFileSync(this.path, formatted, "utf-8");
814
+ console.log(`\u2713 Release notes written to ${this.path}`);
815
+ }
816
+ };
817
+ var SlackPublisher = class {
818
+ constructor(webhookUrl) {
819
+ this.webhookUrl = webhookUrl;
820
+ }
821
+ async publish(notes, _format) {
822
+ const text = this.buildSlackMessage(notes);
823
+ const response = await fetch(this.webhookUrl, {
824
+ method: "POST",
825
+ headers: { "Content-Type": "application/json" },
826
+ body: JSON.stringify({ text })
827
+ });
828
+ if (!response.ok) {
829
+ throw new Error(`Slack webhook failed (${response.status})`);
830
+ }
831
+ console.log("\u2713 Published to Slack");
832
+ }
833
+ buildSlackMessage(notes) {
834
+ let msg = `*${notes.version}* \u2014 ${notes.date}
835
+ `;
836
+ if (notes.summary) msg += `${notes.summary}
837
+
838
+ `;
839
+ const categoryEmoji = {
840
+ features: "\u2728",
841
+ fixes: "\u{1F41B}",
842
+ breaking: "\u26A0\uFE0F",
843
+ improvements: "\u{1F527}",
844
+ chores: "\u{1F9F9}",
845
+ other: "\u{1F4DD}"
846
+ };
847
+ for (const change of notes.changes) {
848
+ const emoji = categoryEmoji[change.category] || "\u2022";
849
+ msg += `${emoji} ${change.description}`;
850
+ if (change.ticketKey) msg += ` (\`${change.ticketKey}\`)`;
851
+ msg += "\n";
852
+ }
853
+ return msg;
854
+ }
855
+ };
856
+ var DiscordPublisher = class {
857
+ constructor(webhookUrl) {
858
+ this.webhookUrl = webhookUrl;
859
+ }
860
+ async publish(notes, _format) {
861
+ const content = this.buildDiscordMessage(notes);
862
+ const response = await fetch(this.webhookUrl, {
863
+ method: "POST",
864
+ headers: { "Content-Type": "application/json" },
865
+ body: JSON.stringify({
866
+ embeds: [{
867
+ title: `Release ${notes.version}`,
868
+ description: content,
869
+ color: 15269703,
870
+ // Cull accent color
871
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
872
+ footer: { text: "Generated by Cull" }
873
+ }]
874
+ })
875
+ });
876
+ if (!response.ok) {
877
+ throw new Error(`Discord webhook failed (${response.status})`);
878
+ }
879
+ console.log("\u2713 Published to Discord");
880
+ }
881
+ buildDiscordMessage(notes) {
882
+ let msg = "";
883
+ if (notes.summary) msg += `${notes.summary}
884
+
885
+ `;
886
+ for (const change of notes.changes) {
887
+ msg += `\u2022 ${change.description}`;
888
+ if (change.ticketKey) msg += ` (${change.ticketKey})`;
889
+ msg += "\n";
890
+ }
891
+ return msg.substring(0, 4e3);
892
+ }
893
+ };
894
+ var GitHubReleasePublisher = class {
895
+ token;
896
+ owner;
897
+ repo;
898
+ constructor() {
899
+ this.token = process.env["GITHUB_TOKEN"] || "";
900
+ const ghRepo = process.env["GITHUB_REPOSITORY"] || "";
901
+ const parts = ghRepo.split("/");
902
+ this.owner = parts[0] || "";
903
+ this.repo = parts[1] || "";
904
+ }
905
+ async publish(notes, format) {
906
+ if (!this.token) {
907
+ throw new Error("GITHUB_TOKEN is required for GitHub Release publishing");
908
+ }
909
+ if (!this.owner || !this.repo) {
910
+ throw new Error(
911
+ "GITHUB_REPOSITORY env var is required (format: owner/repo). This is set automatically in GitHub Actions."
912
+ );
913
+ }
914
+ const formatted = formatNotes(notes, format);
915
+ const tagName = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
916
+ const existing = await this.getRelease(tagName);
917
+ if (existing) {
918
+ await this.updateRelease(existing.id, formatted, notes);
919
+ console.log(`\u2713 Updated GitHub Release: ${tagName}`);
920
+ } else {
921
+ await this.createRelease(tagName, formatted, notes);
922
+ console.log(`\u2713 Created GitHub Release: ${tagName}`);
923
+ }
924
+ }
925
+ async getRelease(tag) {
926
+ const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${encodeURIComponent(tag)}`;
927
+ const response = await fetch(url, {
928
+ headers: this.headers()
929
+ });
930
+ if (response.status === 404) return null;
931
+ if (!response.ok) {
932
+ const error = await response.text();
933
+ throw new Error(`GitHub API error (${response.status}): ${error}`);
934
+ }
935
+ const data = await response.json();
936
+ return data;
937
+ }
938
+ async createRelease(tag, body, notes) {
939
+ const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases`;
940
+ const response = await fetch(url, {
941
+ method: "POST",
942
+ headers: this.headers(),
943
+ body: JSON.stringify({
944
+ tag_name: tag,
945
+ name: `${tag} \u2014 ${notes.date}`,
946
+ body,
947
+ draft: false,
948
+ prerelease: tag.includes("-")
949
+ })
950
+ });
951
+ if (!response.ok) {
952
+ const error = await response.text();
953
+ throw new Error(`GitHub Release creation failed (${response.status}): ${error}`);
954
+ }
955
+ }
956
+ async updateRelease(id, body, notes) {
957
+ const tag = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
958
+ const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/${id}`;
959
+ const response = await fetch(url, {
960
+ method: "PATCH",
961
+ headers: this.headers(),
962
+ body: JSON.stringify({
963
+ body,
964
+ name: `${tag} \u2014 ${notes.date}`
965
+ })
966
+ });
967
+ if (!response.ok) {
968
+ const error = await response.text();
969
+ throw new Error(`GitHub Release update failed (${response.status}): ${error}`);
970
+ }
971
+ }
972
+ headers() {
973
+ return {
974
+ "Accept": "application/vnd.github+json",
975
+ "Authorization": `Bearer ${this.token}`,
976
+ "X-GitHub-Api-Version": "2022-11-28"
977
+ };
978
+ }
979
+ };
980
+
981
+ // src/enrichers/jira.ts
982
+ var JiraEnricher = class {
983
+ config;
984
+ constructor(config) {
985
+ this.config = config;
986
+ }
987
+ async enrich(diff) {
988
+ const keys = this.extractUniqueKeys(diff);
989
+ if (keys.length === 0) return [];
990
+ const tickets = [];
991
+ for (const key of keys) {
992
+ try {
993
+ const ticket = await this.fetchTicket(key);
994
+ if (ticket) tickets.push(ticket);
995
+ } catch (err) {
996
+ console.warn(`\u26A0 Could not fetch Jira ticket ${key}: ${err.message}`);
997
+ }
998
+ }
999
+ return tickets;
1000
+ }
1001
+ extractUniqueKeys(diff) {
1002
+ const allKeys = [];
1003
+ for (const commit of diff.commits) {
1004
+ if (commit.issueKeys) allKeys.push(...commit.issueKeys);
1005
+ }
1006
+ return [...new Set(allKeys)];
1007
+ }
1008
+ async fetchTicket(key) {
1009
+ const { domain, email, apiToken } = this.config;
1010
+ const resolvedEmail = email || process.env.JIRA_EMAIL;
1011
+ const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
1012
+ if (!resolvedEmail || !resolvedToken) {
1013
+ throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
1014
+ }
1015
+ const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
1016
+ const response = await fetch(
1017
+ `https://${domain}/rest/api/3/issue/${key}?fields=summary,issuetype,labels,priority,status,description`,
1018
+ {
1019
+ headers: {
1020
+ "Authorization": `Basic ${auth}`,
1021
+ "Accept": "application/json"
1022
+ }
1023
+ }
1024
+ );
1025
+ if (response.status === 404) return null;
1026
+ if (!response.ok) {
1027
+ throw new Error(`Jira API error (${response.status})`);
1028
+ }
1029
+ const data = await response.json();
1030
+ const fields = data.fields;
1031
+ return {
1032
+ key,
1033
+ title: fields.summary || key,
1034
+ type: fields.issuetype?.name?.toLowerCase(),
1035
+ labels: fields.labels || [],
1036
+ priority: fields.priority?.name,
1037
+ status: fields.status?.name,
1038
+ source: "jira"
1039
+ };
1040
+ }
1041
+ };
1042
+
1043
+ // src/enrichers/linear.ts
1044
+ var LinearEnricher = class {
1045
+ apiKey;
1046
+ constructor(apiKey) {
1047
+ const resolved = apiKey || process.env.LINEAR_API_KEY;
1048
+ if (!resolved) {
1049
+ throw new Error("Linear API key not configured. Set LINEAR_API_KEY.");
1050
+ }
1051
+ this.apiKey = resolved;
1052
+ }
1053
+ async enrich(diff) {
1054
+ const keys = this.extractUniqueKeys(diff);
1055
+ if (keys.length === 0) return [];
1056
+ const tickets = [];
1057
+ for (const key of keys) {
1058
+ try {
1059
+ const ticket = await this.fetchIssue(key);
1060
+ if (ticket) tickets.push(ticket);
1061
+ } catch (err) {
1062
+ console.warn(`\u26A0 Could not fetch Linear issue ${key}: ${err.message}`);
1063
+ }
1064
+ }
1065
+ return tickets;
1066
+ }
1067
+ extractUniqueKeys(diff) {
1068
+ const allKeys = [];
1069
+ for (const commit of diff.commits) {
1070
+ if (commit.issueKeys) allKeys.push(...commit.issueKeys);
1071
+ }
1072
+ return [...new Set(allKeys)];
1073
+ }
1074
+ async fetchIssue(identifier) {
1075
+ const query = `
1076
+ query IssueByIdentifier($id: String!) {
1077
+ issueSearch(filter: { identifier: { eq: $id } }, first: 1) {
1078
+ nodes {
1079
+ identifier
1080
+ title
1081
+ description
1082
+ priority
1083
+ state { name }
1084
+ labels { nodes { name } }
1085
+ }
1086
+ }
1087
+ }
1088
+ `;
1089
+ const response = await fetch("https://api.linear.app/graphql", {
1090
+ method: "POST",
1091
+ headers: {
1092
+ "Content-Type": "application/json",
1093
+ "Authorization": this.apiKey
1094
+ },
1095
+ body: JSON.stringify({ query, variables: { id: identifier } })
1096
+ });
1097
+ if (!response.ok) {
1098
+ throw new Error(`Linear API error (${response.status})`);
1099
+ }
1100
+ const data = await response.json();
1101
+ const issue = data.data?.issueSearch?.nodes?.[0];
1102
+ if (!issue) return null;
1103
+ const priorityMap = {
1104
+ 0: "none",
1105
+ 1: "urgent",
1106
+ 2: "high",
1107
+ 3: "medium",
1108
+ 4: "low"
1109
+ };
1110
+ return {
1111
+ key: issue.identifier,
1112
+ title: issue.title,
1113
+ description: issue.description?.substring(0, 500),
1114
+ labels: issue.labels?.nodes?.map((l) => l.name) || [],
1115
+ priority: priorityMap[issue.priority] || void 0,
1116
+ status: issue.state?.name,
1117
+ source: "linear"
1118
+ };
1119
+ }
1120
+ };
1121
+
1122
+ // src/index.ts
1123
+ async function runPipeline(from, to, config, options = {}) {
1124
+ const startTime = Date.now();
1125
+ const format = options.format || "markdown";
1126
+ let collector;
1127
+ if (config.source.type === "jira") {
1128
+ if (!config.jira) throw new Error("Jira source requires jira config in .cullit.yml");
1129
+ console.log(`\xBB Collecting issues from Jira...`);
1130
+ collector = new JiraCollector(config.jira);
1131
+ } else if (config.source.type === "linear") {
1132
+ console.log(`\xBB Collecting issues from Linear...`);
1133
+ collector = new LinearCollector(config.linear?.apiKey);
1134
+ } else {
1135
+ console.log(`\xBB Collecting commits between ${from}..${to}`);
1136
+ collector = new GitCollector();
1137
+ }
1138
+ const diff = await collector.collect(from, to);
1139
+ const itemLabel = config.source.type === "jira" || config.source.type === "linear" ? "issues" : "commits";
1140
+ console.log(`\xBB Found ${diff.commits.length} ${itemLabel}${diff.filesChanged ? `, ${diff.filesChanged} files changed` : ""}`);
1141
+ if (diff.commits.length === 0) {
1142
+ const source = config.source.type === "jira" ? "Jira" : config.source.type === "linear" ? "Linear" : `${from} and ${to}`;
1143
+ throw new Error(`No ${itemLabel} found from ${source}`);
1144
+ }
1145
+ const tickets = [];
1146
+ const enrichmentSources = config.source.enrichment || [];
1147
+ for (const source of enrichmentSources) {
1148
+ if (source === "jira" && config.jira) {
1149
+ console.log("\xBB Enriching from Jira...");
1150
+ const enricher = new JiraEnricher(config.jira);
1151
+ const jiraTickets = await enricher.enrich(diff);
1152
+ tickets.push(...jiraTickets);
1153
+ console.log(`\xBB Jira: found ${jiraTickets.length} tickets`);
1154
+ }
1155
+ if (source === "linear") {
1156
+ console.log("\xBB Enriching from Linear...");
1157
+ const enricher = new LinearEnricher(config.linear?.apiKey);
1158
+ const linearTickets = await enricher.enrich(diff);
1159
+ tickets.push(...linearTickets);
1160
+ console.log(`\xBB Linear: found ${linearTickets.length} issues`);
1161
+ }
1162
+ }
1163
+ const context = { diff, tickets };
1164
+ const providerNames = {
1165
+ anthropic: "Claude",
1166
+ openai: "OpenAI",
1167
+ gemini: "Gemini",
1168
+ ollama: "Ollama",
1169
+ openclaw: "OpenClaw",
1170
+ none: "Template"
1171
+ };
1172
+ const providerName = providerNames[config.ai.provider] || config.ai.provider;
1173
+ const modelName = config.ai.provider === "none" ? "template" : config.ai.model || DEFAULT_MODELS[config.ai.provider] || "default";
1174
+ console.log(`\xBB Generating with ${providerName} (${modelName})...`);
1175
+ let notes;
1176
+ if (config.ai.provider === "none") {
1177
+ const generator = new TemplateGenerator();
1178
+ notes = await generator.generate(context, config.ai);
1179
+ } else {
1180
+ const generator = new AIGenerator(config.openclaw);
1181
+ notes = await generator.generate(context, config.ai);
1182
+ }
1183
+ console.log(`\xBB Generated ${notes.changes.length} change entries`);
1184
+ const formatted = formatNotes(notes, format);
1185
+ const publishedTo = [];
1186
+ if (!options.dryRun) {
1187
+ for (const target of config.publish) {
1188
+ try {
1189
+ switch (target.type) {
1190
+ case "stdout":
1191
+ await new StdoutPublisher().publish(notes, format);
1192
+ publishedTo.push("stdout");
1193
+ break;
1194
+ case "file":
1195
+ if (target.path) {
1196
+ await new FilePublisher(target.path).publish(notes, format);
1197
+ publishedTo.push(`file:${target.path}`);
1198
+ }
1199
+ break;
1200
+ case "slack":
1201
+ if (target.webhookUrl) {
1202
+ await new SlackPublisher(target.webhookUrl).publish(notes, format);
1203
+ publishedTo.push("slack");
1204
+ }
1205
+ break;
1206
+ case "discord":
1207
+ if (target.webhookUrl) {
1208
+ await new DiscordPublisher(target.webhookUrl).publish(notes, format);
1209
+ publishedTo.push("discord");
1210
+ }
1211
+ break;
1212
+ case "github-release":
1213
+ await new GitHubReleasePublisher().publish(notes, format);
1214
+ publishedTo.push("github-release");
1215
+ break;
1216
+ }
1217
+ } catch (err) {
1218
+ console.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
1219
+ }
1220
+ }
1221
+ } else {
1222
+ console.log("\n[DRY RUN \u2014 Not publishing]\n");
1223
+ console.log(formatted);
1224
+ publishedTo.push("dry-run");
1225
+ }
1226
+ const duration = Date.now() - startTime;
1227
+ console.log(`
1228
+ \u2713 Done in ${(duration / 1e3).toFixed(1)}s`);
1229
+ return { notes, formatted, publishedTo, duration };
1230
+ }
1231
+ export {
1232
+ AIGenerator,
1233
+ DEFAULT_CATEGORIES,
1234
+ DEFAULT_MODELS,
1235
+ DiscordPublisher,
1236
+ FilePublisher,
1237
+ GitCollector,
1238
+ GitHubReleasePublisher,
1239
+ JiraCollector,
1240
+ JiraEnricher,
1241
+ LinearCollector,
1242
+ LinearEnricher,
1243
+ SlackPublisher,
1244
+ StdoutPublisher,
1245
+ TemplateGenerator,
1246
+ VERSION,
1247
+ formatNotes,
1248
+ getLatestTag,
1249
+ getRecentTags,
1250
+ runPipeline
1251
+ };