cli-changescribe 0.1.2 → 0.2.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/README.md +32 -4
- package/package.json +6 -4
- package/src/commit.js +28 -21
- package/src/init.js +12 -0
- package/src/pr-summary.js +146 -75
- package/src/provider.js +42 -0
package/README.md
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
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
|
|
|
7
|
+
Pick the install command that matches your repo's package manager:
|
|
8
|
+
|
|
7
9
|
```bash
|
|
10
|
+
# npm
|
|
8
11
|
npm install -g cli-changescribe
|
|
12
|
+
# or in a repo
|
|
13
|
+
npm install cli-changescribe
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
# pnpm
|
|
18
|
+
pnpm add -g cli-changescribe
|
|
19
|
+
# or in a repo
|
|
20
|
+
pnpm add cli-changescribe
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# yarn
|
|
25
|
+
yarn global add cli-changescribe
|
|
26
|
+
# or in a repo
|
|
27
|
+
yarn add cli-changescribe
|
|
9
28
|
```
|
|
10
29
|
|
|
11
30
|
## Setup
|
|
@@ -13,13 +32,22 @@ npm install -g cli-changescribe
|
|
|
13
32
|
Create a `.env.local` file in the repo where you run the CLI:
|
|
14
33
|
|
|
15
34
|
```bash
|
|
35
|
+
# Pick one (Cerebras is preferred for higher throughput)
|
|
36
|
+
CEREBRAS_API_KEY="your-key-here"
|
|
37
|
+
# or
|
|
16
38
|
GROQ_API_KEY="your-key-here"
|
|
17
39
|
```
|
|
18
40
|
|
|
41
|
+
Provider priority: if both keys are set, Cerebras is used.
|
|
42
|
+
|
|
43
|
+
If your repo uses `pnpm` or `yarn`, make sure you install `cli-changescribe`
|
|
44
|
+
with the same package manager so the correct lockfile is updated (Vercel uses
|
|
45
|
+
`frozen-lockfile` by default).
|
|
46
|
+
|
|
19
47
|
### Setup process (recommended)
|
|
20
48
|
|
|
21
49
|
1. Install `cli-changescribe` (global or per repo).
|
|
22
|
-
2. Add `.env.local` with `GROQ_API_KEY`.
|
|
50
|
+
2. Add `.env.local` with `CEREBRAS_API_KEY` or `GROQ_API_KEY`.
|
|
23
51
|
3. Run `npx changescribe init` to add npm scripts.
|
|
24
52
|
4. If you plan to use `--create-pr`, install and auth GitHub CLI: `gh auth login`.
|
|
25
53
|
5. Run a dry run to validate:
|
|
@@ -32,8 +60,8 @@ Optional environment variables for PR summaries:
|
|
|
32
60
|
- `PR_SUMMARY_OUT` (default: `.pr-summaries/PR_SUMMARY.md`)
|
|
33
61
|
- `PR_SUMMARY_LIMIT` (default: `400`)
|
|
34
62
|
- `PR_SUMMARY_ISSUE` (default: empty)
|
|
35
|
-
- `
|
|
36
|
-
- `GROQ_MODEL` (
|
|
63
|
+
- `CHANGESCRIBE_MODEL` (override model name for any provider)
|
|
64
|
+
- `GROQ_PR_MODEL` / `GROQ_MODEL` (legacy overrides, still supported)
|
|
37
65
|
|
|
38
66
|
## Usage
|
|
39
67
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cli-changescribe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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/init.js
CHANGED
|
@@ -42,6 +42,18 @@ function runInit(cwd = process.cwd()) {
|
|
|
42
42
|
process.exit(1);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
const pnpmLock = path.join(cwd, 'pnpm-lock.yaml');
|
|
46
|
+
const yarnLock = path.join(cwd, 'yarn.lock');
|
|
47
|
+
if (fs.existsSync(pnpmLock)) {
|
|
48
|
+
console.warn(
|
|
49
|
+
'⚠️ pnpm-lock.yaml detected. Use pnpm to install/update dependencies so the lockfile stays in sync.'
|
|
50
|
+
);
|
|
51
|
+
} else if (fs.existsSync(yarnLock)) {
|
|
52
|
+
console.warn(
|
|
53
|
+
'⚠️ yarn.lock detected. Use yarn to install/update dependencies so the lockfile stays in sync.'
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
const pkg = readPackageJson(packagePath);
|
|
46
58
|
const added = ensureScripts(pkg);
|
|
47
59
|
writePackageJson(packagePath, pkg);
|
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,44 @@ function runGit(command) {
|
|
|
75
74
|
}
|
|
76
75
|
}
|
|
77
76
|
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Diff enrichment
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
function getCommitDiffInfo(sha, title) {
|
|
82
|
+
// Skip diff for merge commits — they produce combined diffs that are noisy
|
|
83
|
+
if (title.toLowerCase().startsWith('merge')) {
|
|
84
|
+
return { stat: '(merge commit)', diff: '' };
|
|
85
|
+
}
|
|
86
|
+
try {
|
|
87
|
+
const stat = execSync(`git show ${sha} --stat --format=""`, {
|
|
88
|
+
encoding: 'utf8',
|
|
89
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
90
|
+
}).trim();
|
|
91
|
+
|
|
92
|
+
let diff = '';
|
|
93
|
+
try {
|
|
94
|
+
diff = execSync(`git show ${sha} -U3 --format="" --diff-filter=ACMRT`, {
|
|
95
|
+
encoding: 'utf8',
|
|
96
|
+
maxBuffer: LARGE_BUFFER_SIZE,
|
|
97
|
+
});
|
|
98
|
+
if (diff.length > DIFF_PER_COMMIT_CHARS) {
|
|
99
|
+
diff = `${diff.slice(0, DIFF_PER_COMMIT_CHARS)}\n...[truncated]...`;
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
diff = '(diff unavailable)';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { stat, diff };
|
|
106
|
+
} catch {
|
|
107
|
+
return { stat: '(unavailable)', diff: '' };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Commit collection
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
|
|
78
115
|
function collectCommits(baseRef, limit) {
|
|
79
116
|
const range = `${baseRef}..HEAD`;
|
|
80
117
|
let rawLog = '';
|
|
@@ -101,7 +138,7 @@ function collectCommits(baseRef, limit) {
|
|
|
101
138
|
.map((entry) => {
|
|
102
139
|
const [sha = '', title = '', bodyRaw = ''] = entry.split('\x1f');
|
|
103
140
|
const body = bodyRaw.trim().slice(0, BODY_TRUNCATION);
|
|
104
|
-
return { sha: sha.trim(), title: title.trim(), body };
|
|
141
|
+
return { sha: sha.trim(), title: title.trim(), body, stat: '', diff: '' };
|
|
105
142
|
});
|
|
106
143
|
|
|
107
144
|
if (Number.isFinite(limit) && limit > 0 && commits.length > limit) {
|
|
@@ -111,6 +148,16 @@ function collectCommits(baseRef, limit) {
|
|
|
111
148
|
return commits;
|
|
112
149
|
}
|
|
113
150
|
|
|
151
|
+
function enrichCommitsWithDiffs(commits) {
|
|
152
|
+
step('Enriching commits with diff context...');
|
|
153
|
+
for (const commit of commits) {
|
|
154
|
+
const info = getCommitDiffInfo(commit.sha, commit.title);
|
|
155
|
+
commit.stat = info.stat;
|
|
156
|
+
commit.diff = info.diff;
|
|
157
|
+
}
|
|
158
|
+
success(`Enriched ${commits.length} commits with diffs`);
|
|
159
|
+
}
|
|
160
|
+
|
|
114
161
|
function tryFetchBase(baseBranch) {
|
|
115
162
|
try {
|
|
116
163
|
execSync(`git fetch origin ${baseBranch}`, {
|
|
@@ -146,6 +193,10 @@ function resolveBaseRef(baseBranch) {
|
|
|
146
193
|
}
|
|
147
194
|
}
|
|
148
195
|
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Chunking
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
|
|
149
200
|
function chunkCommits(commits, maxChars) {
|
|
150
201
|
const chunks = [];
|
|
151
202
|
let current = [];
|
|
@@ -170,24 +221,40 @@ function chunkCommits(commits, maxChars) {
|
|
|
170
221
|
}
|
|
171
222
|
|
|
172
223
|
function serializeCommit(commit) {
|
|
173
|
-
|
|
224
|
+
const parts = [commit.sha, commit.title, commit.body];
|
|
225
|
+
if (commit.stat) {
|
|
226
|
+
parts.push(commit.stat);
|
|
227
|
+
}
|
|
228
|
+
if (commit.diff) {
|
|
229
|
+
parts.push(commit.diff);
|
|
230
|
+
}
|
|
231
|
+
parts.push('---');
|
|
232
|
+
return `${parts.join('\n')}\n`;
|
|
174
233
|
}
|
|
175
234
|
|
|
176
|
-
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// LLM helpers
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
async function createCompletionSafe(client, messages, model, maxTokens) {
|
|
177
240
|
try {
|
|
178
|
-
return await
|
|
241
|
+
return await client.chat.completions.create({
|
|
179
242
|
messages,
|
|
180
243
|
model,
|
|
181
244
|
temperature: 0.3,
|
|
182
245
|
max_tokens: maxTokens,
|
|
183
246
|
});
|
|
184
247
|
} catch (error) {
|
|
185
|
-
fail('
|
|
248
|
+
fail('LLM API error while creating completion');
|
|
186
249
|
step(formatError(error));
|
|
187
250
|
process.exit(1);
|
|
188
251
|
}
|
|
189
252
|
}
|
|
190
253
|
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
// Prompt builders
|
|
256
|
+
// ---------------------------------------------------------------------------
|
|
257
|
+
|
|
191
258
|
function buildPass1Messages(commits, branch, base) {
|
|
192
259
|
const titles = commits
|
|
193
260
|
.map((commit) => `- ${commit.title || '(no title)'}`)
|
|
@@ -216,21 +283,28 @@ function buildPass1Messages(commits, branch, base) {
|
|
|
216
283
|
|
|
217
284
|
function buildPass2Messages(commitsChunk) {
|
|
218
285
|
const body = commitsChunk
|
|
219
|
-
.map((commit) =>
|
|
220
|
-
[
|
|
286
|
+
.map((commit) => {
|
|
287
|
+
const parts = [
|
|
221
288
|
`SHA: ${commit.sha}`,
|
|
222
289
|
`Title: ${commit.title || '(no title)'}`,
|
|
223
290
|
`Body:\n${commit.body || '(no body)'}`,
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
291
|
+
];
|
|
292
|
+
if (commit.stat) {
|
|
293
|
+
parts.push(`Files changed:\n${commit.stat}`);
|
|
294
|
+
}
|
|
295
|
+
if (commit.diff) {
|
|
296
|
+
parts.push(`Diff:\n${commit.diff}`);
|
|
297
|
+
}
|
|
298
|
+
parts.push('---');
|
|
299
|
+
return parts.join('\n');
|
|
300
|
+
})
|
|
227
301
|
.join('\n');
|
|
228
302
|
|
|
229
303
|
return [
|
|
230
304
|
{
|
|
231
305
|
role: 'system',
|
|
232
306
|
content:
|
|
233
|
-
'You are producing compact, high-signal summaries per commit
|
|
307
|
+
'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.',
|
|
234
308
|
},
|
|
235
309
|
{
|
|
236
310
|
role: 'user',
|
|
@@ -239,9 +313,9 @@ function buildPass2Messages(commitsChunk) {
|
|
|
239
313
|
body,
|
|
240
314
|
'',
|
|
241
315
|
'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.',
|
|
316
|
+
'- Title-aligned bullet: what changed + why (use the diff to be specific about files and code patterns).',
|
|
317
|
+
'- Risk or test note if visible in the diff.',
|
|
318
|
+
'Keep outputs brief; do not restate bodies verbatim.',
|
|
245
319
|
].join('\n'),
|
|
246
320
|
},
|
|
247
321
|
];
|
|
@@ -286,7 +360,9 @@ function buildPass3Messages(
|
|
|
286
360
|
'Rules:',
|
|
287
361
|
'- If the issue is unknown, write: "Related: (not provided)".',
|
|
288
362
|
'- If testing is unknown, write: "Testing: (not provided)".',
|
|
289
|
-
'-
|
|
363
|
+
'- 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.',
|
|
364
|
+
'- Be thorough and specific. Reference actual file names, functions, and architectural changes.',
|
|
365
|
+
'- Prefer bullets.',
|
|
290
366
|
'',
|
|
291
367
|
`Issue hint: ${issue || '(not provided)'}`,
|
|
292
368
|
].join('\n'),
|
|
@@ -333,7 +409,9 @@ function buildReleaseMessages(
|
|
|
333
409
|
'',
|
|
334
410
|
'Rules:',
|
|
335
411
|
'- If unknown, write: "Unknown".',
|
|
336
|
-
'-
|
|
412
|
+
'- Every commit must appear as its own bullet. Do not group commits under catch-all labels like "miscellaneous".',
|
|
413
|
+
'- Be thorough and specific. Reference actual changes from the commit summaries.',
|
|
414
|
+
'- Prefer bullets.',
|
|
337
415
|
'',
|
|
338
416
|
`Issue hint: ${issue || '(not provided)'}`,
|
|
339
417
|
].join('\n'),
|
|
@@ -341,6 +419,10 @@ function buildReleaseMessages(
|
|
|
341
419
|
];
|
|
342
420
|
}
|
|
343
421
|
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// Utilities
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
344
426
|
function formatError(error) {
|
|
345
427
|
const plain = {};
|
|
346
428
|
for (const key of Object.getOwnPropertyNames(error)) {
|
|
@@ -438,6 +520,10 @@ function isUnknownSummary(summary, mode) {
|
|
|
438
520
|
return unknownCount === headings.length;
|
|
439
521
|
}
|
|
440
522
|
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// GitHub helpers
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
441
527
|
function checkGhCli() {
|
|
442
528
|
try {
|
|
443
529
|
execSync('gh --version', { encoding: 'utf8', stdio: 'ignore' });
|
|
@@ -461,7 +547,6 @@ function checkUncommittedChanges() {
|
|
|
461
547
|
|
|
462
548
|
function checkExistingPr(base, head) {
|
|
463
549
|
try {
|
|
464
|
-
// Check for open PRs from head to base
|
|
465
550
|
const result = spawnSync(
|
|
466
551
|
'gh',
|
|
467
552
|
[
|
|
@@ -483,14 +568,12 @@ function checkExistingPr(base, head) {
|
|
|
483
568
|
);
|
|
484
569
|
|
|
485
570
|
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
571
|
return null;
|
|
488
572
|
}
|
|
489
573
|
|
|
490
574
|
const prs = JSON.parse(result.stdout || '[]');
|
|
491
575
|
return prs.length > 0 ? prs[0] : null;
|
|
492
576
|
} catch {
|
|
493
|
-
// If parsing fails or command fails, assume no PR exists
|
|
494
577
|
return null;
|
|
495
578
|
}
|
|
496
579
|
}
|
|
@@ -508,33 +591,27 @@ function hasNpmScript(scriptName, cwd = process.cwd()) {
|
|
|
508
591
|
|
|
509
592
|
function extractPrTitle(summary, mode) {
|
|
510
593
|
const targetSection = mode === 'release' ? 'release summary' : 'what change';
|
|
511
|
-
// Try to extract a title from the summary
|
|
512
594
|
const lines = summary.split('\n');
|
|
513
595
|
let inChangesSection = false;
|
|
514
596
|
|
|
515
597
|
for (const line of lines) {
|
|
516
598
|
const trimmed = line.trim();
|
|
517
599
|
|
|
518
|
-
// Detect the target section
|
|
519
600
|
if (trimmed.toLowerCase().includes(targetSection)) {
|
|
520
601
|
inChangesSection = true;
|
|
521
602
|
continue;
|
|
522
603
|
}
|
|
523
604
|
|
|
524
|
-
// If we hit another section heading, stop looking
|
|
525
605
|
if (inChangesSection && trimmed && trimmed.endsWith('?')) {
|
|
526
606
|
break;
|
|
527
607
|
}
|
|
528
608
|
|
|
529
|
-
// If we're in the changes section, look for first bullet point
|
|
530
609
|
if (inChangesSection && trimmed.startsWith('-')) {
|
|
531
|
-
// Extract bullet content, clean it up
|
|
532
610
|
const bullet = trimmed.slice(1).trim();
|
|
533
|
-
// Remove markdown formatting and truncate
|
|
534
611
|
const clean = bullet
|
|
535
|
-
.replace(/`([^`]+)`/g, '$1')
|
|
536
|
-
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
537
|
-
.replace(/\*([^*]+)\*/g, '$1')
|
|
612
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
613
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
614
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
538
615
|
.slice(0, 100);
|
|
539
616
|
if (clean.length > 10) {
|
|
540
617
|
return clean;
|
|
@@ -542,13 +619,11 @@ function extractPrTitle(summary, mode) {
|
|
|
542
619
|
}
|
|
543
620
|
}
|
|
544
621
|
|
|
545
|
-
// Fallback: use first commit title or branch name
|
|
546
622
|
return null;
|
|
547
623
|
}
|
|
548
624
|
|
|
549
625
|
function createPrWithGh(base, branch, title, body) {
|
|
550
626
|
try {
|
|
551
|
-
// Ensure branch is pushed first
|
|
552
627
|
step('Pushing branch to remote...');
|
|
553
628
|
try {
|
|
554
629
|
execSync(`git push -u origin ${branch}`, {
|
|
@@ -557,7 +632,6 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
557
632
|
});
|
|
558
633
|
success('Branch pushed to remote');
|
|
559
634
|
} catch (error) {
|
|
560
|
-
// Branch might already be pushed, check if it exists
|
|
561
635
|
const remoteBranches = execSync('git branch -r', {
|
|
562
636
|
encoding: 'utf8',
|
|
563
637
|
});
|
|
@@ -568,11 +642,9 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
568
642
|
}
|
|
569
643
|
}
|
|
570
644
|
|
|
571
|
-
// Create PR using gh CLI
|
|
572
645
|
step('Creating PR with GitHub CLI...');
|
|
573
646
|
const prTitle = title || branch;
|
|
574
647
|
|
|
575
|
-
// Write body to temp file to avoid shell escaping issues
|
|
576
648
|
const bodyFile = path.join(
|
|
577
649
|
os.tmpdir(),
|
|
578
650
|
`pr-body-${Date.now().toString()}.md`
|
|
@@ -592,13 +664,11 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
592
664
|
bodyFile,
|
|
593
665
|
];
|
|
594
666
|
|
|
595
|
-
// Add issue reference if provided via environment
|
|
596
667
|
const issueEnv = process.env.PR_SUMMARY_ISSUE || '';
|
|
597
668
|
if (issueEnv) {
|
|
598
669
|
ghArgs.push('--issue', issueEnv);
|
|
599
670
|
}
|
|
600
671
|
|
|
601
|
-
// Use spawnSync for better argument handling
|
|
602
672
|
const result = spawnSync('gh', ghArgs, {
|
|
603
673
|
encoding: 'utf8',
|
|
604
674
|
stdio: 'pipe',
|
|
@@ -616,7 +686,6 @@ function createPrWithGh(base, branch, title, body) {
|
|
|
616
686
|
|
|
617
687
|
const prUrl = result.stdout.trim();
|
|
618
688
|
|
|
619
|
-
// Cleanup temp file
|
|
620
689
|
try {
|
|
621
690
|
fs.unlinkSync(bodyFile);
|
|
622
691
|
} catch {
|
|
@@ -675,6 +744,10 @@ function updatePrWithGh(prNumber, title, body) {
|
|
|
675
744
|
}
|
|
676
745
|
}
|
|
677
746
|
|
|
747
|
+
// ---------------------------------------------------------------------------
|
|
748
|
+
// CLI
|
|
749
|
+
// ---------------------------------------------------------------------------
|
|
750
|
+
|
|
678
751
|
function parseArgs(argv) {
|
|
679
752
|
const args = {
|
|
680
753
|
base: DEFAULT_BASE,
|
|
@@ -716,21 +789,31 @@ function parseArgs(argv) {
|
|
|
716
789
|
return args;
|
|
717
790
|
}
|
|
718
791
|
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
// Main
|
|
794
|
+
// ---------------------------------------------------------------------------
|
|
795
|
+
|
|
719
796
|
async function main(argv) {
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
797
|
+
const providerInfo = createClient();
|
|
798
|
+
if (!providerInfo) {
|
|
799
|
+
fail('No API key found. Set CEREBRAS_API_KEY or GROQ_API_KEY in .env.local');
|
|
723
800
|
process.exit(1);
|
|
724
801
|
}
|
|
725
802
|
|
|
803
|
+
const { client, provider, defaultModel } = providerInfo;
|
|
804
|
+
const model =
|
|
805
|
+
process.env.CHANGESCRIBE_MODEL ||
|
|
806
|
+
process.env.GROQ_PR_MODEL ||
|
|
807
|
+
process.env.GROQ_MODEL ||
|
|
808
|
+
defaultModel;
|
|
809
|
+
|
|
726
810
|
const args = parseArgs(argv);
|
|
727
811
|
const branch = runGit('git branch --show-current').trim();
|
|
728
812
|
const mode =
|
|
729
813
|
args.mode ||
|
|
730
814
|
(branch === 'staging' && args.base === 'main' ? 'release' : 'feature');
|
|
731
|
-
const groq = new Groq({ apiKey: process.env.GROQ_API_KEY });
|
|
732
815
|
|
|
733
|
-
process.stdout.write(`${banner(branch, args.base)}\n`);
|
|
816
|
+
process.stdout.write(`${banner(branch, args.base, provider)}\n`);
|
|
734
817
|
|
|
735
818
|
const didFetch = tryFetchBase(args.base);
|
|
736
819
|
if (!didFetch) {
|
|
@@ -753,6 +836,8 @@ async function main(argv) {
|
|
|
753
836
|
step(`Issue: ${args.issue || '(not provided)'}`);
|
|
754
837
|
step(`Create PR: ${args.createPr ? 'yes' : 'no'}`);
|
|
755
838
|
step(`Mode: ${mode}`);
|
|
839
|
+
step(`Provider: ${provider}`);
|
|
840
|
+
step(`Model: ${model}`);
|
|
756
841
|
return;
|
|
757
842
|
}
|
|
758
843
|
|
|
@@ -765,7 +850,6 @@ async function main(argv) {
|
|
|
765
850
|
|
|
766
851
|
// Safety checks before creating PR
|
|
767
852
|
if (args.createPr) {
|
|
768
|
-
// Run format as a last-minute verification step
|
|
769
853
|
if (args.skipFormat) {
|
|
770
854
|
warn('Skipping format step (flagged)');
|
|
771
855
|
} else if (!hasNpmScript('format')) {
|
|
@@ -780,7 +864,6 @@ async function main(argv) {
|
|
|
780
864
|
}
|
|
781
865
|
}
|
|
782
866
|
|
|
783
|
-
// Run tests as an extra verification step
|
|
784
867
|
step('Running npm test before PR creation...');
|
|
785
868
|
try {
|
|
786
869
|
execSync('npm test', { encoding: 'utf8', stdio: 'inherit' });
|
|
@@ -789,7 +872,6 @@ async function main(argv) {
|
|
|
789
872
|
process.exit(1);
|
|
790
873
|
}
|
|
791
874
|
|
|
792
|
-
// Run build as a final verification step
|
|
793
875
|
step('Running npm run build before PR creation...');
|
|
794
876
|
try {
|
|
795
877
|
execSync('npm run build', { encoding: 'utf8', stdio: 'inherit' });
|
|
@@ -798,14 +880,12 @@ async function main(argv) {
|
|
|
798
880
|
process.exit(1);
|
|
799
881
|
}
|
|
800
882
|
|
|
801
|
-
// Check for uncommitted changes after formatting
|
|
802
883
|
if (checkUncommittedChanges()) {
|
|
803
884
|
fail('You have uncommitted changes; please commit them first.');
|
|
804
885
|
step('Run: git add . && git commit -m "your message"');
|
|
805
886
|
process.exit(1);
|
|
806
887
|
}
|
|
807
888
|
|
|
808
|
-
// Check for existing open PR
|
|
809
889
|
const existingPr = checkExistingPr(args.base, branch);
|
|
810
890
|
if (existingPr) {
|
|
811
891
|
warn(`Found existing PR #${existingPr.number}: ${existingPr.title}`);
|
|
@@ -814,16 +894,16 @@ async function main(argv) {
|
|
|
814
894
|
|
|
815
895
|
step(`Collecting ${commits.length} commits from ${baseRef}..HEAD`);
|
|
816
896
|
|
|
897
|
+
// Enrich commits with diff context for better LLM understanding
|
|
898
|
+
enrichCommitsWithDiffs(commits);
|
|
899
|
+
|
|
900
|
+
// Pass 1: 5Cs snapshot from titles only
|
|
817
901
|
const pass1Messages = buildPass1Messages(commits, branch, baseRef);
|
|
818
|
-
const pass1 = await createCompletionSafe(
|
|
819
|
-
groq,
|
|
820
|
-
pass1Messages,
|
|
821
|
-
DEFAULT_MODEL,
|
|
822
|
-
2048
|
|
823
|
-
);
|
|
902
|
+
const pass1 = await createCompletionSafe(client, pass1Messages, model, 2048);
|
|
824
903
|
const pass1Text = pass1?.choices?.[0]?.message?.content?.trim() || '';
|
|
825
904
|
success('Pass 1 complete (5Cs snapshot)');
|
|
826
905
|
|
|
906
|
+
// Pass 2: per-commit condensation with diff context
|
|
827
907
|
const chunks = chunkCommits(commits, CHUNK_SIZE_CHARS);
|
|
828
908
|
step(`Pass 2 across ${chunks.length} chunk(s)`);
|
|
829
909
|
const pass2Outputs = [];
|
|
@@ -831,10 +911,10 @@ async function main(argv) {
|
|
|
831
911
|
const messages = buildPass2Messages(chunk);
|
|
832
912
|
// biome-ignore lint/nursery/noAwaitInLoop: sequential LLM calls to avoid rate limits and keep output order predictable
|
|
833
913
|
const completion = await createCompletionSafe(
|
|
834
|
-
|
|
914
|
+
client,
|
|
835
915
|
messages,
|
|
836
|
-
|
|
837
|
-
|
|
916
|
+
model,
|
|
917
|
+
4096
|
|
838
918
|
);
|
|
839
919
|
const chunkText = completion?.choices?.[0]?.message?.content?.trim();
|
|
840
920
|
if (chunkText) {
|
|
@@ -843,6 +923,7 @@ async function main(argv) {
|
|
|
843
923
|
}
|
|
844
924
|
success('Pass 2 complete (per-commit condensation)');
|
|
845
925
|
|
|
926
|
+
// Pass 3: synthesize PR summary
|
|
846
927
|
const pass3Messages =
|
|
847
928
|
mode === 'release'
|
|
848
929
|
? buildReleaseMessages(
|
|
@@ -861,12 +942,7 @@ async function main(argv) {
|
|
|
861
942
|
pass1Text,
|
|
862
943
|
formatCommitTitles(commits, 40)
|
|
863
944
|
);
|
|
864
|
-
const pass3 = await createCompletionSafe(
|
|
865
|
-
groq,
|
|
866
|
-
pass3Messages,
|
|
867
|
-
DEFAULT_MODEL,
|
|
868
|
-
2048
|
|
869
|
-
);
|
|
945
|
+
const pass3 = await createCompletionSafe(client, pass3Messages, model, 4096);
|
|
870
946
|
let finalSummary = pass3?.choices?.[0]?.message?.content?.trim() || '';
|
|
871
947
|
if (isUnknownSummary(finalSummary, mode)) {
|
|
872
948
|
warn('Pass 3 summary returned Unknown; retrying with fallback context...');
|
|
@@ -889,10 +965,10 @@ async function main(argv) {
|
|
|
889
965
|
formatCommitTitles(commits, 80)
|
|
890
966
|
);
|
|
891
967
|
const retry = await createCompletionSafe(
|
|
892
|
-
|
|
968
|
+
client,
|
|
893
969
|
retryMessages,
|
|
894
|
-
|
|
895
|
-
|
|
970
|
+
model,
|
|
971
|
+
4096
|
|
896
972
|
);
|
|
897
973
|
finalSummary =
|
|
898
974
|
retry?.choices?.[0]?.message?.content?.trim() || finalSummary;
|
|
@@ -921,7 +997,6 @@ async function main(argv) {
|
|
|
921
997
|
? args.out
|
|
922
998
|
: path.join(process.cwd(), args.out);
|
|
923
999
|
|
|
924
|
-
// Ensure output directory exists
|
|
925
1000
|
const outDir = path.dirname(resolvedOut);
|
|
926
1001
|
if (!fs.existsSync(outDir)) {
|
|
927
1002
|
fs.mkdirSync(outDir, { recursive: true });
|
|
@@ -930,7 +1005,6 @@ async function main(argv) {
|
|
|
930
1005
|
fs.writeFileSync(resolvedOut, fullOutput, 'utf8');
|
|
931
1006
|
success(`PR summary written to ${resolvedOut}`);
|
|
932
1007
|
|
|
933
|
-
// Write a slim, PR-ready version without appendices to avoid GH body limits
|
|
934
1008
|
const finalOutPath = path.join(
|
|
935
1009
|
path.dirname(resolvedOut),
|
|
936
1010
|
`${path.basename(resolvedOut, path.extname(resolvedOut))}.final.md`
|
|
@@ -938,7 +1012,6 @@ async function main(argv) {
|
|
|
938
1012
|
fs.writeFileSync(finalOutPath, prBlock, 'utf8');
|
|
939
1013
|
success(`PR-ready (slim) summary written to ${finalOutPath}`);
|
|
940
1014
|
|
|
941
|
-
// Also stash an autosave in tmp for debugging
|
|
942
1015
|
const tmpPath = path.join(
|
|
943
1016
|
os.tmpdir(),
|
|
944
1017
|
`pr-summary-${Date.now().toString()}-${path.basename(resolvedOut)}`
|
|
@@ -946,7 +1019,6 @@ async function main(argv) {
|
|
|
946
1019
|
fs.writeFileSync(tmpPath, fullOutput, 'utf8');
|
|
947
1020
|
warn(`Backup copy saved to ${tmpPath}`);
|
|
948
1021
|
|
|
949
|
-
// Create PR if requested
|
|
950
1022
|
if (args.createPr) {
|
|
951
1023
|
const prTitle = extractPrTitle(finalSummary, mode) || branch;
|
|
952
1024
|
try {
|
|
@@ -957,7 +1029,6 @@ async function main(argv) {
|
|
|
957
1029
|
createPrWithGh(args.base, branch, prTitle, finalSummary);
|
|
958
1030
|
}
|
|
959
1031
|
} catch (_error) {
|
|
960
|
-
// Error already logged in createPrWithGh
|
|
961
1032
|
process.exit(1);
|
|
962
1033
|
}
|
|
963
1034
|
}
|
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 };
|