@cullit/core 0.3.0 → 0.5.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 +65 -124
  2. package/dist/index.js +246 -980
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,13 +1,21 @@
1
1
  // src/constants.ts
2
- var VERSION = "0.3.0";
2
+ var VERSION = "0.5.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
+ var AI_PROVIDERS = ["anthropic", "openai", "gemini", "ollama", "openclaw", "none"];
12
+ var OUTPUT_FORMATS = ["markdown", "html", "json"];
13
+ var PUBLISHER_TYPES = ["stdout", "file", "slack", "discord", "github-release"];
14
+ var ENRICHMENT_TYPES = ["jira", "linear"];
15
+ var CHANGE_CATEGORIES = ["features", "fixes", "breaking", "improvements", "chores", "other"];
16
+ var AUDIENCES = ["developer", "end-user", "executive"];
17
+ var TONES = ["professional", "casual", "terse"];
18
+ var SOURCE_TYPES = ["local", "jira", "linear"];
11
19
 
12
20
  // src/logger.ts
13
21
  function createLogger(level = "normal") {
@@ -151,510 +159,28 @@ function getLatestTag(cwd = process.cwd()) {
151
159
  return null;
152
160
  }
153
161
  }
154
-
155
- // src/fetch.ts
156
- var DEFAULT_TIMEOUT = 3e4;
157
- async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT) {
158
- const controller = new AbortController();
159
- const timer = setTimeout(() => controller.abort(), timeoutMs);
160
- try {
161
- return await fetch(url, { ...init, signal: controller.signal });
162
- } catch (err) {
163
- if (err.name === "AbortError") {
164
- throw new Error(`Request to ${new URL(url).hostname} timed out after ${timeoutMs / 1e3}s`);
165
- }
166
- throw err;
167
- } finally {
168
- clearTimeout(timer);
169
- }
170
- }
171
-
172
- // src/collectors/jira.ts
173
- var JiraCollector = class {
174
- config;
175
- constructor(config) {
176
- this.config = config;
177
- }
178
- async collect(from, to) {
179
- const jql = this.buildJQL(from, to);
180
- const issues = await this.fetchIssues(jql);
181
- const commits = issues.map((issue) => ({
182
- hash: issue.key,
183
- shortHash: issue.key,
184
- author: issue.assignee || "unassigned",
185
- date: issue.resolved || issue.updated || (/* @__PURE__ */ new Date()).toISOString(),
186
- message: `${issue.type ? `[${issue.type}] ` : ""}${issue.summary}`,
187
- body: issue.description?.substring(0, 500),
188
- issueKeys: [issue.key]
189
- }));
190
- return {
191
- from: `jira:${from}`,
192
- to: to === "HEAD" ? `jira:${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}` : `jira:${to}`,
193
- commits,
194
- filesChanged: 0
195
- };
196
- }
197
- buildJQL(from, to) {
198
- if (from.includes("=") || from.includes("AND") || from.includes("OR")) {
199
- const statusFilter = " AND status in (Done, Closed, Resolved)";
200
- return from.includes("status") ? from : from + statusFilter;
201
- }
202
- if (!/^[A-Z][A-Z0-9_]{0,30}$/.test(from)) {
203
- throw new Error(`Invalid Jira project key: "${from}". Must be uppercase letters, digits, or underscores (e.g., PROJ, MY_PROJ).`);
204
- }
205
- const safeVersion = to.replace(/["'\\]/g, "");
206
- if (to === "HEAD") {
207
- return `project = ${from} AND status in (Done, Closed, Resolved) AND resolved >= -30d ORDER BY resolved DESC`;
208
- }
209
- return `project = ${from} AND fixVersion = "${safeVersion}" AND status in (Done, Closed, Resolved) ORDER BY resolved DESC`;
210
- }
211
- async fetchIssues(jql) {
212
- const { domain, email, apiToken } = this.config;
213
- if (!/^[a-zA-Z0-9.-]+\.atlassian\.net$/.test(domain)) {
214
- throw new Error(`Invalid Jira domain: "${domain}". Expected format: yourcompany.atlassian.net`);
215
- }
216
- const resolvedEmail = email || process.env.JIRA_EMAIL;
217
- const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
218
- if (!resolvedEmail || !resolvedToken) {
219
- throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
220
- }
221
- const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
222
- const issues = [];
223
- let startAt = 0;
224
- const maxResults = 50;
225
- while (true) {
226
- const url = new URL(`https://${domain}/rest/api/3/search`);
227
- url.searchParams.set("jql", jql);
228
- url.searchParams.set("startAt", String(startAt));
229
- url.searchParams.set("maxResults", String(maxResults));
230
- url.searchParams.set("fields", "summary,issuetype,assignee,status,resolution,resolutiondate,updated,labels,priority,description,fixVersions");
231
- const response = await fetchWithTimeout(url.toString(), {
232
- headers: {
233
- "Authorization": `Basic ${auth}`,
234
- "Accept": "application/json"
235
- }
236
- });
237
- if (!response.ok) {
238
- const error = await response.text();
239
- throw new Error(`Jira API error (${response.status}): ${error}`);
240
- }
241
- const data = await response.json();
242
- const batch = (data.issues || []).map((issue) => ({
243
- key: issue.key,
244
- summary: issue.fields.summary,
245
- type: issue.fields.issuetype?.name?.toLowerCase(),
246
- assignee: issue.fields.assignee?.displayName,
247
- status: issue.fields.status?.name,
248
- resolved: issue.fields.resolutiondate,
249
- updated: issue.fields.updated,
250
- description: issue.fields.description?.content?.[0]?.content?.[0]?.text,
251
- labels: issue.fields.labels || [],
252
- priority: issue.fields.priority?.name
253
- }));
254
- issues.push(...batch);
255
- if (issues.length >= data.total || batch.length < maxResults) break;
256
- startAt += maxResults;
257
- }
258
- return issues;
259
- }
260
- };
261
-
262
- // src/collectors/linear.ts
263
- var LinearCollector = class {
264
- apiKey;
265
- constructor(apiKey) {
266
- const resolved = apiKey || process.env.LINEAR_API_KEY;
267
- if (!resolved) {
268
- throw new Error("Linear API key not configured. Set LINEAR_API_KEY.");
269
- }
270
- this.apiKey = resolved;
271
- }
272
- async collect(from, to) {
273
- const filter = this.parseFilter(from);
274
- const issues = await this.fetchIssues(filter);
275
- const commits = issues.map((issue) => ({
276
- hash: issue.identifier,
277
- shortHash: issue.identifier,
278
- author: issue.assignee || "unassigned",
279
- date: issue.completedAt || issue.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
280
- message: `${issue.type ? `[${issue.type}] ` : ""}${issue.title}`,
281
- body: issue.description?.substring(0, 500),
282
- issueKeys: [issue.identifier]
283
- }));
162
+ function getCommitsSince(from, to, cwd = process.cwd()) {
163
+ validateRef(from);
164
+ validateRef(to);
165
+ const format = "%H|%h|%an|%aI|%s";
166
+ const separator = "---CULLIT_COMMIT---";
167
+ const log = execSync(
168
+ `git log ${from}..${to} --format="${format}${separator}" --no-merges`,
169
+ { cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
170
+ );
171
+ if (!log.trim()) return [];
172
+ return log.split(separator).filter((e) => e.trim()).map((entry) => {
173
+ const [hash, shortHash, author, date, ...msgParts] = entry.trim().split("|");
284
174
  return {
285
- from: `linear:${from}`,
286
- to: to === "HEAD" ? `linear:${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}` : `linear:${to}`,
287
- commits,
288
- filesChanged: 0
289
- };
290
- }
291
- parseFilter(from) {
292
- const [type, ...valueParts] = from.split(":");
293
- const value = valueParts.join(":") || type;
294
- switch (type.toLowerCase()) {
295
- case "team":
296
- return { type: "team", value };
297
- case "project":
298
- return { type: "project", value };
299
- case "cycle":
300
- return { type: "cycle", value };
301
- case "label":
302
- return { type: "label", value };
303
- default:
304
- return { type: "team", value: from };
305
- }
306
- }
307
- async fetchIssues(filter) {
308
- const filterClause = this.buildFilterClause(filter);
309
- const needsVariable = filter.type !== "cycle" || filter.value !== "current";
310
- const query = needsVariable ? `
311
- query CompletedIssues($filterValue: String!) {
312
- issues(
313
- filter: {
314
- state: { type: { in: ["completed", "canceled"] } }
315
- ${filterClause}
316
- }
317
- first: 100
318
- orderBy: completedAt
319
- ) {
320
- nodes {
321
- identifier
322
- title
323
- description
324
- priority
325
- completedAt
326
- updatedAt
327
- assignee { displayName }
328
- state { name type }
329
- labels { nodes { name } }
330
- project { name }
331
- }
332
- }
333
- }
334
- ` : `
335
- query CompletedIssues {
336
- issues(
337
- filter: {
338
- state: { type: { in: ["completed", "canceled"] } }
339
- ${filterClause}
340
- }
341
- first: 100
342
- orderBy: completedAt
343
- ) {
344
- nodes {
345
- identifier
346
- title
347
- description
348
- priority
349
- completedAt
350
- updatedAt
351
- assignee { displayName }
352
- state { name type }
353
- labels { nodes { name } }
354
- project { name }
355
- }
356
- }
357
- }
358
- `;
359
- const variables = needsVariable ? { filterValue: filter.value } : void 0;
360
- const response = await fetchWithTimeout("https://api.linear.app/graphql", {
361
- method: "POST",
362
- headers: {
363
- "Content-Type": "application/json",
364
- "Authorization": `Bearer ${this.apiKey}`
365
- },
366
- body: JSON.stringify({ query, variables })
367
- });
368
- if (!response.ok) {
369
- const error = await response.text();
370
- throw new Error(`Linear API error (${response.status}): ${error}`);
371
- }
372
- const data = await response.json();
373
- const nodes = data.data?.issues?.nodes || [];
374
- const priorityMap = {
375
- 0: "none",
376
- 1: "urgent",
377
- 2: "high",
378
- 3: "medium",
379
- 4: "low"
380
- };
381
- return nodes.map((issue) => ({
382
- identifier: issue.identifier,
383
- title: issue.title,
384
- description: issue.description?.substring(0, 500),
385
- type: issue.labels?.nodes?.[0]?.name?.toLowerCase(),
386
- assignee: issue.assignee?.displayName,
387
- status: issue.state?.name,
388
- completedAt: issue.completedAt,
389
- updatedAt: issue.updatedAt,
390
- labels: issue.labels?.nodes?.map((l) => l.name) || [],
391
- priority: priorityMap[issue.priority]
392
- }));
393
- }
394
- buildFilterClause(filter) {
395
- switch (filter.type) {
396
- case "team":
397
- return `team: { key: { eq: $filterValue } }`;
398
- case "project":
399
- return `project: { name: { containsIgnoreCase: $filterValue } }`;
400
- case "cycle":
401
- if (filter.value === "current") {
402
- return `cycle: { isActive: { eq: true } }`;
403
- }
404
- return `cycle: { name: { containsIgnoreCase: $filterValue } }`;
405
- case "label":
406
- return `labels: { name: { eq: $filterValue } }`;
407
- default:
408
- return "";
409
- }
410
- }
411
- };
412
-
413
- // src/generators/ai.ts
414
- var AIGenerator = class {
415
- openclawConfig;
416
- timeoutMs;
417
- constructor(openclawConfig, timeoutMs = 6e4) {
418
- this.openclawConfig = openclawConfig;
419
- this.timeoutMs = timeoutMs;
420
- }
421
- async fetch(url, init) {
422
- return fetchWithTimeout(url, init, this.timeoutMs);
423
- }
424
- async generate(context, config) {
425
- const prompt = this.buildPrompt(context, config);
426
- const apiKey = this.resolveApiKey(config);
427
- let rawResponse;
428
- if (config.provider === "anthropic") {
429
- rawResponse = await this.callAnthropic(prompt, apiKey, config.model);
430
- } else if (config.provider === "openai") {
431
- rawResponse = await this.callOpenAI(prompt, apiKey, config.model);
432
- } else if (config.provider === "gemini") {
433
- rawResponse = await this.callGemini(prompt, apiKey, config.model);
434
- } else if (config.provider === "ollama") {
435
- rawResponse = await this.callOllama(prompt, config.model);
436
- } else if (config.provider === "openclaw") {
437
- rawResponse = await this.callOpenClaw(prompt, config.model);
438
- } else {
439
- throw new Error(`Unsupported AI provider: ${config.provider}`);
440
- }
441
- return this.parseResponse(rawResponse, context);
442
- }
443
- resolveApiKey(config) {
444
- if (config.apiKey) return config.apiKey;
445
- if (config.provider === "ollama" || config.provider === "openclaw") return "";
446
- const envVarMap = {
447
- anthropic: "ANTHROPIC_API_KEY",
448
- openai: "OPENAI_API_KEY",
449
- gemini: "GOOGLE_API_KEY"
450
- };
451
- const envVar = envVarMap[config.provider];
452
- if (!envVar) throw new Error(`Unknown provider: ${config.provider}`);
453
- const key = process.env[envVar];
454
- if (!key) {
455
- throw new Error(
456
- `No API key found. Set ${envVar} in your environment or provide it in .cullit.yml under ai.apiKey`
457
- );
458
- }
459
- return key;
460
- }
461
- buildPrompt(context, config) {
462
- const { diff, tickets } = context;
463
- const commitList = diff.commits.map((c) => {
464
- let line = `- ${c.shortHash}: ${c.message}`;
465
- if (c.issueKeys?.length) line += ` [${c.issueKeys.join(", ")}]`;
466
- return line;
467
- }).join("\n");
468
- const ticketList = tickets.length > 0 ? tickets.map(
469
- (t) => `- ${t.key}: ${t.title}${t.type ? ` (${t.type})` : ""}${t.labels?.length ? ` [${t.labels.join(", ")}]` : ""}`
470
- ).join("\n") : "No enrichment data available.";
471
- const audienceInstructions = {
472
- "developer": "Write for developers. Include technical details, API changes, and migration notes.",
473
- "end-user": "Write for end users. Use plain language. Focus on benefits and behavior changes. No jargon.",
474
- "executive": "Write a brief executive summary. Focus on business impact, key metrics, and strategic changes."
475
- };
476
- const toneInstructions = {
477
- "professional": "Tone: professional and clear.",
478
- "casual": "Tone: conversational and approachable, but still informative.",
479
- "terse": "Tone: minimal and direct. Short bullet points only."
175
+ hash: hash.trim(),
176
+ shortHash: shortHash.trim(),
177
+ author: author.trim(),
178
+ date: date.trim(),
179
+ message: msgParts.join("|").trim()
480
180
  };
481
- const categories = config.categories.join(", ");
482
- return `You are a release notes generator. Analyze the following git commits and related tickets, then produce structured release notes.
483
-
484
- ## Input
485
-
486
- ### Commits (${diff.from} \u2192 ${diff.to})
487
- ${commitList}
488
-
489
- ### Related Tickets
490
- ${ticketList}
491
-
492
- ## Instructions
493
-
494
- ${audienceInstructions[config.audience]}
495
- ${toneInstructions[config.tone]}
496
-
497
- Categorize each change into one of: ${categories}
498
-
499
- ## Output Format
500
-
501
- Respond with ONLY valid JSON (no markdown, no backticks, no preamble):
502
- {
503
- "summary": "One paragraph summarizing this release",
504
- "changes": [
505
- {
506
- "description": "Human-readable description of the change",
507
- "category": "features|fixes|breaking|improvements|chores",
508
- "ticketKey": "PROJ-123 or null"
509
- }
510
- ]
181
+ });
511
182
  }
512
183
 
513
- Rules:
514
- - Combine related commits into single change entries
515
- - Skip trivial commits (merge commits, formatting, typos) unless they fix bugs
516
- - Each description should be one clear sentence
517
- - Include ticket keys when available
518
- - Group by category
519
- - Maximum 20 change entries
520
- - If a commit message mentions a breaking change, categorize it as "breaking"`;
521
- }
522
- async callAnthropic(prompt, apiKey, model) {
523
- const response = await this.fetch("https://api.anthropic.com/v1/messages", {
524
- method: "POST",
525
- headers: {
526
- "Content-Type": "application/json",
527
- "x-api-key": apiKey,
528
- "anthropic-version": "2023-06-01"
529
- },
530
- body: JSON.stringify({
531
- model: model || "claude-sonnet-4-20250514",
532
- max_tokens: 4096,
533
- messages: [{ role: "user", content: prompt }]
534
- })
535
- });
536
- if (!response.ok) {
537
- const error = await response.text();
538
- throw new Error(`Anthropic API error (${response.status}): ${error}`);
539
- }
540
- const data = await response.json();
541
- return data.content[0]?.text || "";
542
- }
543
- async callOpenAI(prompt, apiKey, model) {
544
- const response = await this.fetch("https://api.openai.com/v1/chat/completions", {
545
- method: "POST",
546
- headers: {
547
- "Content-Type": "application/json",
548
- "Authorization": `Bearer ${apiKey}`
549
- },
550
- body: JSON.stringify({
551
- model: model || "gpt-4o",
552
- messages: [{ role: "user", content: prompt }],
553
- max_tokens: 4096,
554
- temperature: 0.3
555
- })
556
- });
557
- if (!response.ok) {
558
- const error = await response.text();
559
- throw new Error(`OpenAI API error (${response.status}): ${error}`);
560
- }
561
- const data = await response.json();
562
- return data.choices[0]?.message?.content || "";
563
- }
564
- async callGemini(prompt, apiKey, model) {
565
- const modelId = model || "gemini-2.0-flash";
566
- const response = await this.fetch(
567
- `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`,
568
- {
569
- method: "POST",
570
- headers: { "Content-Type": "application/json" },
571
- body: JSON.stringify({
572
- contents: [{ parts: [{ text: prompt }] }],
573
- generationConfig: { temperature: 0.3, maxOutputTokens: 4096 }
574
- })
575
- }
576
- );
577
- if (!response.ok) {
578
- const error = await response.text();
579
- throw new Error(`Gemini API error (${response.status}): ${error}`);
580
- }
581
- const data = await response.json();
582
- return data.candidates?.[0]?.content?.parts?.[0]?.text || "";
583
- }
584
- async callOllama(prompt, model) {
585
- const baseUrl = process.env.OLLAMA_HOST || "http://localhost:11434";
586
- const response = await this.fetch(`${baseUrl}/api/chat`, {
587
- method: "POST",
588
- headers: { "Content-Type": "application/json" },
589
- body: JSON.stringify({
590
- model: model || "llama3.1",
591
- messages: [{ role: "user", content: prompt }],
592
- stream: false,
593
- options: { temperature: 0.3 }
594
- })
595
- });
596
- if (!response.ok) {
597
- const error = await response.text();
598
- throw new Error(`Ollama API error (${response.status}): ${error}`);
599
- }
600
- const data = await response.json();
601
- return data.message?.content || "";
602
- }
603
- async callOpenClaw(prompt, model) {
604
- const baseUrl = this.openclawConfig?.baseUrl || process.env.OPENCLAW_URL || "http://localhost:18789";
605
- const token = this.openclawConfig?.token || process.env.OPENCLAW_TOKEN || "";
606
- const headers = { "Content-Type": "application/json" };
607
- if (token) headers["Authorization"] = `Bearer ${token}`;
608
- const response = await this.fetch(`${baseUrl}/v1/chat/completions`, {
609
- method: "POST",
610
- headers,
611
- body: JSON.stringify({
612
- model: model || "anthropic/claude-sonnet-4-6",
613
- messages: [{ role: "user", content: prompt }],
614
- max_tokens: 4096,
615
- temperature: 0.3
616
- })
617
- });
618
- if (!response.ok) {
619
- const error = await response.text();
620
- throw new Error(`OpenClaw API error (${response.status}): ${error}`);
621
- }
622
- const data = await response.json();
623
- return data.choices?.[0]?.message?.content || "";
624
- }
625
- parseResponse(raw, context) {
626
- const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
627
- let parsed;
628
- try {
629
- parsed = JSON.parse(cleaned);
630
- } catch {
631
- throw new Error(`Failed to parse AI response as JSON. Raw response:
632
- ${raw.substring(0, 500)}`);
633
- }
634
- const validCategories = /* @__PURE__ */ new Set(["features", "fixes", "breaking", "improvements", "chores", "other"]);
635
- const changes = (parsed.changes || []).map((c) => ({
636
- description: c.description,
637
- category: validCategories.has(c.category) ? c.category : "other",
638
- ticketKey: c.ticketKey || void 0
639
- }));
640
- const contributors = [...new Set(context.diff.commits.map((c) => c.author))];
641
- return {
642
- version: context.diff.to,
643
- date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
644
- summary: parsed.summary,
645
- changes,
646
- contributors,
647
- metadata: {
648
- commitCount: context.diff.commits.length,
649
- prCount: context.diff.commits.filter((c) => c.prNumber).length,
650
- ticketCount: context.tickets.length,
651
- generatedBy: "cullit",
652
- generatedAt: (/* @__PURE__ */ new Date()).toISOString()
653
- }
654
- };
655
- }
656
- };
657
-
658
184
  // src/generators/template.ts
659
185
  var TemplateGenerator = class {
660
186
  async generate(context, config) {
@@ -679,7 +205,7 @@ var TemplateGenerator = class {
679
205
  return {
680
206
  version: diff.to,
681
207
  date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
682
- summary: this.buildSummary(deduped, diff.commits.length),
208
+ summary: this.buildSummary(deduped, diff.commits.length, config.tone),
683
209
  changes: deduped.slice(0, 20),
684
210
  contributors,
685
211
  metadata: {
@@ -749,7 +275,7 @@ var TemplateGenerator = class {
749
275
  }
750
276
  return result;
751
277
  }
752
- buildSummary(changes, commitCount) {
278
+ buildSummary(changes, commitCount, tone) {
753
279
  const counts = {};
754
280
  for (const c of changes) {
755
281
  counts[c.category] = (counts[c.category] || 0) + 1;
@@ -759,12 +285,28 @@ var TemplateGenerator = class {
759
285
  if (counts["features"]) parts.push(`${counts["features"]} feature${counts["features"] > 1 ? "s" : ""}`);
760
286
  if (counts["fixes"]) parts.push(`${counts["fixes"]} fix${counts["fixes"] > 1 ? "es" : ""}`);
761
287
  if (counts["improvements"]) parts.push(`${counts["improvements"]} improvement${counts["improvements"] > 1 ? "s" : ""}`);
762
- const summary = parts.length > 0 ? `This release includes ${parts.join(", ")} across ${commitCount} commits.` : `This release includes ${commitCount} commits.`;
763
- return summary;
288
+ if (tone === "terse") {
289
+ return parts.length > 0 ? parts.join(", ") : `${commitCount} commits`;
290
+ }
291
+ if (tone === "casual") {
292
+ if (parts.length === 0) return `A quick update with ${commitCount} commits \u2014 nothing too wild.`;
293
+ return `We've got ${parts.join(", ")} packed into ${commitCount} commits. Let's go!`;
294
+ }
295
+ return parts.length > 0 ? `This release includes ${parts.join(", ")} across ${commitCount} commits.` : `This release includes ${commitCount} commits.`;
764
296
  }
765
297
  };
766
298
 
767
299
  // src/formatter.ts
300
+ var formatters = /* @__PURE__ */ new Map();
301
+ function registerFormatter(format, fn) {
302
+ formatters.set(format, fn);
303
+ }
304
+ function getFormatter(format) {
305
+ return formatters.get(format);
306
+ }
307
+ function listFormatters() {
308
+ return Array.from(formatters.keys());
309
+ }
768
310
  var CATEGORY_LABELS = {
769
311
  features: "\u2728 Features",
770
312
  fixes: "\u{1F41B} Bug Fixes",
@@ -782,16 +324,8 @@ var CATEGORY_ORDER = [
782
324
  "other"
783
325
  ];
784
326
  function formatNotes(notes, format) {
785
- switch (format) {
786
- case "markdown":
787
- return formatMarkdown(notes);
788
- case "html":
789
- return formatHTML(notes);
790
- case "json":
791
- return JSON.stringify(notes, null, 2);
792
- default:
793
- return formatMarkdown(notes);
794
- }
327
+ const fn = formatters.get(format) || formatters.get("markdown");
328
+ return fn(notes);
795
329
  }
796
330
  function formatMarkdown(notes) {
797
331
  const lines = [];
@@ -862,393 +396,27 @@ function groupByCategory(notes) {
862
396
  }
863
397
  return grouped;
864
398
  }
399
+ registerFormatter("markdown", formatMarkdown);
400
+ registerFormatter("html", formatHTML);
401
+ registerFormatter("json", (notes) => JSON.stringify(notes, null, 2));
865
402
 
866
403
  // src/publishers/index.ts
867
404
  import { writeFileSync } from "fs";
868
405
  var StdoutPublisher = class {
869
- async publish(notes, format) {
870
- const formatted = formatNotes(notes, format);
871
- console.log(formatted);
406
+ async publish(notes, format, preformatted) {
407
+ console.log(preformatted || formatNotes(notes, format));
872
408
  }
873
409
  };
874
410
  var FilePublisher = class {
875
411
  constructor(path) {
876
412
  this.path = path;
877
413
  }
878
- async publish(notes, format) {
879
- const formatted = formatNotes(notes, format);
880
- writeFileSync(this.path, formatted, "utf-8");
414
+ async publish(notes, format, preformatted) {
415
+ const output = preformatted || formatNotes(notes, format);
416
+ writeFileSync(this.path, output, "utf-8");
881
417
  console.log(`\u2713 Release notes written to ${this.path}`);
882
418
  }
883
419
  };
884
- var SlackPublisher = class {
885
- constructor(webhookUrl) {
886
- this.webhookUrl = webhookUrl;
887
- if (!webhookUrl.startsWith("https://hooks.slack.com/") && !webhookUrl.startsWith("https://hooks.slack-gov.com/")) {
888
- throw new Error("Invalid Slack webhook URL \u2014 must start with https://hooks.slack.com/ or https://hooks.slack-gov.com/");
889
- }
890
- }
891
- async publish(notes, _format) {
892
- const text = this.buildSlackMessage(notes);
893
- const response = await fetchWithTimeout(this.webhookUrl, {
894
- method: "POST",
895
- headers: { "Content-Type": "application/json" },
896
- body: JSON.stringify({ text })
897
- });
898
- if (!response.ok) {
899
- throw new Error(`Slack webhook failed (${response.status})`);
900
- }
901
- console.log("\u2713 Published to Slack");
902
- }
903
- buildSlackMessage(notes) {
904
- let msg = `*${notes.version}* \u2014 ${notes.date}
905
- `;
906
- if (notes.summary) msg += `${notes.summary}
907
-
908
- `;
909
- const categoryEmoji = {
910
- features: "\u2728",
911
- fixes: "\u{1F41B}",
912
- breaking: "\u26A0\uFE0F",
913
- improvements: "\u{1F527}",
914
- chores: "\u{1F9F9}",
915
- other: "\u{1F4DD}"
916
- };
917
- for (const change of notes.changes) {
918
- const emoji = categoryEmoji[change.category] || "\u2022";
919
- msg += `${emoji} ${change.description}`;
920
- if (change.ticketKey) msg += ` (\`${change.ticketKey}\`)`;
921
- msg += "\n";
922
- }
923
- return msg;
924
- }
925
- };
926
- var DiscordPublisher = class {
927
- constructor(webhookUrl) {
928
- this.webhookUrl = webhookUrl;
929
- if (!webhookUrl.startsWith("https://discord.com/api/webhooks/") && !webhookUrl.startsWith("https://discordapp.com/api/webhooks/")) {
930
- throw new Error("Invalid Discord webhook URL \u2014 must start with https://discord.com/api/webhooks/");
931
- }
932
- }
933
- async publish(notes, _format) {
934
- const content = this.buildDiscordMessage(notes);
935
- const response = await fetchWithTimeout(this.webhookUrl, {
936
- method: "POST",
937
- headers: { "Content-Type": "application/json" },
938
- body: JSON.stringify({
939
- embeds: [{
940
- title: `Release ${notes.version}`,
941
- description: content,
942
- color: 15269703,
943
- // Cullit accent color
944
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
945
- footer: { text: "Generated by Cullit" }
946
- }]
947
- })
948
- });
949
- if (!response.ok) {
950
- throw new Error(`Discord webhook failed (${response.status})`);
951
- }
952
- console.log("\u2713 Published to Discord");
953
- }
954
- buildDiscordMessage(notes) {
955
- let msg = "";
956
- if (notes.summary) msg += `${notes.summary}
957
-
958
- `;
959
- for (const change of notes.changes) {
960
- msg += `\u2022 ${change.description}`;
961
- if (change.ticketKey) msg += ` (${change.ticketKey})`;
962
- msg += "\n";
963
- }
964
- return msg.substring(0, 4e3);
965
- }
966
- };
967
- var GitHubReleasePublisher = class {
968
- token;
969
- owner;
970
- repo;
971
- constructor() {
972
- this.token = process.env["GITHUB_TOKEN"] || "";
973
- const ghRepo = process.env["GITHUB_REPOSITORY"] || "";
974
- const parts = ghRepo.split("/");
975
- this.owner = parts[0] || "";
976
- this.repo = parts[1] || "";
977
- }
978
- async publish(notes, format) {
979
- if (!this.token) {
980
- throw new Error("GITHUB_TOKEN is required for GitHub Release publishing");
981
- }
982
- if (!this.owner || !this.repo) {
983
- throw new Error(
984
- "GITHUB_REPOSITORY env var is required (format: owner/repo). This is set automatically in GitHub Actions."
985
- );
986
- }
987
- const formatted = formatNotes(notes, format);
988
- const tagName = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
989
- const existing = await this.getRelease(tagName);
990
- if (existing) {
991
- await this.updateRelease(existing.id, formatted, notes);
992
- console.log(`\u2713 Updated GitHub Release: ${tagName}`);
993
- } else {
994
- await this.createRelease(tagName, formatted, notes);
995
- console.log(`\u2713 Created GitHub Release: ${tagName}`);
996
- }
997
- }
998
- async getRelease(tag) {
999
- const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${encodeURIComponent(tag)}`;
1000
- const response = await fetchWithTimeout(url, {
1001
- headers: this.headers()
1002
- });
1003
- if (response.status === 404) return null;
1004
- if (!response.ok) {
1005
- const error = await response.text();
1006
- throw new Error(`GitHub API error (${response.status}): ${error}`);
1007
- }
1008
- const data = await response.json();
1009
- return data;
1010
- }
1011
- async createRelease(tag, body, notes) {
1012
- const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases`;
1013
- const response = await fetchWithTimeout(url, {
1014
- method: "POST",
1015
- headers: this.headers(),
1016
- body: JSON.stringify({
1017
- tag_name: tag,
1018
- name: `${tag} \u2014 ${notes.date}`,
1019
- body,
1020
- draft: false,
1021
- prerelease: tag.includes("-")
1022
- })
1023
- });
1024
- if (!response.ok) {
1025
- const error = await response.text();
1026
- throw new Error(`GitHub Release creation failed (${response.status}): ${error}`);
1027
- }
1028
- }
1029
- async updateRelease(id, body, notes) {
1030
- const tag = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
1031
- const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/${id}`;
1032
- const response = await fetchWithTimeout(url, {
1033
- method: "PATCH",
1034
- headers: this.headers(),
1035
- body: JSON.stringify({
1036
- body,
1037
- name: `${tag} \u2014 ${notes.date}`
1038
- })
1039
- });
1040
- if (!response.ok) {
1041
- const error = await response.text();
1042
- throw new Error(`GitHub Release update failed (${response.status}): ${error}`);
1043
- }
1044
- }
1045
- headers() {
1046
- return {
1047
- "Accept": "application/vnd.github+json",
1048
- "Authorization": `Bearer ${this.token}`,
1049
- "X-GitHub-Api-Version": "2022-11-28"
1050
- };
1051
- }
1052
- };
1053
-
1054
- // src/enrichers/jira.ts
1055
- var JiraEnricher = class {
1056
- config;
1057
- constructor(config) {
1058
- this.config = config;
1059
- }
1060
- async enrich(diff) {
1061
- const keys = this.extractUniqueKeys(diff);
1062
- if (keys.length === 0) return [];
1063
- const tickets = [];
1064
- for (const key of keys) {
1065
- try {
1066
- const ticket = await this.fetchTicket(key);
1067
- if (ticket) tickets.push(ticket);
1068
- } catch (err) {
1069
- console.warn(`\u26A0 Could not fetch Jira ticket ${key}: ${err.message}`);
1070
- }
1071
- }
1072
- return tickets;
1073
- }
1074
- extractUniqueKeys(diff) {
1075
- const allKeys = [];
1076
- for (const commit of diff.commits) {
1077
- if (commit.issueKeys) allKeys.push(...commit.issueKeys);
1078
- }
1079
- return [...new Set(allKeys)];
1080
- }
1081
- async fetchTicket(key) {
1082
- const { domain, email, apiToken } = this.config;
1083
- const resolvedEmail = email || process.env.JIRA_EMAIL;
1084
- const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
1085
- if (!resolvedEmail || !resolvedToken) {
1086
- throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
1087
- }
1088
- const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
1089
- const response = await fetchWithTimeout(
1090
- `https://${domain}/rest/api/3/issue/${key}?fields=summary,issuetype,labels,priority,status,description`,
1091
- {
1092
- headers: {
1093
- "Authorization": `Basic ${auth}`,
1094
- "Accept": "application/json"
1095
- }
1096
- }
1097
- );
1098
- if (response.status === 404) return null;
1099
- if (!response.ok) {
1100
- throw new Error(`Jira API error (${response.status})`);
1101
- }
1102
- const data = await response.json();
1103
- const fields = data.fields;
1104
- return {
1105
- key,
1106
- title: fields.summary || key,
1107
- type: fields.issuetype?.name?.toLowerCase(),
1108
- labels: fields.labels || [],
1109
- priority: fields.priority?.name,
1110
- status: fields.status?.name,
1111
- source: "jira"
1112
- };
1113
- }
1114
- };
1115
-
1116
- // src/enrichers/linear.ts
1117
- var LinearEnricher = class {
1118
- apiKey;
1119
- constructor(apiKey) {
1120
- const resolved = apiKey || process.env.LINEAR_API_KEY;
1121
- if (!resolved) {
1122
- throw new Error("Linear API key not configured. Set LINEAR_API_KEY.");
1123
- }
1124
- this.apiKey = resolved;
1125
- }
1126
- async enrich(diff) {
1127
- const keys = this.extractUniqueKeys(diff);
1128
- if (keys.length === 0) return [];
1129
- try {
1130
- return await this.fetchIssuesBatch(keys);
1131
- } catch (err) {
1132
- console.warn(`\u26A0 Linear batch fetch failed, falling back to individual queries: ${err.message}`);
1133
- return this.fetchIssuesIndividually(keys);
1134
- }
1135
- }
1136
- async fetchIssuesIndividually(keys) {
1137
- const tickets = [];
1138
- for (const key of keys) {
1139
- try {
1140
- const ticket = await this.fetchIssue(key);
1141
- if (ticket) tickets.push(ticket);
1142
- } catch (err) {
1143
- console.warn(`\u26A0 Could not fetch Linear issue ${key}: ${err.message}`);
1144
- }
1145
- }
1146
- return tickets;
1147
- }
1148
- extractUniqueKeys(diff) {
1149
- const allKeys = [];
1150
- for (const commit of diff.commits) {
1151
- if (commit.issueKeys) allKeys.push(...commit.issueKeys);
1152
- }
1153
- return [...new Set(allKeys)];
1154
- }
1155
- async fetchIssuesBatch(identifiers) {
1156
- const query = `
1157
- query BatchIssues($filter: IssueFilter!) {
1158
- issues(filter: $filter, first: 100) {
1159
- nodes {
1160
- identifier
1161
- title
1162
- description
1163
- priority
1164
- state { name }
1165
- labels { nodes { name } }
1166
- }
1167
- }
1168
- }
1169
- `;
1170
- const response = await fetchWithTimeout("https://api.linear.app/graphql", {
1171
- method: "POST",
1172
- headers: {
1173
- "Content-Type": "application/json",
1174
- "Authorization": `Bearer ${this.apiKey}`
1175
- },
1176
- body: JSON.stringify({
1177
- query,
1178
- variables: {
1179
- filter: { identifier: { in: identifiers } }
1180
- }
1181
- })
1182
- });
1183
- if (!response.ok) {
1184
- throw new Error(`Linear API error (${response.status})`);
1185
- }
1186
- const data = await response.json();
1187
- const issues = data.data?.issues?.nodes || [];
1188
- const priorityMap = {
1189
- 0: "none",
1190
- 1: "urgent",
1191
- 2: "high",
1192
- 3: "medium",
1193
- 4: "low"
1194
- };
1195
- return issues.map((issue) => ({
1196
- key: issue.identifier,
1197
- title: issue.title,
1198
- description: issue.description?.substring(0, 500),
1199
- labels: issue.labels?.nodes?.map((l) => l.name) || [],
1200
- priority: priorityMap[issue.priority] || void 0,
1201
- status: issue.state?.name,
1202
- source: "linear"
1203
- }));
1204
- }
1205
- async fetchIssue(identifier) {
1206
- const query = `
1207
- query IssueByIdentifier($id: String!) {
1208
- issueSearch(filter: { identifier: { eq: $id } }, first: 1) {
1209
- nodes {
1210
- identifier
1211
- title
1212
- description
1213
- priority
1214
- state { name }
1215
- labels { nodes { name } }
1216
- }
1217
- }
1218
- }
1219
- `;
1220
- const response = await fetchWithTimeout("https://api.linear.app/graphql", {
1221
- method: "POST",
1222
- headers: {
1223
- "Content-Type": "application/json",
1224
- "Authorization": `Bearer ${this.apiKey}`
1225
- },
1226
- body: JSON.stringify({ query, variables: { id: identifier } })
1227
- });
1228
- if (!response.ok) {
1229
- throw new Error(`Linear API error (${response.status})`);
1230
- }
1231
- const data = await response.json();
1232
- const issue = data.data?.issueSearch?.nodes?.[0];
1233
- if (!issue) return null;
1234
- const priorityMap = {
1235
- 0: "none",
1236
- 1: "urgent",
1237
- 2: "high",
1238
- 3: "medium",
1239
- 4: "low"
1240
- };
1241
- return {
1242
- key: issue.identifier,
1243
- title: issue.title,
1244
- description: issue.description?.substring(0, 500),
1245
- labels: issue.labels?.nodes?.map((l) => l.name) || [],
1246
- priority: priorityMap[issue.priority] || void 0,
1247
- status: issue.state?.name,
1248
- source: "linear"
1249
- };
1250
- }
1251
- };
1252
420
 
1253
421
  // src/advisor.ts
1254
422
  import { execSync as execSync2 } from "child_process";
@@ -1280,24 +448,8 @@ function bumpVersion(version, bump) {
1280
448
  }
1281
449
  }
1282
450
  function getCommitsSinceTag(tag, cwd) {
1283
- const format = "%H|%h|%an|%aI|%s";
1284
- const separator = "---CULLIT_COMMIT---";
1285
451
  try {
1286
- const log = execSync2(
1287
- `git log ${tag}..HEAD --format="${format}${separator}" --no-merges`,
1288
- { cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
1289
- );
1290
- if (!log.trim()) return [];
1291
- return log.split(separator).filter((e) => e.trim()).map((entry) => {
1292
- const [hash, shortHash, author, date, ...msgParts] = entry.trim().split("|");
1293
- return {
1294
- hash: hash.trim(),
1295
- shortHash: shortHash.trim(),
1296
- author: author.trim(),
1297
- date: date.trim(),
1298
- message: msgParts.join("|").trim()
1299
- };
1300
- });
452
+ return getCommitsSince(tag, "HEAD", cwd);
1301
453
  } catch {
1302
454
  return [];
1303
455
  }
@@ -1392,9 +544,28 @@ function analyzeReleaseReadiness(cwd = process.cwd()) {
1392
544
  };
1393
545
  }
1394
546
 
547
+ // src/fetch.ts
548
+ var DEFAULT_TIMEOUT = 3e4;
549
+ async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT) {
550
+ const controller = new AbortController();
551
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
552
+ try {
553
+ return await fetch(url, { ...init, signal: controller.signal });
554
+ } catch (err) {
555
+ if (err.name === "AbortError") {
556
+ throw new Error(`Request to ${new URL(url).hostname} timed out after ${timeoutMs / 1e3}s`);
557
+ }
558
+ throw err;
559
+ } finally {
560
+ clearTimeout(timer);
561
+ }
562
+ }
563
+
1395
564
  // src/gate.ts
1396
565
  var FREE_PROVIDERS = /* @__PURE__ */ new Set(["none"]);
1397
566
  var FREE_PUBLISHERS = /* @__PURE__ */ new Set(["stdout", "file"]);
567
+ var LICENSE_CACHE_TTL = 24 * 60 * 60 * 1e3;
568
+ var cachedValidation = null;
1398
569
  function resolveLicense() {
1399
570
  const key = process.env.CULLIT_API_KEY?.trim();
1400
571
  if (!key) {
@@ -1405,6 +576,48 @@ function resolveLicense() {
1405
576
  }
1406
577
  return { tier: "pro", valid: true };
1407
578
  }
579
+ async function validateLicense() {
580
+ const key = process.env.CULLIT_API_KEY?.trim();
581
+ const validationUrl = process.env.CULLIT_LICENSE_URL?.trim();
582
+ if (!key) {
583
+ return { tier: "free", valid: true };
584
+ }
585
+ if (!/^clt_[a-zA-Z0-9]{32,}$/.test(key)) {
586
+ return { tier: "free", valid: false, message: "Invalid CULLIT_API_KEY format. Expected: clt_<key>" };
587
+ }
588
+ if (cachedValidation && cachedValidation.key === key && Date.now() < cachedValidation.expiresAt) {
589
+ return cachedValidation.status;
590
+ }
591
+ if (!validationUrl) {
592
+ return { tier: "pro", valid: true };
593
+ }
594
+ try {
595
+ const res = await fetchWithTimeout(validationUrl, {
596
+ method: "POST",
597
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
598
+ body: JSON.stringify({ key })
599
+ }, 1e4);
600
+ if (res.ok) {
601
+ const data = await res.json();
602
+ const status2 = {
603
+ tier: data.tier === "pro" ? "pro" : "free",
604
+ valid: data.valid !== false,
605
+ message: data.message
606
+ };
607
+ cachedValidation = { status: status2, key, expiresAt: Date.now() + LICENSE_CACHE_TTL };
608
+ return status2;
609
+ }
610
+ const status = {
611
+ tier: "free",
612
+ valid: false,
613
+ message: "License validation failed. Check your API key at https://cullit.io/pricing"
614
+ };
615
+ cachedValidation = { status, key, expiresAt: Date.now() + LICENSE_CACHE_TTL };
616
+ return status;
617
+ } catch {
618
+ return { tier: "pro", valid: true };
619
+ }
620
+ }
1408
621
  function isProviderAllowed(provider, license) {
1409
622
  if (license.tier === "pro" && license.valid) return true;
1410
623
  return FREE_PROVIDERS.has(provider);
@@ -1422,30 +635,85 @@ function upgradeMessage(feature) {
1422
635
  Then set CULLIT_API_KEY in your environment.`;
1423
636
  }
1424
637
 
638
+ // src/registry.ts
639
+ var collectors = /* @__PURE__ */ new Map();
640
+ var enrichers = /* @__PURE__ */ new Map();
641
+ var generators = /* @__PURE__ */ new Map();
642
+ var publishers = /* @__PURE__ */ new Map();
643
+ function registerCollector(type, factory) {
644
+ collectors.set(type, factory);
645
+ }
646
+ function registerEnricher(type, factory) {
647
+ enrichers.set(type, factory);
648
+ }
649
+ function registerGenerator(provider, factory) {
650
+ generators.set(provider, factory);
651
+ }
652
+ function registerPublisher(type, factory) {
653
+ publishers.set(type, factory);
654
+ }
655
+ function getCollector(type) {
656
+ return collectors.get(type);
657
+ }
658
+ function getEnricher(type) {
659
+ return enrichers.get(type);
660
+ }
661
+ function getGenerator(provider) {
662
+ return generators.get(provider);
663
+ }
664
+ function getPublisher(type) {
665
+ return publishers.get(type);
666
+ }
667
+ function hasGenerator(provider) {
668
+ return generators.has(provider);
669
+ }
670
+ function hasCollector(type) {
671
+ return collectors.has(type);
672
+ }
673
+ function hasPublisher(type) {
674
+ return publishers.has(type);
675
+ }
676
+ function hasEnricher(type) {
677
+ return enrichers.has(type);
678
+ }
679
+ function listCollectors() {
680
+ return Array.from(collectors.keys());
681
+ }
682
+ function listEnrichers() {
683
+ return Array.from(enrichers.keys());
684
+ }
685
+ function listGenerators() {
686
+ return Array.from(generators.keys());
687
+ }
688
+ function listPublishers() {
689
+ return Array.from(publishers.keys());
690
+ }
691
+
1425
692
  // src/index.ts
693
+ registerCollector("local", () => new GitCollector());
694
+ registerGenerator("none", () => new TemplateGenerator());
695
+ registerPublisher("stdout", (_target) => new StdoutPublisher());
696
+ registerPublisher("file", (target) => new FilePublisher(target.path));
1426
697
  async function runPipeline(from, to, config, options = {}) {
1427
698
  const startTime = Date.now();
1428
699
  const format = options.format || "markdown";
1429
700
  const log = options.logger || createLogger("normal");
1430
- const license = resolveLicense();
701
+ const license = await validateLicense();
1431
702
  if (!license.valid) {
1432
703
  throw new Error(license.message || "Invalid CULLIT_API_KEY");
1433
704
  }
1434
705
  if (!isProviderAllowed(config.ai.provider, license)) {
1435
706
  throw new Error(upgradeMessage(`AI provider "${config.ai.provider}"`));
1436
707
  }
1437
- let collector;
1438
- if (config.source.type === "jira") {
1439
- if (!config.jira) throw new Error("Jira source requires jira config in .cullit.yml");
1440
- log.info(`\xBB Collecting issues from Jira...`);
1441
- collector = new JiraCollector(config.jira);
1442
- } else if (config.source.type === "linear") {
1443
- log.info(`\xBB Collecting issues from Linear...`);
1444
- collector = new LinearCollector(config.linear?.apiKey);
1445
- } else {
1446
- log.info(`\xBB Collecting commits between ${from}..${to}`);
1447
- collector = new GitCollector();
708
+ const collectorFactory = getCollector(config.source.type);
709
+ if (!collectorFactory) {
710
+ throw new Error(
711
+ `Source type "${config.source.type}" is not available. ` + (config.source.type !== "local" ? "Install @cullit/pro to use this source." : "Valid sources: local")
712
+ );
1448
713
  }
714
+ const sourceLabel = config.source.type === "local" ? `commits between ${from}..${to}` : `items from ${config.source.type}`;
715
+ log.info(`\xBB Collecting ${sourceLabel}`);
716
+ const collector = collectorFactory(config);
1449
717
  const diff = await collector.collect(from, to);
1450
718
  const itemLabel = config.source.type === "jira" || config.source.type === "linear" ? "issues" : "commits";
1451
719
  log.info(`\xBB Found ${diff.commits.length} ${itemLabel}${diff.filesChanged ? `, ${diff.filesChanged} files changed` : ""}`);
@@ -1456,28 +724,20 @@ async function runPipeline(from, to, config, options = {}) {
1456
724
  const tickets = [];
1457
725
  const enrichmentSources = config.source.enrichment || [];
1458
726
  for (const source of enrichmentSources) {
1459
- if (source === "jira" && config.jira) {
1460
- if (!isEnrichmentAllowed(license)) {
1461
- log.info(`\xBB Skipping Jira enrichment \u2014 ${upgradeMessage("Jira enrichment")}`);
1462
- continue;
1463
- }
1464
- log.info("\xBB Enriching from Jira...");
1465
- const enricher = new JiraEnricher(config.jira);
1466
- const jiraTickets = await enricher.enrich(diff);
1467
- tickets.push(...jiraTickets);
1468
- log.info(`\xBB Jira: found ${jiraTickets.length} tickets`);
727
+ if (!isEnrichmentAllowed(license)) {
728
+ log.info(`\xBB Skipping ${source} enrichment \u2014 ${upgradeMessage(`${source} enrichment`)}`);
729
+ continue;
1469
730
  }
1470
- if (source === "linear") {
1471
- if (!isEnrichmentAllowed(license)) {
1472
- log.info(`\xBB Skipping Linear enrichment \u2014 ${upgradeMessage("Linear enrichment")}`);
1473
- continue;
1474
- }
1475
- log.info("\xBB Enriching from Linear...");
1476
- const enricher = new LinearEnricher(config.linear?.apiKey);
1477
- const linearTickets = await enricher.enrich(diff);
1478
- tickets.push(...linearTickets);
1479
- log.info(`\xBB Linear: found ${linearTickets.length} issues`);
731
+ const enricherFactory = getEnricher(source);
732
+ if (!enricherFactory) {
733
+ log.info(`\xBB Skipping ${source} enrichment \u2014 install @cullit/pro to enable`);
734
+ continue;
1480
735
  }
736
+ log.info(`\xBB Enriching from ${source}...`);
737
+ const enricher = enricherFactory(config);
738
+ const enrichedTickets = await enricher.enrich(diff);
739
+ tickets.push(...enrichedTickets);
740
+ log.info(`\xBB ${source}: found ${enrichedTickets.length} ${source === "jira" ? "tickets" : "issues"}`);
1481
741
  }
1482
742
  const context = { diff, tickets };
1483
743
  const providerNames = {
@@ -1491,14 +751,19 @@ async function runPipeline(from, to, config, options = {}) {
1491
751
  const providerName = providerNames[config.ai.provider] || config.ai.provider;
1492
752
  const modelName = config.ai.provider === "none" ? "template" : config.ai.model || DEFAULT_MODELS[config.ai.provider] || "default";
1493
753
  log.info(`\xBB Generating with ${providerName} (${modelName})...`);
1494
- let notes;
754
+ const generatorFactory = getGenerator(config.ai.provider);
755
+ if (!generatorFactory) {
756
+ throw new Error(
757
+ `AI provider "${config.ai.provider}" is not available. ` + (config.ai.provider !== "none" ? "Install @cullit/pro to use AI providers." : "")
758
+ );
759
+ }
760
+ let generator;
1495
761
  if (config.ai.provider === "none") {
1496
- const generator = new TemplateGenerator();
1497
- notes = await generator.generate(context, config.ai);
762
+ generator = generatorFactory();
1498
763
  } else {
1499
- const generator = new AIGenerator(config.openclaw);
1500
- notes = await generator.generate(context, config.ai);
764
+ generator = generatorFactory(config.openclaw);
1501
765
  }
766
+ const notes = await generator.generate(context, config.ai);
1502
767
  log.info(`\xBB Generated ${notes.changes.length} change entries`);
1503
768
  const formatted = formatNotes(notes, format);
1504
769
  const publishedTo = [];
@@ -1509,34 +774,14 @@ async function runPipeline(from, to, config, options = {}) {
1509
774
  log.info(`\xBB Skipping ${target.type} \u2014 ${upgradeMessage(`${target.type} publishing`)}`);
1510
775
  continue;
1511
776
  }
1512
- switch (target.type) {
1513
- case "stdout":
1514
- await new StdoutPublisher().publish(notes, format);
1515
- publishedTo.push("stdout");
1516
- break;
1517
- case "file":
1518
- if (target.path) {
1519
- await new FilePublisher(target.path).publish(notes, format);
1520
- publishedTo.push(`file:${target.path}`);
1521
- }
1522
- break;
1523
- case "slack":
1524
- if (target.webhookUrl) {
1525
- await new SlackPublisher(target.webhookUrl).publish(notes, format);
1526
- publishedTo.push("slack");
1527
- }
1528
- break;
1529
- case "discord":
1530
- if (target.webhookUrl) {
1531
- await new DiscordPublisher(target.webhookUrl).publish(notes, format);
1532
- publishedTo.push("discord");
1533
- }
1534
- break;
1535
- case "github-release":
1536
- await new GitHubReleasePublisher().publish(notes, format);
1537
- publishedTo.push("github-release");
1538
- break;
777
+ const publisherFactory = getPublisher(target.type);
778
+ if (!publisherFactory) {
779
+ log.info(`\xBB Skipping ${target.type} \u2014 install @cullit/pro to enable`);
780
+ continue;
1539
781
  }
782
+ const publisher = publisherFactory(target);
783
+ await publisher.publish(notes, format, formatted);
784
+ publishedTo.push(target.type === "file" ? `file:${target.path}` : target.type);
1540
785
  } catch (err) {
1541
786
  log.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
1542
787
  }
@@ -1552,30 +797,51 @@ async function runPipeline(from, to, config, options = {}) {
1552
797
  return { notes, formatted, publishedTo, duration };
1553
798
  }
1554
799
  export {
1555
- AIGenerator,
800
+ AI_PROVIDERS,
801
+ AUDIENCES,
802
+ CHANGE_CATEGORIES,
1556
803
  DEFAULT_CATEGORIES,
1557
804
  DEFAULT_MODELS,
1558
- DiscordPublisher,
805
+ ENRICHMENT_TYPES,
1559
806
  FilePublisher,
1560
807
  GitCollector,
1561
- GitHubReleasePublisher,
1562
- JiraCollector,
1563
- JiraEnricher,
1564
- LinearCollector,
1565
- LinearEnricher,
1566
- SlackPublisher,
808
+ OUTPUT_FORMATS,
809
+ PUBLISHER_TYPES,
810
+ SOURCE_TYPES,
1567
811
  StdoutPublisher,
812
+ TONES,
1568
813
  TemplateGenerator,
1569
814
  VERSION,
1570
815
  analyzeReleaseReadiness,
1571
816
  createLogger,
817
+ fetchWithTimeout,
1572
818
  formatNotes,
819
+ getCollector,
820
+ getEnricher,
821
+ getFormatter,
822
+ getGenerator,
1573
823
  getLatestTag,
824
+ getPublisher,
1574
825
  getRecentTags,
826
+ hasCollector,
827
+ hasEnricher,
828
+ hasGenerator,
829
+ hasPublisher,
1575
830
  isEnrichmentAllowed,
1576
831
  isProviderAllowed,
1577
832
  isPublisherAllowed,
833
+ listCollectors,
834
+ listEnrichers,
835
+ listFormatters,
836
+ listGenerators,
837
+ listPublishers,
838
+ registerCollector,
839
+ registerEnricher,
840
+ registerFormatter,
841
+ registerGenerator,
842
+ registerPublisher,
1578
843
  resolveLicense,
1579
844
  runPipeline,
1580
- upgradeMessage
845
+ upgradeMessage,
846
+ validateLicense
1581
847
  };