guito 1.0.6 → 1.0.8

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 CHANGED
@@ -4,6 +4,39 @@ All notable changes to Guito are documented in this file. The project follows [S
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.0.8] - 2026-10-02
8
+
9
+ ### Changed
10
+
11
+ - Pull requests are now reviewed with the latest Opus model by default (the **Claude model** setting, `guito.aiReview.model` inside the extension). Pick **Claude Code's default** to go back to whatever Claude Code itself is set to.
12
+
13
+ ### Added
14
+
15
+ - VS Code extension: a Generate Commit Message button in the Source Control view's title bar drafts the message with Claude Code, using the same server call as the working panel's Generate message button, and fills the Git commit message box.
16
+ - Choose how hard Claude thinks. New **Review effort** and **Commit message effort** settings (`guito.aiReview.effort` and `guito.aiReview.commitMessageEffort` inside the extension) pass Claude Code's `--effort` level, from low to max, to pull request reviews and commit message drafts. Reviews default to high and commit messages to low; pick **Claude Code's default** to use whatever Claude Code itself is set to. An effort picker next to the model picker in a pull request's **Review** tab, preselected from the setting, overrides it for a single run, and the review's summary line shows which effort produced it.
17
+ - Choose how long drafted commit messages are. The new **Commit message style** setting (`guito.aiReview.commitMessageStyle` inside the extension) picks Oneliner, Brief or Descriptive. Oneliner is the default and drafts only the subject line; Brief adds a short description and Descriptive explains what changed and why in full.
18
+
19
+ ### Fixed
20
+
21
+ - The **Claude model** and **Commit message model** pickers in Settings, and the model picker in a pull request's Review tab, now show the model actually selected instead of the first option.
22
+
23
+ ## [1.0.7] - 2026-10-01
24
+
25
+ ### Added
26
+
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+ - 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.
31
+
32
+ ### Fixed
33
+
34
+ - 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.
35
+
36
+ ### Removed
37
+
38
+ - 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.
39
+
7
40
  ## [1.0.6] - 2026-09-24
8
41
 
9
42
  ### 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, and **Commit message style** picks how much it writes: Oneliner (the default) drafts only the subject line, Brief adds a short description, and Descriptive explains the change in full. In the VS Code extension, the same button is also in the Source Control view's title bar, and it fills the Git commit message box.
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,14 @@ 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 5. |
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.claudeArgs` | Extra Claude Code arguments, for example `["--model", "opus"]`. |
145
+ | `guito.aiReview.model` | Which Claude model reviews pull requests. Defaults to opus. |
146
+ | `guito.aiReview.commitMessageModel` | Which Claude model drafts commit messages. Defaults to haiku. |
147
+ | `guito.aiReview.effort` | Claude Code effort level for reviews (`low` to `max`). Defaults to high; empty uses Claude Code's default. |
148
+ | `guito.aiReview.commitMessageEffort` | Claude Code effort level for commit messages. Defaults to low; empty uses Claude Code's default. |
149
+ | `guito.aiReview.commitMessageStyle` | `oneliner` (default), `brief`, or `descriptive`. |
110
150
  | `guito.aiReview.timeoutSeconds` | How long one review may take. Defaults to 600. |
111
151
  | `guito.aiReview.instructions` | Extra reviewing instructions, such as your team's conventions. |
112
152
 
package/bin/ai-review.js CHANGED
@@ -3,17 +3,38 @@ import { createHash } from 'node:crypto';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  const SEVERITIES = ['blocker', 'concern', 'suggestion', 'nit'];
6
+ /** Effort levels Claude Code accepts for --effort; '' leaves it to Claude Code. */
7
+ export const AI_REVIEW_EFFORTS = ['', 'low', 'medium', 'high', 'xhigh', 'max'];
8
+ /** How much a drafted commit message says beyond its subject line. */
9
+ export const COMMIT_MESSAGE_STYLES = ['oneliner', 'brief', 'descriptive'];
10
+ /** The --effort arguments for one run, or none when Claude Code decides. */
11
+ export function effortArgs(effort) {
12
+ return effort ? ['--effort', effort] : [];
13
+ }
6
14
  export const DEFAULT_AI_REVIEW_CONFIG = {
7
15
  enabled: false,
8
16
  scope: 'reviewer',
9
- pollMinutes: 5,
17
+ pollMinutes: 30,
10
18
  includeDrafts: false,
11
19
  claudePath: '',
12
- claudeArgs: [],
20
+ model: 'opus',
21
+ commitMessageModel: 'haiku',
22
+ effort: 'high',
23
+ commitMessageEffort: 'low',
24
+ commitMessageStyle: 'oneliner',
13
25
  timeoutSeconds: 600,
14
26
  maxDiffChars: 300000,
15
27
  instructions: '',
16
28
  };
29
+ /** A known effort level, or undefined so an unknown value keeps the default. */
30
+ export function normalizeEffort(value) {
31
+ if (typeof value !== 'string')
32
+ return undefined;
33
+ const effort = value.trim().toLowerCase();
34
+ return AI_REVIEW_EFFORTS.includes(effort)
35
+ ? effort
36
+ : undefined;
37
+ }
17
38
  /** Normalizes a partial config (settings file or VS Code) onto the defaults. */
18
39
  export function resolveAiReviewConfig(...sources) {
19
40
  const config = { ...DEFAULT_AI_REVIEW_CONFIG };
@@ -33,8 +54,21 @@ export function resolveAiReviewConfig(...sources) {
33
54
  if (typeof source.claudePath === 'string' && source.claudePath.trim()) {
34
55
  config.claudePath = source.claudePath.trim();
35
56
  }
36
- if (Array.isArray(source.claudeArgs)) {
37
- config.claudeArgs = source.claudeArgs.map((arg) => String(arg)).filter(Boolean);
57
+ if (typeof source.model === 'string') {
58
+ config.model = source.model.trim();
59
+ }
60
+ if (typeof source.commitMessageModel === 'string') {
61
+ config.commitMessageModel = source.commitMessageModel.trim();
62
+ }
63
+ const effort = normalizeEffort(source.effort);
64
+ if (effort !== undefined)
65
+ config.effort = effort;
66
+ const commitMessageEffort = normalizeEffort(source.commitMessageEffort);
67
+ if (commitMessageEffort !== undefined)
68
+ config.commitMessageEffort = commitMessageEffort;
69
+ const commitMessageStyle = String(source.commitMessageStyle ?? '').trim().toLowerCase();
70
+ if (COMMIT_MESSAGE_STYLES.includes(commitMessageStyle)) {
71
+ config.commitMessageStyle = commitMessageStyle;
38
72
  }
39
73
  if (Number.isFinite(source.timeoutSeconds)) {
40
74
  config.timeoutSeconds = Math.min(Math.max(Number(source.timeoutSeconds), 30), 3600);
@@ -83,12 +117,7 @@ async function bundledClaudeCandidates() {
83
117
  const home = homedir();
84
118
  const binary = process.platform === 'win32' ? 'claude.exe' : 'claude';
85
119
  const found = [];
86
- for (const root of [
87
- '.vscode',
88
- '.vscode-insiders',
89
- '.vscode-server',
90
- '.vscode-server-insiders',
91
- ]) {
120
+ for (const root of ['.vscode', '.vscode-insiders', '.vscode-server', '.vscode-server-insiders']) {
92
121
  const extensions = join(home, root, 'extensions');
93
122
  let entries;
94
123
  try {
@@ -132,8 +161,9 @@ export async function resolveClaudeCommand(configured) {
132
161
  /**
133
162
  * Runs the Claude Code CLI in print mode. The prompt goes over stdin because a
134
163
  * pull request diff is far larger than the Windows command line allows.
164
+ * Exported because the commit message drafter runs the CLI the same way.
135
165
  */
136
- const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise((resolve, reject) => {
166
+ export const runClaudeCli = (prompt, { command, args, cwd, timeoutMs, signal }) => new Promise((resolve, reject) => {
137
167
  const child = spawn(command, args, {
138
168
  cwd,
139
169
  windowsHide: true,
@@ -148,6 +178,7 @@ const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise(
148
178
  return;
149
179
  settled = true;
150
180
  clearTimeout(timer);
181
+ signal?.removeEventListener('abort', onAbort);
151
182
  if (error)
152
183
  reject(error);
153
184
  else
@@ -157,6 +188,16 @@ const runClaudeCli = (prompt, { command, args, cwd, timeoutMs }) => new Promise(
157
188
  child.kill();
158
189
  finish(new Error(`The review timed out after ${Math.round(timeoutMs / 1000)}s.`));
159
190
  }, timeoutMs);
191
+ const onAbort = () => {
192
+ child.kill();
193
+ finish(new Error('The review was cancelled.'));
194
+ };
195
+ if (signal) {
196
+ if (signal.aborted)
197
+ onAbort();
198
+ else
199
+ signal.addEventListener('abort', onAbort);
200
+ }
160
201
  child.stdout.on('data', (chunk) => {
161
202
  stdout += String(chunk);
162
203
  });
@@ -339,9 +380,7 @@ export function buildReviewPrompt(input) {
339
380
  'EXISTING COMMENT THREADS ON THE PULL REQUEST:',
340
381
  renderThreads(threads),
341
382
  '',
342
- input.skipped.length
343
- ? `NOT SHOWN (binary or unreviewable): ${input.skipped.join(', ')}\n`
344
- : '',
383
+ input.skipped.length ? `NOT SHOWN (binary or unreviewable): ${input.skipped.join(', ')}\n` : '',
345
384
  input.instructions.trim() ? `TEAM INSTRUCTIONS:\n${input.instructions.trim()}\n` : '',
346
385
  REVIEW_CONTRACT,
347
386
  '',
@@ -371,6 +410,7 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
371
410
  const running = new Set();
372
411
  let timer;
373
412
  let activePoll = null;
413
+ let pollAbort;
374
414
  // One review at a time: each one spawns a CLI and burns Azure DevOps calls.
375
415
  let queue = Promise.resolve();
376
416
  const serialize = (action) => {
@@ -422,7 +462,10 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
422
462
  const rawLine = Number(entry?.line);
423
463
  const line = inDiff && Number.isInteger(rawLine) && anchors.get(file).has(rawLine) ? rawLine : null;
424
464
  const rawEnd = Number(entry?.endLine);
425
- const endLine = line !== null && Number.isInteger(rawEnd) && rawEnd >= line && anchors.get(file).has(rawEnd)
465
+ const endLine = line !== null &&
466
+ Number.isInteger(rawEnd) &&
467
+ rawEnd >= line &&
468
+ anchors.get(file).has(rawEnd)
426
469
  ? rawEnd
427
470
  : null;
428
471
  const severity = SEVERITIES.includes(entry?.severity)
@@ -451,9 +494,28 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
451
494
  return accepted.sort((a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity));
452
495
  };
453
496
  const runReview = async (pullRequestId, options = {}) => {
497
+ // A pass the poller cancelled before this review's turn must not start.
498
+ if (options.signal?.aborted) {
499
+ return (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
500
+ }
454
501
  const pullRequest = await deps.loadPullRequest(pullRequestId);
455
502
  const previousState = (await stateOf(pullRequestId)) ?? emptyState(pullRequestId);
503
+ // A per-review model choice wins over the configured one. An alias such
504
+ // as "opus" always means the latest model of that family Claude Code has.
505
+ const model = typeof options.model === 'string' && options.model.trim()
506
+ ? options.model.trim()
507
+ : config.model;
508
+ // A per-review effort wins too, '' included: it asks for Claude Code's
509
+ // own default rather than the configured level.
510
+ const effort = normalizeEffort(options.effort) ?? config.effort;
511
+ // A different model or effort earns a fresh look at code already reviewed
512
+ // at this commit; when new commits exist, they alone are reviewed with it.
513
+ // Reviews stored before effort was recorded are not re-run just for that.
514
+ const freshLook = ((previousState.model ?? '') !== model ||
515
+ (previousState.effort !== undefined && previousState.effort !== effort)) &&
516
+ previousState.reviewedCommit === pullRequest.sourceCommit;
456
517
  if (!options.force &&
518
+ !freshLook &&
457
519
  previousState.reviewedCommit &&
458
520
  previousState.reviewedCommit === pullRequest.sourceCommit) {
459
521
  return previousState;
@@ -497,16 +559,20 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
497
559
  const command = await resolveClaudeCommand(config.claudePath);
498
560
  const args = [
499
561
  '-p',
562
+ // Background reviews should not show up in the user's Claude Code session history.
563
+ '--no-session-persistence',
500
564
  '--output-format',
501
565
  'json',
502
- ...(config.claudeArgs.length ? config.claudeArgs : []),
566
+ ...(model ? ['--model', model] : []),
567
+ ...effortArgs(effort),
503
568
  ];
504
- log(`AI review: reviewing pull request ${pullRequestId} with ${command}`);
569
+ log(`AI review: reviewing pull request ${pullRequestId} with ${command}${model ? ` (${model})` : ''}${effort ? `, ${effort} effort` : ''}`);
505
570
  const raw = await runModel(prompt, {
506
571
  command,
507
572
  args,
508
573
  cwd: repositoryPath,
509
574
  timeoutMs: config.timeoutSeconds * 1000,
575
+ signal: options.signal,
510
576
  });
511
577
  const review = parseModelReview(raw);
512
578
  summary = review.summary;
@@ -521,6 +587,8 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
521
587
  ...current,
522
588
  title: pullRequest.title,
523
589
  reviewedCommit: pullRequest.sourceCommit,
590
+ model,
591
+ effort,
524
592
  reviewedAt: now().toISOString(),
525
593
  passes: current.passes + 1,
526
594
  status: 'idle',
@@ -539,16 +607,23 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
539
607
  return next;
540
608
  }
541
609
  catch (error) {
610
+ const cancelled = options.signal?.aborted === true;
542
611
  const message = error?.message ? String(error.message) : String(error);
543
- log(`AI review: pull request ${pullRequestId} failed: ${message}`);
612
+ log(cancelled
613
+ ? `AI review: pull request ${pullRequestId} was cancelled.`
614
+ : `AI review: pull request ${pullRequestId} failed: ${message}`);
544
615
  return mutateStore((store) => {
545
616
  const current = store.pullRequests[String(pullRequestId)] ?? previousState;
546
- const state = {
547
- ...current,
548
- status: 'error',
549
- error: message,
550
- errorCommit: pullRequest.sourceCommit,
551
- };
617
+ // A cancelled review is not a failure: the pull request is left as it
618
+ // was, so switching the reviewer back on reviews the same commit.
619
+ const state = cancelled
620
+ ? { ...current, status: 'idle', error: '' }
621
+ : {
622
+ ...current,
623
+ status: 'error',
624
+ error: message,
625
+ errorCommit: pullRequest.sourceCommit,
626
+ };
552
627
  store.pullRequests[String(pullRequestId)] = state;
553
628
  return state;
554
629
  });
@@ -558,12 +633,14 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
558
633
  }
559
634
  };
560
635
  /** One pass over the pull requests in scope; reviews those with new code. */
561
- const runPoll = async () => {
636
+ const runPoll = async (signal) => {
562
637
  const reviewed = [];
563
638
  try {
564
639
  const pullRequests = (await deps.listPullRequests()).filter(wanted);
565
640
  const store = await deps.readStore();
566
641
  for (const pullRequest of pullRequests) {
642
+ if (signal?.aborted)
643
+ break;
567
644
  if (!pullRequest.sourceCommit)
568
645
  continue;
569
646
  const state = store.pullRequests?.[String(pullRequest.id)];
@@ -573,8 +650,8 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
573
650
  continue;
574
651
  if (state?.status === 'error' && state.errorCommit === pullRequest.sourceCommit)
575
652
  continue;
576
- const next = await serialize(() => runReview(pullRequest.id));
577
- if (next.status !== 'error')
653
+ const next = await serialize(() => runReview(pullRequest.id, { signal }));
654
+ if (!signal?.aborted && next.status !== 'error')
578
655
  reviewed.push(pullRequest.id);
579
656
  }
580
657
  }
@@ -592,6 +669,11 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
592
669
  return pullRequest.isMine;
593
670
  return pullRequest.isReviewer;
594
671
  };
672
+ const clearTimer = () => {
673
+ if (timer)
674
+ clearInterval(timer);
675
+ timer = undefined;
676
+ };
595
677
  const reviewer = {
596
678
  updateConfig(next) {
597
679
  const wasEnabled = config.enabled;
@@ -683,22 +765,24 @@ export function createAiReviewer(deps, initialConfig = DEFAULT_AI_REVIEW_CONFIG,
683
765
  return Promise.resolve([]);
684
766
  // A caller asking for a poll while one runs waits for that pass rather
685
767
  // than being told, misleadingly, that there was nothing to review.
686
- activePoll ?? (activePoll = runPoll().finally(() => {
768
+ activePoll ?? (activePoll = runPoll((pollAbort = new AbortController()).signal).finally(() => {
687
769
  activePoll = null;
770
+ pollAbort = undefined;
688
771
  }));
689
772
  return activePoll;
690
773
  },
691
774
  start() {
692
- reviewer.stop();
775
+ clearTimer();
693
776
  if (!config.enabled)
694
777
  return;
695
778
  timer = setInterval(() => void reviewer.poll(), config.pollMinutes * 60000);
696
779
  timer.unref?.();
697
780
  },
698
781
  stop() {
699
- if (timer)
700
- clearInterval(timer);
701
- timer = undefined;
782
+ clearTimer();
783
+ // Switching the reviewer off also cancels the pass in flight: the CLI
784
+ // run is aborted and the pass's remaining pull requests are skipped.
785
+ pollAbort?.abort();
702
786
  },
703
787
  onFindings(listener) {
704
788
  listeners.add(listener);
@@ -0,0 +1,169 @@
1
+ import { effortArgs, 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
+ /** What the "description" field holds for each commit message style. */
46
+ const DESCRIPTION_BY_STYLE = {
47
+ oneliner: '\\"\\" always; the subject line is the whole message',
48
+ brief: 'one to three short sentences or bullet points on what changed and why, wrapped at 72 characters; \\"\\" when the subject alone is clear',
49
+ descriptive: 'a full body explaining what changed, why, and anything a reviewer should know, wrapped at 72 characters, using bullet points for separate changes',
50
+ };
51
+ const commitMessageContract = (style) => `Reply with one JSON object and nothing else:
52
+ {"subject": "<one line, imperative mood, at most 72 characters, no trailing period>",
53
+ "description": "<${DESCRIPTION_BY_STYLE[style]}>"}
54
+
55
+ Rules:
56
+ - Summarize the change as a whole; do not walk through the diff file by file.
57
+ - Match the language and style of the recent commit subjects when they are given.
58
+ - Do not invent ticket numbers, prefixes or attribution the diff does not show.
59
+ - Answer only from the diff below. Do not use tools and do not ask questions.`;
60
+ /** Builds the drafting prompt for the pending changes. */
61
+ export function buildCommitMessagePrompt(input) {
62
+ const perFile = Math.max(2000, Math.floor(input.maxDiffChars / Math.max(input.files.length, 1)));
63
+ const body = input.files.map((file) => renderFile(file, perFile)).join('\n');
64
+ const sections = [
65
+ 'You are writing the commit message for changes in a Git repository.',
66
+ '',
67
+ input.branch ? `Branch: ${input.branch}` : '',
68
+ input.scope === 'staged'
69
+ ? 'The diff below is exactly what is staged for the next commit.'
70
+ : 'Nothing is staged yet; the diff below is every uncommitted change, staged or not.',
71
+ input.recentSubjects.length
72
+ ? 'RECENT COMMIT SUBJECTS (match their language and style):\n' +
73
+ input.recentSubjects.map((subject) => ` - ${subject}`).join('\n')
74
+ : '',
75
+ '',
76
+ commitMessageContract(input.style),
77
+ '',
78
+ 'DIFF',
79
+ '====',
80
+ body || '(no changes)',
81
+ ];
82
+ return sections.filter((section) => section !== '').join('\n');
83
+ }
84
+ /** Unwraps `--output-format json` and tolerates a fenced or plain-text reply. */
85
+ export function parseCommitMessage(raw) {
86
+ let text = raw.trim();
87
+ if (!text)
88
+ throw new Error('Claude Code returned an empty response.');
89
+ try {
90
+ const envelope = JSON.parse(text);
91
+ if (envelope && typeof envelope === 'object' && !Array.isArray(envelope)) {
92
+ if (envelope.is_error === true) {
93
+ throw new Error(String(envelope.result ?? 'Claude Code reported an error.'));
94
+ }
95
+ if (typeof envelope.result === 'string')
96
+ text = envelope.result.trim();
97
+ }
98
+ }
99
+ catch (error) {
100
+ // Not the CLI envelope; fall through and read the text as the message.
101
+ if (error?.message?.startsWith('Claude Code'))
102
+ throw error;
103
+ }
104
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
105
+ if (fenced)
106
+ text = fenced[1].trim();
107
+ const start = text.indexOf('{');
108
+ const end = text.lastIndexOf('}');
109
+ if (start !== -1 && end > start) {
110
+ try {
111
+ const message = JSON.parse(text.slice(start, end + 1));
112
+ const subject = oneLine(message?.subject);
113
+ if (subject)
114
+ return { subject, description: String(message?.description ?? '').trim() };
115
+ }
116
+ catch {
117
+ // Malformed JSON; fall through and read the text as the message.
118
+ }
119
+ }
120
+ // Plain text: the first line is the subject, the rest is the description.
121
+ const [first, ...rest] = text.split('\n');
122
+ const subject = oneLine(first);
123
+ if (!subject) {
124
+ throw new Error(`Claude Code did not return a commit message: ${text.slice(0, 300)}`);
125
+ }
126
+ return { subject, description: rest.join('\n').trim() };
127
+ }
128
+ /** Drafts a commit message by running the Claude Code CLI over the changes. */
129
+ export async function generateCommitMessage(options) {
130
+ const model = options.model?.trim() ||
131
+ options.config.commitMessageModel.trim() ||
132
+ DEFAULT_COMMIT_MESSAGE_MODEL;
133
+ const prompt = buildCommitMessagePrompt({
134
+ branch: options.branch,
135
+ scope: options.scope,
136
+ files: options.files,
137
+ recentSubjects: options.recentSubjects,
138
+ maxDiffChars: Math.min(options.config.maxDiffChars, COMMIT_MESSAGE_MAX_DIFF_CHARS),
139
+ style: options.config.commitMessageStyle,
140
+ });
141
+ const command = await resolveClaudeCommand(options.config.claudePath);
142
+ const effort = options.config.commitMessageEffort;
143
+ const args = [
144
+ '-p',
145
+ // Background drafts should not show up in the user's Claude Code session history.
146
+ '--no-session-persistence',
147
+ '--output-format',
148
+ 'json',
149
+ ...(model ? ['--model', model] : []),
150
+ ...effortArgs(effort),
151
+ ];
152
+ options.log?.(`AI commit message: drafting with ${command} (${model}${effort ? `, ${effort} effort` : ''})`);
153
+ const runModel = options.runModel ?? runClaudeCli;
154
+ const raw = await runModel(prompt, {
155
+ command,
156
+ args,
157
+ cwd: options.cwd,
158
+ timeoutMs: Math.min(options.config.timeoutSeconds, COMMIT_MESSAGE_TIMEOUT_SECONDS) * 1000,
159
+ signal: options.signal,
160
+ });
161
+ const { subject, description } = parseCommitMessage(raw);
162
+ // A one-liner stays one line even when the model adds a body anyway.
163
+ return {
164
+ subject,
165
+ description: options.config.commitMessageStyle === 'oneliner' ? '' : description,
166
+ scope: options.scope,
167
+ model,
168
+ };
169
+ }