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