@cullit/core 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,50 +1,6 @@
1
- type AIProvider = 'anthropic' | 'openai' | 'gemini' | 'ollama' | 'openclaw' | 'none';
2
- type Audience = 'developer' | 'end-user' | 'executive';
3
- type Tone = 'professional' | 'casual' | 'terse';
4
- type OutputFormat = 'markdown' | 'html' | 'json';
5
- type PublisherType = 'stdout' | 'github-release' | 'slack' | 'discord' | 'file';
6
- type EnrichmentType = 'jira' | 'linear';
7
- interface AIConfig {
8
- provider: AIProvider;
9
- model?: string;
10
- apiKey?: string;
11
- audience: Audience;
12
- tone: Tone;
13
- categories: string[];
14
- maxTokens?: number;
15
- }
16
- interface SourceConfig {
17
- type: 'local' | 'jira' | 'linear';
18
- owner?: string;
19
- repo?: string;
20
- enrichment?: EnrichmentType[];
21
- }
22
- interface PublishTarget {
23
- type: PublisherType;
24
- channel?: string;
25
- webhookUrl?: string;
26
- path?: string;
27
- }
28
- interface JiraConfig {
29
- domain: string;
30
- email?: string;
31
- apiToken?: string;
32
- }
33
- interface LinearConfig {
34
- apiKey?: string;
35
- }
36
- interface OpenClawConfig {
37
- baseUrl?: string;
38
- token?: string;
39
- }
40
- interface CullConfig {
41
- ai: AIConfig;
42
- source: SourceConfig;
43
- publish: PublishTarget[];
44
- jira?: JiraConfig;
45
- linear?: LinearConfig;
46
- openclaw?: OpenClawConfig;
47
- }
1
+ import { EnrichmentType, AIConfig, OutputFormat, JiraConfig, OpenClawConfig, CullConfig } from '@cullit/config';
2
+ export { AIConfig, AIProvider, Audience, CullConfig, EnrichmentType, JiraConfig, LinearConfig, OpenClawConfig, OutputFormat, PublishTarget, PublisherType, SourceConfig, Tone } from '@cullit/config';
3
+
48
4
  interface GitCommit {
49
5
  hash: string;
50
6
  shortHash: string;
@@ -115,10 +71,19 @@ interface PipelineResult {
115
71
  duration: number;
116
72
  }
117
73
 
118
- declare const VERSION = "0.1.0";
74
+ declare const VERSION = "0.3.0";
119
75
  declare const DEFAULT_CATEGORIES: string[];
120
76
  declare const DEFAULT_MODELS: Record<string, string>;
121
77
 
78
+ type LogLevel = 'quiet' | 'normal' | 'verbose';
79
+ interface Logger {
80
+ info(msg: string): void;
81
+ verbose(msg: string): void;
82
+ warn(msg: string): void;
83
+ error(msg: string): void;
84
+ }
85
+ declare function createLogger(level?: LogLevel): Logger;
86
+
122
87
  /**
123
88
  * Collects git log data between two refs (tags, branches, or commit SHAs).
124
89
  * Extracts commits, PR numbers, and issue keys from commit messages.
@@ -195,7 +160,7 @@ declare class AIGenerator implements Generator {
195
160
  private openclawConfig?;
196
161
  private timeoutMs;
197
162
  constructor(openclawConfig?: OpenClawConfig, timeoutMs?: number);
198
- private fetchWithTimeout;
163
+ private fetch;
199
164
  generate(context: EnrichedContext, config: AIConfig): Promise<ReleaseNotes>;
200
165
  private resolveApiKey;
201
166
  private buildPrompt;
@@ -291,16 +256,87 @@ declare class LinearEnricher implements Enricher {
291
256
  private apiKey;
292
257
  constructor(apiKey?: string);
293
258
  enrich(diff: GitDiff): Promise<EnrichedTicket[]>;
259
+ private fetchIssuesIndividually;
294
260
  private extractUniqueKeys;
261
+ private fetchIssuesBatch;
295
262
  private fetchIssue;
296
263
  }
297
264
 
265
+ type SemverBump = 'patch' | 'minor' | 'major';
266
+ interface ReleaseAdvisory {
267
+ /** Whether a release is recommended now */
268
+ shouldRelease: boolean;
269
+ /** Suggested semver bump type */
270
+ suggestedBump: SemverBump;
271
+ /** Current (latest) version tag */
272
+ currentVersion: string | null;
273
+ /** What the next version would be */
274
+ nextVersion: string | null;
275
+ /** Number of unreleased commits */
276
+ commitCount: number;
277
+ /** Number of unique contributors */
278
+ contributorCount: number;
279
+ /** Days since last release */
280
+ daysSinceRelease: number | null;
281
+ /** Breakdown of commit types */
282
+ breakdown: {
283
+ features: number;
284
+ fixes: number;
285
+ breaking: number;
286
+ chores: number;
287
+ other: number;
288
+ };
289
+ /** Human-readable reasons for the recommendation */
290
+ reasons: string[];
291
+ }
292
+ /**
293
+ * Analyzes the repo state and provides a release recommendation.
294
+ * Examines commits since the last tag, categorizes them via conventional
295
+ * commit patterns, and applies industry-standard heuristics.
296
+ */
297
+ declare function analyzeReleaseReadiness(cwd?: string): ReleaseAdvisory;
298
+
299
+ /**
300
+ * Cullit License Gating
301
+ *
302
+ * Free tier (no key): provider=none, publish to stdout/file only
303
+ * Pro tier (with key): all providers, all publishers, all enrichments
304
+ */
305
+ type LicenseTier = 'free' | 'pro';
306
+ interface LicenseStatus {
307
+ tier: LicenseTier;
308
+ valid: boolean;
309
+ message?: string;
310
+ }
311
+ /**
312
+ * Resolve the user's license tier from CULLIT_API_KEY env var.
313
+ * Validates the key format and caches verification result.
314
+ */
315
+ declare function resolveLicense(): LicenseStatus;
316
+ /**
317
+ * Check whether the current license allows the requested provider.
318
+ */
319
+ declare function isProviderAllowed(provider: string, license: LicenseStatus): boolean;
320
+ /**
321
+ * Check whether the current license allows the requested publisher.
322
+ */
323
+ declare function isPublisherAllowed(publisherType: string, license: LicenseStatus): boolean;
324
+ /**
325
+ * Check whether the current license allows enrichment (Jira/Linear).
326
+ */
327
+ declare function isEnrichmentAllowed(license: LicenseStatus): boolean;
328
+ /**
329
+ * Build a human-readable upgrade message for a gated feature.
330
+ */
331
+ declare function upgradeMessage(feature: string): string;
332
+
298
333
  /**
299
334
  * Main pipeline: Collect → Enrich → Generate → Format → Publish
300
335
  */
301
336
  declare function runPipeline(from: string, to: string, config: CullConfig, options?: {
302
337
  format?: OutputFormat;
303
338
  dryRun?: boolean;
339
+ logger?: Logger;
304
340
  }): Promise<PipelineResult>;
305
341
 
306
- export { type AIConfig, AIGenerator, type AIProvider, type Audience, type ChangeCategory, type ChangeEntry, type Collector, type CullConfig, DEFAULT_CATEGORIES, DEFAULT_MODELS, DiscordPublisher, type EnrichedContext, type EnrichedTicket, type Enricher, type EnrichmentType, FilePublisher, type Generator, GitCollector, type GitCommit, type GitDiff, GitHubReleasePublisher, JiraCollector, type JiraConfig, JiraEnricher, LinearCollector, type LinearConfig, LinearEnricher, type OpenClawConfig, type OutputFormat, type PipelineResult, type PublishTarget, type Publisher, type PublisherType, type ReleaseNotes, SlackPublisher, type SourceConfig, StdoutPublisher, TemplateGenerator, type Tone, VERSION, formatNotes, getLatestTag, getRecentTags, runPipeline };
342
+ export { AIGenerator, type ChangeCategory, type ChangeEntry, type Collector, DEFAULT_CATEGORIES, DEFAULT_MODELS, DiscordPublisher, type EnrichedContext, type EnrichedTicket, type Enricher, FilePublisher, type Generator, GitCollector, type GitCommit, type GitDiff, GitHubReleasePublisher, JiraCollector, JiraEnricher, type LicenseStatus, type LicenseTier, LinearCollector, LinearEnricher, type LogLevel, type Logger, type PipelineResult, type Publisher, type ReleaseAdvisory, type ReleaseNotes, type SemverBump, SlackPublisher, StdoutPublisher, TemplateGenerator, VERSION, analyzeReleaseReadiness, createLogger, formatNotes, getLatestTag, getRecentTags, isEnrichmentAllowed, isProviderAllowed, isPublisherAllowed, resolveLicense, runPipeline, upgradeMessage };
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/constants.ts
2
- var VERSION = "0.1.0";
2
+ var VERSION = "0.3.0";
3
3
  var DEFAULT_CATEGORIES = ["features", "fixes", "breaking", "improvements", "chores"];
4
4
  var DEFAULT_MODELS = {
5
5
  anthropic: "claude-sonnet-4-20250514",
@@ -9,14 +9,42 @@ var DEFAULT_MODELS = {
9
9
  openclaw: "claude-sonnet-4-6"
10
10
  };
11
11
 
12
+ // src/logger.ts
13
+ function createLogger(level = "normal") {
14
+ return {
15
+ info(msg) {
16
+ if (level !== "quiet") console.log(msg);
17
+ },
18
+ verbose(msg) {
19
+ if (level === "verbose") console.log(msg);
20
+ },
21
+ warn(msg) {
22
+ if (level !== "quiet") console.warn(msg);
23
+ },
24
+ error(msg) {
25
+ console.error(msg);
26
+ }
27
+ };
28
+ }
29
+
12
30
  // src/collectors/git.ts
13
31
  import { execSync } from "child_process";
32
+ function validateRef(ref) {
33
+ if (!ref || ref.length > 256) {
34
+ throw new Error(`Invalid git ref: too ${ref ? "long" : "short"}`);
35
+ }
36
+ if (!/^[a-zA-Z0-9._\-/~^]+$/.test(ref)) {
37
+ throw new Error(`Invalid git ref "${ref}" \u2014 only alphanumeric, dots, dashes, underscores, slashes, tildes, and carets are allowed`);
38
+ }
39
+ }
14
40
  var GitCollector = class {
15
41
  cwd;
16
42
  constructor(cwd = process.cwd()) {
17
43
  this.cwd = cwd;
18
44
  }
19
45
  async collect(from, to) {
46
+ validateRef(from);
47
+ validateRef(to);
20
48
  const log = this.getLog(from, to);
21
49
  const commits = this.parseLog(log);
22
50
  return {
@@ -124,6 +152,23 @@ function getLatestTag(cwd = process.cwd()) {
124
152
  }
125
153
  }
126
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
+
127
172
  // src/collectors/jira.ts
128
173
  var JiraCollector = class {
129
174
  config;
@@ -154,13 +199,20 @@ var JiraCollector = class {
154
199
  const statusFilter = " AND status in (Done, Closed, Resolved)";
155
200
  return from.includes("status") ? from : from + statusFilter;
156
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, "");
157
206
  if (to === "HEAD") {
158
207
  return `project = ${from} AND status in (Done, Closed, Resolved) AND resolved >= -30d ORDER BY resolved DESC`;
159
208
  }
160
- return `project = ${from} AND fixVersion = "${to}" AND status in (Done, Closed, Resolved) ORDER BY resolved DESC`;
209
+ return `project = ${from} AND fixVersion = "${safeVersion}" AND status in (Done, Closed, Resolved) ORDER BY resolved DESC`;
161
210
  }
162
211
  async fetchIssues(jql) {
163
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
+ }
164
216
  const resolvedEmail = email || process.env.JIRA_EMAIL;
165
217
  const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
166
218
  if (!resolvedEmail || !resolvedToken) {
@@ -176,7 +228,7 @@ var JiraCollector = class {
176
228
  url.searchParams.set("startAt", String(startAt));
177
229
  url.searchParams.set("maxResults", String(maxResults));
178
230
  url.searchParams.set("fields", "summary,issuetype,assignee,status,resolution,resolutiondate,updated,labels,priority,description,fixVersions");
179
- const response = await fetch(url.toString(), {
231
+ const response = await fetchWithTimeout(url.toString(), {
180
232
  headers: {
181
233
  "Authorization": `Basic ${auth}`,
182
234
  "Accept": "application/json"
@@ -254,7 +306,32 @@ var LinearCollector = class {
254
306
  }
255
307
  async fetchIssues(filter) {
256
308
  const filterClause = this.buildFilterClause(filter);
257
- const query = `
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
+ ` : `
258
335
  query CompletedIssues {
259
336
  issues(
260
337
  filter: {
@@ -279,13 +356,14 @@ var LinearCollector = class {
279
356
  }
280
357
  }
281
358
  `;
282
- const response = await fetch("https://api.linear.app/graphql", {
359
+ const variables = needsVariable ? { filterValue: filter.value } : void 0;
360
+ const response = await fetchWithTimeout("https://api.linear.app/graphql", {
283
361
  method: "POST",
284
362
  headers: {
285
363
  "Content-Type": "application/json",
286
- "Authorization": this.apiKey
364
+ "Authorization": `Bearer ${this.apiKey}`
287
365
  },
288
- body: JSON.stringify({ query })
366
+ body: JSON.stringify({ query, variables })
289
367
  });
290
368
  if (!response.ok) {
291
369
  const error = await response.text();
@@ -316,16 +394,16 @@ var LinearCollector = class {
316
394
  buildFilterClause(filter) {
317
395
  switch (filter.type) {
318
396
  case "team":
319
- return `team: { key: { eq: "${filter.value}" } }`;
397
+ return `team: { key: { eq: $filterValue } }`;
320
398
  case "project":
321
- return `project: { name: { containsIgnoreCase: "${filter.value}" } }`;
399
+ return `project: { name: { containsIgnoreCase: $filterValue } }`;
322
400
  case "cycle":
323
401
  if (filter.value === "current") {
324
402
  return `cycle: { isActive: { eq: true } }`;
325
403
  }
326
- return `cycle: { name: { containsIgnoreCase: "${filter.value}" } }`;
404
+ return `cycle: { name: { containsIgnoreCase: $filterValue } }`;
327
405
  case "label":
328
- return `labels: { name: { eq: "${filter.value}" } }`;
406
+ return `labels: { name: { eq: $filterValue } }`;
329
407
  default:
330
408
  return "";
331
409
  }
@@ -340,19 +418,8 @@ var AIGenerator = class {
340
418
  this.openclawConfig = openclawConfig;
341
419
  this.timeoutMs = timeoutMs;
342
420
  }
343
- async fetchWithTimeout(url, init) {
344
- const controller = new AbortController();
345
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
346
- try {
347
- return await fetch(url, { ...init, signal: controller.signal });
348
- } catch (err) {
349
- if (err.name === "AbortError") {
350
- throw new Error(`Request to ${new URL(url).hostname} timed out after ${this.timeoutMs / 1e3}s`);
351
- }
352
- throw err;
353
- } finally {
354
- clearTimeout(timer);
355
- }
421
+ async fetch(url, init) {
422
+ return fetchWithTimeout(url, init, this.timeoutMs);
356
423
  }
357
424
  async generate(context, config) {
358
425
  const prompt = this.buildPrompt(context, config);
@@ -453,7 +520,7 @@ Rules:
453
520
  - If a commit message mentions a breaking change, categorize it as "breaking"`;
454
521
  }
455
522
  async callAnthropic(prompt, apiKey, model) {
456
- const response = await this.fetchWithTimeout("https://api.anthropic.com/v1/messages", {
523
+ const response = await this.fetch("https://api.anthropic.com/v1/messages", {
457
524
  method: "POST",
458
525
  headers: {
459
526
  "Content-Type": "application/json",
@@ -474,7 +541,7 @@ Rules:
474
541
  return data.content[0]?.text || "";
475
542
  }
476
543
  async callOpenAI(prompt, apiKey, model) {
477
- const response = await this.fetchWithTimeout("https://api.openai.com/v1/chat/completions", {
544
+ const response = await this.fetch("https://api.openai.com/v1/chat/completions", {
478
545
  method: "POST",
479
546
  headers: {
480
547
  "Content-Type": "application/json",
@@ -496,7 +563,7 @@ Rules:
496
563
  }
497
564
  async callGemini(prompt, apiKey, model) {
498
565
  const modelId = model || "gemini-2.0-flash";
499
- const response = await this.fetchWithTimeout(
566
+ const response = await this.fetch(
500
567
  `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`,
501
568
  {
502
569
  method: "POST",
@@ -516,7 +583,7 @@ Rules:
516
583
  }
517
584
  async callOllama(prompt, model) {
518
585
  const baseUrl = process.env.OLLAMA_HOST || "http://localhost:11434";
519
- const response = await this.fetchWithTimeout(`${baseUrl}/api/chat`, {
586
+ const response = await this.fetch(`${baseUrl}/api/chat`, {
520
587
  method: "POST",
521
588
  headers: { "Content-Type": "application/json" },
522
589
  body: JSON.stringify({
@@ -538,7 +605,7 @@ Rules:
538
605
  const token = this.openclawConfig?.token || process.env.OPENCLAW_TOKEN || "";
539
606
  const headers = { "Content-Type": "application/json" };
540
607
  if (token) headers["Authorization"] = `Bearer ${token}`;
541
- const response = await this.fetchWithTimeout(`${baseUrl}/v1/chat/completions`, {
608
+ const response = await this.fetch(`${baseUrl}/v1/chat/completions`, {
542
609
  method: "POST",
543
610
  headers,
544
611
  body: JSON.stringify({
@@ -581,7 +648,7 @@ ${raw.substring(0, 500)}`);
581
648
  commitCount: context.diff.commits.length,
582
649
  prCount: context.diff.commits.filter((c) => c.prNumber).length,
583
650
  ticketCount: context.tickets.length,
584
- generatedBy: "cull",
651
+ generatedBy: "cullit",
585
652
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
586
653
  }
587
654
  };
@@ -619,7 +686,7 @@ var TemplateGenerator = class {
619
686
  commitCount: diff.commits.length,
620
687
  prCount: diff.commits.filter((c) => c.prNumber).length,
621
688
  ticketCount: tickets.length,
622
- generatedBy: "cull-template",
689
+ generatedBy: "cullit-template",
623
690
  generatedAt: (/* @__PURE__ */ new Date()).toISOString()
624
691
  }
625
692
  };
@@ -755,7 +822,7 @@ function formatMarkdown(notes) {
755
822
  }
756
823
  if (notes.metadata) {
757
824
  lines.push("---");
758
- lines.push(`*Generated by [Cull](https://cullit.io) \u2022 ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs*`);
825
+ lines.push(`*Generated by [Cullit](https://cullit.io) \u2022 ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs*`);
759
826
  }
760
827
  return lines.join("\n");
761
828
  }
@@ -781,7 +848,7 @@ function formatHTML(notes) {
781
848
  html += `</ul>`;
782
849
  }
783
850
  if (notes.metadata) {
784
- html += `<footer><small>Generated by <a href="https://cullit.io">Cull</a> &bull; ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs</small></footer>`;
851
+ html += `<footer><small>Generated by <a href="https://cullit.io">Cullit</a> &bull; ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs</small></footer>`;
785
852
  }
786
853
  html += `</div>`;
787
854
  return html;
@@ -817,10 +884,13 @@ var FilePublisher = class {
817
884
  var SlackPublisher = class {
818
885
  constructor(webhookUrl) {
819
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
+ }
820
890
  }
821
891
  async publish(notes, _format) {
822
892
  const text = this.buildSlackMessage(notes);
823
- const response = await fetch(this.webhookUrl, {
893
+ const response = await fetchWithTimeout(this.webhookUrl, {
824
894
  method: "POST",
825
895
  headers: { "Content-Type": "application/json" },
826
896
  body: JSON.stringify({ text })
@@ -856,10 +926,13 @@ var SlackPublisher = class {
856
926
  var DiscordPublisher = class {
857
927
  constructor(webhookUrl) {
858
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
+ }
859
932
  }
860
933
  async publish(notes, _format) {
861
934
  const content = this.buildDiscordMessage(notes);
862
- const response = await fetch(this.webhookUrl, {
935
+ const response = await fetchWithTimeout(this.webhookUrl, {
863
936
  method: "POST",
864
937
  headers: { "Content-Type": "application/json" },
865
938
  body: JSON.stringify({
@@ -867,9 +940,9 @@ var DiscordPublisher = class {
867
940
  title: `Release ${notes.version}`,
868
941
  description: content,
869
942
  color: 15269703,
870
- // Cull accent color
943
+ // Cullit accent color
871
944
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
872
- footer: { text: "Generated by Cull" }
945
+ footer: { text: "Generated by Cullit" }
873
946
  }]
874
947
  })
875
948
  });
@@ -924,7 +997,7 @@ var GitHubReleasePublisher = class {
924
997
  }
925
998
  async getRelease(tag) {
926
999
  const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${encodeURIComponent(tag)}`;
927
- const response = await fetch(url, {
1000
+ const response = await fetchWithTimeout(url, {
928
1001
  headers: this.headers()
929
1002
  });
930
1003
  if (response.status === 404) return null;
@@ -937,7 +1010,7 @@ var GitHubReleasePublisher = class {
937
1010
  }
938
1011
  async createRelease(tag, body, notes) {
939
1012
  const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases`;
940
- const response = await fetch(url, {
1013
+ const response = await fetchWithTimeout(url, {
941
1014
  method: "POST",
942
1015
  headers: this.headers(),
943
1016
  body: JSON.stringify({
@@ -956,7 +1029,7 @@ var GitHubReleasePublisher = class {
956
1029
  async updateRelease(id, body, notes) {
957
1030
  const tag = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
958
1031
  const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/${id}`;
959
- const response = await fetch(url, {
1032
+ const response = await fetchWithTimeout(url, {
960
1033
  method: "PATCH",
961
1034
  headers: this.headers(),
962
1035
  body: JSON.stringify({
@@ -1013,7 +1086,7 @@ var JiraEnricher = class {
1013
1086
  throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
1014
1087
  }
1015
1088
  const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
1016
- const response = await fetch(
1089
+ const response = await fetchWithTimeout(
1017
1090
  `https://${domain}/rest/api/3/issue/${key}?fields=summary,issuetype,labels,priority,status,description`,
1018
1091
  {
1019
1092
  headers: {
@@ -1053,6 +1126,14 @@ var LinearEnricher = class {
1053
1126
  async enrich(diff) {
1054
1127
  const keys = this.extractUniqueKeys(diff);
1055
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) {
1056
1137
  const tickets = [];
1057
1138
  for (const key of keys) {
1058
1139
  try {
@@ -1071,6 +1152,56 @@ var LinearEnricher = class {
1071
1152
  }
1072
1153
  return [...new Set(allKeys)];
1073
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
+ }
1074
1205
  async fetchIssue(identifier) {
1075
1206
  const query = `
1076
1207
  query IssueByIdentifier($id: String!) {
@@ -1086,11 +1217,11 @@ var LinearEnricher = class {
1086
1217
  }
1087
1218
  }
1088
1219
  `;
1089
- const response = await fetch("https://api.linear.app/graphql", {
1220
+ const response = await fetchWithTimeout("https://api.linear.app/graphql", {
1090
1221
  method: "POST",
1091
1222
  headers: {
1092
1223
  "Content-Type": "application/json",
1093
- "Authorization": this.apiKey
1224
+ "Authorization": `Bearer ${this.apiKey}`
1094
1225
  },
1095
1226
  body: JSON.stringify({ query, variables: { id: identifier } })
1096
1227
  });
@@ -1119,25 +1250,205 @@ var LinearEnricher = class {
1119
1250
  }
1120
1251
  };
1121
1252
 
1253
+ // src/advisor.ts
1254
+ import { execSync as execSync2 } from "child_process";
1255
+ var COMMIT_PATTERNS = [
1256
+ { pattern: /^breaking[(!:]|^BREAKING CHANGE/i, category: "breaking" },
1257
+ { pattern: /!:/, category: "breaking" },
1258
+ { pattern: /^feat[(!:]|^feature[(!:]/i, category: "features" },
1259
+ { pattern: /^fix[(!:]/i, category: "fixes" },
1260
+ { pattern: /^chore[(!:]|^docs[(!:]|^ci[(!:]|^test[(!:]|^style[(!:]|^refactor[(!:]/i, category: "chores" }
1261
+ ];
1262
+ function categorizeCommit(message) {
1263
+ for (const { pattern, category } of COMMIT_PATTERNS) {
1264
+ if (pattern.test(message)) return category;
1265
+ }
1266
+ return "other";
1267
+ }
1268
+ function bumpVersion(version, bump) {
1269
+ const prefix = version.startsWith("v") ? "v" : "";
1270
+ const clean = version.replace(/^v/, "");
1271
+ const parts = clean.split(".").map(Number);
1272
+ if (parts.length !== 3 || parts.some(isNaN)) return version;
1273
+ switch (bump) {
1274
+ case "major":
1275
+ return `${prefix}${parts[0] + 1}.0.0`;
1276
+ case "minor":
1277
+ return `${prefix}${parts[0]}.${parts[1] + 1}.0`;
1278
+ case "patch":
1279
+ return `${prefix}${parts[0]}.${parts[1]}.${parts[2] + 1}`;
1280
+ }
1281
+ }
1282
+ function getCommitsSinceTag(tag, cwd) {
1283
+ const format = "%H|%h|%an|%aI|%s";
1284
+ const separator = "---CULLIT_COMMIT---";
1285
+ 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
+ });
1301
+ } catch {
1302
+ return [];
1303
+ }
1304
+ }
1305
+ function getTagDate(tag, cwd) {
1306
+ try {
1307
+ const dateStr = execSync2(
1308
+ `git log -1 --format=%aI ${tag}`,
1309
+ { cwd, encoding: "utf-8" }
1310
+ ).trim();
1311
+ return new Date(dateStr);
1312
+ } catch {
1313
+ return null;
1314
+ }
1315
+ }
1316
+ function analyzeReleaseReadiness(cwd = process.cwd()) {
1317
+ const latestTag = getLatestTag(cwd);
1318
+ const reasons = [];
1319
+ if (!latestTag) {
1320
+ return {
1321
+ shouldRelease: true,
1322
+ suggestedBump: "minor",
1323
+ currentVersion: null,
1324
+ nextVersion: null,
1325
+ commitCount: 0,
1326
+ contributorCount: 0,
1327
+ daysSinceRelease: null,
1328
+ breakdown: { features: 0, fixes: 0, breaking: 0, chores: 0, other: 0 },
1329
+ reasons: ["No tags found. Consider creating your first release."]
1330
+ };
1331
+ }
1332
+ const commits = getCommitsSinceTag(latestTag, cwd);
1333
+ const commitCount = commits.length;
1334
+ const breakdown = { features: 0, fixes: 0, breaking: 0, chores: 0, other: 0 };
1335
+ for (const commit of commits) {
1336
+ breakdown[categorizeCommit(commit.message)]++;
1337
+ }
1338
+ const contributors = new Set(commits.map((c) => c.author));
1339
+ const tagDate = getTagDate(latestTag, cwd);
1340
+ const daysSinceRelease = tagDate ? Math.floor((Date.now() - tagDate.getTime()) / (1e3 * 60 * 60 * 24)) : null;
1341
+ let suggestedBump = "patch";
1342
+ if (breakdown.breaking > 0) {
1343
+ suggestedBump = "major";
1344
+ reasons.push(`${breakdown.breaking} breaking change(s) detected \u2014 major bump recommended`);
1345
+ } else if (breakdown.features > 0) {
1346
+ suggestedBump = "minor";
1347
+ reasons.push(`${breakdown.features} new feature(s) \u2014 minor bump recommended`);
1348
+ } else if (breakdown.fixes > 0) {
1349
+ reasons.push(`${breakdown.fixes} bug fix(es) \u2014 patch bump recommended`);
1350
+ }
1351
+ let shouldRelease = false;
1352
+ if (breakdown.breaking > 0) {
1353
+ shouldRelease = true;
1354
+ reasons.push("\u26A0 Breaking changes should be released and communicated promptly");
1355
+ }
1356
+ const securityCommits = commits.filter(
1357
+ (c) => /security|cve|vuln|exploit|xss|injection|auth.*(fix|patch)/i.test(c.message)
1358
+ );
1359
+ if (securityCommits.length > 0) {
1360
+ shouldRelease = true;
1361
+ reasons.push(`\u{1F512} ${securityCommits.length} security-related commit(s) \u2014 release ASAP`);
1362
+ }
1363
+ if (commitCount >= 5) {
1364
+ shouldRelease = true;
1365
+ reasons.push(`${commitCount} unreleased commits \u2014 consider releasing to keep changes small and reviewable`);
1366
+ }
1367
+ if (daysSinceRelease !== null && daysSinceRelease >= 14 && commitCount > 0) {
1368
+ shouldRelease = true;
1369
+ reasons.push(`${daysSinceRelease} days since last release \u2014 regular cadence helps users stay current`);
1370
+ }
1371
+ if (breakdown.features >= 3) {
1372
+ shouldRelease = true;
1373
+ reasons.push("Multiple features accumulated \u2014 users are missing out");
1374
+ }
1375
+ if (commitCount > 0 && commitCount < 5 && !shouldRelease) {
1376
+ reasons.push(`${commitCount} commit(s) since ${latestTag} \u2014 no urgency, but keep an eye on it`);
1377
+ }
1378
+ if (commitCount === 0) {
1379
+ reasons.push("No unreleased commits \u2014 you're up to date!");
1380
+ }
1381
+ const nextVersion = bumpVersion(latestTag, suggestedBump);
1382
+ return {
1383
+ shouldRelease,
1384
+ suggestedBump,
1385
+ currentVersion: latestTag,
1386
+ nextVersion,
1387
+ commitCount,
1388
+ contributorCount: contributors.size,
1389
+ daysSinceRelease,
1390
+ breakdown,
1391
+ reasons
1392
+ };
1393
+ }
1394
+
1395
+ // src/gate.ts
1396
+ var FREE_PROVIDERS = /* @__PURE__ */ new Set(["none"]);
1397
+ var FREE_PUBLISHERS = /* @__PURE__ */ new Set(["stdout", "file"]);
1398
+ function resolveLicense() {
1399
+ const key = process.env.CULLIT_API_KEY?.trim();
1400
+ if (!key) {
1401
+ return { tier: "free", valid: true };
1402
+ }
1403
+ if (!/^clt_[a-zA-Z0-9]{32,}$/.test(key)) {
1404
+ return { tier: "free", valid: false, message: "Invalid CULLIT_API_KEY format. Expected: clt_<key>" };
1405
+ }
1406
+ return { tier: "pro", valid: true };
1407
+ }
1408
+ function isProviderAllowed(provider, license) {
1409
+ if (license.tier === "pro" && license.valid) return true;
1410
+ return FREE_PROVIDERS.has(provider);
1411
+ }
1412
+ function isPublisherAllowed(publisherType, license) {
1413
+ if (license.tier === "pro" && license.valid) return true;
1414
+ return FREE_PUBLISHERS.has(publisherType);
1415
+ }
1416
+ function isEnrichmentAllowed(license) {
1417
+ return license.tier === "pro" && license.valid;
1418
+ }
1419
+ function upgradeMessage(feature) {
1420
+ return `\u{1F512} ${feature} requires a Cullit Pro license.
1421
+ Get your API key at https://cullit.io/pricing
1422
+ Then set CULLIT_API_KEY in your environment.`;
1423
+ }
1424
+
1122
1425
  // src/index.ts
1123
1426
  async function runPipeline(from, to, config, options = {}) {
1124
1427
  const startTime = Date.now();
1125
1428
  const format = options.format || "markdown";
1429
+ const log = options.logger || createLogger("normal");
1430
+ const license = resolveLicense();
1431
+ if (!license.valid) {
1432
+ throw new Error(license.message || "Invalid CULLIT_API_KEY");
1433
+ }
1434
+ if (!isProviderAllowed(config.ai.provider, license)) {
1435
+ throw new Error(upgradeMessage(`AI provider "${config.ai.provider}"`));
1436
+ }
1126
1437
  let collector;
1127
1438
  if (config.source.type === "jira") {
1128
1439
  if (!config.jira) throw new Error("Jira source requires jira config in .cullit.yml");
1129
- console.log(`\xBB Collecting issues from Jira...`);
1440
+ log.info(`\xBB Collecting issues from Jira...`);
1130
1441
  collector = new JiraCollector(config.jira);
1131
1442
  } else if (config.source.type === "linear") {
1132
- console.log(`\xBB Collecting issues from Linear...`);
1443
+ log.info(`\xBB Collecting issues from Linear...`);
1133
1444
  collector = new LinearCollector(config.linear?.apiKey);
1134
1445
  } else {
1135
- console.log(`\xBB Collecting commits between ${from}..${to}`);
1446
+ log.info(`\xBB Collecting commits between ${from}..${to}`);
1136
1447
  collector = new GitCollector();
1137
1448
  }
1138
1449
  const diff = await collector.collect(from, to);
1139
1450
  const itemLabel = config.source.type === "jira" || config.source.type === "linear" ? "issues" : "commits";
1140
- console.log(`\xBB Found ${diff.commits.length} ${itemLabel}${diff.filesChanged ? `, ${diff.filesChanged} files changed` : ""}`);
1451
+ log.info(`\xBB Found ${diff.commits.length} ${itemLabel}${diff.filesChanged ? `, ${diff.filesChanged} files changed` : ""}`);
1141
1452
  if (diff.commits.length === 0) {
1142
1453
  const source = config.source.type === "jira" ? "Jira" : config.source.type === "linear" ? "Linear" : `${from} and ${to}`;
1143
1454
  throw new Error(`No ${itemLabel} found from ${source}`);
@@ -1146,18 +1457,26 @@ async function runPipeline(from, to, config, options = {}) {
1146
1457
  const enrichmentSources = config.source.enrichment || [];
1147
1458
  for (const source of enrichmentSources) {
1148
1459
  if (source === "jira" && config.jira) {
1149
- console.log("\xBB Enriching from 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...");
1150
1465
  const enricher = new JiraEnricher(config.jira);
1151
1466
  const jiraTickets = await enricher.enrich(diff);
1152
1467
  tickets.push(...jiraTickets);
1153
- console.log(`\xBB Jira: found ${jiraTickets.length} tickets`);
1468
+ log.info(`\xBB Jira: found ${jiraTickets.length} tickets`);
1154
1469
  }
1155
1470
  if (source === "linear") {
1156
- console.log("\xBB Enriching from 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...");
1157
1476
  const enricher = new LinearEnricher(config.linear?.apiKey);
1158
1477
  const linearTickets = await enricher.enrich(diff);
1159
1478
  tickets.push(...linearTickets);
1160
- console.log(`\xBB Linear: found ${linearTickets.length} issues`);
1479
+ log.info(`\xBB Linear: found ${linearTickets.length} issues`);
1161
1480
  }
1162
1481
  }
1163
1482
  const context = { diff, tickets };
@@ -1171,7 +1490,7 @@ async function runPipeline(from, to, config, options = {}) {
1171
1490
  };
1172
1491
  const providerName = providerNames[config.ai.provider] || config.ai.provider;
1173
1492
  const modelName = config.ai.provider === "none" ? "template" : config.ai.model || DEFAULT_MODELS[config.ai.provider] || "default";
1174
- console.log(`\xBB Generating with ${providerName} (${modelName})...`);
1493
+ log.info(`\xBB Generating with ${providerName} (${modelName})...`);
1175
1494
  let notes;
1176
1495
  if (config.ai.provider === "none") {
1177
1496
  const generator = new TemplateGenerator();
@@ -1180,12 +1499,16 @@ async function runPipeline(from, to, config, options = {}) {
1180
1499
  const generator = new AIGenerator(config.openclaw);
1181
1500
  notes = await generator.generate(context, config.ai);
1182
1501
  }
1183
- console.log(`\xBB Generated ${notes.changes.length} change entries`);
1502
+ log.info(`\xBB Generated ${notes.changes.length} change entries`);
1184
1503
  const formatted = formatNotes(notes, format);
1185
1504
  const publishedTo = [];
1186
1505
  if (!options.dryRun) {
1187
1506
  for (const target of config.publish) {
1188
1507
  try {
1508
+ if (!isPublisherAllowed(target.type, license)) {
1509
+ log.info(`\xBB Skipping ${target.type} \u2014 ${upgradeMessage(`${target.type} publishing`)}`);
1510
+ continue;
1511
+ }
1189
1512
  switch (target.type) {
1190
1513
  case "stdout":
1191
1514
  await new StdoutPublisher().publish(notes, format);
@@ -1215,16 +1538,16 @@ async function runPipeline(from, to, config, options = {}) {
1215
1538
  break;
1216
1539
  }
1217
1540
  } catch (err) {
1218
- console.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
1541
+ log.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
1219
1542
  }
1220
1543
  }
1221
1544
  } else {
1222
- console.log("\n[DRY RUN \u2014 Not publishing]\n");
1223
- console.log(formatted);
1545
+ log.info("\n[DRY RUN \u2014 Not publishing]\n");
1546
+ log.info(formatted);
1224
1547
  publishedTo.push("dry-run");
1225
1548
  }
1226
1549
  const duration = Date.now() - startTime;
1227
- console.log(`
1550
+ log.info(`
1228
1551
  \u2713 Done in ${(duration / 1e3).toFixed(1)}s`);
1229
1552
  return { notes, formatted, publishedTo, duration };
1230
1553
  }
@@ -1244,8 +1567,15 @@ export {
1244
1567
  StdoutPublisher,
1245
1568
  TemplateGenerator,
1246
1569
  VERSION,
1570
+ analyzeReleaseReadiness,
1571
+ createLogger,
1247
1572
  formatNotes,
1248
1573
  getLatestTag,
1249
1574
  getRecentTags,
1250
- runPipeline
1575
+ isEnrichmentAllowed,
1576
+ isProviderAllowed,
1577
+ isPublisherAllowed,
1578
+ resolveLicense,
1579
+ runPipeline,
1580
+ upgradeMessage
1251
1581
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cullit/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Core engine for Cullit — AI-powered release note generation.",
6
6
  "license": "MIT",
@@ -27,8 +27,11 @@
27
27
  "files": [
28
28
  "dist"
29
29
  ],
30
+ "engines": {
31
+ "node": ">=18"
32
+ },
30
33
  "dependencies": {
31
- "@cullit/config": "0.1.0"
34
+ "@cullit/config": "0.3.0"
32
35
  },
33
36
  "scripts": {
34
37
  "build": "tsup src/index.ts --format esm --dts --clean",