cli-changescribe 0.1.3 → 0.2.1
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/README.md +9 -4
- package/package.json +6 -4
- package/src/commit.js +28 -21
- package/src/pr-summary.js +154 -77
- package/src/provider.js +42 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# changescribe
|
|
2
2
|
|
|
3
|
-
CLI to generate Conventional Commit messages and PR summaries using Groq.
|
|
3
|
+
CLI to generate Conventional Commit messages and PR summaries using Cerebras or Groq.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -32,9 +32,14 @@ yarn add cli-changescribe
|
|
|
32
32
|
Create a `.env.local` file in the repo where you run the CLI:
|
|
33
33
|
|
|
34
34
|
```bash
|
|
35
|
+
# Pick one (Cerebras is preferred for higher throughput)
|
|
36
|
+
CEREBRAS_API_KEY="your-key-here"
|
|
37
|
+
# or
|
|
35
38
|
GROQ_API_KEY="your-key-here"
|
|
36
39
|
```
|
|
37
40
|
|
|
41
|
+
Provider priority: if both keys are set, Cerebras is used.
|
|
42
|
+
|
|
38
43
|
If your repo uses `pnpm` or `yarn`, make sure you install `cli-changescribe`
|
|
39
44
|
with the same package manager so the correct lockfile is updated (Vercel uses
|
|
40
45
|
`frozen-lockfile` by default).
|
|
@@ -42,7 +47,7 @@ with the same package manager so the correct lockfile is updated (Vercel uses
|
|
|
42
47
|
### Setup process (recommended)
|
|
43
48
|
|
|
44
49
|
1. Install `cli-changescribe` (global or per repo).
|
|
45
|
-
2. Add `.env.local` with `GROQ_API_KEY`.
|
|
50
|
+
2. Add `.env.local` with `CEREBRAS_API_KEY` or `GROQ_API_KEY`.
|
|
46
51
|
3. Run `npx changescribe init` to add npm scripts.
|
|
47
52
|
4. If you plan to use `--create-pr`, install and auth GitHub CLI: `gh auth login`.
|
|
48
53
|
5. Run a dry run to validate:
|
|
@@ -55,8 +60,8 @@ Optional environment variables for PR summaries:
|
|
|
55
60
|
- `PR_SUMMARY_OUT` (default: `.pr-summaries/PR_SUMMARY.md`)
|
|
56
61
|
- `PR_SUMMARY_LIMIT` (default: `400`)
|
|
57
62
|
- `PR_SUMMARY_ISSUE` (default: empty)
|
|
58
|
-
- `
|
|
59
|
-
- `GROQ_MODEL` (
|
|
63
|
+
- `CHANGESCRIBE_MODEL` (override model name for any provider)
|
|
64
|
+
- `GROQ_PR_MODEL` / `GROQ_MODEL` (legacy overrides, still supported)
|
|
60
65
|
|
|
61
66
|
## Usage
|
|
62
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli-changescribe",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "CLI for generating commit messages and PR summaries",
|
|
5
5
|
"bin": {
|
|
6
6
|
"changescribe": "bin/changescribe.js"
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
"pr",
|
|
20
20
|
"summary",
|
|
21
21
|
"changelog",
|
|
22
|
-
"groq"
|
|
22
|
+
"groq",
|
|
23
|
+
"cerebras",
|
|
24
|
+
"openai"
|
|
23
25
|
],
|
|
24
26
|
"license": "MIT",
|
|
25
27
|
"engines": {
|
|
@@ -27,6 +29,6 @@
|
|
|
27
29
|
},
|
|
28
30
|
"dependencies": {
|
|
29
31
|
"dotenv": "^17.2.1",
|
|
30
|
-
"
|
|
32
|
+
"openai": "^4.104.0"
|
|
31
33
|
}
|
|
32
|
-
}
|
|
34
|
+
}
|
package/src/commit.js
CHANGED
|
@@ -3,7 +3,7 @@ const fs = require('fs');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { config } = require('dotenv');
|
|
6
|
-
const
|
|
6
|
+
const { createClient } = require('./provider');
|
|
7
7
|
|
|
8
8
|
// Load environment variables from .env.local
|
|
9
9
|
config({ path: '.env.local' });
|
|
@@ -193,18 +193,19 @@ function getFileType(filename) {
|
|
|
193
193
|
async function generateCommitMessage(argv) {
|
|
194
194
|
try {
|
|
195
195
|
const isDryRun = argv.includes('--dry-run');
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
console.
|
|
200
|
-
console.log('💡 Set
|
|
196
|
+
|
|
197
|
+
const providerInfo = createClient();
|
|
198
|
+
if (!providerInfo) {
|
|
199
|
+
console.error('❌ No API key found');
|
|
200
|
+
console.log('💡 Set CEREBRAS_API_KEY or GROQ_API_KEY in .env.local');
|
|
201
201
|
process.exit(1);
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
|
|
205
|
-
const
|
|
206
|
-
|
|
207
|
-
|
|
204
|
+
const { client, provider, defaultModel } = providerInfo;
|
|
205
|
+
const model =
|
|
206
|
+
process.env.CHANGESCRIBE_MODEL ||
|
|
207
|
+
process.env.GROQ_MODEL ||
|
|
208
|
+
defaultModel;
|
|
208
209
|
|
|
209
210
|
// Get comprehensive analysis of all changes
|
|
210
211
|
let changeAnalysis;
|
|
@@ -220,13 +221,14 @@ async function generateCommitMessage(argv) {
|
|
|
220
221
|
return;
|
|
221
222
|
}
|
|
222
223
|
|
|
223
|
-
console.log(
|
|
224
|
+
console.log(`🤖 Generating commit message with AI (${provider})...`);
|
|
224
225
|
|
|
225
|
-
// Generate commit message using
|
|
226
|
+
// Generate commit message using LLM with comprehensive analysis
|
|
226
227
|
const completion = await createCompletionSafe(
|
|
227
|
-
|
|
228
|
+
client,
|
|
228
229
|
buildChatMessages(changeAnalysis),
|
|
229
|
-
|
|
230
|
+
model,
|
|
231
|
+
provider
|
|
230
232
|
);
|
|
231
233
|
|
|
232
234
|
const rawContent = completion?.choices?.[0]?.message?.content
|
|
@@ -249,9 +251,10 @@ async function generateCommitMessage(argv) {
|
|
|
249
251
|
const violations = findCommitViolations(built);
|
|
250
252
|
if (violations.length > 0) {
|
|
251
253
|
const repairCompletion = await createCompletionSafe(
|
|
252
|
-
|
|
254
|
+
client,
|
|
253
255
|
buildRepairMessages(rawContent || '', reasoning || '', built, violations),
|
|
254
|
-
|
|
256
|
+
model,
|
|
257
|
+
provider
|
|
255
258
|
);
|
|
256
259
|
const repairedContent = repairCompletion?.choices?.[0]?.message?.content
|
|
257
260
|
?.trim()
|
|
@@ -324,17 +327,21 @@ async function generateCommitMessage(argv) {
|
|
|
324
327
|
}
|
|
325
328
|
}
|
|
326
329
|
|
|
327
|
-
async function createCompletionSafe(
|
|
330
|
+
async function createCompletionSafe(client, messages, model, provider) {
|
|
328
331
|
try {
|
|
329
|
-
|
|
332
|
+
const params = {
|
|
330
333
|
messages,
|
|
331
334
|
model,
|
|
332
335
|
temperature: 0.3,
|
|
333
336
|
max_tokens: 16_384,
|
|
334
|
-
|
|
335
|
-
|
|
337
|
+
};
|
|
338
|
+
// reasoning_effort is a Groq-specific parameter; omit for other providers
|
|
339
|
+
if (provider === 'groq') {
|
|
340
|
+
params.reasoning_effort = 'high';
|
|
341
|
+
}
|
|
342
|
+
return await client.chat.completions.create(params);
|
|
336
343
|
} catch (error) {
|
|
337
|
-
console.error('❌
|
|
344
|
+
console.error('❌ LLM API error while creating completion');
|
|
338
345
|
console.error(formatError(error));
|
|
339
346
|
process.exit(1);
|
|
340
347
|
}
|
package/src/pr-summary.js
CHANGED
|
@@ -3,14 +3,12 @@ const fs = require('fs');
|
|
|
3
3
|
const os = require('os');
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { config } = require('dotenv');
|
|
6
|
-
const
|
|
6
|
+
const { createClient } = require('./provider');
|
|
7
7
|
|
|
8
8
|
config({ path: '.env.local' });
|
|
9
9
|
|
|
10
10
|
const DEFAULT_BASE = process.env.PR_SUMMARY_BASE || 'main';
|
|
11
11
|
const DEFAULT_OUT = process.env.PR_SUMMARY_OUT || '.pr-summaries/PR_SUMMARY.md';
|
|
12
|
-
const DEFAULT_MODEL =
|
|
13
|
-
process.env.GROQ_PR_MODEL || process.env.GROQ_MODEL || 'openai/gpt-oss-120b';
|
|
14
12
|
const DEFAULT_LIMIT = Number.parseInt(
|
|
15
13
|
process.env.PR_SUMMARY_LIMIT || '400',
|
|
16
14
|
10
|
|
@@ -18,7 +16,8 @@ const DEFAULT_LIMIT = Number.parseInt(
|
|
|
18
16
|
const DEFAULT_ISSUE = process.env.PR_SUMMARY_ISSUE || '';
|
|
19
17
|
const LARGE_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
20
18
|
const BODY_TRUNCATION = 4000;
|
|
21
|
-
const CHUNK_SIZE_CHARS =
|
|
19
|
+
const CHUNK_SIZE_CHARS = 20000;
|
|
20
|
+
const DIFF_PER_COMMIT_CHARS = 3000;
|
|
22
21
|
const NEWLINE_SPLIT_RE = /\r?\n/;
|
|
23
22
|
|
|
24
23
|
const ui = {
|
|
@@ -38,13 +37,13 @@ function paint(text, color) {
|
|
|
38
37
|
return `${color}${text}${ui.reset}`;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
function banner(branch, base) {
|
|
40
|
+
function banner(branch, base, providerName) {
|
|
42
41
|
const title = paint('PR SYNTHESIZER', ui.magenta);
|
|
43
42
|
const line = paint('═'.repeat(36), ui.purple);
|
|
44
43
|
const meta = `${paint('branch', ui.cyan)} ${branch} ${paint(
|
|
45
44
|
'base',
|
|
46
45
|
ui.cyan
|
|
47
|
-
)} ${base}`;
|
|
46
|
+
)} ${base} ${paint('provider', ui.cyan)} ${providerName}`;
|
|
48
47
|
return `${line}\n${title}\n${meta}\n${line}`;
|
|
49
48
|
}
|
|
50
49
|
|
|
@@ -75,6 +74,50 @@ function runGit(command) {
|
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Diff enrichment
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function getCommitDiffInfo(sha, title) {
|
|
82
|
+
const isMerge = title.toLowerCase().startsWith('merge');
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
// For merge commits, diff against the first parent to see what the merge introduced.
|
|
86
|
+
// For normal commits, use git show which diffs against the single parent.
|
|
87
|
+
const statCmd = isMerge
|
|
88
|
+
? `git diff ${sha}^1..${sha} --stat`
|
|
89
|
+
: `git show ${sha} --stat --format=""`;
|
|
90
|
+
const stat = execSync(statCmd, {
|
|
91
|
+
encoding: 'utf8',
|
|
92
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
93
|
+
}).trim();
|
|
94
|
+
|
|
95
|
+
let diff = '';
|
|
96
|
+
try {
|
|
97
|
+
const diffCmd = isMerge
|
|
98
|
+
? `git diff ${sha}^1..${sha} -U3 --diff-filter=ACMRT`
|
|
99
|
+
: `git show ${sha} -U3 --format="" --diff-filter=ACMRT`;
|
|
100
|
+
diff = execSync(diffCmd, {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
103
|
+
});
|
|
104
|
+
if (diff.length > DIFF_PER_COMMIT_CHARS) {
|
|
105
|
+
diff = `${diff.slice(0, DIFF_PER_COMMIT_CHARS)}\n...[truncated]...`;
|
|
106
|
+
}
|
|
107
|
+
} catch {
|
|
108
|
+
diff = '(diff unavailable)';
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { stat, diff };
|
|
112
|
+
} catch {
|
|
113
|
+
return { stat: '(unavailable)', diff: '' };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Commit collection
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
78
121
|
function collectCommits(baseRef, limit) {
|
|
79
122
|
const range = `${baseRef}..HEAD`;
|
|
80
123
|
let rawLog = '';
|
|
@@ -101,7 +144,7 @@ function collectCommits(baseRef, limit) {
|
|
|
101
144
|
.map((entry) => {
|
|
102
145
|
const [sha = '', title = '', bodyRaw = ''] = entry.split('\x1f');
|
|
103
146
|
const body = bodyRaw.trim().slice(0, BODY_TRUNCATION);
|
|
104
|
-
return { sha: sha.trim(), title: title.trim(), body };
|
|
147
|
+
return { sha: sha.trim(), title: title.trim(), body, stat: '', diff: '' };
|
|
105
148
|
});
|
|
106
149
|
|
|
107
150
|
if (Number.isFinite(limit) && limit > 0 && commits.length > limit) {
|
|
@@ -111,6 +154,16 @@ function collectCommits(baseRef, limit) {
|
|
|
111
154
|
return commits;
|
|
112
155
|
}
|
|
113
156
|
|
|
157
|
+
function enrichCommitsWithDiffs(commits) {
|
|
158
|
+
step('Enriching commits with diff context...');
|
|
159
|
+
for (const commit of commits) {
|
|
160
|
+
const info = getCommitDiffInfo(commit.sha, commit.title);
|
|
161
|
+
commit.stat = info.stat;
|
|
162
|
+
commit.diff = info.diff;
|
|
163
|
+
}
|
|
164
|
+
success(`Enriched ${commits.length} commits with diffs`);
|
|
165
|
+
}
|
|
166
|
+
|
|
114
167
|
function tryFetchBase(baseBranch) {
|
|
115
168
|
try {
|
|
116
169
|
execSync(`git fetch origin ${baseBranch}`, {
|
|
@@ -146,6 +199,10 @@ function resolveBaseRef(baseBranch) {
|
|
|
146
199
|
}
|
|
147
200
|
}
|
|
148
201
|
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Chunking
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
149
206
|
function chunkCommits(commits, maxChars) {
|
|
150
207
|
const chunks = [];
|
|
151
208
|
let current = [];
|
|
@@ -170,24 +227,40 @@ function chunkCommits(commits, maxChars) {
|
|
|
170
227
|
}
|
|
171
228
|
|
|
172
229
|
function serializeCommit(commit) {
|
|
173
|
-
|
|
230
|
+
const parts = [commit.sha, commit.title, commit.body];
|
|
231
|
+
if (commit.stat) {
|
|
232
|
+
parts.push(commit.stat);
|
|
233
|
+
}
|
|
234
|
+
if (commit.diff) {
|
|
235
|
+
parts.push(commit.diff);
|
|
236
|
+
}
|
|
237
|
+
parts.push('---');
|
|
238
|
+
return `${parts.join('\n')}\n`;
|
|
174
239
|
}
|
|
175
240
|
|
|
176
|
-
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// LLM helpers
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
async function createCompletionSafe(client, messages, model, maxTokens) {
|
|
177
246
|
try {
|
|
178
|
-
return await
|
|
247
|
+
return await client.chat.completions.create({
|
|
179
248
|
messages,
|
|
180
249
|
model,
|
|
181
250
|
temperature: 0.3,
|
|
182
251
|
max_tokens: maxTokens,
|
|
183
252
|
});
|
|
184
253
|
} catch (error) {
|
|
185
|
-
fail('
|
|
254
|
+
fail('LLM API error while creating completion');
|
|
186
255
|
step(formatError(error));
|
|
187
256
|
process.exit(1);
|
|
188
257
|
}
|
|
189
258
|
}
|
|
190
259
|
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Prompt builders
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
191
264
|
function buildPass1Messages(commits, branch, base) {
|
|
192
265
|
const titles = commits
|
|
193
266
|
.map((commit) => `- ${commit.title || '(no title)'}`)
|
|
@@ -216,21 +289,28 @@ function buildPass1Messages(commits, branch, base) {
|
|
|
216
289
|
|
|
217
290
|
function buildPass2Messages(commitsChunk) {
|
|
218
291
|
const body = commitsChunk
|
|
219
|
-
.map((commit) =>
|
|
220
|
-
[
|
|
292
|
+
.map((commit) => {
|
|
293
|
+
const parts = [
|
|
221
294
|
`SHA: ${commit.sha}`,
|
|
222
295
|
`Title: ${commit.title || '(no title)'}`,
|
|
223
296
|
`Body:\n${commit.body || '(no body)'}`,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
297
|
+
];
|
|
298
|
+
if (commit.stat) {
|
|
299
|
+
parts.push(`Files changed:\n${commit.stat}`);
|
|
300
|
+
}
|
|
301
|
+
if (commit.diff) {
|
|
302
|
+
parts.push(`Diff:\n${commit.diff}`);
|
|
303
|
+
}
|
|
304
|
+
parts.push('---');
|
|
305
|
+
return parts.join('\n');
|
|
306
|
+
})
|
|
227
307
|
.join('\n');
|
|
228
308
|
|
|
229
309
|
return [
|
|
230
310
|
{
|
|
231
311
|
role: 'system',
|
|
232
312
|
content:
|
|
233
|
-
'You are producing compact, high-signal summaries per commit
|
|
313
|
+
'You are producing compact, high-signal summaries per commit. Use the diff and file stats to understand exactly what changed in the code. Produce 2-3 bullets each (change, rationale, risk/test note). Flag any breaking changes or migrations. IMPORTANT: Only reference technologies, frameworks, and patterns that are explicitly visible in the diff or file names. Do not infer or guess technologies that are not shown (e.g., do not mention GraphQL unless you see .graphql files or GraphQL client imports in the diff).',
|
|
234
314
|
},
|
|
235
315
|
{
|
|
236
316
|
role: 'user',
|
|
@@ -239,9 +319,9 @@ function buildPass2Messages(commitsChunk) {
|
|
|
239
319
|
body,
|
|
240
320
|
'',
|
|
241
321
|
'Return for each commit:',
|
|
242
|
-
'- Title-aligned bullet: what changed + why.',
|
|
243
|
-
'- Risk or test note if visible.',
|
|
244
|
-
'Keep outputs brief; do not restate bodies.',
|
|
322
|
+
'- Title-aligned bullet: what changed + why (use the diff to be specific about files and code patterns).',
|
|
323
|
+
'- Risk or test note if visible in the diff.',
|
|
324
|
+
'Keep outputs brief; do not restate bodies verbatim.',
|
|
245
325
|
].join('\n'),
|
|
246
326
|
},
|
|
247
327
|
];
|
|
@@ -259,7 +339,7 @@ function buildPass3Messages(
|
|
|
259
339
|
{
|
|
260
340
|
role: 'system',
|
|
261
341
|
content:
|
|
262
|
-
'You write PR summaries that are easy to review. Be concise, specific, and action-oriented. Do not include markdown fences.',
|
|
342
|
+
'You write PR summaries that are easy to review. Be concise, specific, and action-oriented. Do not include markdown fences. Only reference technologies and patterns that appear in the commit summaries provided. Never fabricate or assume technologies not explicitly mentioned (e.g., do not mention GraphQL, Apollo, Relay, or similar unless the summaries explicitly reference them).',
|
|
263
343
|
},
|
|
264
344
|
{
|
|
265
345
|
role: 'user',
|
|
@@ -286,7 +366,9 @@ function buildPass3Messages(
|
|
|
286
366
|
'Rules:',
|
|
287
367
|
'- If the issue is unknown, write: "Related: (not provided)".',
|
|
288
368
|
'- If testing is unknown, write: "Testing: (not provided)".',
|
|
289
|
-
'-
|
|
369
|
+
'- Every commit must appear as its own bullet under "What change does this PR add?". Do not group commits under "miscellaneous" or similar catch-all labels.',
|
|
370
|
+
'- Be thorough and specific. Reference actual file names, functions, and architectural changes.',
|
|
371
|
+
'- Prefer bullets.',
|
|
290
372
|
'',
|
|
291
373
|
`Issue hint: ${issue || '(not provided)'}`,
|
|
292
374
|
].join('\n'),
|
|
@@ -306,7 +388,7 @@ function buildReleaseMessages(
|
|
|
306
388
|
{
|
|
307
389
|
role: 'system',
|
|
308
390
|
content:
|
|
309
|
-
'You write release PR summaries for QA to production. Be concise, concrete, and action-oriented. Do not include markdown fences.',
|
|
391
|
+
'You write release PR summaries for QA to production. Be concise, concrete, and action-oriented. Do not include markdown fences. Only reference technologies and patterns that appear in the commit summaries provided. Never fabricate or assume technologies not explicitly mentioned.',
|
|
310
392
|
},
|
|
311
393
|
{
|
|
312
394
|
role: 'user',
|
|
@@ -333,7 +415,9 @@ function buildReleaseMessages(
|
|
|
333
415
|
'',
|
|
334
416
|
'Rules:',
|
|
335
417
|
'- If unknown, write: "Unknown".',
|
|
336
|
-
'-
|
|
418
|
+
'- Every commit must appear as its own bullet. Do not group commits under catch-all labels like "miscellaneous".',
|
|
419
|
+
'- Be thorough and specific. Reference actual changes from the commit summaries.',
|
|
420
|
+
'- Prefer bullets.',
|
|
337
421
|
'',
|
|
338
422
|
`Issue hint: ${issue || '(not provided)'}`,
|
|
339
423
|
].join('\n'),
|
|
@@ -341,6 +425,10 @@ function buildReleaseMessages(
|
|
|
341
425
|
];
|
|
342
426
|
}
|
|
343
427
|
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Utilities
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
|
|
344
432
|
function formatError(error) {
|
|
345
433
|
const plain = {};
|
|
346
434
|
for (const key of Object.getOwnPropertyNames(error)) {
|
|
@@ -438,6 +526,10 @@ function isUnknownSummary(summary, mode) {
|
|
|
438
526
|
return unknownCount === headings.length;
|
|
439
527
|
}
|
|
440
528
|
|
|
529
|
+
// ---------------------------------------------------------------------------
|
|
530
|
+
// GitHub helpers
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
|
|
441
533
|
function checkGhCli() {
|
|
442
534
|
try {
|
|
443
535
|
execSync('gh --version', { encoding: 'utf8', stdio: 'ignore' });
|
|
@@ -461,7 +553,6 @@ function checkUncommittedChanges() {
|
|
|
461
553
|
|
|
462
554
|
function checkExistingPr(base, head) {
|
|
463
555
|
try {
|
|
464
|
-
// Check for open PRs from head to base
|
|
465
556
|
const result = spawnSync(
|
|
466
557
|
'gh',
|
|
467
558
|
[
|
|
@@ -483,14 +574,12 @@ function checkExistingPr(base, head) {
|
|
|
483
574
|
);
|
|
484
575
|
|
|
485
576
|
if (result.error || result.status !== 0) {
|
|
486
|
-
// If gh command fails, assume no PR exists (might be auth issue, but we'll catch that later)
|
|
487
577
|
return null;
|
|
488
578
|
}
|
|
489
579
|
|
|
490
580
|
const prs = JSON.parse(result.stdout || '[]');
|
|
491
581
|
return prs.length > 0 ? prs[0] : null;
|
|
492
582
|
} catch {
|
|
493
|
-
// If parsing fails or command fails, assume no PR exists
|
|
494
583
|
return null;
|
|
495
584
|
}
|
|
496
585
|
}
|
|
@@ -508,33 +597,27 @@ function hasNpmScript(scriptName, cwd = process.cwd()) {
|
|
|
508
597
|
|
|
509
598
|
function extractPrTitle(summary, mode) {
|
|
510
599
|
const targetSection = mode === 'release' ? 'release summary' : 'what change';
|
|
511
|
-
// Try to extract a title from the summary
|
|
512
600
|
const lines = summary.split('\n');
|
|
513
601
|
let inChangesSection = false;
|
|
514
602
|
|
|
515
603
|
for (const line of lines) {
|
|
516
604
|
const trimmed = line.trim();
|
|
517
605
|
|
|
518
|
-
// Detect the target section
|
|
519
606
|
if (trimmed.toLowerCase().includes(targetSection)) {
|
|
520
607
|
inChangesSection = true;
|
|
521
608
|
continue;
|
|
522
609
|
}
|
|
523
610
|
|
|
524
|
-
// If we hit another section heading, stop looking
|
|
525
611
|
if (inChangesSection && trimmed && trimmed.endsWith('?')) {
|
|
526
612
|
break;
|
|
527
613
|
}
|
|
528
614
|
|
|
529
|
-
// If we're in the changes section, look for first bullet point
|
|
530
615
|
if (inChangesSection && trimmed.startsWith('-')) {
|
|
531
|
-
// Extract bullet content, clean it up
|
|
532
616
|
const bullet = trimmed.slice(1).trim();
|
|
533
|
-
// Remove markdown formatting and truncate
|
|
534
617
|
const clean = bullet
|
|
535
|
-
.replace(/`([^`]+)`/g, '$1')
|
|
536
|
-
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
537
|
-
.replace(/\*([^*]+)\*/g, '$1')
|
|
618
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
619
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
620
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
538
621
|
.slice(0, 100);
|
|
539
622
|
if (clean.length > 10) {
|
|
540
623
|
return clean;
|
|
@@ -542,13 +625,11 @@ function extractPrTitle(summary, mode) {
|
|
|
542
625
|
}
|
|
543
626
|
}
|
|
544
627
|
|
|
545
|
-
// Fallback: use first commit title or branch name
|
|
546
628
|
return null;
|
|
547
629
|
}
|
|
548
630
|
|
|
549
631
|
function createPrWithGh(base, branch, title, body) {
|
|
550
632
|
try {
|
|
551
|
-
// Ensure branch is pushed first
|
|
552
633
|
step('Pushing branch to remote...');
|
|
553
634
|
try {
|
|
554
635
|
execSync(`git push -u origin ${branch}`, {
|
|
@@ -557,7 +638,6 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
557
638
|
});
|
|
558
639
|
success('Branch pushed to remote');
|
|
559
640
|
} catch (error) {
|
|
560
|
-
// Branch might already be pushed, check if it exists
|
|
561
641
|
const remoteBranches = execSync('git branch -r', {
|
|
562
642
|
encoding: 'utf8',
|
|
563
643
|
});
|
|
@@ -568,11 +648,9 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
568
648
|
}
|
|
569
649
|
}
|
|
570
650
|
|
|
571
|
-
// Create PR using gh CLI
|
|
572
651
|
step('Creating PR with GitHub CLI...');
|
|
573
652
|
const prTitle = title || branch;
|
|
574
653
|
|
|
575
|
-
// Write body to temp file to avoid shell escaping issues
|
|
576
654
|
const bodyFile = path.join(
|
|
577
655
|
os.tmpdir(),
|
|
578
656
|
`pr-body-${Date.now().toString()}.md`
|
|
@@ -592,13 +670,11 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
592
670
|
bodyFile,
|
|
593
671
|
];
|
|
594
672
|
|
|
595
|
-
// Add issue reference if provided via environment
|
|
596
673
|
const issueEnv = process.env.PR_SUMMARY_ISSUE || '';
|
|
597
674
|
if (issueEnv) {
|
|
598
675
|
ghArgs.push('--issue', issueEnv);
|
|
599
676
|
}
|
|
600
677
|
|
|
601
|
-
// Use spawnSync for better argument handling
|
|
602
678
|
const result = spawnSync('gh', ghArgs, {
|
|
603
679
|
encoding: 'utf8',
|
|
604
680
|
stdio: 'pipe',
|
|
@@ -616,7 +692,6 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
616
692
|
|
|
617
693
|
const prUrl = result.stdout.trim();
|
|
618
694
|
|
|
619
|
-
// Cleanup temp file
|
|
620
695
|
try {
|
|
621
696
|
fs.unlinkSync(bodyFile);
|
|
622
697
|
} catch {
|
|
@@ -675,6 +750,10 @@ function updatePrWithGh(prNumber, title, body) {
|
|
|
675
750
|
}
|
|
676
751
|
}
|
|
677
752
|
|
|
753
|
+
// ---------------------------------------------------------------------------
|
|
754
|
+
// CLI
|
|
755
|
+
// ---------------------------------------------------------------------------
|
|
756
|
+
|
|
678
757
|
function parseArgs(argv) {
|
|
679
758
|
const args = {
|
|
680
759
|
base: DEFAULT_BASE,
|
|
@@ -716,21 +795,31 @@ function parseArgs(argv) {
|
|
|
716
795
|
return args;
|
|
717
796
|
}
|
|
718
797
|
|
|
798
|
+
// ---------------------------------------------------------------------------
|
|
799
|
+
// Main
|
|
800
|
+
// ---------------------------------------------------------------------------
|
|
801
|
+
|
|
719
802
|
async function main(argv) {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
803
|
+
const providerInfo = createClient();
|
|
804
|
+
if (!providerInfo) {
|
|
805
|
+
fail('No API key found. Set CEREBRAS_API_KEY or GROQ_API_KEY in .env.local');
|
|
723
806
|
process.exit(1);
|
|
724
807
|
}
|
|
725
808
|
|
|
809
|
+
const { client, provider, defaultModel } = providerInfo;
|
|
810
|
+
const model =
|
|
811
|
+
process.env.CHANGESCRIBE_MODEL ||
|
|
812
|
+
process.env.GROQ_PR_MODEL ||
|
|
813
|
+
process.env.GROQ_MODEL ||
|
|
814
|
+
defaultModel;
|
|
815
|
+
|
|
726
816
|
const args = parseArgs(argv);
|
|
727
817
|
const branch = runGit('git branch --show-current').trim();
|
|
728
818
|
const mode =
|
|
729
819
|
args.mode ||
|
|
730
820
|
(branch === 'staging' && args.base === 'main' ? 'release' : 'feature');
|
|
731
|
-
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
|
|
732
821
|
|
|
733
|
-
process.stdout.write(`${banner(branch, args.base)}\n`);
|
|
822
|
+
process.stdout.write(`${banner(branch, args.base, provider)}\n`);
|
|
734
823
|
|
|
735
824
|
const didFetch = tryFetchBase(args.base);
|
|
736
825
|
if (!didFetch) {
|
|
@@ -753,6 +842,8 @@ async function main(argv) {
|
|
|
753
842
|
step(`Issue: ${args.issue || '(not provided)'}`);
|
|
754
843
|
step(`Create PR: ${args.createPr ? 'yes' : 'no'}`);
|
|
755
844
|
step(`Mode: ${mode}`);
|
|
845
|
+
step(`Provider: ${provider}`);
|
|
846
|
+
step(`Model: ${model}`);
|
|
756
847
|
return;
|
|
757
848
|
}
|
|
758
849
|
|
|
@@ -765,7 +856,6 @@ async function main(argv) {
|
|
|
765
856
|
|
|
766
857
|
// Safety checks before creating PR
|
|
767
858
|
if (args.createPr) {
|
|
768
|
-
// Run format as a last-minute verification step
|
|
769
859
|
if (args.skipFormat) {
|
|
770
860
|
warn('Skipping format step (flagged)');
|
|
771
861
|
} else if (!hasNpmScript('format')) {
|
|
@@ -780,7 +870,6 @@ async function main(argv) {
|
|
|
780
870
|
}
|
|
781
871
|
}
|
|
782
872
|
|
|
783
|
-
// Run tests as an extra verification step
|
|
784
873
|
step('Running npm test before PR creation...');
|
|
785
874
|
try {
|
|
786
875
|
execSync('npm test', { encoding: 'utf8', stdio: 'inherit' });
|
|
@@ -789,7 +878,6 @@ async function main(argv) {
|
|
|
789
878
|
process.exit(1);
|
|
790
879
|
}
|
|
791
880
|
|
|
792
|
-
// Run build as a final verification step
|
|
793
881
|
step('Running npm run build before PR creation...');
|
|
794
882
|
try {
|
|
795
883
|
execSync('npm run build', { encoding: 'utf8', stdio: 'inherit' });
|
|
@@ -798,14 +886,12 @@ async function main(argv) {
|
|
|
798
886
|
process.exit(1);
|
|
799
887
|
}
|
|
800
888
|
|
|
801
|
-
// Check for uncommitted changes after formatting
|
|
802
889
|
if (checkUncommittedChanges()) {
|
|
803
890
|
fail('You have uncommitted changes; please commit them first.');
|
|
804
891
|
step('Run: git add . && git commit -m "your message"');
|
|
805
892
|
process.exit(1);
|
|
806
893
|
}
|
|
807
894
|
|
|
808
|
-
// Check for existing open PR
|
|
809
895
|
const existingPr = checkExistingPr(args.base, branch);
|
|
810
896
|
if (existingPr) {
|
|
811
897
|
warn(`Found existing PR #${existingPr.number}: ${existingPr.title}`);
|
|
@@ -814,16 +900,16 @@ async function main(argv) {
|
|
|
814
900
|
|
|
815
901
|
step(`Collecting ${commits.length} commits from ${baseRef}..HEAD`);
|
|
816
902
|
|
|
903
|
+
// Enrich commits with diff context for better LLM understanding
|
|
904
|
+
enrichCommitsWithDiffs(commits);
|
|
905
|
+
|
|
906
|
+
// Pass 1: 5Cs snapshot from titles only
|
|
817
907
|
const pass1Messages = buildPass1Messages(commits, branch, baseRef);
|
|
818
|
-
const pass1 = await createCompletionSafe(
|
|
819
|
-
groq,
|
|
820
|
-
pass1Messages,
|
|
821
|
-
DEFAULT_MODEL,
|
|
822
|
-
2048
|
|
823
|
-
);
|
|
908
|
+
const pass1 = await createCompletionSafe(client, pass1Messages, model, 2048);
|
|
824
909
|
const pass1Text = pass1?.choices?.[0]?.message?.content?.trim() || '';
|
|
825
910
|
success('Pass 1 complete (5Cs snapshot)');
|
|
826
911
|
|
|
912
|
+
// Pass 2: per-commit condensation with diff context
|
|
827
913
|
const chunks = chunkCommits(commits, CHUNK_SIZE_CHARS);
|
|
828
914
|
step(`Pass 2 across ${chunks.length} chunk(s)`);
|
|
829
915
|
const pass2Outputs = [];
|
|
@@ -831,10 +917,10 @@ async function main(argv) {
|
|
|
831
917
|
const messages = buildPass2Messages(chunk);
|
|
832
918
|
// biome-ignore lint/nursery/noAwaitInLoop: sequential LLM calls to avoid rate limits and keep output order predictable
|
|
833
919
|
const completion = await createCompletionSafe(
|
|
834
|
-
|
|
920
|
+
client,
|
|
835
921
|
messages,
|
|
836
|
-
|
|
837
|
-
|
|
922
|
+
model,
|
|
923
|
+
4096
|
|
838
924
|
);
|
|
839
925
|
const chunkText = completion?.choices?.[0]?.message?.content?.trim();
|
|
840
926
|
if (chunkText) {
|
|
@@ -843,6 +929,7 @@ async function main(argv) {
|
|
|
843
929
|
}
|
|
844
930
|
success('Pass 2 complete (per-commit condensation)');
|
|
845
931
|
|
|
932
|
+
// Pass 3: synthesize PR summary
|
|
846
933
|
const pass3Messages =
|
|
847
934
|
mode === 'release'
|
|
848
935
|
? buildReleaseMessages(
|
|
@@ -861,12 +948,7 @@ async function main(argv) {
|
|
|
861
948
|
pass1Text,
|
|
862
949
|
formatCommitTitles(commits, 40)
|
|
863
950
|
);
|
|
864
|
-
const pass3 = await createCompletionSafe(
|
|
865
|
-
groq,
|
|
866
|
-
pass3Messages,
|
|
867
|
-
DEFAULT_MODEL,
|
|
868
|
-
2048
|
|
869
|
-
);
|
|
951
|
+
const pass3 = await createCompletionSafe(client, pass3Messages, model, 4096);
|
|
870
952
|
let finalSummary = pass3?.choices?.[0]?.message?.content?.trim() || '';
|
|
871
953
|
if (isUnknownSummary(finalSummary, mode)) {
|
|
872
954
|
warn('Pass 3 summary returned Unknown; retrying with fallback context...');
|
|
@@ -889,10 +971,10 @@ async function main(argv) {
|
|
|
889
971
|
formatCommitTitles(commits, 80)
|
|
890
972
|
);
|
|
891
973
|
const retry = await createCompletionSafe(
|
|
892
|
-
|
|
974
|
+
client,
|
|
893
975
|
retryMessages,
|
|
894
|
-
|
|
895
|
-
|
|
976
|
+
model,
|
|
977
|
+
4096
|
|
896
978
|
);
|
|
897
979
|
finalSummary =
|
|
898
980
|
retry?.choices?.[0]?.message?.content?.trim() || finalSummary;
|
|
@@ -921,7 +1003,6 @@ async function main(argv) {
|
|
|
921
1003
|
? args.out
|
|
922
1004
|
: path.join(process.cwd(), args.out);
|
|
923
1005
|
|
|
924
|
-
// Ensure output directory exists
|
|
925
1006
|
const outDir = path.dirname(resolvedOut);
|
|
926
1007
|
if (!fs.existsSync(outDir)) {
|
|
927
1008
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -930,7 +1011,6 @@ async function main(argv) {
|
|
|
930
1011
|
fs.writeFileSync(resolvedOut, fullOutput, 'utf8');
|
|
931
1012
|
success(`PR summary written to ${resolvedOut}`);
|
|
932
1013
|
|
|
933
|
-
// Write a slim, PR-ready version without appendices to avoid GH body limits
|
|
934
1014
|
const finalOutPath = path.join(
|
|
935
1015
|
path.dirname(resolvedOut),
|
|
936
1016
|
`${path.basename(resolvedOut, path.extname(resolvedOut))}.final.md`
|
|
@@ -938,7 +1018,6 @@ async function main(argv) {
|
|
|
938
1018
|
fs.writeFileSync(finalOutPath, prBlock, 'utf8');
|
|
939
1019
|
success(`PR-ready (slim) summary written to ${finalOutPath}`);
|
|
940
1020
|
|
|
941
|
-
// Also stash an autosave in tmp for debugging
|
|
942
1021
|
const tmpPath = path.join(
|
|
943
1022
|
os.tmpdir(),
|
|
944
1023
|
`pr-summary-${Date.now().toString()}-${path.basename(resolvedOut)}`
|
|
@@ -946,7 +1025,6 @@ async function main(argv) {
|
|
|
946
1025
|
fs.writeFileSync(tmpPath, fullOutput, 'utf8');
|
|
947
1026
|
warn(`Backup copy saved to ${tmpPath}`);
|
|
948
1027
|
|
|
949
|
-
// Create PR if requested
|
|
950
1028
|
if (args.createPr) {
|
|
951
1029
|
const prTitle = extractPrTitle(finalSummary, mode) || branch;
|
|
952
1030
|
try {
|
|
@@ -957,7 +1035,6 @@ async function main(argv) {
|
|
|
957
1035
|
createPrWithGh(args.base, branch, prTitle, finalSummary);
|
|
958
1036
|
}
|
|
959
1037
|
} catch (_error) {
|
|
960
|
-
// Error already logged in createPrWithGh
|
|
961
1038
|
process.exit(1);
|
|
962
1039
|
}
|
|
963
1040
|
}
|
package/src/provider.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const OpenAI = require('openai');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create an LLM client that works with Cerebras or Groq.
|
|
5
|
+
*
|
|
6
|
+
* Priority:
|
|
7
|
+
* 1. CEREBRAS_API_KEY → Cerebras (64K TPM, 1M TPD)
|
|
8
|
+
* 2. GROQ_API_KEY → Groq (fallback)
|
|
9
|
+
*
|
|
10
|
+
* Both providers expose an OpenAI-compatible API so we use the
|
|
11
|
+
* `openai` SDK for both, swapping only baseURL and model name.
|
|
12
|
+
*/
|
|
13
|
+
function createClient() {
|
|
14
|
+
const cerebrasKey = process.env.CEREBRAS_API_KEY;
|
|
15
|
+
const groqKey = process.env.GROQ_API_KEY;
|
|
16
|
+
|
|
17
|
+
if (cerebrasKey) {
|
|
18
|
+
return {
|
|
19
|
+
client: new OpenAI({
|
|
20
|
+
apiKey: cerebrasKey,
|
|
21
|
+
baseURL: 'https://api.cerebras.ai/v1',
|
|
22
|
+
}),
|
|
23
|
+
provider: 'cerebras',
|
|
24
|
+
defaultModel: 'gpt-oss-120b',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (groqKey) {
|
|
29
|
+
return {
|
|
30
|
+
client: new OpenAI({
|
|
31
|
+
apiKey: groqKey,
|
|
32
|
+
baseURL: 'https://api.groq.com/openai/v1',
|
|
33
|
+
}),
|
|
34
|
+
provider: 'groq',
|
|
35
|
+
defaultModel: 'openai/gpt-oss-120b',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = { createClient };
|