guito 1.0.6 → 1.0.7
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/CHANGELOG.md +17 -0
- package/README.md +39 -2
- package/bin/ai-review.js +77 -37
- package/bin/commit-message.js +146 -0
- package/bin/guito-server.js +182 -37
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-2QNMM6BC.js +67 -0
- package/{guito-1.0.6.vsix → guito-1.0.7.vsix} +0 -0
- package/package.json +1 -1
- package/bin/ui/browser/main-TJBW4QCI.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,23 @@ All notable changes to Guito are documented in this file. The project follows [S
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [1.0.7] - 2026-10-01
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- Draft commit messages with Claude. A new **Generate message** button in the uncommitted-changes panel hands the pending changes to Claude Code running on this machine and fills the commit message and description boxes with the reply, matching the language and style of your recent commits. The **Commit message model** setting (`guito.aiReview.commitMessageModel` inside the extension) picks the model; haiku is the default, since a subject line is a small question. Nothing is committed automatically: the draft lands in the boxes for you to review and edit, and Guito falls back to every uncommitted change when nothing is staged.
|
|
12
|
+
- Choose which Claude model reviews your pull requests. A new **Claude model** setting (the `guito.aiReview.model` VS Code setting inside the extension) sets the default for the automated reviewer, and the model picker in the pull request dialog's **Review** tab — preselected from that setting — overrides it for a single run; the review's summary line shows which model produced it. Aliases like "opus" always mean the latest model of that family Claude Code has.
|
|
13
|
+
- Scrolling to the end of the commit history now loads the next page automatically, so long histories can be explored without touching the footer; the **Load more commits** and **Load all** buttons remain available for explicit control.
|
|
14
|
+
- Pull request completion and auto-complete now follow the target branch's merge policy. Guito reads the project's Azure DevOps branch policies and offers only the merge strategies the "Merge Types" policies covering the pull request allow (so a squash-only branch no longer lists the other strategies, which Azure would reject anyway), notes in the dialog when the list is limited by policy, and still applies the **Allow merge completion** setting on top. Branches without a merge policy keep offering every strategy.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Canceling auto-complete on an Azure DevOps pull request now verifies the server actually cleared it. Azure DevOps sometimes answers the clearing PATCH with 200 OK while silently leaving auto-complete enabled, which made the button appear to do nothing; Guito now checks the pull request the server returns, retries with the alternate payload shape, and reports an error when auto-complete is still set.
|
|
19
|
+
|
|
20
|
+
### Removed
|
|
21
|
+
|
|
22
|
+
- The `guito.aiReview.claudeArgs` setting. Models are chosen with the new model settings instead: `guito.aiReview.model` for pull request reviews and `guito.aiReview.commitMessageModel` for commit messages.
|
|
23
|
+
|
|
7
24
|
## [1.0.6] - 2026-09-24
|
|
8
25
|
|
|
9
26
|
### Added
|
package/README.md
CHANGED
|
@@ -60,6 +60,8 @@ Click **Uncommitted changes** to open the staged and unstaged file lists. Click
|
|
|
60
60
|
|
|
61
61
|
Enter a commit message and optional description, then choose **Commit staged changes**. Only staged changes are committed; unstaged edits remain on disk. Commit drafts stay available when you close and reopen the panel during the session.
|
|
62
62
|
|
|
63
|
+
**Generate message** drafts a subject and description with Claude Code running on your machine, from the staged diff when anything is staged and from every uncommitted change otherwise, matching the language and style of your recent commits. The draft fills the boxes for you to review and edit; nothing is committed automatically. The **Commit message model** setting picks the model.
|
|
64
|
+
|
|
63
65
|
## CLI options
|
|
64
66
|
|
|
65
67
|
```text
|
|
@@ -83,6 +85,40 @@ Run the command from anywhere inside the Git worktree you want Guito to manage.
|
|
|
83
85
|
|
|
84
86
|
The standalone server can execute Git commands against the current repository. Keep it on a trusted machine and do not expose its port to untrusted networks.
|
|
85
87
|
|
|
88
|
+
## Settings
|
|
89
|
+
|
|
90
|
+
Inside the VS Code extension, every preference below is a native VS Code setting in the `guito` namespace: open the Settings editor and search for "Guito". In the browser client, the same preferences live in the settings dialog behind the toolbar's gear icon and are stored per repository in `.git/guito-settings.json`, which is never committed. VS Code settings win over the stored file when both are set.
|
|
91
|
+
|
|
92
|
+
The settings dialog also manages preferences that have no VS Code equivalent: the repository's Git user name and email, the fetch and push URLs of the configured remotes, and issue-linking rules. An issue-linking rule (a regular expression plus a URL template) turns issue references in commit messages into links; it can be saved for the repository or globally, and global rules are stored in your Git configuration as `guito.issueRegex` and `guito.issueUrl`.
|
|
93
|
+
|
|
94
|
+
### General
|
|
95
|
+
|
|
96
|
+
| Setting | Description |
|
|
97
|
+
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
98
|
+
| `guito.autoReload` | Reload commits and the working tree automatically when the repository changes outside Guito, for example from a terminal. On by default. |
|
|
99
|
+
| `guito.showGraph` | Show the Git graph beside the commit history. On by default. |
|
|
100
|
+
| `guito.showStashes` | Show stash entries above the commit history. Off by default. |
|
|
101
|
+
| `guito.showTags` | Show tag badges attached to commits. On by default. |
|
|
102
|
+
| `guito.showRemoteBranches` | Show remote branches in the repository panel and the branch pills on commits. On by default. |
|
|
103
|
+
| `guito.fileListView` | How changed-file lists are shown: `flat` (default) lists every file with its full path, `tree` groups them into collapsible folders. |
|
|
104
|
+
| `guito.refListView` | How branches and tags are shown in the repository panel: `flat` (default) lists full names, `tree` groups them into collapsible namespace folders. |
|
|
105
|
+
| `guito.sidePanelSectionsExpanded`| Open the repository panel with all sections expanded. On by default. |
|
|
106
|
+
| `guito.searchMode` | How commit search behaves: `navigate` (default) keeps the full history and jumps between matches, `filter` shows only matching commits. |
|
|
107
|
+
| `guito.searchCaseSensitive` | Match commit and repository panel searches using the query's exact letter casing and accents. Off by default. |
|
|
108
|
+
| `guito.diffViewer` | Where file diffs open: `vscode` (default) uses the native VS Code diff tab, `guito` uses Guito's own dialog. Binary files always use Guito's dialog. |
|
|
109
|
+
| `guito.issueRegex` | Regular expression that recognizes issue references in commit messages, for example `#(\d+)`. Inside the extension this is the issue-linking rule; it wins over the rule saved in the settings dialog. |
|
|
110
|
+
| `guito.issueUrl` | URL template for issue links; capture groups from the issue regex are inserted with `$1`, `$2`, and so on. |
|
|
111
|
+
|
|
112
|
+
### Azure DevOps
|
|
113
|
+
|
|
114
|
+
| Setting | Description |
|
|
115
|
+
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| `guito.azureDevOpsUrl` | Base URL of your on-prem Azure DevOps Server, for example `https://server/DefaultCollection`. Enables pull request creation and the automated reviewer; requests authenticate with Windows integrated auth. |
|
|
117
|
+
| `guito.prBranchNameTemplate` | Branch name template used when Guito creates a new branch for a pull request. Defaults to `pr/${randomstring}`. Variables: `${username}`, `${randomstring}`, `${branch}`, `${targetbranch}`, `${title}`, `${repository}`, `${date}` (UTC `YYYY-MM-DD`), `${time}` (UTC `HHmmss`), and `${timestamp}`. |
|
|
118
|
+
| `guito.allowMerge` | Offer the "No fast-forward (merge commit)" and "Semi-linear merge" pull request completion strategies. On by default. |
|
|
119
|
+
|
|
120
|
+
The `guito.aiReview.*` settings that configure the automated pull request reviewer are listed under [Automated pull request review](#automated-pull-request-review).
|
|
121
|
+
|
|
86
122
|
## Automated pull request review
|
|
87
123
|
|
|
88
124
|
Guito can hand your Azure DevOps pull requests to [Claude Code](https://claude.com/claude-code) running on your own machine, then queue what it finds for you to approve.
|
|
@@ -103,10 +139,11 @@ Inside VS Code the reviewer also runs while no Guito panel is open, so pull requ
|
|
|
103
139
|
| -------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
104
140
|
| `guito.aiReview.enabled` | Turns automated review on. Off by default. |
|
|
105
141
|
| `guito.aiReview.scope` | `reviewer` (default), `mine`, or `all`. |
|
|
106
|
-
| `guito.aiReview.pollMinutes` | Minutes between checks. Defaults to
|
|
142
|
+
| `guito.aiReview.pollMinutes` | Minutes between checks. Defaults to 30. |
|
|
107
143
|
| `guito.aiReview.includeDrafts` | Also review draft pull requests. Off by default. |
|
|
108
144
|
| `guito.aiReview.claudePath` | Path to the Claude Code executable. Empty means Guito looks for it (see above). |
|
|
109
|
-
| `guito.aiReview.
|
|
145
|
+
| `guito.aiReview.model` | Which Claude model reviews pull requests. Default uses whatever Claude Code is set to. |
|
|
146
|
+
| `guito.aiReview.commitMessageModel` | Which Claude model drafts commit messages. Defaults to haiku. |
|
|
110
147
|
| `guito.aiReview.timeoutSeconds` | How long one review may take. Defaults to 600. |
|
|
111
148
|
| `guito.aiReview.instructions` | Extra reviewing instructions, such as your team's conventions. |
|
|
112
149
|
|
package/bin/ai-review.js
CHANGED
|
@@ -6,10 +6,11 @@ const SEVERITIES = ['blocker', 'concern', 'suggestion', 'nit'];
|
|
|
6
6
|
export const DEFAULT_AI_REVIEW_CONFIG = {
|
|
7
7
|
enabled: false,
|
|
8
8
|
scope: 'reviewer',
|
|
9
|
-
pollMinutes:
|
|
9
|
+
pollMinutes: 30,
|
|
10
10
|
includeDrafts: false,
|
|
11
11
|
claudePath: '',
|
|
12
|
-
|
|
12
|
+
model: '',
|
|
13
|
+
commitMessageModel: 'haiku',
|
|
13
14
|
timeoutSeconds: 600,
|
|
14
15
|
maxDiffChars: 300000,
|
|
15
16
|
instructions: '',
|
|
@@ -33,8 +34,11 @@ export function resolveAiReviewConfig(...sources) {
|
|
|
33
34
|
if (typeof source.claudePath === 'string' && source.claudePath.trim()) {
|
|
34
35
|
config.claudePath = source.claudePath.trim();
|
|
35
36
|
}
|
|
36
|
-
if (
|
|
37
|
-
config.
|
|
37
|
+
if (typeof source.model === 'string') {
|
|
38
|
+
config.model = source.model.trim();
|
|
39
|
+
}
|
|
40
|
+
if (typeof source.commitMessageModel === 'string') {
|
|
41
|
+
config.commitMessageModel = source.commitMessageModel.trim();
|
|
38
42
|
}
|
|
39
43
|
if (Number.isFinite(source.timeoutSeconds)) {
|
|
40
44
|
config.timeoutSeconds = Math.min(Math.max(Number(source.timeoutSeconds), 30), 3600);
|
|
@@ -83,12 +87,7 @@ async function bundledClaudeCandidates() {
|
|
|
83
87
|
const home = homedir();
|
|
84
88
|
const binary = process.platform === 'win32' ? 'claude.exe' : 'claude';
|
|
85
89
|
const found = [];
|
|
86
|
-
for (const root of [
|
|
87
|
-
'.vscode',
|
|
88
|
-
'.vscode-insiders',
|
|
89
|
-
'.vscode-server',
|
|
90
|
-
'.vscode-server-insiders',
|
|
91
|
-
]) {
|
|
90
|
+
for (const root of ['.vscode', '.vscode-insiders', '.vscode-server', '.vscode-server-insiders']) {
|
|
92
91
|
const extensions = join(home, root, 'extensions');
|
|
93
92
|
let entries;
|
|
94
93
|
try {
|
|
@@ -132,8 +131,9 @@ export async function resolveClaudeCommand(configured) {
|
|
|
132
131
|
/**
|
|
133
132
|
* Runs the Claude Code CLI in print mode. The prompt goes over stdin because a
|
|
134
133
|
* pull request diff is far larger than the Windows command line allows.
|
|
134
|
+
* Exported because the commit message drafter runs the CLI the same way.
|
|
135
135
|
*/
|
|
136
|
-
const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise((resolve, reject) => {
|
|
136
|
+
export const runClaudeCli = (prompt, { command, args, cwd, timeoutMs, signal }) => new Promise((resolve, reject) => {
|
|
137
137
|
const child = spawn(command, args, {
|
|
138
138
|
cwd,
|
|
139
139
|
windowsHide: true,
|
|
@@ -148,6 +148,7 @@ const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise(
|
|
|
148
148
|
return;
|
|
149
149
|
settled = true;
|
|
150
150
|
clearTimeout(timer);
|
|
151
|
+
signal?.removeEventListener('abort', onAbort);
|
|
151
152
|
if (error)
|
|
152
153
|
reject(error);
|
|
153
154
|
else
|
|
@@ -157,6 +158,16 @@ const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise(
|
|
|
157
158
|
child.kill();
|
|
158
159
|
finish(new Error(`The review timed out after ${Math.round(timeoutMs / 1000)}s.`));
|
|
159
160
|
}, timeoutMs);
|
|
161
|
+
const onAbort = () => {
|
|
162
|
+
child.kill();
|
|
163
|
+
finish(new Error('The review was cancelled.'));
|
|
164
|
+
};
|
|
165
|
+
if (signal) {
|
|
166
|
+
if (signal.aborted)
|
|
167
|
+
onAbort();
|
|
168
|
+
else
|
|
169
|
+
signal.addEventListener('abort', onAbort);
|
|
170
|
+
}
|
|
160
171
|
child.stdout.on('data', (chunk) => {
|
|
161
172
|
stdout += String(chunk);
|
|
162
173
|
});
|
|
@@ -339,9 +350,7 @@ export function buildReviewPrompt(input) {
|
|
|
339
350
|
'EXISTING COMMENT THREADS ON THE PULL REQUEST:',
|
|
340
351
|
renderThreads(threads),
|
|
341
352
|
'',
|
|
342
|
-
input.skipped.length
|
|
343
|
-
? `NOT SHOWN (binary or unreviewable): ${input.skipped.join(', ')}\n`
|
|
344
|
-
: '',
|
|
353
|
+
input.skipped.length ? `NOT SHOWN (binary or unreviewable): ${input.skipped.join(', ')}\n` : '',
|
|
345
354
|
input.instructions.trim() ? `TEAM INSTRUCTIONS:\n${input.instructions.trim()}\n` : '',
|
|
346
355
|
REVIEW_CONTRACT,
|
|
347
356
|
'',
|
|
@@ -371,6 +380,7 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
371
380
|
const running = new Set();
|
|
372
381
|
let timer;
|
|
373
382
|
let activePoll = null;
|
|
383
|
+
let pollAbort;
|
|
374
384
|
// One review at a time: each one spawns a CLI and burns Azure DevOps calls.
|
|
375
385
|
let queue = Promise.resolve();
|
|
376
386
|
const serialize = (action) => {
|
|
@@ -422,7 +432,10 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
422
432
|
const rawLine = Number(entry?.line);
|
|
423
433
|
const line = inDiff && Number.isInteger(rawLine) && anchors.get(file).has(rawLine) ? rawLine : null;
|
|
424
434
|
const rawEnd = Number(entry?.endLine);
|
|
425
|
-
const endLine = line !== null &&
|
|
435
|
+
const endLine = line !== null &&
|
|
436
|
+
Number.isInteger(rawEnd) &&
|
|
437
|
+
rawEnd >= line &&
|
|
438
|
+
anchors.get(file).has(rawEnd)
|
|
426
439
|
? rawEnd
|
|
427
440
|
: null;
|
|
428
441
|
const severity = SEVERITIES.includes(entry?.severity)
|
|
@@ -451,9 +464,23 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
451
464
|
return accepted.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
|
|
452
465
|
};
|
|
453
466
|
const runReview = async (pullRequestId, options = {}) => {
|
|
467
|
+
// A pass the poller cancelled before this review's turn must not start.
|
|
468
|
+
if (options.signal?.aborted) {
|
|
469
|
+
return (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
|
|
470
|
+
}
|
|
454
471
|
const pullRequest = await deps.loadPullRequest(pullRequestId);
|
|
455
472
|
const previousState = (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
|
|
473
|
+
// A per-review model choice wins over the configured one. An alias such
|
|
474
|
+
// as "opus" always means the latest model of that family Claude Code has.
|
|
475
|
+
const model = typeof options.model === 'string' && options.model.trim()
|
|
476
|
+
? options.model.trim()
|
|
477
|
+
: config.model;
|
|
478
|
+
// A different model earns a fresh look at code already reviewed at this
|
|
479
|
+
// commit; when new commits exist, they alone are reviewed with it.
|
|
480
|
+
const freshLook = (previousState.model ?? '') !== model &&
|
|
481
|
+
previousState.reviewedCommit === pullRequest.sourceCommit;
|
|
456
482
|
if (!options.force &&
|
|
483
|
+
!freshLook &&
|
|
457
484
|
previousState.reviewedCommit &&
|
|
458
485
|
previousState.reviewedCommit === pullRequest.sourceCommit) {
|
|
459
486
|
return previousState;
|
|
@@ -495,18 +522,14 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
495
522
|
instructions: config.instructions,
|
|
496
523
|
});
|
|
497
524
|
const command = await resolveClaudeCommand(config.claudePath);
|
|
498
|
-
const args = [
|
|
499
|
-
|
|
500
|
-
'--output-format',
|
|
501
|
-
'json',
|
|
502
|
-
...(config.claudeArgs.length ? config.claudeArgs : []),
|
|
503
|
-
];
|
|
504
|
-
log(`AI review: reviewing pull request ${pullRequestId} with ${command}`);
|
|
525
|
+
const args = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])];
|
|
526
|
+
log(`AI review: reviewing pull request ${pullRequestId} with ${command}${model ? ` (${model})` : ''}`);
|
|
505
527
|
const raw = await runModel(prompt, {
|
|
506
528
|
command,
|
|
507
529
|
args,
|
|
508
530
|
cwd: repositoryPath,
|
|
509
531
|
timeoutMs: config.timeoutSeconds * 1000,
|
|
532
|
+
signal: options.signal,
|
|
510
533
|
});
|
|
511
534
|
const review = parseModelReview(raw);
|
|
512
535
|
summary = review.summary;
|
|
@@ -521,6 +544,7 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
521
544
|
...current,
|
|
522
545
|
title: pullRequest.title,
|
|
523
546
|
reviewedCommit: pullRequest.sourceCommit,
|
|
547
|
+
model,
|
|
524
548
|
reviewedAt: now().toISOString(),
|
|
525
549
|
passes: current.passes + 1,
|
|
526
550
|
status: 'idle',
|
|
@@ -539,16 +563,23 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
539
563
|
return next;
|
|
540
564
|
}
|
|
541
565
|
catch (error) {
|
|
566
|
+
const cancelled = options.signal?.aborted === true;
|
|
542
567
|
const message = error?.message ? String(error.message) : String(error);
|
|
543
|
-
log(
|
|
568
|
+
log(cancelled
|
|
569
|
+
? `AI review: pull request ${pullRequestId} was cancelled.`
|
|
570
|
+
: `AI review: pull request ${pullRequestId} failed: ${message}`);
|
|
544
571
|
return mutateStore((store) => {
|
|
545
572
|
const current = store.pullRequests[String(pullRequestId)] ?? previousState;
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
error:
|
|
550
|
-
|
|
551
|
-
|
|
573
|
+
// A cancelled review is not a failure: the pull request is left as it
|
|
574
|
+
// was, so switching the reviewer back on reviews the same commit.
|
|
575
|
+
const state = cancelled
|
|
576
|
+
? { ...current, status: 'idle', error: '' }
|
|
577
|
+
: {
|
|
578
|
+
...current,
|
|
579
|
+
status: 'error',
|
|
580
|
+
error: message,
|
|
581
|
+
errorCommit: pullRequest.sourceCommit,
|
|
582
|
+
};
|
|
552
583
|
store.pullRequests[String(pullRequestId)] = state;
|
|
553
584
|
return state;
|
|
554
585
|
});
|
|
@@ -558,12 +589,14 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
558
589
|
}
|
|
559
590
|
};
|
|
560
591
|
/** One pass over the pull requests in scope; reviews those with new code. */
|
|
561
|
-
const runPoll = async () => {
|
|
592
|
+
const runPoll = async (signal) => {
|
|
562
593
|
const reviewed = [];
|
|
563
594
|
try {
|
|
564
595
|
const pullRequests = (await deps.listPullRequests()).filter(wanted);
|
|
565
596
|
const store = await deps.readStore();
|
|
566
597
|
for (const pullRequest of pullRequests) {
|
|
598
|
+
if (signal?.aborted)
|
|
599
|
+
break;
|
|
567
600
|
if (!pullRequest.sourceCommit)
|
|
568
601
|
continue;
|
|
569
602
|
const state = store.pullRequests?.[String(pullRequest.id)];
|
|
@@ -573,8 +606,8 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
573
606
|
continue;
|
|
574
607
|
if (state?.status === 'error' && state.errorCommit === pullRequest.sourceCommit)
|
|
575
608
|
continue;
|
|
576
|
-
const next = await serialize(() => runReview(pullRequest.id));
|
|
577
|
-
if (next.status !== 'error')
|
|
609
|
+
const next = await serialize(() => runReview(pullRequest.id, { signal }));
|
|
610
|
+
if (!signal?.aborted && next.status !== 'error')
|
|
578
611
|
reviewed.push(pullRequest.id);
|
|
579
612
|
}
|
|
580
613
|
}
|
|
@@ -592,6 +625,11 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
592
625
|
return pullRequest.isMine;
|
|
593
626
|
return pullRequest.isReviewer;
|
|
594
627
|
};
|
|
628
|
+
const clearTimer = () => {
|
|
629
|
+
if (timer)
|
|
630
|
+
clearInterval(timer);
|
|
631
|
+
timer = undefined;
|
|
632
|
+
};
|
|
595
633
|
const reviewer = {
|
|
596
634
|
updateConfig(next) {
|
|
597
635
|
const wasEnabled = config.enabled;
|
|
@@ -683,22 +721,24 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
|
|
|
683
721
|
return Promise.resolve([]);
|
|
684
722
|
// A caller asking for a poll while one runs waits for that pass rather
|
|
685
723
|
// than being told, misleadingly, that there was nothing to review.
|
|
686
|
-
activePoll ?? (activePoll = runPoll().finally(() => {
|
|
724
|
+
activePoll ?? (activePoll = runPoll((pollAbort = new AbortController()).signal).finally(() => {
|
|
687
725
|
activePoll = null;
|
|
726
|
+
pollAbort = undefined;
|
|
688
727
|
}));
|
|
689
728
|
return activePoll;
|
|
690
729
|
},
|
|
691
730
|
start() {
|
|
692
|
-
|
|
731
|
+
clearTimer();
|
|
693
732
|
if (!config.enabled)
|
|
694
733
|
return;
|
|
695
734
|
timer = setInterval(() => void reviewer.poll(), config.pollMinutes * 60000);
|
|
696
735
|
timer.unref?.();
|
|
697
736
|
},
|
|
698
737
|
stop() {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
738
|
+
clearTimer();
|
|
739
|
+
// Switching the reviewer off also cancels the pass in flight: the CLI
|
|
740
|
+
// run is aborted and the pass's remaining pull requests are skipped.
|
|
741
|
+
pollAbort?.abort();
|
|
702
742
|
},
|
|
703
743
|
onFindings(listener) {
|
|
704
744
|
listeners.add(listener);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { resolveClaudeCommand, runClaudeCli, } from './ai-review.js';
|
|
2
|
+
/**
|
|
3
|
+
* One-shot commit message drafting.
|
|
4
|
+
*
|
|
5
|
+
* The pending changes are handed to the Claude Code CLI running on this
|
|
6
|
+
* machine (Claude Haiku by default: a subject line is cheap), and the reply
|
|
7
|
+
* fills the working panel's message box. Nothing is committed — the user
|
|
8
|
+
* still reviews and edits the text before pressing the commit button.
|
|
9
|
+
*/
|
|
10
|
+
/** Alias handed to `--model`; '' would leave the choice to Claude Code. */
|
|
11
|
+
export const DEFAULT_COMMIT_MESSAGE_MODEL = 'haiku';
|
|
12
|
+
/** A message draft is a small question; it must not outstay the review budget. */
|
|
13
|
+
const COMMIT_MESSAGE_TIMEOUT_SECONDS = 120;
|
|
14
|
+
/** Haiku does not need the whole diff budget the reviewer configures. */
|
|
15
|
+
const COMMIT_MESSAGE_MAX_DIFF_CHARS = 100000;
|
|
16
|
+
const oneLine = (value) => String(value ?? '')
|
|
17
|
+
.replace(/\s+/g, ' ')
|
|
18
|
+
.trim();
|
|
19
|
+
/** Renders one file's diff compactly; only +/- markers, no line numbers. */
|
|
20
|
+
function renderFile(file, budget) {
|
|
21
|
+
const renamed = file.oldPath && file.oldPath !== file.path ? ` (renamed from ${file.oldPath})` : '';
|
|
22
|
+
const header = `--- ${file.path}${renamed} [${file.status}] ---`;
|
|
23
|
+
if (file.status === 'binary' || !file.lines.length)
|
|
24
|
+
return `${header}\n(binary or empty diff)`;
|
|
25
|
+
const rows = [header];
|
|
26
|
+
let used = header.length;
|
|
27
|
+
let truncated = false;
|
|
28
|
+
for (const line of file.lines) {
|
|
29
|
+
const row = line.type === 'add'
|
|
30
|
+
? `+${line.text}`
|
|
31
|
+
: line.type === 'del'
|
|
32
|
+
? `-${line.text}`
|
|
33
|
+
: ` ${line.text}`;
|
|
34
|
+
if (used + row.length + 1 > budget) {
|
|
35
|
+
truncated = true;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
rows.push(row);
|
|
39
|
+
used += row.length + 1;
|
|
40
|
+
}
|
|
41
|
+
if (truncated)
|
|
42
|
+
rows.push('(diff truncated: file too large)');
|
|
43
|
+
return rows.join('\n');
|
|
44
|
+
}
|
|
45
|
+
const COMMIT_MESSAGE_CONTRACT = `Reply with one JSON object and nothing else:
|
|
46
|
+
{"subject": "<one line, imperative mood, at most 72 characters, no trailing period>",
|
|
47
|
+
"description": "<body explaining what and why, wrapped at 72 characters; \\"\\" when the subject alone is clear>"}
|
|
48
|
+
|
|
49
|
+
Rules:
|
|
50
|
+
- Summarize the change as a whole; do not walk through the diff file by file.
|
|
51
|
+
- Match the language and style of the recent commit subjects when they are given.
|
|
52
|
+
- Do not invent ticket numbers, prefixes or attribution the diff does not show.
|
|
53
|
+
- Answer only from the diff below. Do not use tools and do not ask questions.`;
|
|
54
|
+
/** Builds the drafting prompt for the pending changes. */
|
|
55
|
+
export function buildCommitMessagePrompt(input) {
|
|
56
|
+
const perFile = Math.max(2000, Math.floor(input.maxDiffChars / Math.max(input.files.length, 1)));
|
|
57
|
+
const body = input.files.map((file) => renderFile(file, perFile)).join('\n');
|
|
58
|
+
const sections = [
|
|
59
|
+
'You are writing the commit message for changes in a Git repository.',
|
|
60
|
+
'',
|
|
61
|
+
input.branch ? `Branch: ${input.branch}` : '',
|
|
62
|
+
input.scope === 'staged'
|
|
63
|
+
? 'The diff below is exactly what is staged for the next commit.'
|
|
64
|
+
: 'Nothing is staged yet; the diff below is every uncommitted change, staged or not.',
|
|
65
|
+
input.recentSubjects.length
|
|
66
|
+
? 'RECENT COMMIT SUBJECTS (match their language and style):\n' +
|
|
67
|
+
input.recentSubjects.map((subject) => ` - ${subject}`).join('\n')
|
|
68
|
+
: '',
|
|
69
|
+
'',
|
|
70
|
+
COMMIT_MESSAGE_CONTRACT,
|
|
71
|
+
'',
|
|
72
|
+
'DIFF',
|
|
73
|
+
'====',
|
|
74
|
+
body || '(no changes)',
|
|
75
|
+
];
|
|
76
|
+
return sections.filter((section) => section !== '').join('\n');
|
|
77
|
+
}
|
|
78
|
+
/** Unwraps `--output-format json` and tolerates a fenced or plain-text reply. */
|
|
79
|
+
export function parseCommitMessage(raw) {
|
|
80
|
+
let text = raw.trim();
|
|
81
|
+
if (!text)
|
|
82
|
+
throw new Error('Claude Code returned an empty response.');
|
|
83
|
+
try {
|
|
84
|
+
const envelope = JSON.parse(text);
|
|
85
|
+
if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
|
|
86
|
+
if (envelope.is_error === true) {
|
|
87
|
+
throw new Error(String(envelope.result ?? 'Claude Code reported an error.'));
|
|
88
|
+
}
|
|
89
|
+
if (typeof envelope.result === 'string')
|
|
90
|
+
text = envelope.result.trim();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
// Not the CLI envelope; fall through and read the text as the message.
|
|
95
|
+
if (error?.message?.startsWith('Claude Code'))
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
99
|
+
if (fenced)
|
|
100
|
+
text = fenced[1].trim();
|
|
101
|
+
const start = text.indexOf('{');
|
|
102
|
+
const end = text.lastIndexOf('}');
|
|
103
|
+
if (start !== -1 && end > start) {
|
|
104
|
+
try {
|
|
105
|
+
const message = JSON.parse(text.slice(start, end + 1));
|
|
106
|
+
const subject = oneLine(message?.subject);
|
|
107
|
+
if (subject)
|
|
108
|
+
return { subject, description: String(message?.description ?? '').trim() };
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Malformed JSON; fall through and read the text as the message.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
// Plain text: the first line is the subject, the rest is the description.
|
|
115
|
+
const [first, ...rest] = text.split('\n');
|
|
116
|
+
const subject = oneLine(first);
|
|
117
|
+
if (!subject) {
|
|
118
|
+
throw new Error(`Claude Code did not return a commit message: ${text.slice(0, 300)}`);
|
|
119
|
+
}
|
|
120
|
+
return { subject, description: rest.join('\n').trim() };
|
|
121
|
+
}
|
|
122
|
+
/** Drafts a commit message by running the Claude Code CLI over the changes. */
|
|
123
|
+
export async function generateCommitMessage(options) {
|
|
124
|
+
const model = options.model?.trim() ||
|
|
125
|
+
options.config.commitMessageModel.trim() ||
|
|
126
|
+
DEFAULT_COMMIT_MESSAGE_MODEL;
|
|
127
|
+
const prompt = buildCommitMessagePrompt({
|
|
128
|
+
branch: options.branch,
|
|
129
|
+
scope: options.scope,
|
|
130
|
+
files: options.files,
|
|
131
|
+
recentSubjects: options.recentSubjects,
|
|
132
|
+
maxDiffChars: Math.min(options.config.maxDiffChars, COMMIT_MESSAGE_MAX_DIFF_CHARS),
|
|
133
|
+
});
|
|
134
|
+
const command = await resolveClaudeCommand(options.config.claudePath);
|
|
135
|
+
const args = ['-p', '--output-format', 'json', ...(model ? ['--model', model] : [])];
|
|
136
|
+
options.log?.(`AI commit message: drafting with ${command} (${model})`);
|
|
137
|
+
const runModel = options.runModel ?? runClaudeCli;
|
|
138
|
+
const raw = await runModel(prompt, {
|
|
139
|
+
command,
|
|
140
|
+
args,
|
|
141
|
+
cwd: options.cwd,
|
|
142
|
+
timeoutMs: Math.min(options.config.timeoutSeconds, COMMIT_MESSAGE_TIMEOUT_SECONDS) * 1000,
|
|
143
|
+
});
|
|
144
|
+
const { subject, description } = parseCommitMessage(raw);
|
|
145
|
+
return { subject, description, scope: options.scope, model };
|
|
146
|
+
}
|