@cullit/core 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +38 -122
- package/dist/index.js +168 -953
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
// src/constants.ts
|
|
2
|
-
var VERSION = "0.
|
|
2
|
+
var VERSION = "0.4.0";
|
|
3
3
|
var DEFAULT_CATEGORIES = ["features", "fixes", "breaking", "improvements", "chores"];
|
|
4
4
|
var DEFAULT_MODELS = {
|
|
5
5
|
anthropic: "claude-sonnet-4-20250514",
|
|
6
6
|
openai: "gpt-4o",
|
|
7
7
|
gemini: "gemini-2.0-flash",
|
|
8
8
|
ollama: "llama3.1",
|
|
9
|
-
openclaw: "claude-sonnet-4-6"
|
|
9
|
+
openclaw: "anthropic/claude-sonnet-4-6"
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
// src/logger.ts
|
|
@@ -151,510 +151,28 @@ function getLatestTag(cwd = process.cwd()) {
|
|
|
151
151
|
return null;
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
}));
|
|
154
|
+
function getCommitsSince(from, to, cwd = process.cwd()) {
|
|
155
|
+
validateRef(from);
|
|
156
|
+
validateRef(to);
|
|
157
|
+
const format = "%H|%h|%an|%aI|%s";
|
|
158
|
+
const separator = "---CULLIT_COMMIT---";
|
|
159
|
+
const log = execSync(
|
|
160
|
+
`git log ${from}..${to} --format="${format}${separator}" --no-merges`,
|
|
161
|
+
{ cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
|
|
162
|
+
);
|
|
163
|
+
if (!log.trim()) return [];
|
|
164
|
+
return log.split(separator).filter((e) => e.trim()).map((entry) => {
|
|
165
|
+
const [hash, shortHash, author, date, ...msgParts] = entry.trim().split("|");
|
|
284
166
|
return {
|
|
285
|
-
|
|
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."
|
|
167
|
+
hash: hash.trim(),
|
|
168
|
+
shortHash: shortHash.trim(),
|
|
169
|
+
author: author.trim(),
|
|
170
|
+
date: date.trim(),
|
|
171
|
+
message: msgParts.join("|").trim()
|
|
480
172
|
};
|
|
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
|
-
]
|
|
173
|
+
});
|
|
511
174
|
}
|
|
512
175
|
|
|
513
|
-
Rules:
|
|
514
|
-
- Combine related commits into single change entries
|
|
515
|
-
- Skip trivial commits (merge commits, formatting, typos) unless they fix bugs
|
|
516
|
-
- Each description should be one clear sentence
|
|
517
|
-
- Include ticket keys when available
|
|
518
|
-
- Group by category
|
|
519
|
-
- Maximum 20 change entries
|
|
520
|
-
- If a commit message mentions a breaking change, categorize it as "breaking"`;
|
|
521
|
-
}
|
|
522
|
-
async callAnthropic(prompt, apiKey, model) {
|
|
523
|
-
const response = await this.fetch("https://api.anthropic.com/v1/messages", {
|
|
524
|
-
method: "POST",
|
|
525
|
-
headers: {
|
|
526
|
-
"Content-Type": "application/json",
|
|
527
|
-
"x-api-key": apiKey,
|
|
528
|
-
"anthropic-version": "2023-06-01"
|
|
529
|
-
},
|
|
530
|
-
body: JSON.stringify({
|
|
531
|
-
model: model || "claude-sonnet-4-20250514",
|
|
532
|
-
max_tokens: 4096,
|
|
533
|
-
messages: [{ role: "user", content: prompt }]
|
|
534
|
-
})
|
|
535
|
-
});
|
|
536
|
-
if (!response.ok) {
|
|
537
|
-
const error = await response.text();
|
|
538
|
-
throw new Error(`Anthropic API error (${response.status}): ${error}`);
|
|
539
|
-
}
|
|
540
|
-
const data = await response.json();
|
|
541
|
-
return data.content[0]?.text || "";
|
|
542
|
-
}
|
|
543
|
-
async callOpenAI(prompt, apiKey, model) {
|
|
544
|
-
const response = await this.fetch("https://api.openai.com/v1/chat/completions", {
|
|
545
|
-
method: "POST",
|
|
546
|
-
headers: {
|
|
547
|
-
"Content-Type": "application/json",
|
|
548
|
-
"Authorization": `Bearer ${apiKey}`
|
|
549
|
-
},
|
|
550
|
-
body: JSON.stringify({
|
|
551
|
-
model: model || "gpt-4o",
|
|
552
|
-
messages: [{ role: "user", content: prompt }],
|
|
553
|
-
max_tokens: 4096,
|
|
554
|
-
temperature: 0.3
|
|
555
|
-
})
|
|
556
|
-
});
|
|
557
|
-
if (!response.ok) {
|
|
558
|
-
const error = await response.text();
|
|
559
|
-
throw new Error(`OpenAI API error (${response.status}): ${error}`);
|
|
560
|
-
}
|
|
561
|
-
const data = await response.json();
|
|
562
|
-
return data.choices[0]?.message?.content || "";
|
|
563
|
-
}
|
|
564
|
-
async callGemini(prompt, apiKey, model) {
|
|
565
|
-
const modelId = model || "gemini-2.0-flash";
|
|
566
|
-
const response = await this.fetch(
|
|
567
|
-
`https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`,
|
|
568
|
-
{
|
|
569
|
-
method: "POST",
|
|
570
|
-
headers: { "Content-Type": "application/json" },
|
|
571
|
-
body: JSON.stringify({
|
|
572
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
573
|
-
generationConfig: { temperature: 0.3, maxOutputTokens: 4096 }
|
|
574
|
-
})
|
|
575
|
-
}
|
|
576
|
-
);
|
|
577
|
-
if (!response.ok) {
|
|
578
|
-
const error = await response.text();
|
|
579
|
-
throw new Error(`Gemini API error (${response.status}): ${error}`);
|
|
580
|
-
}
|
|
581
|
-
const data = await response.json();
|
|
582
|
-
return data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
583
|
-
}
|
|
584
|
-
async callOllama(prompt, model) {
|
|
585
|
-
const baseUrl = process.env.OLLAMA_HOST || "http://localhost:11434";
|
|
586
|
-
const response = await this.fetch(`${baseUrl}/api/chat`, {
|
|
587
|
-
method: "POST",
|
|
588
|
-
headers: { "Content-Type": "application/json" },
|
|
589
|
-
body: JSON.stringify({
|
|
590
|
-
model: model || "llama3.1",
|
|
591
|
-
messages: [{ role: "user", content: prompt }],
|
|
592
|
-
stream: false,
|
|
593
|
-
options: { temperature: 0.3 }
|
|
594
|
-
})
|
|
595
|
-
});
|
|
596
|
-
if (!response.ok) {
|
|
597
|
-
const error = await response.text();
|
|
598
|
-
throw new Error(`Ollama API error (${response.status}): ${error}`);
|
|
599
|
-
}
|
|
600
|
-
const data = await response.json();
|
|
601
|
-
return data.message?.content || "";
|
|
602
|
-
}
|
|
603
|
-
async callOpenClaw(prompt, model) {
|
|
604
|
-
const baseUrl = this.openclawConfig?.baseUrl || process.env.OPENCLAW_URL || "http://localhost:18789";
|
|
605
|
-
const token = this.openclawConfig?.token || process.env.OPENCLAW_TOKEN || "";
|
|
606
|
-
const headers = { "Content-Type": "application/json" };
|
|
607
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
608
|
-
const response = await this.fetch(`${baseUrl}/v1/chat/completions`, {
|
|
609
|
-
method: "POST",
|
|
610
|
-
headers,
|
|
611
|
-
body: JSON.stringify({
|
|
612
|
-
model: model || "anthropic/claude-sonnet-4-6",
|
|
613
|
-
messages: [{ role: "user", content: prompt }],
|
|
614
|
-
max_tokens: 4096,
|
|
615
|
-
temperature: 0.3
|
|
616
|
-
})
|
|
617
|
-
});
|
|
618
|
-
if (!response.ok) {
|
|
619
|
-
const error = await response.text();
|
|
620
|
-
throw new Error(`OpenClaw API error (${response.status}): ${error}`);
|
|
621
|
-
}
|
|
622
|
-
const data = await response.json();
|
|
623
|
-
return data.choices?.[0]?.message?.content || "";
|
|
624
|
-
}
|
|
625
|
-
parseResponse(raw, context) {
|
|
626
|
-
const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
|
|
627
|
-
let parsed;
|
|
628
|
-
try {
|
|
629
|
-
parsed = JSON.parse(cleaned);
|
|
630
|
-
} catch {
|
|
631
|
-
throw new Error(`Failed to parse AI response as JSON. Raw response:
|
|
632
|
-
${raw.substring(0, 500)}`);
|
|
633
|
-
}
|
|
634
|
-
const validCategories = /* @__PURE__ */ new Set(["features", "fixes", "breaking", "improvements", "chores", "other"]);
|
|
635
|
-
const changes = (parsed.changes || []).map((c) => ({
|
|
636
|
-
description: c.description,
|
|
637
|
-
category: validCategories.has(c.category) ? c.category : "other",
|
|
638
|
-
ticketKey: c.ticketKey || void 0
|
|
639
|
-
}));
|
|
640
|
-
const contributors = [...new Set(context.diff.commits.map((c) => c.author))];
|
|
641
|
-
return {
|
|
642
|
-
version: context.diff.to,
|
|
643
|
-
date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
644
|
-
summary: parsed.summary,
|
|
645
|
-
changes,
|
|
646
|
-
contributors,
|
|
647
|
-
metadata: {
|
|
648
|
-
commitCount: context.diff.commits.length,
|
|
649
|
-
prCount: context.diff.commits.filter((c) => c.prNumber).length,
|
|
650
|
-
ticketCount: context.tickets.length,
|
|
651
|
-
generatedBy: "cullit",
|
|
652
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
653
|
-
}
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
};
|
|
657
|
-
|
|
658
176
|
// src/generators/template.ts
|
|
659
177
|
var TemplateGenerator = class {
|
|
660
178
|
async generate(context, config) {
|
|
@@ -679,7 +197,7 @@ var TemplateGenerator = class {
|
|
|
679
197
|
return {
|
|
680
198
|
version: diff.to,
|
|
681
199
|
date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
682
|
-
summary: this.buildSummary(deduped, diff.commits.length),
|
|
200
|
+
summary: this.buildSummary(deduped, diff.commits.length, config.tone),
|
|
683
201
|
changes: deduped.slice(0, 20),
|
|
684
202
|
contributors,
|
|
685
203
|
metadata: {
|
|
@@ -749,7 +267,7 @@ var TemplateGenerator = class {
|
|
|
749
267
|
}
|
|
750
268
|
return result;
|
|
751
269
|
}
|
|
752
|
-
buildSummary(changes, commitCount) {
|
|
270
|
+
buildSummary(changes, commitCount, tone) {
|
|
753
271
|
const counts = {};
|
|
754
272
|
for (const c of changes) {
|
|
755
273
|
counts[c.category] = (counts[c.category] || 0) + 1;
|
|
@@ -759,8 +277,14 @@ var TemplateGenerator = class {
|
|
|
759
277
|
if (counts["features"]) parts.push(`${counts["features"]} feature${counts["features"] > 1 ? "s" : ""}`);
|
|
760
278
|
if (counts["fixes"]) parts.push(`${counts["fixes"]} fix${counts["fixes"] > 1 ? "es" : ""}`);
|
|
761
279
|
if (counts["improvements"]) parts.push(`${counts["improvements"]} improvement${counts["improvements"] > 1 ? "s" : ""}`);
|
|
762
|
-
|
|
763
|
-
|
|
280
|
+
if (tone === "terse") {
|
|
281
|
+
return parts.length > 0 ? parts.join(", ") : `${commitCount} commits`;
|
|
282
|
+
}
|
|
283
|
+
if (tone === "casual") {
|
|
284
|
+
if (parts.length === 0) return `A quick update with ${commitCount} commits \u2014 nothing too wild.`;
|
|
285
|
+
return `We've got ${parts.join(", ")} packed into ${commitCount} commits. Let's go!`;
|
|
286
|
+
}
|
|
287
|
+
return parts.length > 0 ? `This release includes ${parts.join(", ")} across ${commitCount} commits.` : `This release includes ${commitCount} commits.`;
|
|
764
288
|
}
|
|
765
289
|
};
|
|
766
290
|
|
|
@@ -866,389 +390,20 @@ function groupByCategory(notes) {
|
|
|
866
390
|
// src/publishers/index.ts
|
|
867
391
|
import { writeFileSync } from "fs";
|
|
868
392
|
var StdoutPublisher = class {
|
|
869
|
-
async publish(notes, format) {
|
|
870
|
-
|
|
871
|
-
console.log(formatted);
|
|
393
|
+
async publish(notes, format, preformatted) {
|
|
394
|
+
console.log(preformatted || formatNotes(notes, format));
|
|
872
395
|
}
|
|
873
396
|
};
|
|
874
397
|
var FilePublisher = class {
|
|
875
398
|
constructor(path) {
|
|
876
399
|
this.path = path;
|
|
877
400
|
}
|
|
878
|
-
async publish(notes, format) {
|
|
879
|
-
const
|
|
880
|
-
writeFileSync(this.path,
|
|
401
|
+
async publish(notes, format, preformatted) {
|
|
402
|
+
const output = preformatted || formatNotes(notes, format);
|
|
403
|
+
writeFileSync(this.path, output, "utf-8");
|
|
881
404
|
console.log(`\u2713 Release notes written to ${this.path}`);
|
|
882
405
|
}
|
|
883
406
|
};
|
|
884
|
-
var SlackPublisher = class {
|
|
885
|
-
constructor(webhookUrl) {
|
|
886
|
-
this.webhookUrl = webhookUrl;
|
|
887
|
-
if (!webhookUrl.startsWith("https://hooks.slack.com/") && !webhookUrl.startsWith("https://hooks.slack-gov.com/")) {
|
|
888
|
-
throw new Error("Invalid Slack webhook URL \u2014 must start with https://hooks.slack.com/ or https://hooks.slack-gov.com/");
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
async publish(notes, _format) {
|
|
892
|
-
const text = this.buildSlackMessage(notes);
|
|
893
|
-
const response = await fetchWithTimeout(this.webhookUrl, {
|
|
894
|
-
method: "POST",
|
|
895
|
-
headers: { "Content-Type": "application/json" },
|
|
896
|
-
body: JSON.stringify({ text })
|
|
897
|
-
});
|
|
898
|
-
if (!response.ok) {
|
|
899
|
-
throw new Error(`Slack webhook failed (${response.status})`);
|
|
900
|
-
}
|
|
901
|
-
console.log("\u2713 Published to Slack");
|
|
902
|
-
}
|
|
903
|
-
buildSlackMessage(notes) {
|
|
904
|
-
let msg = `*${notes.version}* \u2014 ${notes.date}
|
|
905
|
-
`;
|
|
906
|
-
if (notes.summary) msg += `${notes.summary}
|
|
907
|
-
|
|
908
|
-
`;
|
|
909
|
-
const categoryEmoji = {
|
|
910
|
-
features: "\u2728",
|
|
911
|
-
fixes: "\u{1F41B}",
|
|
912
|
-
breaking: "\u26A0\uFE0F",
|
|
913
|
-
improvements: "\u{1F527}",
|
|
914
|
-
chores: "\u{1F9F9}",
|
|
915
|
-
other: "\u{1F4DD}"
|
|
916
|
-
};
|
|
917
|
-
for (const change of notes.changes) {
|
|
918
|
-
const emoji = categoryEmoji[change.category] || "\u2022";
|
|
919
|
-
msg += `${emoji} ${change.description}`;
|
|
920
|
-
if (change.ticketKey) msg += ` (\`${change.ticketKey}\`)`;
|
|
921
|
-
msg += "\n";
|
|
922
|
-
}
|
|
923
|
-
return msg;
|
|
924
|
-
}
|
|
925
|
-
};
|
|
926
|
-
var DiscordPublisher = class {
|
|
927
|
-
constructor(webhookUrl) {
|
|
928
|
-
this.webhookUrl = webhookUrl;
|
|
929
|
-
if (!webhookUrl.startsWith("https://discord.com/api/webhooks/") && !webhookUrl.startsWith("https://discordapp.com/api/webhooks/")) {
|
|
930
|
-
throw new Error("Invalid Discord webhook URL \u2014 must start with https://discord.com/api/webhooks/");
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
async publish(notes, _format) {
|
|
934
|
-
const content = this.buildDiscordMessage(notes);
|
|
935
|
-
const response = await fetchWithTimeout(this.webhookUrl, {
|
|
936
|
-
method: "POST",
|
|
937
|
-
headers: { "Content-Type": "application/json" },
|
|
938
|
-
body: JSON.stringify({
|
|
939
|
-
embeds: [{
|
|
940
|
-
title: `Release ${notes.version}`,
|
|
941
|
-
description: content,
|
|
942
|
-
color: 15269703,
|
|
943
|
-
// Cullit accent color
|
|
944
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
945
|
-
footer: { text: "Generated by Cullit" }
|
|
946
|
-
}]
|
|
947
|
-
})
|
|
948
|
-
});
|
|
949
|
-
if (!response.ok) {
|
|
950
|
-
throw new Error(`Discord webhook failed (${response.status})`);
|
|
951
|
-
}
|
|
952
|
-
console.log("\u2713 Published to Discord");
|
|
953
|
-
}
|
|
954
|
-
buildDiscordMessage(notes) {
|
|
955
|
-
let msg = "";
|
|
956
|
-
if (notes.summary) msg += `${notes.summary}
|
|
957
|
-
|
|
958
|
-
`;
|
|
959
|
-
for (const change of notes.changes) {
|
|
960
|
-
msg += `\u2022 ${change.description}`;
|
|
961
|
-
if (change.ticketKey) msg += ` (${change.ticketKey})`;
|
|
962
|
-
msg += "\n";
|
|
963
|
-
}
|
|
964
|
-
return msg.substring(0, 4e3);
|
|
965
|
-
}
|
|
966
|
-
};
|
|
967
|
-
var GitHubReleasePublisher = class {
|
|
968
|
-
token;
|
|
969
|
-
owner;
|
|
970
|
-
repo;
|
|
971
|
-
constructor() {
|
|
972
|
-
this.token = process.env["GITHUB_TOKEN"] || "";
|
|
973
|
-
const ghRepo = process.env["GITHUB_REPOSITORY"] || "";
|
|
974
|
-
const parts = ghRepo.split("/");
|
|
975
|
-
this.owner = parts[0] || "";
|
|
976
|
-
this.repo = parts[1] || "";
|
|
977
|
-
}
|
|
978
|
-
async publish(notes, format) {
|
|
979
|
-
if (!this.token) {
|
|
980
|
-
throw new Error("GITHUB_TOKEN is required for GitHub Release publishing");
|
|
981
|
-
}
|
|
982
|
-
if (!this.owner || !this.repo) {
|
|
983
|
-
throw new Error(
|
|
984
|
-
"GITHUB_REPOSITORY env var is required (format: owner/repo). This is set automatically in GitHub Actions."
|
|
985
|
-
);
|
|
986
|
-
}
|
|
987
|
-
const formatted = formatNotes(notes, format);
|
|
988
|
-
const tagName = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
|
|
989
|
-
const existing = await this.getRelease(tagName);
|
|
990
|
-
if (existing) {
|
|
991
|
-
await this.updateRelease(existing.id, formatted, notes);
|
|
992
|
-
console.log(`\u2713 Updated GitHub Release: ${tagName}`);
|
|
993
|
-
} else {
|
|
994
|
-
await this.createRelease(tagName, formatted, notes);
|
|
995
|
-
console.log(`\u2713 Created GitHub Release: ${tagName}`);
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
async getRelease(tag) {
|
|
999
|
-
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/tags/${encodeURIComponent(tag)}`;
|
|
1000
|
-
const response = await fetchWithTimeout(url, {
|
|
1001
|
-
headers: this.headers()
|
|
1002
|
-
});
|
|
1003
|
-
if (response.status === 404) return null;
|
|
1004
|
-
if (!response.ok) {
|
|
1005
|
-
const error = await response.text();
|
|
1006
|
-
throw new Error(`GitHub API error (${response.status}): ${error}`);
|
|
1007
|
-
}
|
|
1008
|
-
const data = await response.json();
|
|
1009
|
-
return data;
|
|
1010
|
-
}
|
|
1011
|
-
async createRelease(tag, body, notes) {
|
|
1012
|
-
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases`;
|
|
1013
|
-
const response = await fetchWithTimeout(url, {
|
|
1014
|
-
method: "POST",
|
|
1015
|
-
headers: this.headers(),
|
|
1016
|
-
body: JSON.stringify({
|
|
1017
|
-
tag_name: tag,
|
|
1018
|
-
name: `${tag} \u2014 ${notes.date}`,
|
|
1019
|
-
body,
|
|
1020
|
-
draft: false,
|
|
1021
|
-
prerelease: tag.includes("-")
|
|
1022
|
-
})
|
|
1023
|
-
});
|
|
1024
|
-
if (!response.ok) {
|
|
1025
|
-
const error = await response.text();
|
|
1026
|
-
throw new Error(`GitHub Release creation failed (${response.status}): ${error}`);
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
async updateRelease(id, body, notes) {
|
|
1030
|
-
const tag = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
|
|
1031
|
-
const url = `https://api.github.com/repos/${this.owner}/${this.repo}/releases/${id}`;
|
|
1032
|
-
const response = await fetchWithTimeout(url, {
|
|
1033
|
-
method: "PATCH",
|
|
1034
|
-
headers: this.headers(),
|
|
1035
|
-
body: JSON.stringify({
|
|
1036
|
-
body,
|
|
1037
|
-
name: `${tag} \u2014 ${notes.date}`
|
|
1038
|
-
})
|
|
1039
|
-
});
|
|
1040
|
-
if (!response.ok) {
|
|
1041
|
-
const error = await response.text();
|
|
1042
|
-
throw new Error(`GitHub Release update failed (${response.status}): ${error}`);
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
headers() {
|
|
1046
|
-
return {
|
|
1047
|
-
"Accept": "application/vnd.github+json",
|
|
1048
|
-
"Authorization": `Bearer ${this.token}`,
|
|
1049
|
-
"X-GitHub-Api-Version": "2022-11-28"
|
|
1050
|
-
};
|
|
1051
|
-
}
|
|
1052
|
-
};
|
|
1053
|
-
|
|
1054
|
-
// src/enrichers/jira.ts
|
|
1055
|
-
var JiraEnricher = class {
|
|
1056
|
-
config;
|
|
1057
|
-
constructor(config) {
|
|
1058
|
-
this.config = config;
|
|
1059
|
-
}
|
|
1060
|
-
async enrich(diff) {
|
|
1061
|
-
const keys = this.extractUniqueKeys(diff);
|
|
1062
|
-
if (keys.length === 0) return [];
|
|
1063
|
-
const tickets = [];
|
|
1064
|
-
for (const key of keys) {
|
|
1065
|
-
try {
|
|
1066
|
-
const ticket = await this.fetchTicket(key);
|
|
1067
|
-
if (ticket) tickets.push(ticket);
|
|
1068
|
-
} catch (err) {
|
|
1069
|
-
console.warn(`\u26A0 Could not fetch Jira ticket ${key}: ${err.message}`);
|
|
1070
|
-
}
|
|
1071
|
-
}
|
|
1072
|
-
return tickets;
|
|
1073
|
-
}
|
|
1074
|
-
extractUniqueKeys(diff) {
|
|
1075
|
-
const allKeys = [];
|
|
1076
|
-
for (const commit of diff.commits) {
|
|
1077
|
-
if (commit.issueKeys) allKeys.push(...commit.issueKeys);
|
|
1078
|
-
}
|
|
1079
|
-
return [...new Set(allKeys)];
|
|
1080
|
-
}
|
|
1081
|
-
async fetchTicket(key) {
|
|
1082
|
-
const { domain, email, apiToken } = this.config;
|
|
1083
|
-
const resolvedEmail = email || process.env.JIRA_EMAIL;
|
|
1084
|
-
const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
|
|
1085
|
-
if (!resolvedEmail || !resolvedToken) {
|
|
1086
|
-
throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
|
|
1087
|
-
}
|
|
1088
|
-
const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
|
|
1089
|
-
const response = await fetchWithTimeout(
|
|
1090
|
-
`https://${domain}/rest/api/3/issue/${key}?fields=summary,issuetype,labels,priority,status,description`,
|
|
1091
|
-
{
|
|
1092
|
-
headers: {
|
|
1093
|
-
"Authorization": `Basic ${auth}`,
|
|
1094
|
-
"Accept": "application/json"
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
);
|
|
1098
|
-
if (response.status === 404) return null;
|
|
1099
|
-
if (!response.ok) {
|
|
1100
|
-
throw new Error(`Jira API error (${response.status})`);
|
|
1101
|
-
}
|
|
1102
|
-
const data = await response.json();
|
|
1103
|
-
const fields = data.fields;
|
|
1104
|
-
return {
|
|
1105
|
-
key,
|
|
1106
|
-
title: fields.summary || key,
|
|
1107
|
-
type: fields.issuetype?.name?.toLowerCase(),
|
|
1108
|
-
labels: fields.labels || [],
|
|
1109
|
-
priority: fields.priority?.name,
|
|
1110
|
-
status: fields.status?.name,
|
|
1111
|
-
source: "jira"
|
|
1112
|
-
};
|
|
1113
|
-
}
|
|
1114
|
-
};
|
|
1115
|
-
|
|
1116
|
-
// src/enrichers/linear.ts
|
|
1117
|
-
var LinearEnricher = class {
|
|
1118
|
-
apiKey;
|
|
1119
|
-
constructor(apiKey) {
|
|
1120
|
-
const resolved = apiKey || process.env.LINEAR_API_KEY;
|
|
1121
|
-
if (!resolved) {
|
|
1122
|
-
throw new Error("Linear API key not configured. Set LINEAR_API_KEY.");
|
|
1123
|
-
}
|
|
1124
|
-
this.apiKey = resolved;
|
|
1125
|
-
}
|
|
1126
|
-
async enrich(diff) {
|
|
1127
|
-
const keys = this.extractUniqueKeys(diff);
|
|
1128
|
-
if (keys.length === 0) return [];
|
|
1129
|
-
try {
|
|
1130
|
-
return await this.fetchIssuesBatch(keys);
|
|
1131
|
-
} catch (err) {
|
|
1132
|
-
console.warn(`\u26A0 Linear batch fetch failed, falling back to individual queries: ${err.message}`);
|
|
1133
|
-
return this.fetchIssuesIndividually(keys);
|
|
1134
|
-
}
|
|
1135
|
-
}
|
|
1136
|
-
async fetchIssuesIndividually(keys) {
|
|
1137
|
-
const tickets = [];
|
|
1138
|
-
for (const key of keys) {
|
|
1139
|
-
try {
|
|
1140
|
-
const ticket = await this.fetchIssue(key);
|
|
1141
|
-
if (ticket) tickets.push(ticket);
|
|
1142
|
-
} catch (err) {
|
|
1143
|
-
console.warn(`\u26A0 Could not fetch Linear issue ${key}: ${err.message}`);
|
|
1144
|
-
}
|
|
1145
|
-
}
|
|
1146
|
-
return tickets;
|
|
1147
|
-
}
|
|
1148
|
-
extractUniqueKeys(diff) {
|
|
1149
|
-
const allKeys = [];
|
|
1150
|
-
for (const commit of diff.commits) {
|
|
1151
|
-
if (commit.issueKeys) allKeys.push(...commit.issueKeys);
|
|
1152
|
-
}
|
|
1153
|
-
return [...new Set(allKeys)];
|
|
1154
|
-
}
|
|
1155
|
-
async fetchIssuesBatch(identifiers) {
|
|
1156
|
-
const query = `
|
|
1157
|
-
query BatchIssues($filter: IssueFilter!) {
|
|
1158
|
-
issues(filter: $filter, first: 100) {
|
|
1159
|
-
nodes {
|
|
1160
|
-
identifier
|
|
1161
|
-
title
|
|
1162
|
-
description
|
|
1163
|
-
priority
|
|
1164
|
-
state { name }
|
|
1165
|
-
labels { nodes { name } }
|
|
1166
|
-
}
|
|
1167
|
-
}
|
|
1168
|
-
}
|
|
1169
|
-
`;
|
|
1170
|
-
const response = await fetchWithTimeout("https://api.linear.app/graphql", {
|
|
1171
|
-
method: "POST",
|
|
1172
|
-
headers: {
|
|
1173
|
-
"Content-Type": "application/json",
|
|
1174
|
-
"Authorization": `Bearer ${this.apiKey}`
|
|
1175
|
-
},
|
|
1176
|
-
body: JSON.stringify({
|
|
1177
|
-
query,
|
|
1178
|
-
variables: {
|
|
1179
|
-
filter: { identifier: { in: identifiers } }
|
|
1180
|
-
}
|
|
1181
|
-
})
|
|
1182
|
-
});
|
|
1183
|
-
if (!response.ok) {
|
|
1184
|
-
throw new Error(`Linear API error (${response.status})`);
|
|
1185
|
-
}
|
|
1186
|
-
const data = await response.json();
|
|
1187
|
-
const issues = data.data?.issues?.nodes || [];
|
|
1188
|
-
const priorityMap = {
|
|
1189
|
-
0: "none",
|
|
1190
|
-
1: "urgent",
|
|
1191
|
-
2: "high",
|
|
1192
|
-
3: "medium",
|
|
1193
|
-
4: "low"
|
|
1194
|
-
};
|
|
1195
|
-
return issues.map((issue) => ({
|
|
1196
|
-
key: issue.identifier,
|
|
1197
|
-
title: issue.title,
|
|
1198
|
-
description: issue.description?.substring(0, 500),
|
|
1199
|
-
labels: issue.labels?.nodes?.map((l) => l.name) || [],
|
|
1200
|
-
priority: priorityMap[issue.priority] || void 0,
|
|
1201
|
-
status: issue.state?.name,
|
|
1202
|
-
source: "linear"
|
|
1203
|
-
}));
|
|
1204
|
-
}
|
|
1205
|
-
async fetchIssue(identifier) {
|
|
1206
|
-
const query = `
|
|
1207
|
-
query IssueByIdentifier($id: String!) {
|
|
1208
|
-
issueSearch(filter: { identifier: { eq: $id } }, first: 1) {
|
|
1209
|
-
nodes {
|
|
1210
|
-
identifier
|
|
1211
|
-
title
|
|
1212
|
-
description
|
|
1213
|
-
priority
|
|
1214
|
-
state { name }
|
|
1215
|
-
labels { nodes { name } }
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
`;
|
|
1220
|
-
const response = await fetchWithTimeout("https://api.linear.app/graphql", {
|
|
1221
|
-
method: "POST",
|
|
1222
|
-
headers: {
|
|
1223
|
-
"Content-Type": "application/json",
|
|
1224
|
-
"Authorization": `Bearer ${this.apiKey}`
|
|
1225
|
-
},
|
|
1226
|
-
body: JSON.stringify({ query, variables: { id: identifier } })
|
|
1227
|
-
});
|
|
1228
|
-
if (!response.ok) {
|
|
1229
|
-
throw new Error(`Linear API error (${response.status})`);
|
|
1230
|
-
}
|
|
1231
|
-
const data = await response.json();
|
|
1232
|
-
const issue = data.data?.issueSearch?.nodes?.[0];
|
|
1233
|
-
if (!issue) return null;
|
|
1234
|
-
const priorityMap = {
|
|
1235
|
-
0: "none",
|
|
1236
|
-
1: "urgent",
|
|
1237
|
-
2: "high",
|
|
1238
|
-
3: "medium",
|
|
1239
|
-
4: "low"
|
|
1240
|
-
};
|
|
1241
|
-
return {
|
|
1242
|
-
key: issue.identifier,
|
|
1243
|
-
title: issue.title,
|
|
1244
|
-
description: issue.description?.substring(0, 500),
|
|
1245
|
-
labels: issue.labels?.nodes?.map((l) => l.name) || [],
|
|
1246
|
-
priority: priorityMap[issue.priority] || void 0,
|
|
1247
|
-
status: issue.state?.name,
|
|
1248
|
-
source: "linear"
|
|
1249
|
-
};
|
|
1250
|
-
}
|
|
1251
|
-
};
|
|
1252
407
|
|
|
1253
408
|
// src/advisor.ts
|
|
1254
409
|
import { execSync as execSync2 } from "child_process";
|
|
@@ -1280,24 +435,8 @@ function bumpVersion(version, bump) {
|
|
|
1280
435
|
}
|
|
1281
436
|
}
|
|
1282
437
|
function getCommitsSinceTag(tag, cwd) {
|
|
1283
|
-
const format = "%H|%h|%an|%aI|%s";
|
|
1284
|
-
const separator = "---CULLIT_COMMIT---";
|
|
1285
438
|
try {
|
|
1286
|
-
|
|
1287
|
-
`git log ${tag}..HEAD --format="${format}${separator}" --no-merges`,
|
|
1288
|
-
{ cwd, encoding: "utf-8", maxBuffer: 10 * 1024 * 1024 }
|
|
1289
|
-
);
|
|
1290
|
-
if (!log.trim()) return [];
|
|
1291
|
-
return log.split(separator).filter((e) => e.trim()).map((entry) => {
|
|
1292
|
-
const [hash, shortHash, author, date, ...msgParts] = entry.trim().split("|");
|
|
1293
|
-
return {
|
|
1294
|
-
hash: hash.trim(),
|
|
1295
|
-
shortHash: shortHash.trim(),
|
|
1296
|
-
author: author.trim(),
|
|
1297
|
-
date: date.trim(),
|
|
1298
|
-
message: msgParts.join("|").trim()
|
|
1299
|
-
};
|
|
1300
|
-
});
|
|
439
|
+
return getCommitsSince(tag, "HEAD", cwd);
|
|
1301
440
|
} catch {
|
|
1302
441
|
return [];
|
|
1303
442
|
}
|
|
@@ -1422,7 +561,70 @@ function upgradeMessage(feature) {
|
|
|
1422
561
|
Then set CULLIT_API_KEY in your environment.`;
|
|
1423
562
|
}
|
|
1424
563
|
|
|
564
|
+
// src/registry.ts
|
|
565
|
+
var collectors = /* @__PURE__ */ new Map();
|
|
566
|
+
var enrichers = /* @__PURE__ */ new Map();
|
|
567
|
+
var generators = /* @__PURE__ */ new Map();
|
|
568
|
+
var publishers = /* @__PURE__ */ new Map();
|
|
569
|
+
function registerCollector(type, factory) {
|
|
570
|
+
collectors.set(type, factory);
|
|
571
|
+
}
|
|
572
|
+
function registerEnricher(type, factory) {
|
|
573
|
+
enrichers.set(type, factory);
|
|
574
|
+
}
|
|
575
|
+
function registerGenerator(provider, factory) {
|
|
576
|
+
generators.set(provider, factory);
|
|
577
|
+
}
|
|
578
|
+
function registerPublisher(type, factory) {
|
|
579
|
+
publishers.set(type, factory);
|
|
580
|
+
}
|
|
581
|
+
function getCollector(type) {
|
|
582
|
+
return collectors.get(type);
|
|
583
|
+
}
|
|
584
|
+
function getEnricher(type) {
|
|
585
|
+
return enrichers.get(type);
|
|
586
|
+
}
|
|
587
|
+
function getGenerator(provider) {
|
|
588
|
+
return generators.get(provider);
|
|
589
|
+
}
|
|
590
|
+
function getPublisher(type) {
|
|
591
|
+
return publishers.get(type);
|
|
592
|
+
}
|
|
593
|
+
function hasGenerator(provider) {
|
|
594
|
+
return generators.has(provider);
|
|
595
|
+
}
|
|
596
|
+
function hasCollector(type) {
|
|
597
|
+
return collectors.has(type);
|
|
598
|
+
}
|
|
599
|
+
function hasPublisher(type) {
|
|
600
|
+
return publishers.has(type);
|
|
601
|
+
}
|
|
602
|
+
function hasEnricher(type) {
|
|
603
|
+
return enrichers.has(type);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// src/fetch.ts
|
|
607
|
+
var DEFAULT_TIMEOUT = 3e4;
|
|
608
|
+
async function fetchWithTimeout(url, init, timeoutMs = DEFAULT_TIMEOUT) {
|
|
609
|
+
const controller = new AbortController();
|
|
610
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
611
|
+
try {
|
|
612
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
613
|
+
} catch (err) {
|
|
614
|
+
if (err.name === "AbortError") {
|
|
615
|
+
throw new Error(`Request to ${new URL(url).hostname} timed out after ${timeoutMs / 1e3}s`);
|
|
616
|
+
}
|
|
617
|
+
throw err;
|
|
618
|
+
} finally {
|
|
619
|
+
clearTimeout(timer);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
1425
623
|
// src/index.ts
|
|
624
|
+
registerCollector("local", () => new GitCollector());
|
|
625
|
+
registerGenerator("none", () => new TemplateGenerator());
|
|
626
|
+
registerPublisher("stdout", () => new StdoutPublisher());
|
|
627
|
+
registerPublisher("file", (path) => new FilePublisher(path));
|
|
1426
628
|
async function runPipeline(from, to, config, options = {}) {
|
|
1427
629
|
const startTime = Date.now();
|
|
1428
630
|
const format = options.format || "markdown";
|
|
@@ -1434,17 +636,22 @@ async function runPipeline(from, to, config, options = {}) {
|
|
|
1434
636
|
if (!isProviderAllowed(config.ai.provider, license)) {
|
|
1435
637
|
throw new Error(upgradeMessage(`AI provider "${config.ai.provider}"`));
|
|
1436
638
|
}
|
|
639
|
+
const collectorFactory = getCollector(config.source.type);
|
|
640
|
+
if (!collectorFactory) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
`Source type "${config.source.type}" is not available. ` + (config.source.type === "jira" || config.source.type === "linear" ? "Install @cullit/pro to use this source." : "Valid sources: local")
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
const sourceLabel = config.source.type === "jira" ? "issues from Jira" : config.source.type === "linear" ? "issues from Linear" : `commits between ${from}..${to}`;
|
|
646
|
+
log.info(`\xBB Collecting ${sourceLabel}`);
|
|
1437
647
|
let collector;
|
|
1438
648
|
if (config.source.type === "jira") {
|
|
1439
649
|
if (!config.jira) throw new Error("Jira source requires jira config in .cullit.yml");
|
|
1440
|
-
|
|
1441
|
-
collector = new JiraCollector(config.jira);
|
|
650
|
+
collector = collectorFactory(config.jira);
|
|
1442
651
|
} else if (config.source.type === "linear") {
|
|
1443
|
-
|
|
1444
|
-
collector = new LinearCollector(config.linear?.apiKey);
|
|
652
|
+
collector = collectorFactory(config.linear?.apiKey);
|
|
1445
653
|
} else {
|
|
1446
|
-
|
|
1447
|
-
collector = new GitCollector();
|
|
654
|
+
collector = collectorFactory();
|
|
1448
655
|
}
|
|
1449
656
|
const diff = await collector.collect(from, to);
|
|
1450
657
|
const itemLabel = config.source.type === "jira" || config.source.type === "linear" ? "issues" : "commits";
|
|
@@ -1456,28 +663,27 @@ async function runPipeline(from, to, config, options = {}) {
|
|
|
1456
663
|
const tickets = [];
|
|
1457
664
|
const enrichmentSources = config.source.enrichment || [];
|
|
1458
665
|
for (const source of enrichmentSources) {
|
|
1459
|
-
if (
|
|
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`);
|
|
666
|
+
if (!isEnrichmentAllowed(license)) {
|
|
667
|
+
log.info(`\xBB Skipping ${source} enrichment \u2014 ${upgradeMessage(`${source} enrichment`)}`);
|
|
668
|
+
continue;
|
|
1469
669
|
}
|
|
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`);
|
|
670
|
+
const enricherFactory = getEnricher(source);
|
|
671
|
+
if (!enricherFactory) {
|
|
672
|
+
log.info(`\xBB Skipping ${source} enrichment \u2014 install @cullit/pro to enable`);
|
|
673
|
+
continue;
|
|
1480
674
|
}
|
|
675
|
+
log.info(`\xBB Enriching from ${source}...`);
|
|
676
|
+
let enricher;
|
|
677
|
+
if (source === "jira" && config.jira) {
|
|
678
|
+
enricher = enricherFactory(config.jira);
|
|
679
|
+
} else if (source === "linear") {
|
|
680
|
+
enricher = enricherFactory(config.linear?.apiKey);
|
|
681
|
+
} else {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
const enrichedTickets = await enricher.enrich(diff);
|
|
685
|
+
tickets.push(...enrichedTickets);
|
|
686
|
+
log.info(`\xBB ${source}: found ${enrichedTickets.length} ${source === "jira" ? "tickets" : "issues"}`);
|
|
1481
687
|
}
|
|
1482
688
|
const context = { diff, tickets };
|
|
1483
689
|
const providerNames = {
|
|
@@ -1491,14 +697,19 @@ async function runPipeline(from, to, config, options = {}) {
|
|
|
1491
697
|
const providerName = providerNames[config.ai.provider] || config.ai.provider;
|
|
1492
698
|
const modelName = config.ai.provider === "none" ? "template" : config.ai.model || DEFAULT_MODELS[config.ai.provider] || "default";
|
|
1493
699
|
log.info(`\xBB Generating with ${providerName} (${modelName})...`);
|
|
1494
|
-
|
|
700
|
+
const generatorFactory = getGenerator(config.ai.provider);
|
|
701
|
+
if (!generatorFactory) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`AI provider "${config.ai.provider}" is not available. ` + (config.ai.provider !== "none" ? "Install @cullit/pro to use AI providers." : "")
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
let generator;
|
|
1495
707
|
if (config.ai.provider === "none") {
|
|
1496
|
-
|
|
1497
|
-
notes = await generator.generate(context, config.ai);
|
|
708
|
+
generator = generatorFactory();
|
|
1498
709
|
} else {
|
|
1499
|
-
|
|
1500
|
-
notes = await generator.generate(context, config.ai);
|
|
710
|
+
generator = generatorFactory(config.openclaw);
|
|
1501
711
|
}
|
|
712
|
+
const notes = await generator.generate(context, config.ai);
|
|
1502
713
|
log.info(`\xBB Generated ${notes.changes.length} change entries`);
|
|
1503
714
|
const formatted = formatNotes(notes, format);
|
|
1504
715
|
const publishedTo = [];
|
|
@@ -1509,34 +720,33 @@ async function runPipeline(from, to, config, options = {}) {
|
|
|
1509
720
|
log.info(`\xBB Skipping ${target.type} \u2014 ${upgradeMessage(`${target.type} publishing`)}`);
|
|
1510
721
|
continue;
|
|
1511
722
|
}
|
|
723
|
+
const publisherFactory = getPublisher(target.type);
|
|
724
|
+
if (!publisherFactory) {
|
|
725
|
+
log.info(`\xBB Skipping ${target.type} \u2014 install @cullit/pro to enable`);
|
|
726
|
+
continue;
|
|
727
|
+
}
|
|
728
|
+
let publisher;
|
|
1512
729
|
switch (target.type) {
|
|
1513
730
|
case "stdout":
|
|
1514
|
-
|
|
1515
|
-
publishedTo.push("stdout");
|
|
731
|
+
publisher = publisherFactory();
|
|
1516
732
|
break;
|
|
1517
733
|
case "file":
|
|
1518
|
-
if (target.path)
|
|
1519
|
-
|
|
1520
|
-
publishedTo.push(`file:${target.path}`);
|
|
1521
|
-
}
|
|
734
|
+
if (!target.path) continue;
|
|
735
|
+
publisher = publisherFactory(target.path);
|
|
1522
736
|
break;
|
|
1523
737
|
case "slack":
|
|
1524
|
-
if (target.webhookUrl) {
|
|
1525
|
-
await new SlackPublisher(target.webhookUrl).publish(notes, format);
|
|
1526
|
-
publishedTo.push("slack");
|
|
1527
|
-
}
|
|
1528
|
-
break;
|
|
1529
738
|
case "discord":
|
|
1530
|
-
if (target.webhookUrl)
|
|
1531
|
-
|
|
1532
|
-
publishedTo.push("discord");
|
|
1533
|
-
}
|
|
739
|
+
if (!target.webhookUrl) continue;
|
|
740
|
+
publisher = publisherFactory(target.webhookUrl);
|
|
1534
741
|
break;
|
|
1535
742
|
case "github-release":
|
|
1536
|
-
|
|
1537
|
-
publishedTo.push("github-release");
|
|
743
|
+
publisher = publisherFactory();
|
|
1538
744
|
break;
|
|
745
|
+
default:
|
|
746
|
+
continue;
|
|
1539
747
|
}
|
|
748
|
+
await publisher.publish(notes, format, formatted);
|
|
749
|
+
publishedTo.push(target.type === "file" ? `file:${target.path}` : target.type);
|
|
1540
750
|
} catch (err) {
|
|
1541
751
|
log.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
|
|
1542
752
|
}
|
|
@@ -1552,29 +762,34 @@ async function runPipeline(from, to, config, options = {}) {
|
|
|
1552
762
|
return { notes, formatted, publishedTo, duration };
|
|
1553
763
|
}
|
|
1554
764
|
export {
|
|
1555
|
-
AIGenerator,
|
|
1556
765
|
DEFAULT_CATEGORIES,
|
|
1557
766
|
DEFAULT_MODELS,
|
|
1558
|
-
DiscordPublisher,
|
|
1559
767
|
FilePublisher,
|
|
1560
768
|
GitCollector,
|
|
1561
|
-
GitHubReleasePublisher,
|
|
1562
|
-
JiraCollector,
|
|
1563
|
-
JiraEnricher,
|
|
1564
|
-
LinearCollector,
|
|
1565
|
-
LinearEnricher,
|
|
1566
|
-
SlackPublisher,
|
|
1567
769
|
StdoutPublisher,
|
|
1568
770
|
TemplateGenerator,
|
|
1569
771
|
VERSION,
|
|
1570
772
|
analyzeReleaseReadiness,
|
|
1571
773
|
createLogger,
|
|
774
|
+
fetchWithTimeout,
|
|
1572
775
|
formatNotes,
|
|
776
|
+
getCollector,
|
|
777
|
+
getEnricher,
|
|
778
|
+
getGenerator,
|
|
1573
779
|
getLatestTag,
|
|
780
|
+
getPublisher,
|
|
1574
781
|
getRecentTags,
|
|
782
|
+
hasCollector,
|
|
783
|
+
hasEnricher,
|
|
784
|
+
hasGenerator,
|
|
785
|
+
hasPublisher,
|
|
1575
786
|
isEnrichmentAllowed,
|
|
1576
787
|
isProviderAllowed,
|
|
1577
788
|
isPublisherAllowed,
|
|
789
|
+
registerCollector,
|
|
790
|
+
registerEnricher,
|
|
791
|
+
registerGenerator,
|
|
792
|
+
registerPublisher,
|
|
1578
793
|
resolveLicense,
|
|
1579
794
|
runPipeline,
|
|
1580
795
|
upgradeMessage
|