@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 +86 -50
- package/dist/index.js +388 -58
- package/package.json +5 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,50 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
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.
|
|
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
|
|
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 {
|
|
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.
|
|
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 = "${
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
397
|
+
return `team: { key: { eq: $filterValue } }`;
|
|
320
398
|
case "project":
|
|
321
|
-
return `project: { name: { containsIgnoreCase:
|
|
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:
|
|
404
|
+
return `cycle: { name: { containsIgnoreCase: $filterValue } }`;
|
|
327
405
|
case "label":
|
|
328
|
-
return `labels: { name: { eq:
|
|
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
|
|
344
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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: "
|
|
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: "
|
|
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 [
|
|
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">
|
|
851
|
+
html += `<footer><small>Generated by <a href="https://cullit.io">Cullit</a> • ${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
|
|
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
|
|
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
|
-
//
|
|
943
|
+
// Cullit accent color
|
|
871
944
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
872
|
-
footer: { text: "Generated by
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1440
|
+
log.info(`\xBB Collecting issues from Jira...`);
|
|
1130
1441
|
collector = new JiraCollector(config.jira);
|
|
1131
1442
|
} else if (config.source.type === "linear") {
|
|
1132
|
-
|
|
1443
|
+
log.info(`\xBB Collecting issues from Linear...`);
|
|
1133
1444
|
collector = new LinearCollector(config.linear?.apiKey);
|
|
1134
1445
|
} else {
|
|
1135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1468
|
+
log.info(`\xBB Jira: found ${jiraTickets.length} tickets`);
|
|
1154
1469
|
}
|
|
1155
1470
|
if (source === "linear") {
|
|
1156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1541
|
+
log.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
|
|
1219
1542
|
}
|
|
1220
1543
|
}
|
|
1221
1544
|
} else {
|
|
1222
|
-
|
|
1223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
34
|
+
"@cullit/config": "0.3.0"
|
|
32
35
|
},
|
|
33
36
|
"scripts": {
|
|
34
37
|
"build": "tsup src/index.ts --format esm --dts --clean",
|