@cullit/core 0.1.0 → 0.4.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.
Files changed (3) hide show
  1. package/dist/index.d.ts +105 -153
  2. package/dist/index.js +369 -824
  3. package/package.json +5 -2
package/dist/index.js CHANGED
@@ -1,22 +1,50 @@
1
1
  // src/constants.ts
2
- var VERSION = "0.1.0";
2
+ var VERSION = "0.4.0";
3
3
  var DEFAULT_CATEGORIES = ["features", "fixes", "breaking", "improvements", "chores"];
4
4
  var DEFAULT_MODELS = {
5
5
  anthropic: "claude-sonnet-4-20250514",
6
6
  openai: "gpt-4o",
7
7
  gemini: "gemini-2.0-flash",
8
8
  ollama: "llama3.1",
9
- openclaw: "claude-sonnet-4-6"
9
+ openclaw: "anthropic/claude-sonnet-4-6"
10
10
  };
11
11
 
12
+ // src/logger.ts
13
+ function createLogger(level = "normal") {
14
+ return {
15
+ info(msg) {
16
+ if (level !== "quiet") console.log(msg);
17
+ },
18
+ verbose(msg) {
19
+ if (level === "verbose") console.log(msg);
20
+ },
21
+ warn(msg) {
22
+ if (level !== "quiet") console.warn(msg);
23
+ },
24
+ error(msg) {
25
+ console.error(msg);
26
+ }
27
+ };
28
+ }
29
+
12
30
  // src/collectors/git.ts
13
31
  import { execSync } from "child_process";
32
+ function validateRef(ref) {
33
+ if (!ref || ref.length > 256) {
34
+ throw new Error(`Invalid git ref: too ${ref ? "long" : "short"}`);
35
+ }
36
+ if (!/^[a-zA-Z0-9._\-/~^]+$/.test(ref)) {
37
+ throw new Error(`Invalid git ref "${ref}" \u2014 only alphanumeric, dots, dashes, underscores, slashes, tildes, and carets are allowed`);
38
+ }
39
+ }
14
40
  var GitCollector = class {
15
41
  cwd;
16
42
  constructor(cwd = process.cwd()) {
17
43
  this.cwd = cwd;
18
44
  }
19
45
  async collect(from, to) {
46
+ validateRef(from);
47
+ validateRef(to);
20
48
  const log = this.getLog(from, to);
21
49
  const commits = this.parseLog(log);
22
50
  return {
@@ -123,471 +151,28 @@ function getLatestTag(cwd = process.cwd()) {
123
151
  return null;
124
152
  }
125
153
  }
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
- }));
154
+ function getCommitsSince(from, to, cwd = process.cwd()) {
155
+ validateRef(from);
156
+ validateRef(to);
157
+ const format = "%H|%h|%an|%aI|%s";
158
+ const separator = "---CULLIT_COMMIT---";
159
+ const log = execSync(
160
+ `git log ${from}..${to} --format="${format}${separator}" --no-merges`,
161
+ { cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
162
+ );
163
+ if (!log.trim()) return [];
164
+ return log.split(separator).filter((e) => e.trim()).map((entry) => {
165
+ const [hash, shortHash, author, date, ...msgParts] = entry.trim().split("|");
232
166
  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"
167
+ hash: hash.trim(),
168
+ shortHash: shortHash.trim(),
169
+ author: author.trim(),
170
+ date: date.trim(),
171
+ message: msgParts.join("|").trim()
302
172
  };
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
- ]
173
+ });
444
174
  }
445
175
 
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
176
  // src/generators/template.ts
592
177
  var TemplateGenerator = class {
593
178
  async generate(context, config) {
@@ -612,14 +197,14 @@ var TemplateGenerator = class {
612
197
  return {
613
198
  version: diff.to,
614
199
  date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
615
- summary: this.buildSummary(deduped, diff.commits.length),
200
+ summary: this.buildSummary(deduped, diff.commits.length, config.tone),
616
201
  changes: deduped.slice(0, 20),
617
202
  contributors,
618
203
  metadata: {
619
204
  commitCount: diff.commits.length,
620
205
  prCount: diff.commits.filter((c) => c.prNumber).length,
621
206
  ticketCount: tickets.length,
622
- generatedBy: "cull-template",
207
+ generatedBy: "cullit-template",
623
208
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
624
209
  }
625
210
  };
@@ -682,7 +267,7 @@ var TemplateGenerator = class {
682
267
  }
683
268
  return result;
684
269
  }
685
- buildSummary(changes, commitCount) {
270
+ buildSummary(changes, commitCount, tone) {
686
271
  const counts = {};
687
272
  for (const c of changes) {
688
273
  counts[c.category] = (counts[c.category] || 0) + 1;
@@ -692,8 +277,14 @@ var TemplateGenerator = class {
692
277
  if (counts["features"]) parts.push(`${counts["features"]} feature${counts["features"] > 1 ? "s" : ""}`);
693
278
  if (counts["fixes"]) parts.push(`${counts["fixes"]} fix${counts["fixes"] > 1 ? "es" : ""}`);
694
279
  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;
280
+ if (tone === "terse") {
281
+ return parts.length > 0 ? parts.join(", ") : `${commitCount} commits`;
282
+ }
283
+ if (tone === "casual") {
284
+ if (parts.length === 0) return `A quick update with ${commitCount} commits \u2014 nothing too wild.`;
285
+ return `We've got ${parts.join(", ")} packed into ${commitCount} commits. Let's go!`;
286
+ }
287
+ return parts.length > 0 ? `This release includes ${parts.join(", ")} across ${commitCount} commits.` : `This release includes ${commitCount} commits.`;
697
288
  }
698
289
  };
699
290
 
@@ -755,7 +346,7 @@ function formatMarkdown(notes) {
755
346
  }
756
347
  if (notes.metadata) {
757
348
  lines.push("---");
758
- lines.push(`*Generated by [Cull](https://cullit.io) \u2022 ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs*`);
349
+ lines.push(`*Generated by [Cullit](https://cullit.io) \u2022 ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs*`);
759
350
  }
760
351
  return lines.join("\n");
761
352
  }
@@ -781,7 +372,7 @@ function formatHTML(notes) {
781
372
  html += `</ul>`;
782
373
  }
783
374
  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>`;
375
+ html += `<footer><small>Generated by <a href="https://cullit.io">Cullit</a> &bull; ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs</small></footer>`;
785
376
  }
786
377
  html += `</div>`;
787
378
  return html;
@@ -799,345 +390,272 @@ function groupByCategory(notes) {
799
390
  // src/publishers/index.ts
800
391
  import { writeFileSync } from "fs";
801
392
  var StdoutPublisher = class {
802
- async publish(notes, format) {
803
- const formatted = formatNotes(notes, format);
804
- console.log(formatted);
393
+ async publish(notes, format, preformatted) {
394
+ console.log(preformatted || formatNotes(notes, format));
805
395
  }
806
396
  };
807
397
  var FilePublisher = class {
808
398
  constructor(path) {
809
399
  this.path = path;
810
400
  }
811
- async publish(notes, format) {
812
- const formatted = formatNotes(notes, format);
813
- writeFileSync(this.path, formatted, "utf-8");
401
+ async publish(notes, format, preformatted) {
402
+ const output = preformatted || formatNotes(notes, format);
403
+ writeFileSync(this.path, output, "utf-8");
814
404
  console.log(`\u2713 Release notes written to ${this.path}`);
815
405
  }
816
406
  };
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
407
 
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
- }
408
+ // src/advisor.ts
409
+ import { execSync as execSync2 } from "child_process";
410
+ var COMMIT_PATTERNS = [
411
+ { pattern: /^breaking[(!:]|^BREAKING CHANGE/i, category: "breaking" },
412
+ { pattern: /!:/, category: "breaking" },
413
+ { pattern: /^feat[(!:]|^feature[(!:]/i, category: "features" },
414
+ { pattern: /^fix[(!:]/i, category: "fixes" },
415
+ { pattern: /^chore[(!:]|^docs[(!:]|^ci[(!:]|^test[(!:]|^style[(!:]|^refactor[(!:]/i, category: "chores" }
416
+ ];
417
+ function categorizeCommit(message) {
418
+ for (const { pattern, category } of COMMIT_PATTERNS) {
419
+ if (pattern.test(message)) return category;
924
420
  }
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;
421
+ return "other";
422
+ }
423
+ function bumpVersion(version, bump) {
424
+ const prefix = version.startsWith("v") ? "v" : "";
425
+ const clean = version.replace(/^v/, "");
426
+ const parts = clean.split(".").map(Number);
427
+ if (parts.length !== 3 || parts.some(isNaN)) return version;
428
+ switch (bump) {
429
+ case "major":
430
+ return `${prefix}${parts[0] + 1}.0.0`;
431
+ case "minor":
432
+ return `${prefix}${parts[0]}.${parts[1] + 1}.0`;
433
+ case "patch":
434
+ return `${prefix}${parts[0]}.${parts[1]}.${parts[2] + 1}`;
937
435
  }
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
- }
436
+ }
437
+ function getCommitsSinceTag(tag, cwd) {
438
+ try {
439
+ return getCommitsSince(tag, "HEAD", cwd);
440
+ } catch {
441
+ return [];
955
442
  }
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
- }
443
+ }
444
+ function getTagDate(tag, cwd) {
445
+ try {
446
+ const dateStr = execSync2(
447
+ `git log -1 --format=%aI ${tag}`,
448
+ { cwd, encoding: "utf-8" }
449
+ ).trim();
450
+ return new Date(dateStr);
451
+ } catch {
452
+ return null;
971
453
  }
972
- headers() {
454
+ }
455
+ function analyzeReleaseReadiness(cwd = process.cwd()) {
456
+ const latestTag = getLatestTag(cwd);
457
+ const reasons = [];
458
+ if (!latestTag) {
973
459
  return {
974
- "Accept": "application/vnd.github+json",
975
- "Authorization": `Bearer ${this.token}`,
976
- "X-GitHub-Api-Version": "2022-11-28"
460
+ shouldRelease: true,
461
+ suggestedBump: "minor",
462
+ currentVersion: null,
463
+ nextVersion: null,
464
+ commitCount: 0,
465
+ contributorCount: 0,
466
+ daysSinceRelease: null,
467
+ breakdown: { features: 0, fixes: 0, breaking: 0, chores: 0, other: 0 },
468
+ reasons: ["No tags found. Consider creating your first release."]
977
469
  };
978
470
  }
979
- };
471
+ const commits = getCommitsSinceTag(latestTag, cwd);
472
+ const commitCount = commits.length;
473
+ const breakdown = { features: 0, fixes: 0, breaking: 0, chores: 0, other: 0 };
474
+ for (const commit of commits) {
475
+ breakdown[categorizeCommit(commit.message)]++;
476
+ }
477
+ const contributors = new Set(commits.map((c) => c.author));
478
+ const tagDate = getTagDate(latestTag, cwd);
479
+ const daysSinceRelease = tagDate ? Math.floor((Date.now() - tagDate.getTime()) / (1e3 * 60 * 60 * 24)) : null;
480
+ let suggestedBump = "patch";
481
+ if (breakdown.breaking > 0) {
482
+ suggestedBump = "major";
483
+ reasons.push(`${breakdown.breaking} breaking change(s) detected \u2014 major bump recommended`);
484
+ } else if (breakdown.features > 0) {
485
+ suggestedBump = "minor";
486
+ reasons.push(`${breakdown.features} new feature(s) \u2014 minor bump recommended`);
487
+ } else if (breakdown.fixes > 0) {
488
+ reasons.push(`${breakdown.fixes} bug fix(es) \u2014 patch bump recommended`);
489
+ }
490
+ let shouldRelease = false;
491
+ if (breakdown.breaking > 0) {
492
+ shouldRelease = true;
493
+ reasons.push("\u26A0 Breaking changes should be released and communicated promptly");
494
+ }
495
+ const securityCommits = commits.filter(
496
+ (c) => /security|cve|vuln|exploit|xss|injection|auth.*(fix|patch)/i.test(c.message)
497
+ );
498
+ if (securityCommits.length > 0) {
499
+ shouldRelease = true;
500
+ reasons.push(`\u{1F512} ${securityCommits.length} security-related commit(s) \u2014 release ASAP`);
501
+ }
502
+ if (commitCount >= 5) {
503
+ shouldRelease = true;
504
+ reasons.push(`${commitCount} unreleased commits \u2014 consider releasing to keep changes small and reviewable`);
505
+ }
506
+ if (daysSinceRelease !== null && daysSinceRelease >= 14 && commitCount > 0) {
507
+ shouldRelease = true;
508
+ reasons.push(`${daysSinceRelease} days since last release \u2014 regular cadence helps users stay current`);
509
+ }
510
+ if (breakdown.features >= 3) {
511
+ shouldRelease = true;
512
+ reasons.push("Multiple features accumulated \u2014 users are missing out");
513
+ }
514
+ if (commitCount > 0 && commitCount < 5 && !shouldRelease) {
515
+ reasons.push(`${commitCount} commit(s) since ${latestTag} \u2014 no urgency, but keep an eye on it`);
516
+ }
517
+ if (commitCount === 0) {
518
+ reasons.push("No unreleased commits \u2014 you're up to date!");
519
+ }
520
+ const nextVersion = bumpVersion(latestTag, suggestedBump);
521
+ return {
522
+ shouldRelease,
523
+ suggestedBump,
524
+ currentVersion: latestTag,
525
+ nextVersion,
526
+ commitCount,
527
+ contributorCount: contributors.size,
528
+ daysSinceRelease,
529
+ breakdown,
530
+ reasons
531
+ };
532
+ }
980
533
 
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
- };
534
+ // src/gate.ts
535
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["none"]);
536
+ var FREE_PUBLISHERS = /* @__PURE__ */ new Set(["stdout", "file"]);
537
+ function resolveLicense() {
538
+ const key = process.env.CULLIT_API_KEY?.trim();
539
+ if (!key) {
540
+ return { tier: "free", valid: true };
541
+ }
542
+ if (!/^clt_[a-zA-Z0-9]{32,}$/.test(key)) {
543
+ return { tier: "free", valid: false, message: "Invalid CULLIT_API_KEY format. Expected: clt_<key>" };
544
+ }
545
+ return { tier: "pro", valid: true };
546
+ }
547
+ function isProviderAllowed(provider, license) {
548
+ if (license.tier === "pro" && license.valid) return true;
549
+ return FREE_PROVIDERS.has(provider);
550
+ }
551
+ function isPublisherAllowed(publisherType, license) {
552
+ if (license.tier === "pro" && license.valid) return true;
553
+ return FREE_PUBLISHERS.has(publisherType);
554
+ }
555
+ function isEnrichmentAllowed(license) {
556
+ return license.tier === "pro" && license.valid;
557
+ }
558
+ function upgradeMessage(feature) {
559
+ return `\u{1F512} ${feature} requires a Cullit Pro license.
560
+ Get your API key at https://cullit.io/pricing
561
+ Then set CULLIT_API_KEY in your environment.`;
562
+ }
1042
563
 
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})`);
564
+ // src/registry.ts
565
+ var collectors = /* @__PURE__ */ new Map();
566
+ var enrichers = /* @__PURE__ */ new Map();
567
+ var generators = /* @__PURE__ */ new Map();
568
+ var publishers = /* @__PURE__ */ new Map();
569
+ function registerCollector(type, factory) {
570
+ collectors.set(type, factory);
571
+ }
572
+ function registerEnricher(type, factory) {
573
+ enrichers.set(type, factory);
574
+ }
575
+ function registerGenerator(provider, factory) {
576
+ generators.set(provider, factory);
577
+ }
578
+ function registerPublisher(type, factory) {
579
+ publishers.set(type, factory);
580
+ }
581
+ function getCollector(type) {
582
+ return collectors.get(type);
583
+ }
584
+ function getEnricher(type) {
585
+ return enrichers.get(type);
586
+ }
587
+ function getGenerator(provider) {
588
+ return generators.get(provider);
589
+ }
590
+ function getPublisher(type) {
591
+ return publishers.get(type);
592
+ }
593
+ function hasGenerator(provider) {
594
+ return generators.has(provider);
595
+ }
596
+ function hasCollector(type) {
597
+ return collectors.has(type);
598
+ }
599
+ function hasPublisher(type) {
600
+ return publishers.has(type);
601
+ }
602
+ function hasEnricher(type) {
603
+ return enrichers.has(type);
604
+ }
605
+
606
+ // src/fetch.ts
607
+ var DEFAULT_TIMEOUT = 3e4;
608
+ async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT) {
609
+ const controller = new AbortController();
610
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
611
+ try {
612
+ return await fetch(url, { ...init, signal: controller.signal });
613
+ } catch (err) {
614
+ if (err.name === "AbortError") {
615
+ throw new Error(`Request to ${new URL(url).hostname} timed out after ${timeoutMs / 1e3}s`);
1099
616
  }
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
- };
617
+ throw err;
618
+ } finally {
619
+ clearTimeout(timer);
1119
620
  }
1120
- };
621
+ }
1121
622
 
1122
623
  // src/index.ts
624
+ registerCollector("local", () => new GitCollector());
625
+ registerGenerator("none", () => new TemplateGenerator());
626
+ registerPublisher("stdout", () => new StdoutPublisher());
627
+ registerPublisher("file", (path) => new FilePublisher(path));
1123
628
  async function runPipeline(from, to, config, options = {}) {
1124
629
  const startTime = Date.now();
1125
630
  const format = options.format || "markdown";
631
+ const log = options.logger || createLogger("normal");
632
+ const license = resolveLicense();
633
+ if (!license.valid) {
634
+ throw new Error(license.message || "Invalid CULLIT_API_KEY");
635
+ }
636
+ if (!isProviderAllowed(config.ai.provider, license)) {
637
+ throw new Error(upgradeMessage(`AI provider "${config.ai.provider}"`));
638
+ }
639
+ const collectorFactory = getCollector(config.source.type);
640
+ if (!collectorFactory) {
641
+ throw new Error(
642
+ `Source type "${config.source.type}" is not available. ` + (config.source.type === "jira" || config.source.type === "linear" ? "Install @cullit/pro to use this source." : "Valid sources: local")
643
+ );
644
+ }
645
+ const sourceLabel = config.source.type === "jira" ? "issues from Jira" : config.source.type === "linear" ? "issues from Linear" : `commits between ${from}..${to}`;
646
+ log.info(`\xBB Collecting ${sourceLabel}`);
1126
647
  let collector;
1127
648
  if (config.source.type === "jira") {
1128
649
  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);
650
+ collector = collectorFactory(config.jira);
1131
651
  } else if (config.source.type === "linear") {
1132
- console.log(`\xBB Collecting issues from Linear...`);
1133
- collector = new LinearCollector(config.linear?.apiKey);
652
+ collector = collectorFactory(config.linear?.apiKey);
1134
653
  } else {
1135
- console.log(`\xBB Collecting commits between ${from}..${to}`);
1136
- collector = new GitCollector();
654
+ collector = collectorFactory();
1137
655
  }
1138
656
  const diff = await collector.collect(from, to);
1139
657
  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` : ""}`);
658
+ log.info(`\xBB Found ${diff.commits.length} ${itemLabel}${diff.filesChanged ? `, ${diff.filesChanged} files changed` : ""}`);
1141
659
  if (diff.commits.length === 0) {
1142
660
  const source = config.source.type === "jira" ? "Jira" : config.source.type === "linear" ? "Linear" : `${from} and ${to}`;
1143
661
  throw new Error(`No ${itemLabel} found from ${source}`);
@@ -1145,20 +663,27 @@ async function runPipeline(from, to, config, options = {}) {
1145
663
  const tickets = [];
1146
664
  const enrichmentSources = config.source.enrichment || [];
1147
665
  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`);
666
+ if (!isEnrichmentAllowed(license)) {
667
+ log.info(`\xBB Skipping ${source} enrichment \u2014 ${upgradeMessage(`${source} enrichment`)}`);
668
+ continue;
1154
669
  }
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`);
670
+ const enricherFactory = getEnricher(source);
671
+ if (!enricherFactory) {
672
+ log.info(`\xBB Skipping ${source} enrichment \u2014 install @cullit/pro to enable`);
673
+ continue;
1161
674
  }
675
+ log.info(`\xBB Enriching from ${source}...`);
676
+ let enricher;
677
+ if (source === "jira" && config.jira) {
678
+ enricher = enricherFactory(config.jira);
679
+ } else if (source === "linear") {
680
+ enricher = enricherFactory(config.linear?.apiKey);
681
+ } else {
682
+ continue;
683
+ }
684
+ const enrichedTickets = await enricher.enrich(diff);
685
+ tickets.push(...enrichedTickets);
686
+ log.info(`\xBB ${source}: found ${enrichedTickets.length} ${source === "jira" ? "tickets" : "issues"}`);
1162
687
  }
1163
688
  const context = { diff, tickets };
1164
689
  const providerNames = {
@@ -1171,81 +696,101 @@ async function runPipeline(from, to, config, options = {}) {
1171
696
  };
1172
697
  const providerName = providerNames[config.ai.provider] || config.ai.provider;
1173
698
  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;
699
+ log.info(`\xBB Generating with ${providerName} (${modelName})...`);
700
+ const generatorFactory = getGenerator(config.ai.provider);
701
+ if (!generatorFactory) {
702
+ throw new Error(
703
+ `AI provider "${config.ai.provider}" is not available. ` + (config.ai.provider !== "none" ? "Install @cullit/pro to use AI providers." : "")
704
+ );
705
+ }
706
+ let generator;
1176
707
  if (config.ai.provider === "none") {
1177
- const generator = new TemplateGenerator();
1178
- notes = await generator.generate(context, config.ai);
708
+ generator = generatorFactory();
1179
709
  } else {
1180
- const generator = new AIGenerator(config.openclaw);
1181
- notes = await generator.generate(context, config.ai);
710
+ generator = generatorFactory(config.openclaw);
1182
711
  }
1183
- console.log(`\xBB Generated ${notes.changes.length} change entries`);
712
+ const notes = await generator.generate(context, config.ai);
713
+ log.info(`\xBB Generated ${notes.changes.length} change entries`);
1184
714
  const formatted = formatNotes(notes, format);
1185
715
  const publishedTo = [];
1186
716
  if (!options.dryRun) {
1187
717
  for (const target of config.publish) {
1188
718
  try {
719
+ if (!isPublisherAllowed(target.type, license)) {
720
+ log.info(`\xBB Skipping ${target.type} \u2014 ${upgradeMessage(`${target.type} publishing`)}`);
721
+ continue;
722
+ }
723
+ const publisherFactory = getPublisher(target.type);
724
+ if (!publisherFactory) {
725
+ log.info(`\xBB Skipping ${target.type} \u2014 install @cullit/pro to enable`);
726
+ continue;
727
+ }
728
+ let publisher;
1189
729
  switch (target.type) {
1190
730
  case "stdout":
1191
- await new StdoutPublisher().publish(notes, format);
1192
- publishedTo.push("stdout");
731
+ publisher = publisherFactory();
1193
732
  break;
1194
733
  case "file":
1195
- if (target.path) {
1196
- await new FilePublisher(target.path).publish(notes, format);
1197
- publishedTo.push(`file:${target.path}`);
1198
- }
734
+ if (!target.path) continue;
735
+ publisher = publisherFactory(target.path);
1199
736
  break;
1200
737
  case "slack":
1201
- if (target.webhookUrl) {
1202
- await new SlackPublisher(target.webhookUrl).publish(notes, format);
1203
- publishedTo.push("slack");
1204
- }
1205
- break;
1206
738
  case "discord":
1207
- if (target.webhookUrl) {
1208
- await new DiscordPublisher(target.webhookUrl).publish(notes, format);
1209
- publishedTo.push("discord");
1210
- }
739
+ if (!target.webhookUrl) continue;
740
+ publisher = publisherFactory(target.webhookUrl);
1211
741
  break;
1212
742
  case "github-release":
1213
- await new GitHubReleasePublisher().publish(notes, format);
1214
- publishedTo.push("github-release");
743
+ publisher = publisherFactory();
1215
744
  break;
745
+ default:
746
+ continue;
1216
747
  }
748
+ await publisher.publish(notes, format, formatted);
749
+ publishedTo.push(target.type === "file" ? `file:${target.path}` : target.type);
1217
750
  } catch (err) {
1218
- console.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
751
+ log.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
1219
752
  }
1220
753
  }
1221
754
  } else {
1222
- console.log("\n[DRY RUN \u2014 Not publishing]\n");
1223
- console.log(formatted);
755
+ log.info("\n[DRY RUN \u2014 Not publishing]\n");
756
+ log.info(formatted);
1224
757
  publishedTo.push("dry-run");
1225
758
  }
1226
759
  const duration = Date.now() - startTime;
1227
- console.log(`
760
+ log.info(`
1228
761
  \u2713 Done in ${(duration / 1e3).toFixed(1)}s`);
1229
762
  return { notes, formatted, publishedTo, duration };
1230
763
  }
1231
764
  export {
1232
- AIGenerator,
1233
765
  DEFAULT_CATEGORIES,
1234
766
  DEFAULT_MODELS,
1235
- DiscordPublisher,
1236
767
  FilePublisher,
1237
768
  GitCollector,
1238
- GitHubReleasePublisher,
1239
- JiraCollector,
1240
- JiraEnricher,
1241
- LinearCollector,
1242
- LinearEnricher,
1243
- SlackPublisher,
1244
769
  StdoutPublisher,
1245
770
  TemplateGenerator,
1246
771
  VERSION,
772
+ analyzeReleaseReadiness,
773
+ createLogger,
774
+ fetchWithTimeout,
1247
775
  formatNotes,
776
+ getCollector,
777
+ getEnricher,
778
+ getGenerator,
1248
779
  getLatestTag,
780
+ getPublisher,
1249
781
  getRecentTags,
1250
- runPipeline
782
+ hasCollector,
783
+ hasEnricher,
784
+ hasGenerator,
785
+ hasPublisher,
786
+ isEnrichmentAllowed,
787
+ isProviderAllowed,
788
+ isPublisherAllowed,
789
+ registerCollector,
790
+ registerEnricher,
791
+ registerGenerator,
792
+ registerPublisher,
793
+ resolveLicense,
794
+ runPipeline,
795
+ upgradeMessage
1251
796
  };