@cullit/core 0.3.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 +38 -122
  2. package/dist/index.js +168 -953
  3. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  // src/constants.ts
2
- var VERSION = "0.3.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
12
  // src/logger.ts
@@ -151,510 +151,28 @@ function getLatestTag(cwd = process.cwd()) {
151
151
  return null;
152
152
  }
153
153
  }
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
- }));
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("|");
284
166
  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."
167
+ hash: hash.trim(),
168
+ shortHash: shortHash.trim(),
169
+ author: author.trim(),
170
+ date: date.trim(),
171
+ message: msgParts.join("|").trim()
480
172
  };
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
- ]
173
+ });
511
174
  }
512
175
 
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
176
  // src/generators/template.ts
659
177
  var TemplateGenerator = class {
660
178
  async generate(context, config) {
@@ -679,7 +197,7 @@ var TemplateGenerator = class {
679
197
  return {
680
198
  version: diff.to,
681
199
  date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
682
- summary: this.buildSummary(deduped, diff.commits.length),
200
+ summary: this.buildSummary(deduped, diff.commits.length, config.tone),
683
201
  changes: deduped.slice(0, 20),
684
202
  contributors,
685
203
  metadata: {
@@ -749,7 +267,7 @@ var TemplateGenerator = class {
749
267
  }
750
268
  return result;
751
269
  }
752
- buildSummary(changes, commitCount) {
270
+ buildSummary(changes, commitCount, tone) {
753
271
  const counts = {};
754
272
  for (const c of changes) {
755
273
  counts[c.category] = (counts[c.category] || 0) + 1;
@@ -759,8 +277,14 @@ var TemplateGenerator = class {
759
277
  if (counts["features"]) parts.push(`${counts["features"]} feature${counts["features"] > 1 ? "s" : ""}`);
760
278
  if (counts["fixes"]) parts.push(`${counts["fixes"]} fix${counts["fixes"] > 1 ? "es" : ""}`);
761
279
  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;
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.`;
764
288
  }
765
289
  };
766
290
 
@@ -866,389 +390,20 @@ function groupByCategory(notes) {
866
390
  // src/publishers/index.ts
867
391
  import { writeFileSync } from "fs";
868
392
  var StdoutPublisher = class {
869
- async publish(notes, format) {
870
- const formatted = formatNotes(notes, format);
871
- console.log(formatted);
393
+ async publish(notes, format, preformatted) {
394
+ console.log(preformatted || formatNotes(notes, format));
872
395
  }
873
396
  };
874
397
  var FilePublisher = class {
875
398
  constructor(path) {
876
399
  this.path = path;
877
400
  }
878
- async publish(notes, format) {
879
- const formatted = formatNotes(notes, format);
880
- 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");
881
404
  console.log(`\u2713 Release notes written to ${this.path}`);
882
405
  }
883
406
  };
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
407
 
1253
408
  // src/advisor.ts
1254
409
  import { execSync as execSync2 } from "child_process";
@@ -1280,24 +435,8 @@ function bumpVersion(version, bump) {
1280
435
  }
1281
436
  }
1282
437
  function getCommitsSinceTag(tag, cwd) {
1283
- const format = "%H|%h|%an|%aI|%s";
1284
- const separator = "---CULLIT_COMMIT---";
1285
438
  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
- });
439
+ return getCommitsSince(tag, "HEAD", cwd);
1301
440
  } catch {
1302
441
  return [];
1303
442
  }
@@ -1422,7 +561,70 @@ function upgradeMessage(feature) {
1422
561
  Then set CULLIT_API_KEY in your environment.`;
1423
562
  }
1424
563
 
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`);
616
+ }
617
+ throw err;
618
+ } finally {
619
+ clearTimeout(timer);
620
+ }
621
+ }
622
+
1425
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));
1426
628
  async function runPipeline(from, to, config, options = {}) {
1427
629
  const startTime = Date.now();
1428
630
  const format = options.format || "markdown";
@@ -1434,17 +636,22 @@ async function runPipeline(from, to, config, options = {}) {
1434
636
  if (!isProviderAllowed(config.ai.provider, license)) {
1435
637
  throw new Error(upgradeMessage(`AI provider "${config.ai.provider}"`));
1436
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}`);
1437
647
  let collector;
1438
648
  if (config.source.type === "jira") {
1439
649
  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);
650
+ collector = collectorFactory(config.jira);
1442
651
  } else if (config.source.type === "linear") {
1443
- log.info(`\xBB Collecting issues from Linear...`);
1444
- collector = new LinearCollector(config.linear?.apiKey);
652
+ collector = collectorFactory(config.linear?.apiKey);
1445
653
  } else {
1446
- log.info(`\xBB Collecting commits between ${from}..${to}`);
1447
- collector = new GitCollector();
654
+ collector = collectorFactory();
1448
655
  }
1449
656
  const diff = await collector.collect(from, to);
1450
657
  const itemLabel = config.source.type === "jira" || config.source.type === "linear" ? "issues" : "commits";
@@ -1456,28 +663,27 @@ async function runPipeline(from, to, config, options = {}) {
1456
663
  const tickets = [];
1457
664
  const enrichmentSources = config.source.enrichment || [];
1458
665
  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`);
666
+ if (!isEnrichmentAllowed(license)) {
667
+ log.info(`\xBB Skipping ${source} enrichment \u2014 ${upgradeMessage(`${source} enrichment`)}`);
668
+ continue;
1469
669
  }
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`);
670
+ const enricherFactory = getEnricher(source);
671
+ if (!enricherFactory) {
672
+ log.info(`\xBB Skipping ${source} enrichment \u2014 install @cullit/pro to enable`);
673
+ continue;
1480
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"}`);
1481
687
  }
1482
688
  const context = { diff, tickets };
1483
689
  const providerNames = {
@@ -1491,14 +697,19 @@ async function runPipeline(from, to, config, options = {}) {
1491
697
  const providerName = providerNames[config.ai.provider] || config.ai.provider;
1492
698
  const modelName = config.ai.provider === "none" ? "template" : config.ai.model || DEFAULT_MODELS[config.ai.provider] || "default";
1493
699
  log.info(`\xBB Generating with ${providerName} (${modelName})...`);
1494
- let notes;
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;
1495
707
  if (config.ai.provider === "none") {
1496
- const generator = new TemplateGenerator();
1497
- notes = await generator.generate(context, config.ai);
708
+ generator = generatorFactory();
1498
709
  } else {
1499
- const generator = new AIGenerator(config.openclaw);
1500
- notes = await generator.generate(context, config.ai);
710
+ generator = generatorFactory(config.openclaw);
1501
711
  }
712
+ const notes = await generator.generate(context, config.ai);
1502
713
  log.info(`\xBB Generated ${notes.changes.length} change entries`);
1503
714
  const formatted = formatNotes(notes, format);
1504
715
  const publishedTo = [];
@@ -1509,34 +720,33 @@ async function runPipeline(from, to, config, options = {}) {
1509
720
  log.info(`\xBB Skipping ${target.type} \u2014 ${upgradeMessage(`${target.type} publishing`)}`);
1510
721
  continue;
1511
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;
1512
729
  switch (target.type) {
1513
730
  case "stdout":
1514
- await new StdoutPublisher().publish(notes, format);
1515
- publishedTo.push("stdout");
731
+ publisher = publisherFactory();
1516
732
  break;
1517
733
  case "file":
1518
- if (target.path) {
1519
- await new FilePublisher(target.path).publish(notes, format);
1520
- publishedTo.push(`file:${target.path}`);
1521
- }
734
+ if (!target.path) continue;
735
+ publisher = publisherFactory(target.path);
1522
736
  break;
1523
737
  case "slack":
1524
- if (target.webhookUrl) {
1525
- await new SlackPublisher(target.webhookUrl).publish(notes, format);
1526
- publishedTo.push("slack");
1527
- }
1528
- break;
1529
738
  case "discord":
1530
- if (target.webhookUrl) {
1531
- await new DiscordPublisher(target.webhookUrl).publish(notes, format);
1532
- publishedTo.push("discord");
1533
- }
739
+ if (!target.webhookUrl) continue;
740
+ publisher = publisherFactory(target.webhookUrl);
1534
741
  break;
1535
742
  case "github-release":
1536
- await new GitHubReleasePublisher().publish(notes, format);
1537
- publishedTo.push("github-release");
743
+ publisher = publisherFactory();
1538
744
  break;
745
+ default:
746
+ continue;
1539
747
  }
748
+ await publisher.publish(notes, format, formatted);
749
+ publishedTo.push(target.type === "file" ? `file:${target.path}` : target.type);
1540
750
  } catch (err) {
1541
751
  log.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
1542
752
  }
@@ -1552,29 +762,34 @@ async function runPipeline(from, to, config, options = {}) {
1552
762
  return { notes, formatted, publishedTo, duration };
1553
763
  }
1554
764
  export {
1555
- AIGenerator,
1556
765
  DEFAULT_CATEGORIES,
1557
766
  DEFAULT_MODELS,
1558
- DiscordPublisher,
1559
767
  FilePublisher,
1560
768
  GitCollector,
1561
- GitHubReleasePublisher,
1562
- JiraCollector,
1563
- JiraEnricher,
1564
- LinearCollector,
1565
- LinearEnricher,
1566
- SlackPublisher,
1567
769
  StdoutPublisher,
1568
770
  TemplateGenerator,
1569
771
  VERSION,
1570
772
  analyzeReleaseReadiness,
1571
773
  createLogger,
774
+ fetchWithTimeout,
1572
775
  formatNotes,
776
+ getCollector,
777
+ getEnricher,
778
+ getGenerator,
1573
779
  getLatestTag,
780
+ getPublisher,
1574
781
  getRecentTags,
782
+ hasCollector,
783
+ hasEnricher,
784
+ hasGenerator,
785
+ hasPublisher,
1575
786
  isEnrichmentAllowed,
1576
787
  isProviderAllowed,
1577
788
  isPublisherAllowed,
789
+ registerCollector,
790
+ registerEnricher,
791
+ registerGenerator,
792
+ registerPublisher,
1578
793
  resolveLicense,
1579
794
  runPipeline,
1580
795
  upgradeMessage