@cullit/core 0.1.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 +105 -153
- package/dist/index.js +369 -824
- package/package.json +5 -2
package/dist/index.js
CHANGED
|
@@ -1,22 +1,50 @@
|
|
|
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
|
+
// src/logger.ts
|
|
13
|
+
function createLogger(level = "normal") {
|
|
14
|
+
return {
|
|
15
|
+
info(msg) {
|
|
16
|
+
if (level !== "quiet") console.log(msg);
|
|
17
|
+
},
|
|
18
|
+
verbose(msg) {
|
|
19
|
+
if (level === "verbose") console.log(msg);
|
|
20
|
+
},
|
|
21
|
+
warn(msg) {
|
|
22
|
+
if (level !== "quiet") console.warn(msg);
|
|
23
|
+
},
|
|
24
|
+
error(msg) {
|
|
25
|
+
console.error(msg);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
12
30
|
// src/collectors/git.ts
|
|
13
31
|
import { execSync } from "child_process";
|
|
32
|
+
function validateRef(ref) {
|
|
33
|
+
if (!ref || ref.length > 256) {
|
|
34
|
+
throw new Error(`Invalid git ref: too ${ref ? "long" : "short"}`);
|
|
35
|
+
}
|
|
36
|
+
if (!/^[a-zA-Z0-9._\-/~^]+$/.test(ref)) {
|
|
37
|
+
throw new Error(`Invalid git ref "${ref}" \u2014 only alphanumeric, dots, dashes, underscores, slashes, tildes, and carets are allowed`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
14
40
|
var GitCollector = class {
|
|
15
41
|
cwd;
|
|
16
42
|
constructor(cwd = process.cwd()) {
|
|
17
43
|
this.cwd = cwd;
|
|
18
44
|
}
|
|
19
45
|
async collect(from, to) {
|
|
46
|
+
validateRef(from);
|
|
47
|
+
validateRef(to);
|
|
20
48
|
const log = this.getLog(from, to);
|
|
21
49
|
const commits = this.parseLog(log);
|
|
22
50
|
return {
|
|
@@ -123,471 +151,28 @@ function getLatestTag(cwd = process.cwd()) {
|
|
|
123
151
|
return null;
|
|
124
152
|
}
|
|
125
153
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
shortHash: issue.key,
|
|
139
|
-
author: issue.assignee || "unassigned",
|
|
140
|
-
date: issue.resolved || issue.updated || (/* @__PURE__ */ new Date()).toISOString(),
|
|
141
|
-
message: `${issue.type ? `[${issue.type}] ` : ""}${issue.summary}`,
|
|
142
|
-
body: issue.description?.substring(0, 500),
|
|
143
|
-
issueKeys: [issue.key]
|
|
144
|
-
}));
|
|
145
|
-
return {
|
|
146
|
-
from: `jira:${from}`,
|
|
147
|
-
to: to === "HEAD" ? `jira:${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]}` : `jira:${to}`,
|
|
148
|
-
commits,
|
|
149
|
-
filesChanged: 0
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
buildJQL(from, to) {
|
|
153
|
-
if (from.includes("=") || from.includes("AND") || from.includes("OR")) {
|
|
154
|
-
const statusFilter = " AND status in (Done, Closed, Resolved)";
|
|
155
|
-
return from.includes("status") ? from : from + statusFilter;
|
|
156
|
-
}
|
|
157
|
-
if (to === "HEAD") {
|
|
158
|
-
return `project = ${from} AND status in (Done, Closed, Resolved) AND resolved >= -30d ORDER BY resolved DESC`;
|
|
159
|
-
}
|
|
160
|
-
return `project = ${from} AND fixVersion = "${to}" AND status in (Done, Closed, Resolved) ORDER BY resolved DESC`;
|
|
161
|
-
}
|
|
162
|
-
async fetchIssues(jql) {
|
|
163
|
-
const { domain, email, apiToken } = this.config;
|
|
164
|
-
const resolvedEmail = email || process.env.JIRA_EMAIL;
|
|
165
|
-
const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
|
|
166
|
-
if (!resolvedEmail || !resolvedToken) {
|
|
167
|
-
throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
|
|
168
|
-
}
|
|
169
|
-
const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
|
|
170
|
-
const issues = [];
|
|
171
|
-
let startAt = 0;
|
|
172
|
-
const maxResults = 50;
|
|
173
|
-
while (true) {
|
|
174
|
-
const url = new URL(`https://${domain}/rest/api/3/search`);
|
|
175
|
-
url.searchParams.set("jql", jql);
|
|
176
|
-
url.searchParams.set("startAt", String(startAt));
|
|
177
|
-
url.searchParams.set("maxResults", String(maxResults));
|
|
178
|
-
url.searchParams.set("fields", "summary,issuetype,assignee,status,resolution,resolutiondate,updated,labels,priority,description,fixVersions");
|
|
179
|
-
const response = await fetch(url.toString(), {
|
|
180
|
-
headers: {
|
|
181
|
-
"Authorization": `Basic ${auth}`,
|
|
182
|
-
"Accept": "application/json"
|
|
183
|
-
}
|
|
184
|
-
});
|
|
185
|
-
if (!response.ok) {
|
|
186
|
-
const error = await response.text();
|
|
187
|
-
throw new Error(`Jira API error (${response.status}): ${error}`);
|
|
188
|
-
}
|
|
189
|
-
const data = await response.json();
|
|
190
|
-
const batch = (data.issues || []).map((issue) => ({
|
|
191
|
-
key: issue.key,
|
|
192
|
-
summary: issue.fields.summary,
|
|
193
|
-
type: issue.fields.issuetype?.name?.toLowerCase(),
|
|
194
|
-
assignee: issue.fields.assignee?.displayName,
|
|
195
|
-
status: issue.fields.status?.name,
|
|
196
|
-
resolved: issue.fields.resolutiondate,
|
|
197
|
-
updated: issue.fields.updated,
|
|
198
|
-
description: issue.fields.description?.content?.[0]?.content?.[0]?.text,
|
|
199
|
-
labels: issue.fields.labels || [],
|
|
200
|
-
priority: issue.fields.priority?.name
|
|
201
|
-
}));
|
|
202
|
-
issues.push(...batch);
|
|
203
|
-
if (issues.length >= data.total || batch.length < maxResults) break;
|
|
204
|
-
startAt += maxResults;
|
|
205
|
-
}
|
|
206
|
-
return issues;
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
// src/collectors/linear.ts
|
|
211
|
-
var LinearCollector = class {
|
|
212
|
-
apiKey;
|
|
213
|
-
constructor(apiKey) {
|
|
214
|
-
const resolved = apiKey || process.env.LINEAR_API_KEY;
|
|
215
|
-
if (!resolved) {
|
|
216
|
-
throw new Error("Linear API key not configured. Set LINEAR_API_KEY.");
|
|
217
|
-
}
|
|
218
|
-
this.apiKey = resolved;
|
|
219
|
-
}
|
|
220
|
-
async collect(from, to) {
|
|
221
|
-
const filter = this.parseFilter(from);
|
|
222
|
-
const issues = await this.fetchIssues(filter);
|
|
223
|
-
const commits = issues.map((issue) => ({
|
|
224
|
-
hash: issue.identifier,
|
|
225
|
-
shortHash: issue.identifier,
|
|
226
|
-
author: issue.assignee || "unassigned",
|
|
227
|
-
date: issue.completedAt || issue.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
|
|
228
|
-
message: `${issue.type ? `[${issue.type}] ` : ""}${issue.title}`,
|
|
229
|
-
body: issue.description?.substring(0, 500),
|
|
230
|
-
issueKeys: [issue.identifier]
|
|
231
|
-
}));
|
|
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("|");
|
|
232
166
|
return {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
parseFilter(from) {
|
|
240
|
-
const [type, ...valueParts] = from.split(":");
|
|
241
|
-
const value = valueParts.join(":") || type;
|
|
242
|
-
switch (type.toLowerCase()) {
|
|
243
|
-
case "team":
|
|
244
|
-
return { type: "team", value };
|
|
245
|
-
case "project":
|
|
246
|
-
return { type: "project", value };
|
|
247
|
-
case "cycle":
|
|
248
|
-
return { type: "cycle", value };
|
|
249
|
-
case "label":
|
|
250
|
-
return { type: "label", value };
|
|
251
|
-
default:
|
|
252
|
-
return { type: "team", value: from };
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
async fetchIssues(filter) {
|
|
256
|
-
const filterClause = this.buildFilterClause(filter);
|
|
257
|
-
const query = `
|
|
258
|
-
query CompletedIssues {
|
|
259
|
-
issues(
|
|
260
|
-
filter: {
|
|
261
|
-
state: { type: { in: ["completed", "canceled"] } }
|
|
262
|
-
${filterClause}
|
|
263
|
-
}
|
|
264
|
-
first: 100
|
|
265
|
-
orderBy: completedAt
|
|
266
|
-
) {
|
|
267
|
-
nodes {
|
|
268
|
-
identifier
|
|
269
|
-
title
|
|
270
|
-
description
|
|
271
|
-
priority
|
|
272
|
-
completedAt
|
|
273
|
-
updatedAt
|
|
274
|
-
assignee { displayName }
|
|
275
|
-
state { name type }
|
|
276
|
-
labels { nodes { name } }
|
|
277
|
-
project { name }
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
`;
|
|
282
|
-
const response = await fetch("https://api.linear.app/graphql", {
|
|
283
|
-
method: "POST",
|
|
284
|
-
headers: {
|
|
285
|
-
"Content-Type": "application/json",
|
|
286
|
-
"Authorization": this.apiKey
|
|
287
|
-
},
|
|
288
|
-
body: JSON.stringify({ query })
|
|
289
|
-
});
|
|
290
|
-
if (!response.ok) {
|
|
291
|
-
const error = await response.text();
|
|
292
|
-
throw new Error(`Linear API error (${response.status}): ${error}`);
|
|
293
|
-
}
|
|
294
|
-
const data = await response.json();
|
|
295
|
-
const nodes = data.data?.issues?.nodes || [];
|
|
296
|
-
const priorityMap = {
|
|
297
|
-
0: "none",
|
|
298
|
-
1: "urgent",
|
|
299
|
-
2: "high",
|
|
300
|
-
3: "medium",
|
|
301
|
-
4: "low"
|
|
167
|
+
hash: hash.trim(),
|
|
168
|
+
shortHash: shortHash.trim(),
|
|
169
|
+
author: author.trim(),
|
|
170
|
+
date: date.trim(),
|
|
171
|
+
message: msgParts.join("|").trim()
|
|
302
172
|
};
|
|
303
|
-
|
|
304
|
-
identifier: issue.identifier,
|
|
305
|
-
title: issue.title,
|
|
306
|
-
description: issue.description?.substring(0, 500),
|
|
307
|
-
type: issue.labels?.nodes?.[0]?.name?.toLowerCase(),
|
|
308
|
-
assignee: issue.assignee?.displayName,
|
|
309
|
-
status: issue.state?.name,
|
|
310
|
-
completedAt: issue.completedAt,
|
|
311
|
-
updatedAt: issue.updatedAt,
|
|
312
|
-
labels: issue.labels?.nodes?.map((l) => l.name) || [],
|
|
313
|
-
priority: priorityMap[issue.priority]
|
|
314
|
-
}));
|
|
315
|
-
}
|
|
316
|
-
buildFilterClause(filter) {
|
|
317
|
-
switch (filter.type) {
|
|
318
|
-
case "team":
|
|
319
|
-
return `team: { key: { eq: "${filter.value}" } }`;
|
|
320
|
-
case "project":
|
|
321
|
-
return `project: { name: { containsIgnoreCase: "${filter.value}" } }`;
|
|
322
|
-
case "cycle":
|
|
323
|
-
if (filter.value === "current") {
|
|
324
|
-
return `cycle: { isActive: { eq: true } }`;
|
|
325
|
-
}
|
|
326
|
-
return `cycle: { name: { containsIgnoreCase: "${filter.value}" } }`;
|
|
327
|
-
case "label":
|
|
328
|
-
return `labels: { name: { eq: "${filter.value}" } }`;
|
|
329
|
-
default:
|
|
330
|
-
return "";
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
// src/generators/ai.ts
|
|
336
|
-
var AIGenerator = class {
|
|
337
|
-
openclawConfig;
|
|
338
|
-
timeoutMs;
|
|
339
|
-
constructor(openclawConfig, timeoutMs = 6e4) {
|
|
340
|
-
this.openclawConfig = openclawConfig;
|
|
341
|
-
this.timeoutMs = timeoutMs;
|
|
342
|
-
}
|
|
343
|
-
async fetchWithTimeout(url, init) {
|
|
344
|
-
const controller = new AbortController();
|
|
345
|
-
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
346
|
-
try {
|
|
347
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
348
|
-
} catch (err) {
|
|
349
|
-
if (err.name === "AbortError") {
|
|
350
|
-
throw new Error(`Request to ${new URL(url).hostname} timed out after ${this.timeoutMs / 1e3}s`);
|
|
351
|
-
}
|
|
352
|
-
throw err;
|
|
353
|
-
} finally {
|
|
354
|
-
clearTimeout(timer);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
async generate(context, config) {
|
|
358
|
-
const prompt = this.buildPrompt(context, config);
|
|
359
|
-
const apiKey = this.resolveApiKey(config);
|
|
360
|
-
let rawResponse;
|
|
361
|
-
if (config.provider === "anthropic") {
|
|
362
|
-
rawResponse = await this.callAnthropic(prompt, apiKey, config.model);
|
|
363
|
-
} else if (config.provider === "openai") {
|
|
364
|
-
rawResponse = await this.callOpenAI(prompt, apiKey, config.model);
|
|
365
|
-
} else if (config.provider === "gemini") {
|
|
366
|
-
rawResponse = await this.callGemini(prompt, apiKey, config.model);
|
|
367
|
-
} else if (config.provider === "ollama") {
|
|
368
|
-
rawResponse = await this.callOllama(prompt, config.model);
|
|
369
|
-
} else if (config.provider === "openclaw") {
|
|
370
|
-
rawResponse = await this.callOpenClaw(prompt, config.model);
|
|
371
|
-
} else {
|
|
372
|
-
throw new Error(`Unsupported AI provider: ${config.provider}`);
|
|
373
|
-
}
|
|
374
|
-
return this.parseResponse(rawResponse, context);
|
|
375
|
-
}
|
|
376
|
-
resolveApiKey(config) {
|
|
377
|
-
if (config.apiKey) return config.apiKey;
|
|
378
|
-
if (config.provider === "ollama" || config.provider === "openclaw") return "";
|
|
379
|
-
const envVarMap = {
|
|
380
|
-
anthropic: "ANTHROPIC_API_KEY",
|
|
381
|
-
openai: "OPENAI_API_KEY",
|
|
382
|
-
gemini: "GOOGLE_API_KEY"
|
|
383
|
-
};
|
|
384
|
-
const envVar = envVarMap[config.provider];
|
|
385
|
-
if (!envVar) throw new Error(`Unknown provider: ${config.provider}`);
|
|
386
|
-
const key = process.env[envVar];
|
|
387
|
-
if (!key) {
|
|
388
|
-
throw new Error(
|
|
389
|
-
`No API key found. Set ${envVar} in your environment or provide it in .cullit.yml under ai.apiKey`
|
|
390
|
-
);
|
|
391
|
-
}
|
|
392
|
-
return key;
|
|
393
|
-
}
|
|
394
|
-
buildPrompt(context, config) {
|
|
395
|
-
const { diff, tickets } = context;
|
|
396
|
-
const commitList = diff.commits.map((c) => {
|
|
397
|
-
let line = `- ${c.shortHash}: ${c.message}`;
|
|
398
|
-
if (c.issueKeys?.length) line += ` [${c.issueKeys.join(", ")}]`;
|
|
399
|
-
return line;
|
|
400
|
-
}).join("\n");
|
|
401
|
-
const ticketList = tickets.length > 0 ? tickets.map(
|
|
402
|
-
(t) => `- ${t.key}: ${t.title}${t.type ? ` (${t.type})` : ""}${t.labels?.length ? ` [${t.labels.join(", ")}]` : ""}`
|
|
403
|
-
).join("\n") : "No enrichment data available.";
|
|
404
|
-
const audienceInstructions = {
|
|
405
|
-
"developer": "Write for developers. Include technical details, API changes, and migration notes.",
|
|
406
|
-
"end-user": "Write for end users. Use plain language. Focus on benefits and behavior changes. No jargon.",
|
|
407
|
-
"executive": "Write a brief executive summary. Focus on business impact, key metrics, and strategic changes."
|
|
408
|
-
};
|
|
409
|
-
const toneInstructions = {
|
|
410
|
-
"professional": "Tone: professional and clear.",
|
|
411
|
-
"casual": "Tone: conversational and approachable, but still informative.",
|
|
412
|
-
"terse": "Tone: minimal and direct. Short bullet points only."
|
|
413
|
-
};
|
|
414
|
-
const categories = config.categories.join(", ");
|
|
415
|
-
return `You are a release notes generator. Analyze the following git commits and related tickets, then produce structured release notes.
|
|
416
|
-
|
|
417
|
-
## Input
|
|
418
|
-
|
|
419
|
-
### Commits (${diff.from} \u2192 ${diff.to})
|
|
420
|
-
${commitList}
|
|
421
|
-
|
|
422
|
-
### Related Tickets
|
|
423
|
-
${ticketList}
|
|
424
|
-
|
|
425
|
-
## Instructions
|
|
426
|
-
|
|
427
|
-
${audienceInstructions[config.audience]}
|
|
428
|
-
${toneInstructions[config.tone]}
|
|
429
|
-
|
|
430
|
-
Categorize each change into one of: ${categories}
|
|
431
|
-
|
|
432
|
-
## Output Format
|
|
433
|
-
|
|
434
|
-
Respond with ONLY valid JSON (no markdown, no backticks, no preamble):
|
|
435
|
-
{
|
|
436
|
-
"summary": "One paragraph summarizing this release",
|
|
437
|
-
"changes": [
|
|
438
|
-
{
|
|
439
|
-
"description": "Human-readable description of the change",
|
|
440
|
-
"category": "features|fixes|breaking|improvements|chores",
|
|
441
|
-
"ticketKey": "PROJ-123 or null"
|
|
442
|
-
}
|
|
443
|
-
]
|
|
173
|
+
});
|
|
444
174
|
}
|
|
445
175
|
|
|
446
|
-
Rules:
|
|
447
|
-
- Combine related commits into single change entries
|
|
448
|
-
- Skip trivial commits (merge commits, formatting, typos) unless they fix bugs
|
|
449
|
-
- Each description should be one clear sentence
|
|
450
|
-
- Include ticket keys when available
|
|
451
|
-
- Group by category
|
|
452
|
-
- Maximum 20 change entries
|
|
453
|
-
- If a commit message mentions a breaking change, categorize it as "breaking"`;
|
|
454
|
-
}
|
|
455
|
-
async callAnthropic(prompt, apiKey, model) {
|
|
456
|
-
const response = await this.fetchWithTimeout("https://api.anthropic.com/v1/messages", {
|
|
457
|
-
method: "POST",
|
|
458
|
-
headers: {
|
|
459
|
-
"Content-Type": "application/json",
|
|
460
|
-
"x-api-key": apiKey,
|
|
461
|
-
"anthropic-version": "2023-06-01"
|
|
462
|
-
},
|
|
463
|
-
body: JSON.stringify({
|
|
464
|
-
model: model || "claude-sonnet-4-20250514",
|
|
465
|
-
max_tokens: 4096,
|
|
466
|
-
messages: [{ role: "user", content: prompt }]
|
|
467
|
-
})
|
|
468
|
-
});
|
|
469
|
-
if (!response.ok) {
|
|
470
|
-
const error = await response.text();
|
|
471
|
-
throw new Error(`Anthropic API error (${response.status}): ${error}`);
|
|
472
|
-
}
|
|
473
|
-
const data = await response.json();
|
|
474
|
-
return data.content[0]?.text || "";
|
|
475
|
-
}
|
|
476
|
-
async callOpenAI(prompt, apiKey, model) {
|
|
477
|
-
const response = await this.fetchWithTimeout("https://api.openai.com/v1/chat/completions", {
|
|
478
|
-
method: "POST",
|
|
479
|
-
headers: {
|
|
480
|
-
"Content-Type": "application/json",
|
|
481
|
-
"Authorization": `Bearer ${apiKey}`
|
|
482
|
-
},
|
|
483
|
-
body: JSON.stringify({
|
|
484
|
-
model: model || "gpt-4o",
|
|
485
|
-
messages: [{ role: "user", content: prompt }],
|
|
486
|
-
max_tokens: 4096,
|
|
487
|
-
temperature: 0.3
|
|
488
|
-
})
|
|
489
|
-
});
|
|
490
|
-
if (!response.ok) {
|
|
491
|
-
const error = await response.text();
|
|
492
|
-
throw new Error(`OpenAI API error (${response.status}): ${error}`);
|
|
493
|
-
}
|
|
494
|
-
const data = await response.json();
|
|
495
|
-
return data.choices[0]?.message?.content || "";
|
|
496
|
-
}
|
|
497
|
-
async callGemini(prompt, apiKey, model) {
|
|
498
|
-
const modelId = model || "gemini-2.0-flash";
|
|
499
|
-
const response = await this.fetchWithTimeout(
|
|
500
|
-
`https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${encodeURIComponent(apiKey)}`,
|
|
501
|
-
{
|
|
502
|
-
method: "POST",
|
|
503
|
-
headers: { "Content-Type": "application/json" },
|
|
504
|
-
body: JSON.stringify({
|
|
505
|
-
contents: [{ parts: [{ text: prompt }] }],
|
|
506
|
-
generationConfig: { temperature: 0.3, maxOutputTokens: 4096 }
|
|
507
|
-
})
|
|
508
|
-
}
|
|
509
|
-
);
|
|
510
|
-
if (!response.ok) {
|
|
511
|
-
const error = await response.text();
|
|
512
|
-
throw new Error(`Gemini API error (${response.status}): ${error}`);
|
|
513
|
-
}
|
|
514
|
-
const data = await response.json();
|
|
515
|
-
return data.candidates?.[0]?.content?.parts?.[0]?.text || "";
|
|
516
|
-
}
|
|
517
|
-
async callOllama(prompt, model) {
|
|
518
|
-
const baseUrl = process.env.OLLAMA_HOST || "http://localhost:11434";
|
|
519
|
-
const response = await this.fetchWithTimeout(`${baseUrl}/api/chat`, {
|
|
520
|
-
method: "POST",
|
|
521
|
-
headers: { "Content-Type": "application/json" },
|
|
522
|
-
body: JSON.stringify({
|
|
523
|
-
model: model || "llama3.1",
|
|
524
|
-
messages: [{ role: "user", content: prompt }],
|
|
525
|
-
stream: false,
|
|
526
|
-
options: { temperature: 0.3 }
|
|
527
|
-
})
|
|
528
|
-
});
|
|
529
|
-
if (!response.ok) {
|
|
530
|
-
const error = await response.text();
|
|
531
|
-
throw new Error(`Ollama API error (${response.status}): ${error}`);
|
|
532
|
-
}
|
|
533
|
-
const data = await response.json();
|
|
534
|
-
return data.message?.content || "";
|
|
535
|
-
}
|
|
536
|
-
async callOpenClaw(prompt, model) {
|
|
537
|
-
const baseUrl = this.openclawConfig?.baseUrl || process.env.OPENCLAW_URL || "http://localhost:18789";
|
|
538
|
-
const token = this.openclawConfig?.token || process.env.OPENCLAW_TOKEN || "";
|
|
539
|
-
const headers = { "Content-Type": "application/json" };
|
|
540
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
541
|
-
const response = await this.fetchWithTimeout(`${baseUrl}/v1/chat/completions`, {
|
|
542
|
-
method: "POST",
|
|
543
|
-
headers,
|
|
544
|
-
body: JSON.stringify({
|
|
545
|
-
model: model || "anthropic/claude-sonnet-4-6",
|
|
546
|
-
messages: [{ role: "user", content: prompt }],
|
|
547
|
-
max_tokens: 4096,
|
|
548
|
-
temperature: 0.3
|
|
549
|
-
})
|
|
550
|
-
});
|
|
551
|
-
if (!response.ok) {
|
|
552
|
-
const error = await response.text();
|
|
553
|
-
throw new Error(`OpenClaw API error (${response.status}): ${error}`);
|
|
554
|
-
}
|
|
555
|
-
const data = await response.json();
|
|
556
|
-
return data.choices?.[0]?.message?.content || "";
|
|
557
|
-
}
|
|
558
|
-
parseResponse(raw, context) {
|
|
559
|
-
const cleaned = raw.replace(/```json\s*/g, "").replace(/```\s*/g, "").trim();
|
|
560
|
-
let parsed;
|
|
561
|
-
try {
|
|
562
|
-
parsed = JSON.parse(cleaned);
|
|
563
|
-
} catch {
|
|
564
|
-
throw new Error(`Failed to parse AI response as JSON. Raw response:
|
|
565
|
-
${raw.substring(0, 500)}`);
|
|
566
|
-
}
|
|
567
|
-
const validCategories = /* @__PURE__ */ new Set(["features", "fixes", "breaking", "improvements", "chores", "other"]);
|
|
568
|
-
const changes = (parsed.changes || []).map((c) => ({
|
|
569
|
-
description: c.description,
|
|
570
|
-
category: validCategories.has(c.category) ? c.category : "other",
|
|
571
|
-
ticketKey: c.ticketKey || void 0
|
|
572
|
-
}));
|
|
573
|
-
const contributors = [...new Set(context.diff.commits.map((c) => c.author))];
|
|
574
|
-
return {
|
|
575
|
-
version: context.diff.to,
|
|
576
|
-
date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
577
|
-
summary: parsed.summary,
|
|
578
|
-
changes,
|
|
579
|
-
contributors,
|
|
580
|
-
metadata: {
|
|
581
|
-
commitCount: context.diff.commits.length,
|
|
582
|
-
prCount: context.diff.commits.filter((c) => c.prNumber).length,
|
|
583
|
-
ticketCount: context.tickets.length,
|
|
584
|
-
generatedBy: "cull",
|
|
585
|
-
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
586
|
-
}
|
|
587
|
-
};
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
|
|
591
176
|
// src/generators/template.ts
|
|
592
177
|
var TemplateGenerator = class {
|
|
593
178
|
async generate(context, config) {
|
|
@@ -612,14 +197,14 @@ var TemplateGenerator = class {
|
|
|
612
197
|
return {
|
|
613
198
|
version: diff.to,
|
|
614
199
|
date: (/* @__PURE__ */ new Date()).toISOString().split("T")[0],
|
|
615
|
-
summary: this.buildSummary(deduped, diff.commits.length),
|
|
200
|
+
summary: this.buildSummary(deduped, diff.commits.length, config.tone),
|
|
616
201
|
changes: deduped.slice(0, 20),
|
|
617
202
|
contributors,
|
|
618
203
|
metadata: {
|
|
619
204
|
commitCount: diff.commits.length,
|
|
620
205
|
prCount: diff.commits.filter((c) => c.prNumber).length,
|
|
621
206
|
ticketCount: tickets.length,
|
|
622
|
-
generatedBy: "
|
|
207
|
+
generatedBy: "cullit-template",
|
|
623
208
|
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
624
209
|
}
|
|
625
210
|
};
|
|
@@ -682,7 +267,7 @@ var TemplateGenerator = class {
|
|
|
682
267
|
}
|
|
683
268
|
return result;
|
|
684
269
|
}
|
|
685
|
-
buildSummary(changes, commitCount) {
|
|
270
|
+
buildSummary(changes, commitCount, tone) {
|
|
686
271
|
const counts = {};
|
|
687
272
|
for (const c of changes) {
|
|
688
273
|
counts[c.category] = (counts[c.category] || 0) + 1;
|
|
@@ -692,8 +277,14 @@ var TemplateGenerator = class {
|
|
|
692
277
|
if (counts["features"]) parts.push(`${counts["features"]} feature${counts["features"] > 1 ? "s" : ""}`);
|
|
693
278
|
if (counts["fixes"]) parts.push(`${counts["fixes"]} fix${counts["fixes"] > 1 ? "es" : ""}`);
|
|
694
279
|
if (counts["improvements"]) parts.push(`${counts["improvements"]} improvement${counts["improvements"] > 1 ? "s" : ""}`);
|
|
695
|
-
|
|
696
|
-
|
|
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.`;
|
|
697
288
|
}
|
|
698
289
|
};
|
|
699
290
|
|
|
@@ -755,7 +346,7 @@ function formatMarkdown(notes) {
|
|
|
755
346
|
}
|
|
756
347
|
if (notes.metadata) {
|
|
757
348
|
lines.push("---");
|
|
758
|
-
lines.push(`*Generated by [
|
|
349
|
+
lines.push(`*Generated by [Cullit](https://cullit.io) \u2022 ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs*`);
|
|
759
350
|
}
|
|
760
351
|
return lines.join("\n");
|
|
761
352
|
}
|
|
@@ -781,7 +372,7 @@ function formatHTML(notes) {
|
|
|
781
372
|
html += `</ul>`;
|
|
782
373
|
}
|
|
783
374
|
if (notes.metadata) {
|
|
784
|
-
html += `<footer><small>Generated by <a href="https://cullit.io">
|
|
375
|
+
html += `<footer><small>Generated by <a href="https://cullit.io">Cullit</a> • ${notes.metadata.commitCount} commits, ${notes.metadata.prCount} PRs</small></footer>`;
|
|
785
376
|
}
|
|
786
377
|
html += `</div>`;
|
|
787
378
|
return html;
|
|
@@ -799,345 +390,272 @@ function groupByCategory(notes) {
|
|
|
799
390
|
// src/publishers/index.ts
|
|
800
391
|
import { writeFileSync } from "fs";
|
|
801
392
|
var StdoutPublisher = class {
|
|
802
|
-
async publish(notes, format) {
|
|
803
|
-
|
|
804
|
-
console.log(formatted);
|
|
393
|
+
async publish(notes, format, preformatted) {
|
|
394
|
+
console.log(preformatted || formatNotes(notes, format));
|
|
805
395
|
}
|
|
806
396
|
};
|
|
807
397
|
var FilePublisher = class {
|
|
808
398
|
constructor(path) {
|
|
809
399
|
this.path = path;
|
|
810
400
|
}
|
|
811
|
-
async publish(notes, format) {
|
|
812
|
-
const
|
|
813
|
-
writeFileSync(this.path,
|
|
401
|
+
async publish(notes, format, preformatted) {
|
|
402
|
+
const output = preformatted || formatNotes(notes, format);
|
|
403
|
+
writeFileSync(this.path, output, "utf-8");
|
|
814
404
|
console.log(`\u2713 Release notes written to ${this.path}`);
|
|
815
405
|
}
|
|
816
406
|
};
|
|
817
|
-
var SlackPublisher = class {
|
|
818
|
-
constructor(webhookUrl) {
|
|
819
|
-
this.webhookUrl = webhookUrl;
|
|
820
|
-
}
|
|
821
|
-
async publish(notes, _format) {
|
|
822
|
-
const text = this.buildSlackMessage(notes);
|
|
823
|
-
const response = await fetch(this.webhookUrl, {
|
|
824
|
-
method: "POST",
|
|
825
|
-
headers: { "Content-Type": "application/json" },
|
|
826
|
-
body: JSON.stringify({ text })
|
|
827
|
-
});
|
|
828
|
-
if (!response.ok) {
|
|
829
|
-
throw new Error(`Slack webhook failed (${response.status})`);
|
|
830
|
-
}
|
|
831
|
-
console.log("\u2713 Published to Slack");
|
|
832
|
-
}
|
|
833
|
-
buildSlackMessage(notes) {
|
|
834
|
-
let msg = `*${notes.version}* \u2014 ${notes.date}
|
|
835
|
-
`;
|
|
836
|
-
if (notes.summary) msg += `${notes.summary}
|
|
837
407
|
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
if (change.ticketKey) msg += ` (\`${change.ticketKey}\`)`;
|
|
851
|
-
msg += "\n";
|
|
852
|
-
}
|
|
853
|
-
return msg;
|
|
854
|
-
}
|
|
855
|
-
};
|
|
856
|
-
var DiscordPublisher = class {
|
|
857
|
-
constructor(webhookUrl) {
|
|
858
|
-
this.webhookUrl = webhookUrl;
|
|
859
|
-
}
|
|
860
|
-
async publish(notes, _format) {
|
|
861
|
-
const content = this.buildDiscordMessage(notes);
|
|
862
|
-
const response = await fetch(this.webhookUrl, {
|
|
863
|
-
method: "POST",
|
|
864
|
-
headers: { "Content-Type": "application/json" },
|
|
865
|
-
body: JSON.stringify({
|
|
866
|
-
embeds: [{
|
|
867
|
-
title: `Release ${notes.version}`,
|
|
868
|
-
description: content,
|
|
869
|
-
color: 15269703,
|
|
870
|
-
// Cull accent color
|
|
871
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
872
|
-
footer: { text: "Generated by Cull" }
|
|
873
|
-
}]
|
|
874
|
-
})
|
|
875
|
-
});
|
|
876
|
-
if (!response.ok) {
|
|
877
|
-
throw new Error(`Discord webhook failed (${response.status})`);
|
|
878
|
-
}
|
|
879
|
-
console.log("\u2713 Published to Discord");
|
|
880
|
-
}
|
|
881
|
-
buildDiscordMessage(notes) {
|
|
882
|
-
let msg = "";
|
|
883
|
-
if (notes.summary) msg += `${notes.summary}
|
|
884
|
-
|
|
885
|
-
`;
|
|
886
|
-
for (const change of notes.changes) {
|
|
887
|
-
msg += `\u2022 ${change.description}`;
|
|
888
|
-
if (change.ticketKey) msg += ` (${change.ticketKey})`;
|
|
889
|
-
msg += "\n";
|
|
890
|
-
}
|
|
891
|
-
return msg.substring(0, 4e3);
|
|
892
|
-
}
|
|
893
|
-
};
|
|
894
|
-
var GitHubReleasePublisher = class {
|
|
895
|
-
token;
|
|
896
|
-
owner;
|
|
897
|
-
repo;
|
|
898
|
-
constructor() {
|
|
899
|
-
this.token = process.env["GITHUB_TOKEN"] || "";
|
|
900
|
-
const ghRepo = process.env["GITHUB_REPOSITORY"] || "";
|
|
901
|
-
const parts = ghRepo.split("/");
|
|
902
|
-
this.owner = parts[0] || "";
|
|
903
|
-
this.repo = parts[1] || "";
|
|
904
|
-
}
|
|
905
|
-
async publish(notes, format) {
|
|
906
|
-
if (!this.token) {
|
|
907
|
-
throw new Error("GITHUB_TOKEN is required for GitHub Release publishing");
|
|
908
|
-
}
|
|
909
|
-
if (!this.owner || !this.repo) {
|
|
910
|
-
throw new Error(
|
|
911
|
-
"GITHUB_REPOSITORY env var is required (format: owner/repo). This is set automatically in GitHub Actions."
|
|
912
|
-
);
|
|
913
|
-
}
|
|
914
|
-
const formatted = formatNotes(notes, format);
|
|
915
|
-
const tagName = notes.version.startsWith("v") ? notes.version : `v${notes.version}`;
|
|
916
|
-
const existing = await this.getRelease(tagName);
|
|
917
|
-
if (existing) {
|
|
918
|
-
await this.updateRelease(existing.id, formatted, notes);
|
|
919
|
-
console.log(`\u2713 Updated GitHub Release: ${tagName}`);
|
|
920
|
-
} else {
|
|
921
|
-
await this.createRelease(tagName, formatted, notes);
|
|
922
|
-
console.log(`\u2713 Created GitHub Release: ${tagName}`);
|
|
923
|
-
}
|
|
408
|
+
// src/advisor.ts
|
|
409
|
+
import { execSync as execSync2 } from "child_process";
|
|
410
|
+
var COMMIT_PATTERNS = [
|
|
411
|
+
{ pattern: /^breaking[(!:]|^BREAKING CHANGE/i, category: "breaking" },
|
|
412
|
+
{ pattern: /!:/, category: "breaking" },
|
|
413
|
+
{ pattern: /^feat[(!:]|^feature[(!:]/i, category: "features" },
|
|
414
|
+
{ pattern: /^fix[(!:]/i, category: "fixes" },
|
|
415
|
+
{ pattern: /^chore[(!:]|^docs[(!:]|^ci[(!:]|^test[(!:]|^style[(!:]|^refactor[(!:]/i, category: "chores" }
|
|
416
|
+
];
|
|
417
|
+
function categorizeCommit(message) {
|
|
418
|
+
for (const { pattern, category } of COMMIT_PATTERNS) {
|
|
419
|
+
if (pattern.test(message)) return category;
|
|
924
420
|
}
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
421
|
+
return "other";
|
|
422
|
+
}
|
|
423
|
+
function bumpVersion(version, bump) {
|
|
424
|
+
const prefix = version.startsWith("v") ? "v" : "";
|
|
425
|
+
const clean = version.replace(/^v/, "");
|
|
426
|
+
const parts = clean.split(".").map(Number);
|
|
427
|
+
if (parts.length !== 3 || parts.some(isNaN)) return version;
|
|
428
|
+
switch (bump) {
|
|
429
|
+
case "major":
|
|
430
|
+
return `${prefix}${parts[0] + 1}.0.0`;
|
|
431
|
+
case "minor":
|
|
432
|
+
return `${prefix}${parts[0]}.${parts[1] + 1}.0`;
|
|
433
|
+
case "patch":
|
|
434
|
+
return `${prefix}${parts[0]}.${parts[1]}.${parts[2] + 1}`;
|
|
937
435
|
}
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
tag_name: tag,
|
|
945
|
-
name: `${tag} \u2014 ${notes.date}`,
|
|
946
|
-
body,
|
|
947
|
-
draft: false,
|
|
948
|
-
prerelease: tag.includes("-")
|
|
949
|
-
})
|
|
950
|
-
});
|
|
951
|
-
if (!response.ok) {
|
|
952
|
-
const error = await response.text();
|
|
953
|
-
throw new Error(`GitHub Release creation failed (${response.status}): ${error}`);
|
|
954
|
-
}
|
|
436
|
+
}
|
|
437
|
+
function getCommitsSinceTag(tag, cwd) {
|
|
438
|
+
try {
|
|
439
|
+
return getCommitsSince(tag, "HEAD", cwd);
|
|
440
|
+
} catch {
|
|
441
|
+
return [];
|
|
955
442
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
});
|
|
967
|
-
if (!response.ok) {
|
|
968
|
-
const error = await response.text();
|
|
969
|
-
throw new Error(`GitHub Release update failed (${response.status}): ${error}`);
|
|
970
|
-
}
|
|
443
|
+
}
|
|
444
|
+
function getTagDate(tag, cwd) {
|
|
445
|
+
try {
|
|
446
|
+
const dateStr = execSync2(
|
|
447
|
+
`git log -1 --format=%aI ${tag}`,
|
|
448
|
+
{ cwd, encoding: "utf-8" }
|
|
449
|
+
).trim();
|
|
450
|
+
return new Date(dateStr);
|
|
451
|
+
} catch {
|
|
452
|
+
return null;
|
|
971
453
|
}
|
|
972
|
-
|
|
454
|
+
}
|
|
455
|
+
function analyzeReleaseReadiness(cwd = process.cwd()) {
|
|
456
|
+
const latestTag = getLatestTag(cwd);
|
|
457
|
+
const reasons = [];
|
|
458
|
+
if (!latestTag) {
|
|
973
459
|
return {
|
|
974
|
-
|
|
975
|
-
"
|
|
976
|
-
|
|
460
|
+
shouldRelease: true,
|
|
461
|
+
suggestedBump: "minor",
|
|
462
|
+
currentVersion: null,
|
|
463
|
+
nextVersion: null,
|
|
464
|
+
commitCount: 0,
|
|
465
|
+
contributorCount: 0,
|
|
466
|
+
daysSinceRelease: null,
|
|
467
|
+
breakdown: { features: 0, fixes: 0, breaking: 0, chores: 0, other: 0 },
|
|
468
|
+
reasons: ["No tags found. Consider creating your first release."]
|
|
977
469
|
};
|
|
978
470
|
}
|
|
979
|
-
|
|
471
|
+
const commits = getCommitsSinceTag(latestTag, cwd);
|
|
472
|
+
const commitCount = commits.length;
|
|
473
|
+
const breakdown = { features: 0, fixes: 0, breaking: 0, chores: 0, other: 0 };
|
|
474
|
+
for (const commit of commits) {
|
|
475
|
+
breakdown[categorizeCommit(commit.message)]++;
|
|
476
|
+
}
|
|
477
|
+
const contributors = new Set(commits.map((c) => c.author));
|
|
478
|
+
const tagDate = getTagDate(latestTag, cwd);
|
|
479
|
+
const daysSinceRelease = tagDate ? Math.floor((Date.now() - tagDate.getTime()) / (1e3 * 60 * 60 * 24)) : null;
|
|
480
|
+
let suggestedBump = "patch";
|
|
481
|
+
if (breakdown.breaking > 0) {
|
|
482
|
+
suggestedBump = "major";
|
|
483
|
+
reasons.push(`${breakdown.breaking} breaking change(s) detected \u2014 major bump recommended`);
|
|
484
|
+
} else if (breakdown.features > 0) {
|
|
485
|
+
suggestedBump = "minor";
|
|
486
|
+
reasons.push(`${breakdown.features} new feature(s) \u2014 minor bump recommended`);
|
|
487
|
+
} else if (breakdown.fixes > 0) {
|
|
488
|
+
reasons.push(`${breakdown.fixes} bug fix(es) \u2014 patch bump recommended`);
|
|
489
|
+
}
|
|
490
|
+
let shouldRelease = false;
|
|
491
|
+
if (breakdown.breaking > 0) {
|
|
492
|
+
shouldRelease = true;
|
|
493
|
+
reasons.push("\u26A0 Breaking changes should be released and communicated promptly");
|
|
494
|
+
}
|
|
495
|
+
const securityCommits = commits.filter(
|
|
496
|
+
(c) => /security|cve|vuln|exploit|xss|injection|auth.*(fix|patch)/i.test(c.message)
|
|
497
|
+
);
|
|
498
|
+
if (securityCommits.length > 0) {
|
|
499
|
+
shouldRelease = true;
|
|
500
|
+
reasons.push(`\u{1F512} ${securityCommits.length} security-related commit(s) \u2014 release ASAP`);
|
|
501
|
+
}
|
|
502
|
+
if (commitCount >= 5) {
|
|
503
|
+
shouldRelease = true;
|
|
504
|
+
reasons.push(`${commitCount} unreleased commits \u2014 consider releasing to keep changes small and reviewable`);
|
|
505
|
+
}
|
|
506
|
+
if (daysSinceRelease !== null && daysSinceRelease >= 14 && commitCount > 0) {
|
|
507
|
+
shouldRelease = true;
|
|
508
|
+
reasons.push(`${daysSinceRelease} days since last release \u2014 regular cadence helps users stay current`);
|
|
509
|
+
}
|
|
510
|
+
if (breakdown.features >= 3) {
|
|
511
|
+
shouldRelease = true;
|
|
512
|
+
reasons.push("Multiple features accumulated \u2014 users are missing out");
|
|
513
|
+
}
|
|
514
|
+
if (commitCount > 0 && commitCount < 5 && !shouldRelease) {
|
|
515
|
+
reasons.push(`${commitCount} commit(s) since ${latestTag} \u2014 no urgency, but keep an eye on it`);
|
|
516
|
+
}
|
|
517
|
+
if (commitCount === 0) {
|
|
518
|
+
reasons.push("No unreleased commits \u2014 you're up to date!");
|
|
519
|
+
}
|
|
520
|
+
const nextVersion = bumpVersion(latestTag, suggestedBump);
|
|
521
|
+
return {
|
|
522
|
+
shouldRelease,
|
|
523
|
+
suggestedBump,
|
|
524
|
+
currentVersion: latestTag,
|
|
525
|
+
nextVersion,
|
|
526
|
+
commitCount,
|
|
527
|
+
contributorCount: contributors.size,
|
|
528
|
+
daysSinceRelease,
|
|
529
|
+
breakdown,
|
|
530
|
+
reasons
|
|
531
|
+
};
|
|
532
|
+
}
|
|
980
533
|
|
|
981
|
-
// src/
|
|
982
|
-
var
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
const resolvedEmail = email || process.env.JIRA_EMAIL;
|
|
1011
|
-
const resolvedToken = apiToken || process.env.JIRA_API_TOKEN;
|
|
1012
|
-
if (!resolvedEmail || !resolvedToken) {
|
|
1013
|
-
throw new Error("Jira credentials not configured. Set JIRA_EMAIL and JIRA_API_TOKEN.");
|
|
1014
|
-
}
|
|
1015
|
-
const auth = Buffer.from(`${resolvedEmail}:${resolvedToken}`).toString("base64");
|
|
1016
|
-
const response = await fetch(
|
|
1017
|
-
`https://${domain}/rest/api/3/issue/${key}?fields=summary,issuetype,labels,priority,status,description`,
|
|
1018
|
-
{
|
|
1019
|
-
headers: {
|
|
1020
|
-
"Authorization": `Basic ${auth}`,
|
|
1021
|
-
"Accept": "application/json"
|
|
1022
|
-
}
|
|
1023
|
-
}
|
|
1024
|
-
);
|
|
1025
|
-
if (response.status === 404) return null;
|
|
1026
|
-
if (!response.ok) {
|
|
1027
|
-
throw new Error(`Jira API error (${response.status})`);
|
|
1028
|
-
}
|
|
1029
|
-
const data = await response.json();
|
|
1030
|
-
const fields = data.fields;
|
|
1031
|
-
return {
|
|
1032
|
-
key,
|
|
1033
|
-
title: fields.summary || key,
|
|
1034
|
-
type: fields.issuetype?.name?.toLowerCase(),
|
|
1035
|
-
labels: fields.labels || [],
|
|
1036
|
-
priority: fields.priority?.name,
|
|
1037
|
-
status: fields.status?.name,
|
|
1038
|
-
source: "jira"
|
|
1039
|
-
};
|
|
1040
|
-
}
|
|
1041
|
-
};
|
|
534
|
+
// src/gate.ts
|
|
535
|
+
var FREE_PROVIDERS = /* @__PURE__ */ new Set(["none"]);
|
|
536
|
+
var FREE_PUBLISHERS = /* @__PURE__ */ new Set(["stdout", "file"]);
|
|
537
|
+
function resolveLicense() {
|
|
538
|
+
const key = process.env.CULLIT_API_KEY?.trim();
|
|
539
|
+
if (!key) {
|
|
540
|
+
return { tier: "free", valid: true };
|
|
541
|
+
}
|
|
542
|
+
if (!/^clt_[a-zA-Z0-9]{32,}$/.test(key)) {
|
|
543
|
+
return { tier: "free", valid: false, message: "Invalid CULLIT_API_KEY format. Expected: clt_<key>" };
|
|
544
|
+
}
|
|
545
|
+
return { tier: "pro", valid: true };
|
|
546
|
+
}
|
|
547
|
+
function isProviderAllowed(provider, license) {
|
|
548
|
+
if (license.tier === "pro" && license.valid) return true;
|
|
549
|
+
return FREE_PROVIDERS.has(provider);
|
|
550
|
+
}
|
|
551
|
+
function isPublisherAllowed(publisherType, license) {
|
|
552
|
+
if (license.tier === "pro" && license.valid) return true;
|
|
553
|
+
return FREE_PUBLISHERS.has(publisherType);
|
|
554
|
+
}
|
|
555
|
+
function isEnrichmentAllowed(license) {
|
|
556
|
+
return license.tier === "pro" && license.valid;
|
|
557
|
+
}
|
|
558
|
+
function upgradeMessage(feature) {
|
|
559
|
+
return `\u{1F512} ${feature} requires a Cullit Pro license.
|
|
560
|
+
Get your API key at https://cullit.io/pricing
|
|
561
|
+
Then set CULLIT_API_KEY in your environment.`;
|
|
562
|
+
}
|
|
1042
563
|
|
|
1043
|
-
// src/
|
|
1044
|
-
var
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
}
|
|
1095
|
-
body: JSON.stringify({ query, variables: { id: identifier } })
|
|
1096
|
-
});
|
|
1097
|
-
if (!response.ok) {
|
|
1098
|
-
throw new Error(`Linear API error (${response.status})`);
|
|
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`);
|
|
1099
616
|
}
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
const priorityMap = {
|
|
1104
|
-
0: "none",
|
|
1105
|
-
1: "urgent",
|
|
1106
|
-
2: "high",
|
|
1107
|
-
3: "medium",
|
|
1108
|
-
4: "low"
|
|
1109
|
-
};
|
|
1110
|
-
return {
|
|
1111
|
-
key: issue.identifier,
|
|
1112
|
-
title: issue.title,
|
|
1113
|
-
description: issue.description?.substring(0, 500),
|
|
1114
|
-
labels: issue.labels?.nodes?.map((l) => l.name) || [],
|
|
1115
|
-
priority: priorityMap[issue.priority] || void 0,
|
|
1116
|
-
status: issue.state?.name,
|
|
1117
|
-
source: "linear"
|
|
1118
|
-
};
|
|
617
|
+
throw err;
|
|
618
|
+
} finally {
|
|
619
|
+
clearTimeout(timer);
|
|
1119
620
|
}
|
|
1120
|
-
}
|
|
621
|
+
}
|
|
1121
622
|
|
|
1122
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));
|
|
1123
628
|
async function runPipeline(from, to, config, options = {}) {
|
|
1124
629
|
const startTime = Date.now();
|
|
1125
630
|
const format = options.format || "markdown";
|
|
631
|
+
const log = options.logger || createLogger("normal");
|
|
632
|
+
const license = resolveLicense();
|
|
633
|
+
if (!license.valid) {
|
|
634
|
+
throw new Error(license.message || "Invalid CULLIT_API_KEY");
|
|
635
|
+
}
|
|
636
|
+
if (!isProviderAllowed(config.ai.provider, license)) {
|
|
637
|
+
throw new Error(upgradeMessage(`AI provider "${config.ai.provider}"`));
|
|
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}`);
|
|
1126
647
|
let collector;
|
|
1127
648
|
if (config.source.type === "jira") {
|
|
1128
649
|
if (!config.jira) throw new Error("Jira source requires jira config in .cullit.yml");
|
|
1129
|
-
|
|
1130
|
-
collector = new JiraCollector(config.jira);
|
|
650
|
+
collector = collectorFactory(config.jira);
|
|
1131
651
|
} else if (config.source.type === "linear") {
|
|
1132
|
-
|
|
1133
|
-
collector = new LinearCollector(config.linear?.apiKey);
|
|
652
|
+
collector = collectorFactory(config.linear?.apiKey);
|
|
1134
653
|
} else {
|
|
1135
|
-
|
|
1136
|
-
collector = new GitCollector();
|
|
654
|
+
collector = collectorFactory();
|
|
1137
655
|
}
|
|
1138
656
|
const diff = await collector.collect(from, to);
|
|
1139
657
|
const itemLabel = config.source.type === "jira" || config.source.type === "linear" ? "issues" : "commits";
|
|
1140
|
-
|
|
658
|
+
log.info(`\xBB Found ${diff.commits.length} ${itemLabel}${diff.filesChanged ? `, ${diff.filesChanged} files changed` : ""}`);
|
|
1141
659
|
if (diff.commits.length === 0) {
|
|
1142
660
|
const source = config.source.type === "jira" ? "Jira" : config.source.type === "linear" ? "Linear" : `${from} and ${to}`;
|
|
1143
661
|
throw new Error(`No ${itemLabel} found from ${source}`);
|
|
@@ -1145,20 +663,27 @@ async function runPipeline(from, to, config, options = {}) {
|
|
|
1145
663
|
const tickets = [];
|
|
1146
664
|
const enrichmentSources = config.source.enrichment || [];
|
|
1147
665
|
for (const source of enrichmentSources) {
|
|
1148
|
-
if (
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
const jiraTickets = await enricher.enrich(diff);
|
|
1152
|
-
tickets.push(...jiraTickets);
|
|
1153
|
-
console.log(`\xBB Jira: found ${jiraTickets.length} tickets`);
|
|
666
|
+
if (!isEnrichmentAllowed(license)) {
|
|
667
|
+
log.info(`\xBB Skipping ${source} enrichment \u2014 ${upgradeMessage(`${source} enrichment`)}`);
|
|
668
|
+
continue;
|
|
1154
669
|
}
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
tickets.push(...linearTickets);
|
|
1160
|
-
console.log(`\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;
|
|
1161
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"}`);
|
|
1162
687
|
}
|
|
1163
688
|
const context = { diff, tickets };
|
|
1164
689
|
const providerNames = {
|
|
@@ -1171,81 +696,101 @@ async function runPipeline(from, to, config, options = {}) {
|
|
|
1171
696
|
};
|
|
1172
697
|
const providerName = providerNames[config.ai.provider] || config.ai.provider;
|
|
1173
698
|
const modelName = config.ai.provider === "none" ? "template" : config.ai.model || DEFAULT_MODELS[config.ai.provider] || "default";
|
|
1174
|
-
|
|
1175
|
-
|
|
699
|
+
log.info(`\xBB Generating with ${providerName} (${modelName})...`);
|
|
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;
|
|
1176
707
|
if (config.ai.provider === "none") {
|
|
1177
|
-
|
|
1178
|
-
notes = await generator.generate(context, config.ai);
|
|
708
|
+
generator = generatorFactory();
|
|
1179
709
|
} else {
|
|
1180
|
-
|
|
1181
|
-
notes = await generator.generate(context, config.ai);
|
|
710
|
+
generator = generatorFactory(config.openclaw);
|
|
1182
711
|
}
|
|
1183
|
-
|
|
712
|
+
const notes = await generator.generate(context, config.ai);
|
|
713
|
+
log.info(`\xBB Generated ${notes.changes.length} change entries`);
|
|
1184
714
|
const formatted = formatNotes(notes, format);
|
|
1185
715
|
const publishedTo = [];
|
|
1186
716
|
if (!options.dryRun) {
|
|
1187
717
|
for (const target of config.publish) {
|
|
1188
718
|
try {
|
|
719
|
+
if (!isPublisherAllowed(target.type, license)) {
|
|
720
|
+
log.info(`\xBB Skipping ${target.type} \u2014 ${upgradeMessage(`${target.type} publishing`)}`);
|
|
721
|
+
continue;
|
|
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;
|
|
1189
729
|
switch (target.type) {
|
|
1190
730
|
case "stdout":
|
|
1191
|
-
|
|
1192
|
-
publishedTo.push("stdout");
|
|
731
|
+
publisher = publisherFactory();
|
|
1193
732
|
break;
|
|
1194
733
|
case "file":
|
|
1195
|
-
if (target.path)
|
|
1196
|
-
|
|
1197
|
-
publishedTo.push(`file:${target.path}`);
|
|
1198
|
-
}
|
|
734
|
+
if (!target.path) continue;
|
|
735
|
+
publisher = publisherFactory(target.path);
|
|
1199
736
|
break;
|
|
1200
737
|
case "slack":
|
|
1201
|
-
if (target.webhookUrl) {
|
|
1202
|
-
await new SlackPublisher(target.webhookUrl).publish(notes, format);
|
|
1203
|
-
publishedTo.push("slack");
|
|
1204
|
-
}
|
|
1205
|
-
break;
|
|
1206
738
|
case "discord":
|
|
1207
|
-
if (target.webhookUrl)
|
|
1208
|
-
|
|
1209
|
-
publishedTo.push("discord");
|
|
1210
|
-
}
|
|
739
|
+
if (!target.webhookUrl) continue;
|
|
740
|
+
publisher = publisherFactory(target.webhookUrl);
|
|
1211
741
|
break;
|
|
1212
742
|
case "github-release":
|
|
1213
|
-
|
|
1214
|
-
publishedTo.push("github-release");
|
|
743
|
+
publisher = publisherFactory();
|
|
1215
744
|
break;
|
|
745
|
+
default:
|
|
746
|
+
continue;
|
|
1216
747
|
}
|
|
748
|
+
await publisher.publish(notes, format, formatted);
|
|
749
|
+
publishedTo.push(target.type === "file" ? `file:${target.path}` : target.type);
|
|
1217
750
|
} catch (err) {
|
|
1218
|
-
|
|
751
|
+
log.error(`\u2717 Failed to publish to ${target.type}: ${err.message}`);
|
|
1219
752
|
}
|
|
1220
753
|
}
|
|
1221
754
|
} else {
|
|
1222
|
-
|
|
1223
|
-
|
|
755
|
+
log.info("\n[DRY RUN \u2014 Not publishing]\n");
|
|
756
|
+
log.info(formatted);
|
|
1224
757
|
publishedTo.push("dry-run");
|
|
1225
758
|
}
|
|
1226
759
|
const duration = Date.now() - startTime;
|
|
1227
|
-
|
|
760
|
+
log.info(`
|
|
1228
761
|
\u2713 Done in ${(duration / 1e3).toFixed(1)}s`);
|
|
1229
762
|
return { notes, formatted, publishedTo, duration };
|
|
1230
763
|
}
|
|
1231
764
|
export {
|
|
1232
|
-
AIGenerator,
|
|
1233
765
|
DEFAULT_CATEGORIES,
|
|
1234
766
|
DEFAULT_MODELS,
|
|
1235
|
-
DiscordPublisher,
|
|
1236
767
|
FilePublisher,
|
|
1237
768
|
GitCollector,
|
|
1238
|
-
GitHubReleasePublisher,
|
|
1239
|
-
JiraCollector,
|
|
1240
|
-
JiraEnricher,
|
|
1241
|
-
LinearCollector,
|
|
1242
|
-
LinearEnricher,
|
|
1243
|
-
SlackPublisher,
|
|
1244
769
|
StdoutPublisher,
|
|
1245
770
|
TemplateGenerator,
|
|
1246
771
|
VERSION,
|
|
772
|
+
analyzeReleaseReadiness,
|
|
773
|
+
createLogger,
|
|
774
|
+
fetchWithTimeout,
|
|
1247
775
|
formatNotes,
|
|
776
|
+
getCollector,
|
|
777
|
+
getEnricher,
|
|
778
|
+
getGenerator,
|
|
1248
779
|
getLatestTag,
|
|
780
|
+
getPublisher,
|
|
1249
781
|
getRecentTags,
|
|
1250
|
-
|
|
782
|
+
hasCollector,
|
|
783
|
+
hasEnricher,
|
|
784
|
+
hasGenerator,
|
|
785
|
+
hasPublisher,
|
|
786
|
+
isEnrichmentAllowed,
|
|
787
|
+
isProviderAllowed,
|
|
788
|
+
isPublisherAllowed,
|
|
789
|
+
registerCollector,
|
|
790
|
+
registerEnricher,
|
|
791
|
+
registerGenerator,
|
|
792
|
+
registerPublisher,
|
|
793
|
+
resolveLicense,
|
|
794
|
+
runPipeline,
|
|
795
|
+
upgradeMessage
|
|
1251
796
|
};
|