guito 1.0.4 → 1.0.6

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.
@@ -14,6 +14,7 @@ import { promisify } from 'node:util';
14
14
  import { workingTree } from './working-tree.js';
15
15
  import { avatarCache } from './avatars.js';
16
16
  import { repositoryState } from './repository-state.js';
17
+ import { createAiReviewer, resolveAiReviewConfig, } from './ai-review.js';
17
18
  const execFileAsync = promisify(execFile);
18
19
  const AZURE_API_VERSION = '5.0-preview';
19
20
  export const DEFAULT_PR_BRANCH_NAME_TEMPLATE = 'pr/${randomstring}';
@@ -235,7 +236,7 @@ const azureErrorMessage = (result) => {
235
236
  return result.body;
236
237
  }
237
238
  };
238
- export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, allowMerge, fileListView, refListView, sidePanelSectionsExpanded, searchMode, searchCaseSensitive, azureRequestImpl, avatarFetchImpl, onLog, }) {
239
+ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, allowMerge, fileListView, refListView, sidePanelSectionsExpanded, searchMode, searchCaseSensitive, aiReview, azureRequestImpl, aiReviewModelImpl, avatarFetchImpl, onLog, }) {
239
240
  // Initialize server
240
241
  const app = fastify({
241
242
  logger: false,
@@ -401,18 +402,99 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
401
402
  mutationQueue = result.catch(() => { });
402
403
  return result;
403
404
  };
405
+ // ==================== Mutation option helpers ====================
406
+ // Shared guards for the option fields the Git operation dialogs send.
407
+ // Every value is allowlisted before it reaches a Git argument array.
408
+ /** Reads a trimmed string body field; non-strings become ''. */
409
+ const strField = (value) => (typeof value === 'string' ? value.trim() : '');
410
+ /** Reads a boolean body field; only an explicit true enables the option. */
411
+ const boolField = (value) => value === true;
412
+ /** Sends `{ success: true }`, adding warnings for partially-failed composites. */
413
+ const mutationResult = (resp, warnings) => resp.type('application/json').send({
414
+ success: true,
415
+ ...(warnings.length ? { warnings } : {}),
416
+ });
417
+ /**
418
+ * Resolves the remote a remote-capable action should use: an explicit name
419
+ * (which must be configured), else "origin", else the first remote.
420
+ */
421
+ const resolveRemote = async (requested) => {
422
+ const names = (await git.raw(['remote']))
423
+ .split(/\r?\n/)
424
+ .map((name) => name.trim())
425
+ .filter(Boolean);
426
+ if (names.length === 0)
427
+ throw new Error('No remotes are configured for this repository.');
428
+ if (requested) {
429
+ if (!names.includes(requested))
430
+ throw new Error(`Remote "${requested}" is not configured.`);
431
+ return requested;
432
+ }
433
+ return names.includes('origin') ? 'origin' : names[0];
434
+ };
435
+ /** Verifies a branch name Git would accept, before it is used as an argument. */
436
+ const verifyBranchName = async (name) => {
437
+ await git.raw(['check-ref-format', '--branch', name]);
438
+ };
439
+ /** Verifies a tag name Git would accept (rejects "", "..", "~", "^", ":"...). */
440
+ const verifyTagName = async (name) => {
441
+ await git.raw(['check-ref-format', `refs/tags/${name}`]);
442
+ };
443
+ /** Parent hashes of a commit, used for merge-commit mainline validation. */
444
+ const commitParents = async (commit) => {
445
+ const raw = await git.raw(['rev-list', '--parents', '-n', '1', commit]);
446
+ return raw.trim().split(/\s+/).slice(1).filter(Boolean);
447
+ };
448
+ /** Validates a mainline parent selection for a merge commit. */
449
+ const mainlineArg = (mainline, parents) => {
450
+ const parsed = Number(mainline);
451
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > parents.length) {
452
+ throw new Error(`Choose a mainline parent between 1 and ${parents.length}.`);
453
+ }
454
+ return String(parsed);
455
+ };
456
+ /** Rejects tracked changes that would block a history-rewriting operation. */
457
+ const requireCleanWorktree = async () => {
458
+ const status = await git.raw(['status', '--porcelain']);
459
+ const dirty = status
460
+ .split(/\r?\n/)
461
+ .filter(Boolean)
462
+ .filter((line) => !line.startsWith('??'));
463
+ if (dirty.length > 0) {
464
+ throw new Error('The working tree has uncommitted changes. Commit or stash them first.');
465
+ }
466
+ };
467
+ /** Requires HEAD to sit on a branch (not detached), returning its name. */
468
+ const currentBranch = async () => {
469
+ // simple-git may swallow the non-zero exit of a detached symbolic-ref, so
470
+ // parse the output instead of relying on the rejection.
471
+ let head = '';
472
+ try {
473
+ head = await git.raw(['symbolic-ref', '--quiet', 'HEAD']);
474
+ }
475
+ catch {
476
+ head = '';
477
+ }
478
+ const branch = head.trim().replace(/^refs\/heads\//, '');
479
+ if (!branch)
480
+ throw new Error('HEAD is detached. Check out a branch first.');
481
+ return branch;
482
+ };
404
483
  const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
405
484
  // ==================== Azure DevOps settings ====================
406
485
  // Settings live in the repository's git directory so they are per-repo and
407
486
  // never committed. The VS Code extension passes its own setting via
408
487
  // azureDevOpsUrl, which wins over the file.
409
488
  let settingsPath = '';
489
+ let reviewStorePath = '';
410
490
  try {
411
491
  const gitDir = (await git.revparse(['--absolute-git-dir'])).trim();
412
492
  settingsPath = join(gitDir, 'guito-settings.json');
493
+ reviewStorePath = join(gitDir, 'guito-ai-reviews.json');
413
494
  }
414
495
  catch {
415
496
  settingsPath = join(repositoryPath, '.git', 'guito-settings.json');
497
+ reviewStorePath = join(repositoryPath, '.git', 'guito-ai-reviews.json');
416
498
  }
417
499
  const readSettings = async () => {
418
500
  try {
@@ -643,6 +725,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
643
725
  ? searchCaseSensitive
644
726
  : (fileSearchCaseSensitive ?? false),
645
727
  allowMerge: typeof allowMerge === 'boolean' ? allowMerge : (fileAllowMerge ?? true),
728
+ aiReview: resolveAiReviewConfig(file.aiReview, aiReview),
646
729
  issueLinking,
647
730
  };
648
731
  };
@@ -794,9 +877,25 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
794
877
  });
795
878
  app.post('/api/revert', async (req, resp) => {
796
879
  try {
797
- const { commit } = req.body;
798
- await git.revert(commit);
799
- return resp.type('application/json').send({ success: true });
880
+ const hash = strField(req.body?.commit);
881
+ if (!hash)
882
+ throw new Error('A commit is required.');
883
+ const noCommit = boolField(req.body?.noCommit);
884
+ const signoff = boolField(req.body?.signoff);
885
+ await mutate(async () => {
886
+ const parents = await commitParents(hash);
887
+ const args = ['revert'];
888
+ // Merge commits must pick the parent the changes are reverted against.
889
+ if (parents.length > 1)
890
+ args.push('--mainline', mainlineArg(req.body?.mainline, parents));
891
+ if (noCommit)
892
+ args.push('--no-commit');
893
+ if (signoff)
894
+ args.push('--signoff');
895
+ args.push(hash);
896
+ await git.raw(args);
897
+ });
898
+ return mutationResult(resp, []);
800
899
  }
801
900
  catch (err) {
802
901
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -804,8 +903,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
804
903
  });
805
904
  app.post('/api/cherry-pick', async (req, resp) => {
806
905
  try {
807
- await git.raw(['cherry-pick', req.body.commit]);
808
- return resp.type('application/json').send({ success: true });
906
+ const hash = strField(req.body?.commit);
907
+ if (!hash)
908
+ throw new Error('A commit is required.');
909
+ const noCommit = boolField(req.body?.noCommit);
910
+ const recordSource = boolField(req.body?.recordSource);
911
+ const signoff = boolField(req.body?.signoff);
912
+ await mutate(async () => {
913
+ const parents = await commitParents(hash);
914
+ const args = ['cherry-pick'];
915
+ // Merge commits must pick the parent the changes are picked against.
916
+ if (parents.length > 1)
917
+ args.push('--mainline', mainlineArg(req.body?.mainline, parents));
918
+ if (noCommit)
919
+ args.push('--no-commit');
920
+ if (recordSource)
921
+ args.push('-x');
922
+ if (signoff)
923
+ args.push('--signoff');
924
+ args.push(hash);
925
+ await git.raw(args);
926
+ });
927
+ return mutationResult(resp, []);
809
928
  }
810
929
  catch (err) {
811
930
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -813,8 +932,32 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
813
932
  });
814
933
  app.post('/api/commit/drop', async (req, resp) => {
815
934
  try {
816
- await git.raw(['reset', '--hard', `${req.body.commit}^`]);
817
- return resp.type('application/json').send({ success: true });
935
+ const hash = strField(req.body?.commit);
936
+ if (!hash)
937
+ throw new Error('A commit is required.');
938
+ await mutate(async () => {
939
+ // Dropping rewrites history: only a clean, branch-bound working tree
940
+ // can safely replay the descendants of the selected commit.
941
+ await currentBranch();
942
+ await requireCleanWorktree();
943
+ const parents = await commitParents(hash);
944
+ if (parents.length === 0)
945
+ throw new Error('The root commit cannot be dropped.');
946
+ if (parents.length > 1)
947
+ throw new Error('Merge commits cannot be dropped.');
948
+ // The commit must be part of the current branch's history: when it is
949
+ // an ancestor of HEAD, it is also the merge base of the two.
950
+ const base = (await git.raw(['merge-base', hash, 'HEAD'])).trim();
951
+ if (base !== hash) {
952
+ throw new Error('The commit is not reachable from the current branch.');
953
+ }
954
+ // Replay every descendant onto the dropped commit's parent, removing
955
+ // only the selected commit (a hard reset would lose the descendants).
956
+ // Rebase by branch name: rebasing HEAD detaches it instead of moving
957
+ // the branch ref.
958
+ await git.raw(['rebase', '--onto', `${hash}~1`, hash, await currentBranch()]);
959
+ });
960
+ return mutationResult(resp, []);
818
961
  }
819
962
  catch (err) {
820
963
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -822,9 +965,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
822
965
  });
823
966
  app.post('/api/reset-commit', async (req, resp) => {
824
967
  try {
968
+ const hash = strField(req.body?.commit);
969
+ if (!hash)
970
+ throw new Error('A commit is required.');
825
971
  const mode = ['soft', 'mixed', 'hard'].includes(req.body?.mode) ? req.body.mode : 'hard';
826
- await git.raw(['reset', `--${mode}`, req.body.commit]);
827
- return resp.type('application/json').send({ success: true });
972
+ await mutate(() => git.raw(['reset', `--${mode}`, hash]));
973
+ return mutationResult(resp, []);
828
974
  }
829
975
  catch (err) {
830
976
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -905,9 +1051,36 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
905
1051
  });
906
1052
  app.post('/api/branch/create', async (req, resp) => {
907
1053
  try {
908
- const { name, startPoint } = req.body;
909
- await git.branch([...(startPoint ? [name, startPoint] : [name])]);
910
- return resp.type('application/json').send({ success: true });
1054
+ const name = strField(req.body?.name);
1055
+ if (!name)
1056
+ throw new Error('A branch name is required.');
1057
+ await verifyBranchName(name);
1058
+ const startPoint = strField(req.body?.startPoint);
1059
+ const checkout = boolField(req.body?.checkout);
1060
+ const warnings = [];
1061
+ await mutate(async () => {
1062
+ // Resolve the remote up front so a bad configuration fails before any
1063
+ // local branch is created.
1064
+ const publishRemote = boolField(req.body?.publish)
1065
+ ? await resolveRemote(strField(req.body?.remote))
1066
+ : '';
1067
+ if (checkout) {
1068
+ await git.raw(['checkout', '-b', name, ...(startPoint ? [startPoint] : [])]);
1069
+ }
1070
+ else {
1071
+ await git.branch([...(startPoint ? [name, startPoint] : [name])]);
1072
+ }
1073
+ // Publishing is a follow-up step: keep the created branch when it fails.
1074
+ if (publishRemote) {
1075
+ try {
1076
+ await git.push(['-u', publishRemote, name]);
1077
+ }
1078
+ catch (err) {
1079
+ warnings.push(`Branch "${name}" was created but could not be published to "${publishRemote}": ${err.message}`);
1080
+ }
1081
+ }
1082
+ });
1083
+ return mutationResult(resp, warnings);
911
1084
  }
912
1085
  catch (err) {
913
1086
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -915,9 +1088,29 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
915
1088
  });
916
1089
  app.post('/api/branch/delete', async (req, resp) => {
917
1090
  try {
918
- const { name, force } = req.body;
919
- await git.deleteLocalBranch(name, force);
920
- return resp.type('application/json').send({ success: true });
1091
+ const name = strField(req.body?.name);
1092
+ if (!name)
1093
+ throw new Error('A branch name is required.');
1094
+ const force = boolField(req.body?.force);
1095
+ const warnings = [];
1096
+ await mutate(async () => {
1097
+ // Resolve the remote up front so a bad configuration fails before the
1098
+ // local branch is deleted.
1099
+ const deleteRemote = boolField(req.body?.deleteRemote)
1100
+ ? await resolveRemote(strField(req.body?.remote))
1101
+ : '';
1102
+ await git.deleteLocalBranch(name, force);
1103
+ // Deleting on the remote is a follow-up: keep the local deletion when it fails.
1104
+ if (deleteRemote) {
1105
+ try {
1106
+ await git.push([deleteRemote, '--delete', `refs/heads/${name}`]);
1107
+ }
1108
+ catch (err) {
1109
+ warnings.push(`Branch "${name}" was deleted locally but could not be deleted on "${deleteRemote}": ${err.message}`);
1110
+ }
1111
+ }
1112
+ });
1113
+ return mutationResult(resp, warnings);
921
1114
  }
922
1115
  catch (err) {
923
1116
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -925,9 +1118,13 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
925
1118
  });
926
1119
  app.post('/api/branch/delete-remote', async (req, resp) => {
927
1120
  try {
928
- const { remote, branch } = req.body;
929
- await git.push([remote, '--delete', branch]);
930
- return resp.type('application/json').send({ success: true });
1121
+ const remote = strField(req.body?.remote);
1122
+ const branch = strField(req.body?.branch);
1123
+ if (!remote || !branch)
1124
+ throw new Error('A remote and branch are required.');
1125
+ const resolved = await resolveRemote(remote);
1126
+ await mutate(() => git.push([resolved, '--delete', `refs/heads/${branch}`]));
1127
+ return mutationResult(resp, []);
931
1128
  }
932
1129
  catch (err) {
933
1130
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -935,9 +1132,41 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
935
1132
  });
936
1133
  app.post('/api/branch/rename', async (req, resp) => {
937
1134
  try {
938
- const { oldName, newName } = req.body;
939
- await git.branch(['-m', oldName, newName]);
940
- return resp.type('application/json').send({ success: true });
1135
+ const oldName = strField(req.body?.oldName);
1136
+ const newName = strField(req.body?.newName);
1137
+ if (!oldName || !newName)
1138
+ throw new Error('The current and new branch names are required.');
1139
+ await verifyBranchName(newName);
1140
+ const publish = boolField(req.body?.publish);
1141
+ const deleteOld = boolField(req.body?.deleteRemoteOld);
1142
+ const warnings = [];
1143
+ await mutate(async () => {
1144
+ // Resolve the remote up front so a bad configuration fails before the
1145
+ // branch is renamed.
1146
+ const needsRemote = publish || deleteOld;
1147
+ const remote = needsRemote ? await resolveRemote(strField(req.body?.remote)) : '';
1148
+ await git.branch(['-m', oldName, newName]);
1149
+ // Publishing/deleting the old ref are follow-ups: keep the rename when they fail.
1150
+ if (remote) {
1151
+ if (publish) {
1152
+ try {
1153
+ await git.push(['-u', remote, newName]);
1154
+ }
1155
+ catch (err) {
1156
+ warnings.push(`Branch "${newName}" was renamed but could not be published to "${remote}": ${err.message}`);
1157
+ }
1158
+ }
1159
+ if (deleteOld) {
1160
+ try {
1161
+ await git.push([remote, '--delete', `refs/heads/${oldName}`]);
1162
+ }
1163
+ catch (err) {
1164
+ warnings.push(`The old branch "${oldName}" was renamed locally but could not be deleted on "${remote}": ${err.message}`);
1165
+ }
1166
+ }
1167
+ }
1168
+ });
1169
+ return mutationResult(resp, warnings);
941
1170
  }
942
1171
  catch (err) {
943
1172
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1039,9 +1268,44 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1039
1268
  });
1040
1269
  app.post('/api/checkout', async (req, resp) => {
1041
1270
  try {
1042
- const { ref } = req.body;
1043
- await git.checkout(ref);
1044
- return resp.type('application/json').send({ success: true });
1271
+ const ref = strField(req.body?.ref);
1272
+ if (!ref)
1273
+ throw new Error('A ref is required.');
1274
+ const newBranch = strField(req.body?.newBranch);
1275
+ const detach = boolField(req.body?.detach);
1276
+ const track = boolField(req.body?.track);
1277
+ const warnings = [];
1278
+ await mutate(async () => {
1279
+ // Resolve the remote up front so a bad configuration fails before the
1280
+ // checkout happens.
1281
+ const publishRemote = newBranch && boolField(req.body?.publish)
1282
+ ? await resolveRemote(strField(req.body?.remote))
1283
+ : '';
1284
+ if (newBranch) {
1285
+ await verifyBranchName(newBranch);
1286
+ // --track sets the upstream when checking out a remote-tracking ref.
1287
+ await git.raw([
1288
+ 'checkout',
1289
+ '-b',
1290
+ newBranch,
1291
+ ...(track ? ['--track'] : []),
1292
+ ref,
1293
+ ]);
1294
+ }
1295
+ else {
1296
+ await git.raw(['checkout', ...(detach ? ['--detach'] : []), ref]);
1297
+ }
1298
+ // Publishing the new branch is a follow-up: keep the checkout when it fails.
1299
+ if (publishRemote) {
1300
+ try {
1301
+ await git.push(['-u', publishRemote, newBranch]);
1302
+ }
1303
+ catch (err) {
1304
+ warnings.push(`Branch "${newBranch}" was checked out but could not be published to "${publishRemote}": ${err.message}`);
1305
+ }
1306
+ }
1307
+ });
1308
+ return mutationResult(resp, warnings);
1045
1309
  }
1046
1310
  catch (err) {
1047
1311
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1050,9 +1314,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1050
1314
  // ==================== Merge & Rebase ====================
1051
1315
  app.post('/api/merge', async (req, resp) => {
1052
1316
  try {
1053
- const { branch } = req.body;
1054
- await git.merge([branch]);
1055
- return resp.type('application/json').send({ success: true });
1317
+ const branch = strField(req.body?.branch);
1318
+ if (!branch)
1319
+ throw new Error('A branch or commit is required.');
1320
+ const mode = ['default', 'no-ff', 'ff-only', 'squash'].includes(req.body?.mode)
1321
+ ? req.body.mode
1322
+ : 'default';
1323
+ // --no-commit only applies to modes that would create a commit.
1324
+ const noCommit = boolField(req.body?.noCommit) && (mode === 'default' || mode === 'no-ff');
1325
+ const args = ['merge'];
1326
+ if (mode === 'no-ff')
1327
+ args.push('--no-ff');
1328
+ if (mode === 'ff-only')
1329
+ args.push('--ff-only');
1330
+ if (mode === 'squash')
1331
+ args.push('--squash');
1332
+ if (noCommit)
1333
+ args.push('--no-commit');
1334
+ if (boolField(req.body?.autostash))
1335
+ args.push('--autostash');
1336
+ args.push(branch);
1337
+ await mutate(() => git.raw(args));
1338
+ return mutationResult(resp, []);
1056
1339
  }
1057
1340
  catch (err) {
1058
1341
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1080,9 +1363,18 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1080
1363
  });
1081
1364
  app.post('/api/rebase', async (req, resp) => {
1082
1365
  try {
1083
- const { branch } = req.body;
1084
- await git.rebase([branch]);
1085
- return resp.type('application/json').send({ success: true });
1366
+ const branch = strField(req.body?.branch);
1367
+ if (!branch)
1368
+ throw new Error('A branch or commit is required.');
1369
+ await mutate(() => git.raw([
1370
+ 'rebase',
1371
+ ...(boolField(req.body?.autostash) ? ['--autostash'] : []),
1372
+ // --preserve-merges was removed in Git 2.35+; --rebase-merges is the
1373
+ // supported way to keep merge commits while rebasing.
1374
+ ...(boolField(req.body?.preserveMerges) ? ['--rebase-merges'] : []),
1375
+ branch,
1376
+ ]));
1377
+ return mutationResult(resp, []);
1086
1378
  }
1087
1379
  catch (err) {
1088
1380
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1131,23 +1423,24 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1131
1423
  });
1132
1424
  app.post('/api/stash/save', async (req, resp) => {
1133
1425
  try {
1134
- const { message, scope } = req.body ?? {};
1426
+ const message = strField(req.body?.message);
1427
+ const scope = ['staged', 'unstaged', 'all'].includes(req.body?.scope) ? req.body.scope : 'all';
1135
1428
  // 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
1136
1429
  // (--keep-index leaves staged changes staged); 'all' → index + working tree.
1137
- // Untracked files ride along for 'all'/'unstaged' since the panel lists them as unstaged.
1430
+ // Untracked files ride along unless includeUntracked opts out (the legacy
1431
+ // callers kept them for 'all'/'unstaged', which stay the defaults).
1432
+ const includeUntracked = typeof req.body?.includeUntracked === 'boolean' ? req.body.includeUntracked : scope !== 'staged';
1138
1433
  const args = ['push'];
1139
- if (scope === 'staged') {
1434
+ if (scope === 'staged')
1140
1435
  args.push('--staged');
1141
- }
1142
- else {
1143
- if (scope === 'unstaged')
1144
- args.push('--keep-index');
1436
+ if (scope === 'unstaged')
1437
+ args.push('--keep-index');
1438
+ if (includeUntracked)
1145
1439
  args.push('-u');
1146
- }
1147
1440
  if (message)
1148
1441
  args.push('-m', message);
1149
- await git.stash(args);
1150
- return resp.type('application/json').send({ success: true });
1442
+ await mutate(() => git.stash(args));
1443
+ return mutationResult(resp, []);
1151
1444
  }
1152
1445
  catch (err) {
1153
1446
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1155,9 +1448,13 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1155
1448
  });
1156
1449
  app.post('/api/stash/apply', async (req, resp) => {
1157
1450
  try {
1158
- const { index } = req.body;
1159
- await git.stash(['apply', `stash@{${index}}`]);
1160
- return resp.type('application/json').send({ success: true });
1451
+ const index = Number(req.body?.index);
1452
+ if (!Number.isInteger(index) || index < 0)
1453
+ throw new Error('A stash index is required.');
1454
+ // --index restores the staged/unstaged split recorded by the stash.
1455
+ const args = ['apply', ...(boolField(req.body?.restoreIndex) ? ['--index'] : []), `stash@{${index}}`];
1456
+ await mutate(() => git.stash(args));
1457
+ return mutationResult(resp, []);
1161
1458
  }
1162
1459
  catch (err) {
1163
1460
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1165,9 +1462,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1165
1462
  });
1166
1463
  app.post('/api/stash/pop', async (req, resp) => {
1167
1464
  try {
1168
- const { index } = req.body;
1169
- await git.stash(['pop', `stash@{${index}}`]);
1170
- return resp.type('application/json').send({ success: true });
1465
+ const index = Number(req.body?.index);
1466
+ if (!Number.isInteger(index) || index < 0)
1467
+ throw new Error('A stash index is required.');
1468
+ const args = ['pop', ...(boolField(req.body?.restoreIndex) ? ['--index'] : []), `stash@{${index}}`];
1469
+ await mutate(() => git.stash(args));
1470
+ return mutationResult(resp, []);
1171
1471
  }
1172
1472
  catch (err) {
1173
1473
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1176,8 +1476,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1176
1476
  app.post('/api/stash/drop', async (req, resp) => {
1177
1477
  try {
1178
1478
  const { index } = req.body;
1179
- await git.stash(['drop', `stash@{${index}}`]);
1180
- return resp.type('application/json').send({ success: true });
1479
+ await mutate(() => git.stash(['drop', `stash@{${index}}`]));
1480
+ return mutationResult(resp, []);
1181
1481
  }
1182
1482
  catch (err) {
1183
1483
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1217,14 +1517,41 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1217
1517
  });
1218
1518
  app.post('/api/tag/create', async (req, resp) => {
1219
1519
  try {
1220
- const { name, message, commit } = req.body;
1221
- if (message) {
1222
- await git.tag(['-a', name, '-m', message, ...(commit ? [commit] : [])]);
1223
- }
1224
- else {
1225
- await git.tag([name, ...(commit ? [commit] : [])]);
1226
- }
1227
- return resp.type('application/json').send({ success: true });
1520
+ const name = strField(req.body?.name);
1521
+ if (!name)
1522
+ throw new Error('A tag name is required.');
1523
+ await verifyTagName(name);
1524
+ const message = strField(req.body?.message);
1525
+ // A message always produces an annotated tag (legacy callers relied on it);
1526
+ // the dialog sends annotate explicitly and must then include a message.
1527
+ const annotate = boolField(req.body?.annotate) || !!message;
1528
+ if (annotate && !message)
1529
+ throw new Error('An annotated tag requires a message.');
1530
+ const commit = strField(req.body?.commit);
1531
+ const warnings = [];
1532
+ await mutate(async () => {
1533
+ // Resolve the remote up front so a bad configuration fails before the
1534
+ // tag is created.
1535
+ const pushRemote = boolField(req.body?.push)
1536
+ ? await resolveRemote(strField(req.body?.remote))
1537
+ : '';
1538
+ if (annotate) {
1539
+ await git.tag(['-a', name, '-m', message, ...(commit ? [commit] : [])]);
1540
+ }
1541
+ else {
1542
+ await git.tag([name, ...(commit ? [commit] : [])]);
1543
+ }
1544
+ // Pushing is a follow-up: keep the created tag when it fails.
1545
+ if (pushRemote) {
1546
+ try {
1547
+ await git.push([pushRemote, `refs/tags/${name}`]);
1548
+ }
1549
+ catch (err) {
1550
+ warnings.push(`Tag "${name}" was created but could not be pushed to "${pushRemote}": ${err.message}`);
1551
+ }
1552
+ }
1553
+ });
1554
+ return mutationResult(resp, warnings);
1228
1555
  }
1229
1556
  catch (err) {
1230
1557
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1232,9 +1559,28 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1232
1559
  });
1233
1560
  app.post('/api/tag/delete', async (req, resp) => {
1234
1561
  try {
1235
- const { name } = req.body;
1236
- await git.tag(['-d', name]);
1237
- return resp.type('application/json').send({ success: true });
1562
+ const name = strField(req.body?.name);
1563
+ if (!name)
1564
+ throw new Error('A tag name is required.');
1565
+ const warnings = [];
1566
+ await mutate(async () => {
1567
+ // Resolve the remote up front so a bad configuration fails before the
1568
+ // local tag is deleted.
1569
+ const deleteRemote = boolField(req.body?.deleteRemote)
1570
+ ? await resolveRemote(strField(req.body?.remote))
1571
+ : '';
1572
+ await git.tag(['-d', name]);
1573
+ // Deleting on the remote is a follow-up: keep the local deletion when it fails.
1574
+ if (deleteRemote) {
1575
+ try {
1576
+ await git.push([deleteRemote, '--delete', `refs/tags/${name}`]);
1577
+ }
1578
+ catch (err) {
1579
+ warnings.push(`Tag "${name}" was deleted locally but could not be deleted on "${deleteRemote}": ${err.message}`);
1580
+ }
1581
+ }
1582
+ });
1583
+ return mutationResult(resp, warnings);
1238
1584
  }
1239
1585
  catch (err) {
1240
1586
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1242,12 +1588,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1242
1588
  });
1243
1589
  app.post('/api/tag/push', async (req, resp) => {
1244
1590
  try {
1245
- const { name, remote } = req.body;
1246
- if (!name) {
1247
- return resp.status(400).type('application/json').send({ error: 'name required' });
1248
- }
1249
- await git.push(remote || 'origin', `refs/tags/${name}`);
1250
- return resp.type('application/json').send({ success: true });
1591
+ const name = strField(req.body?.name);
1592
+ if (!name)
1593
+ throw new Error('A tag name is required.');
1594
+ const remote = await resolveRemote(strField(req.body?.remote));
1595
+ await mutate(() => git.push([remote, ...(boolField(req.body?.force) ? ['-f'] : []), `refs/tags/${name}`]));
1596
+ return mutationResult(resp, []);
1251
1597
  }
1252
1598
  catch (err) {
1253
1599
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1266,9 +1612,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1266
1612
  });
1267
1613
  app.post('/api/pull', async (req, resp) => {
1268
1614
  try {
1269
- const { remote, branch, rebase } = req.body;
1270
- await git.pull(remote || 'origin', branch || undefined, rebase ? { '--rebase': null } : {});
1271
- return resp.type('application/json').send({ success: true });
1615
+ const remote = strField(req.body?.remote);
1616
+ const branch = strField(req.body?.branch);
1617
+ // 'mode' supersedes the legacy 'rebase' boolean (kept for older callers).
1618
+ const mode = ['merge', 'rebase', 'ff-only'].includes(req.body?.mode) ? req.body.mode : 'merge';
1619
+ const rebase = boolField(req.body?.rebase) || mode === 'rebase';
1620
+ const ffOnly = mode === 'ff-only';
1621
+ // An explicit remote must be configured; without one, keep the legacy
1622
+ // 'origin' default so upstream-based pulls keep working.
1623
+ const resolvedRemote = remote ? await resolveRemote(remote) : 'origin';
1624
+ await mutate(() => git.pull(resolvedRemote, branch || undefined, {
1625
+ ...(rebase ? { '--rebase': null } : {}),
1626
+ ...(ffOnly ? { '--ff-only': null } : {}),
1627
+ }));
1628
+ return mutationResult(resp, []);
1272
1629
  }
1273
1630
  catch (err) {
1274
1631
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1466,6 +1823,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1466
1823
  if (typeof body.allowMerge === 'boolean') {
1467
1824
  settings.allowMerge = body.allowMerge;
1468
1825
  }
1826
+ if (body.aiReview && typeof body.aiReview === 'object') {
1827
+ // Only the keys the user actually sent are stored, so a VS Code
1828
+ // override stays in charge of everything else.
1829
+ settings.aiReview = resolveAiReviewConfig(settings.aiReview, body.aiReview);
1830
+ }
1469
1831
  if ('issueLinking' in body) {
1470
1832
  if (body.issueLinking === null) {
1471
1833
  if (body.issueLinkingGlobal) {
@@ -1509,6 +1871,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1509
1871
  }
1510
1872
  await writeSettings(settings);
1511
1873
  const effective = await effectiveSettings();
1874
+ await refreshAiReviewConfig();
1512
1875
  return resp.type('application/json').send(effective);
1513
1876
  }
1514
1877
  catch (err) {
@@ -1838,9 +2201,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1838
2201
  });
1839
2202
  // Pull requests the current user created or reviews ("mine"), merged and
1840
2203
  // de-duplicated. Status defaults to active.
1841
- app.get('/api/azure-devops/pullrequests', async (req, resp) => {
1842
- try {
1843
- const status = String(req.query?.status ?? 'active').trim() || 'active';
2204
+ /** Pull requests the current user created or is a reviewer on. */
2205
+ const azurePrList = async (status) => {
2206
+ {
1844
2207
  const { prefix, remoteUrl } = await azureProjectContext();
1845
2208
  const repo = parseAzureRemoteUrl(remoteUrl).repo;
1846
2209
  const me = await azureMe();
@@ -1868,18 +2231,32 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1868
2231
  collect(`reviewerId=${encodeURIComponent(me.id)}`),
1869
2232
  ]);
1870
2233
  const merged = new Map([...created, ...assigned]);
1871
- const pullRequests = [...merged.values()]
1872
- .map((request) => mapAzurePullRequest(request, prefix, remoteUrl, me))
2234
+ return [...merged.values()]
2235
+ .map((request) => ({
2236
+ ...mapAzurePullRequest(request, prefix, remoteUrl, me),
2237
+ description: String(request?.description ?? ''),
2238
+ isMine: String(request?.createdBy?.id ?? '') === me.id,
2239
+ isReviewer: (Array.isArray(request?.reviewers) ? request.reviewers : []).some((entry) => String(entry?.id ?? '') === me.id),
2240
+ sourceCommit: String(request?.lastMergeSourceCommit?.commitId ?? ''),
2241
+ targetCommit: String(request?.lastMergeTargetCommit?.commitId ?? ''),
2242
+ }))
1873
2243
  .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
2244
+ }
2245
+ };
2246
+ app.get('/api/azure-devops/pullrequests', async (req, resp) => {
2247
+ try {
2248
+ const status = String(req.query?.status ?? 'active').trim() || 'active';
2249
+ const pullRequests = await azurePrList(status);
1874
2250
  return resp.type('application/json').send({ pullRequests });
1875
2251
  }
1876
2252
  catch (err) {
1877
2253
  return resp.status(400).type('application/json').send({ error: err.message });
1878
2254
  }
1879
2255
  });
1880
- app.get('/api/azure-devops/pullrequests/:id', async (req, resp) => {
1881
- try {
1882
- const { base, prefix, remoteUrl } = await azurePrApiBase(Number(req.params?.id));
2256
+ /** Full detail of one pull request, as the dialog and the reviewer see it. */
2257
+ const azurePrDetail = async (id) => {
2258
+ {
2259
+ const { base, prefix, remoteUrl } = await azurePrApiBase(id);
1883
2260
  const [pr, me] = await Promise.all([
1884
2261
  azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`),
1885
2262
  azureMe(),
@@ -1902,9 +2279,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1902
2279
  .map((label) => String(label?.name ?? ''))
1903
2280
  .filter(Boolean);
1904
2281
  }
1905
- return resp.type('application/json').send({
2282
+ return {
1906
2283
  ...summary,
1907
2284
  description: String(pr?.description ?? ''),
2285
+ isMine: String(pr?.createdBy?.id ?? '') === me.id,
2286
+ isReviewer: (Array.isArray(pr?.reviewers) ? pr.reviewers : []).some((entry) => String(entry?.id ?? '') === me.id),
1908
2287
  autoCompleteSetBy: pr?.autoCompleteSetBy
1909
2288
  ? {
1910
2289
  id: String(pr.autoCompleteSetBy.id ?? ''),
@@ -1916,7 +2295,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1916
2295
  lastMergeTargetCommit: String(pr?.lastMergeTargetCommit?.commitId ?? ''),
1917
2296
  labels,
1918
2297
  mergeStatus: azureMergeStatus(pr),
1919
- });
2298
+ };
2299
+ }
2300
+ };
2301
+ app.get('/api/azure-devops/pullrequests/:id', async (req, resp) => {
2302
+ try {
2303
+ return resp.type('application/json').send(await azurePrDetail(Number(req.params?.id)));
1920
2304
  }
1921
2305
  catch (err) {
1922
2306
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2068,11 +2452,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2068
2452
  return resp.status(400).type('application/json').send({ error: err.message });
2069
2453
  }
2070
2454
  });
2071
- app.get('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
2072
- try {
2073
- const { base } = await azurePrApiBase(Number(req.params?.id));
2455
+ /** Comment threads on a pull request, normalized for the UI and reviewer. */
2456
+ const azurePrThreads = async (id) => {
2457
+ {
2458
+ const { base } = await azurePrApiBase(id);
2074
2459
  const data = await azureJson('GET', `${base}/threads?api-version=${AZURE_API_VERSION}`);
2075
- const threads = (Array.isArray(data?.value) ? data.value : [])
2460
+ return (Array.isArray(data?.value) ? data.value : [])
2076
2461
  .filter((thread) => thread?.isDeleted !== true)
2077
2462
  .map((thread) => {
2078
2463
  const context = thread?.threadContext ?? {};
@@ -2098,6 +2483,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2098
2483
  })),
2099
2484
  };
2100
2485
  });
2486
+ }
2487
+ };
2488
+ app.get('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
2489
+ try {
2490
+ const threads = await azurePrThreads(Number(req.params?.id));
2101
2491
  return resp.type('application/json').send({ threads });
2102
2492
  }
2103
2493
  catch (err) {
@@ -2106,27 +2496,31 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2106
2496
  });
2107
2497
  // Adds a comment: a reply to a thread, a general PR comment, or an inline
2108
2498
  // comment anchored to a file line (thread context).
2109
- app.post('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
2110
- try {
2111
- const content = String(req.body?.content ?? '').trim();
2499
+ /**
2500
+ * Adds a comment: a reply to a thread, a general pull request comment, or an
2501
+ * inline comment anchored to a file line. Returns the thread it landed in.
2502
+ */
2503
+ const azurePrComment = async (id, body) => {
2504
+ {
2505
+ const content = String(body?.content ?? '').trim();
2112
2506
  if (!content) {
2113
- return resp.status(400).type('application/json').send({ error: 'Comment text required.' });
2507
+ throw new Error('Comment text required.');
2114
2508
  }
2115
- const { base } = await azurePrApiBase(Number(req.params?.id));
2116
- const threadId = Number(req.body?.threadId ?? 0);
2509
+ const { base } = await azurePrApiBase(id);
2510
+ const threadId = Number(body?.threadId ?? 0);
2117
2511
  if (Number.isInteger(threadId) && threadId > 0) {
2118
2512
  await azureJson('POST', `${base}/threads/${threadId}/comments?api-version=${AZURE_API_VERSION}`, JSON.stringify({ content }));
2119
- return resp.type('application/json').send({ ok: true });
2513
+ return { threadId };
2120
2514
  }
2121
2515
  const payload = {
2122
2516
  status: AZURE_THREAD_STATUSES.active,
2123
2517
  comments: [{ content, parentCommentId: 0 }],
2124
2518
  };
2125
- const filePath = String(req.body?.filePath ?? '').trim();
2126
- const line = Number(req.body?.line ?? 0);
2127
- const requestedEndLine = Number(req.body?.endLine ?? line);
2519
+ const filePath = String(body?.filePath ?? '').trim();
2520
+ const line = Number(body?.line ?? 0);
2521
+ const requestedEndLine = Number(body?.endLine ?? line);
2128
2522
  const endLine = Number.isInteger(requestedEndLine) && requestedEndLine >= line ? requestedEndLine : line;
2129
- const left = req.body?.side === 'left';
2523
+ const left = body?.side === 'left';
2130
2524
  if (filePath && Number.isInteger(line) && line > 0) {
2131
2525
  // Azure anchors a whole-line comment from offset 1 to "end of line".
2132
2526
  payload.threadContext = {
@@ -2138,8 +2532,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2138
2532
  },
2139
2533
  };
2140
2534
  }
2141
- await azureJson('POST', `${base}/threads?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
2142
- return resp.type('application/json').send({ ok: true });
2535
+ const created = await azureJson('POST', `${base}/threads?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
2536
+ return { threadId: Number(created?.id ?? 0) };
2537
+ }
2538
+ };
2539
+ app.post('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
2540
+ try {
2541
+ const created = await azurePrComment(Number(req.params?.id), req.body ?? {});
2542
+ return resp.type('application/json').send({ ok: true, ...created });
2143
2543
  }
2144
2544
  catch (err) {
2145
2545
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2165,17 +2565,31 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2165
2565
  });
2166
2566
  // Changed files of the latest PR iteration (names and change types only;
2167
2567
  // content is fetched per file on demand by /file-diff).
2168
- app.get('/api/azure-devops/pullrequests/:id/changes', async (req, resp) => {
2169
- try {
2170
- const { base } = await azurePrApiBase(Number(req.params?.id));
2568
+ /**
2569
+ * Changed files of the latest pull request iteration. With sinceCommit, only
2570
+ * the files touched after the iteration that ended at that commit, which is
2571
+ * how a re-review looks at nothing but the new work.
2572
+ */
2573
+ const azurePrChangedFiles = async (id, sinceCommit = '') => {
2574
+ {
2575
+ const { base } = await azurePrApiBase(id);
2171
2576
  const iterations = await azureJson('GET', `${base}/iterations?api-version=${AZURE_API_VERSION}`);
2172
2577
  const list = Array.isArray(iterations?.value) ? iterations.value : [];
2173
2578
  const latest = list.length
2174
2579
  ? list.reduce((newest, entry) => String(entry?.createdDate ?? '') > String(newest?.createdDate ?? '') ? entry : newest)
2175
2580
  : null;
2176
2581
  if (!latest) {
2177
- return resp.type('application/json').send({ files: [] });
2582
+ return [];
2178
2583
  }
2584
+ // Azure compares iterations, not commits, so map the commit onto the
2585
+ // iteration that produced it; an unknown commit falls back to the
2586
+ // full change list rather than silently reviewing nothing.
2587
+ const baseIteration = sinceCommit
2588
+ ? list.find((entry) => String(entry?.sourceRefCommit?.commitId ?? '') === sinceCommit)
2589
+ : null;
2590
+ const compareTo = baseIteration && Number(baseIteration.id) !== Number(latest.id)
2591
+ ? `&$compareTo=${Number(baseIteration.id)}`
2592
+ : '';
2179
2593
  const changeStatus = {
2180
2594
  add: 'added',
2181
2595
  edit: 'modified',
@@ -2190,7 +2604,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2190
2604
  const entries = [];
2191
2605
  let skip = 0;
2192
2606
  for (;;) {
2193
- const data = await azureJson('GET', `${base}/iterations/${latest.id}/changes?$top=1000&$skip=${skip}&api-version=${AZURE_API_VERSION}`);
2607
+ const data = await azureJson('GET', `${base}/iterations/${latest.id}/changes?$top=1000&$skip=${skip}${compareTo}` +
2608
+ `&api-version=${AZURE_API_VERSION}`);
2194
2609
  const page = Array.isArray(data?.changeEntries)
2195
2610
  ? data.changeEntries
2196
2611
  : Array.isArray(data?.value)
@@ -2208,7 +2623,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2208
2623
  }
2209
2624
  break;
2210
2625
  }
2211
- const files = entries
2626
+ return entries
2212
2627
  .filter((entry) => entry?.item?.isFolder !== true)
2213
2628
  .map((entry) => {
2214
2629
  const path = String(entry?.item?.path ?? '').replace(/^\//, '');
@@ -2219,6 +2634,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2219
2634
  return { path, oldPath, changeType };
2220
2635
  })
2221
2636
  .filter((file) => Boolean(file.path));
2637
+ }
2638
+ };
2639
+ app.get('/api/azure-devops/pullrequests/:id/changes', async (req, resp) => {
2640
+ try {
2641
+ const files = await azurePrChangedFiles(Number(req.params?.id), String(req.query?.since ?? ''));
2222
2642
  return resp.type('application/json').send({ files });
2223
2643
  }
2224
2644
  catch (err) {
@@ -2585,16 +3005,17 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2585
3005
  // Unified diff of one PR file, computed from the Azure item contents at the
2586
3006
  // PR merge commits. source/target are optional overrides; default comes from
2587
3007
  // the pull request detail.
2588
- app.get('/api/azure-devops/pullrequests/:id/file-diff', async (req, resp) => {
2589
- try {
2590
- const path = String(req.query?.path ?? '').replace(/^\//, '');
3008
+ /** One file's diff between the pull request's merge source and target. */
3009
+ const azurePrFileDiff = async (id, query) => {
3010
+ {
3011
+ const path = String(query?.path ?? '').replace(/^\//, '');
2591
3012
  if (!path) {
2592
- return resp.status(400).type('application/json').send({ error: 'path required' });
3013
+ throw new Error('path required');
2593
3014
  }
2594
- const oldPath = String(req.query?.oldPath ?? '').replace(/^\//, '');
2595
- const { base, prefix, repo } = await azurePrApiBase(Number(req.params?.id));
2596
- let source = String(req.query?.source ?? '');
2597
- let target = String(req.query?.target ?? '');
3015
+ const oldPath = String(query?.oldPath ?? '').replace(/^\//, '');
3016
+ const { base, prefix, repo } = await azurePrApiBase(id);
3017
+ let source = String(query?.source ?? '');
3018
+ let target = String(query?.target ?? '');
2598
3019
  if (!source || !target) {
2599
3020
  const pr = await azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`);
2600
3021
  source = source || String(pr?.lastMergeSourceCommit?.commitId ?? '');
@@ -2645,7 +3066,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2645
3066
  fetchContent(path, source),
2646
3067
  ]);
2647
3068
  if (oldFile.binary || newFile.binary) {
2648
- return resp.type('application/json').send({
3069
+ return {
2649
3070
  path,
2650
3071
  oldPath,
2651
3072
  status: 'binary',
@@ -2653,9 +3074,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2653
3074
  additions: 0,
2654
3075
  deletions: 0,
2655
3076
  lines: [],
2656
- });
3077
+ };
2657
3078
  }
2658
- const status = String(req.query?.changeType ?? '') ||
3079
+ const status = String(query?.changeType ?? '') ||
2659
3080
  (!oldFile.content ? 'added' : !newFile.content ? 'deleted' : 'modified');
2660
3081
  // jsdiff omits the "diff --git" header parseUnifiedDiff expects, and
2661
3082
  // prefixing it lets the same parser serve both local and PR diffs.
@@ -2671,7 +3092,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2671
3092
  text: 'Diff truncated (file too large).',
2672
3093
  });
2673
3094
  }
2674
- return resp.type('application/json').send({
3095
+ return {
2675
3096
  path,
2676
3097
  oldPath,
2677
3098
  status,
@@ -2681,7 +3102,169 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2681
3102
  originalContent: oldFile.content,
2682
3103
  modifiedContent: newFile.content,
2683
3104
  lines,
3105
+ };
3106
+ }
3107
+ };
3108
+ app.get('/api/azure-devops/pullrequests/:id/file-diff', async (req, resp) => {
3109
+ try {
3110
+ const diff = await azurePrFileDiff(Number(req.params?.id), req.query ?? {});
3111
+ return resp.type('application/json').send(diff);
3112
+ }
3113
+ catch (err) {
3114
+ return resp.status(400).type('application/json').send({ error: err.message });
3115
+ }
3116
+ });
3117
+ // ==================== Automated pull request review ====================
3118
+ // The poller watches the pull requests the user cares about and hands each
3119
+ // new merge source commit to the Claude Code CLI installed on this machine.
3120
+ // Findings are queued locally; posting them to Azure DevOps stays manual.
3121
+ const readReviewStore = async () => {
3122
+ try {
3123
+ const store = JSON.parse(await readFile(reviewStorePath, 'utf8'));
3124
+ return { version: 1, pullRequests: store?.pullRequests ?? {} };
3125
+ }
3126
+ catch {
3127
+ return { version: 1, pullRequests: {} };
3128
+ }
3129
+ };
3130
+ const aiReviewer = createAiReviewer({
3131
+ listPullRequests: async () => (await azurePrList('active')).map((pr) => ({
3132
+ id: pr.id,
3133
+ title: pr.title,
3134
+ description: pr.description,
3135
+ author: pr.author.name,
3136
+ sourceBranch: pr.sourceBranch,
3137
+ targetBranch: pr.targetBranch,
3138
+ isDraft: pr.isDraft,
3139
+ isReviewer: pr.isReviewer,
3140
+ isMine: pr.isMine,
3141
+ sourceCommit: pr.sourceCommit,
3142
+ targetCommit: pr.targetCommit,
3143
+ })),
3144
+ loadPullRequest: async (id) => {
3145
+ const pr = await azurePrDetail(id);
3146
+ return {
3147
+ id: pr.id,
3148
+ title: pr.title,
3149
+ description: pr.description,
3150
+ author: pr.author.name,
3151
+ sourceBranch: pr.sourceBranch,
3152
+ targetBranch: pr.targetBranch,
3153
+ isDraft: pr.isDraft,
3154
+ isReviewer: pr.isReviewer,
3155
+ isMine: pr.isMine,
3156
+ sourceCommit: pr.lastMergeSourceCommit,
3157
+ targetCommit: pr.lastMergeTargetCommit,
3158
+ };
3159
+ },
3160
+ loadChanges: (id, sinceCommit) => azurePrChangedFiles(id, sinceCommit ?? ''),
3161
+ loadFileDiff: async (id, file) => {
3162
+ const diff = await azurePrFileDiff(id, {
3163
+ path: file.path,
3164
+ oldPath: file.oldPath,
3165
+ changeType: file.changeType,
3166
+ });
3167
+ return {
3168
+ path: diff.path,
3169
+ oldPath: diff.oldPath,
3170
+ status: diff.status,
3171
+ binary: diff.binary,
3172
+ lines: diff.lines,
3173
+ };
3174
+ },
3175
+ loadThreads: async (id) => (await azurePrThreads(id)).map((thread) => ({
3176
+ id: thread.id,
3177
+ status: thread.status,
3178
+ filePath: thread.filePath,
3179
+ line: thread.line,
3180
+ comments: thread.comments,
3181
+ })),
3182
+ postComment: (id, comment) => azurePrComment(id, comment),
3183
+ readStore: readReviewStore,
3184
+ writeStore: (store) => writeFile(reviewStorePath, JSON.stringify(store, null, 2) + '\n', 'utf8'),
3185
+ runModel: aiReviewModelImpl,
3186
+ log: (line) => onLog?.(line),
3187
+ }, resolveAiReviewConfig(aiReview), repositoryPath);
3188
+ /** Re-reads the settings file so file-level review settings take effect. */
3189
+ const refreshAiReviewConfig = async () => {
3190
+ const file = await readSettings();
3191
+ const config = resolveAiReviewConfig(file.aiReview, aiReview);
3192
+ aiReviewer.updateConfig(config);
3193
+ return config;
3194
+ };
3195
+ await refreshAiReviewConfig();
3196
+ aiReviewer.start();
3197
+ app.get('/api/azure-devops/ai-review', async (_req, resp) => {
3198
+ try {
3199
+ const overview = await aiReviewer.overview();
3200
+ return resp.type('application/json').send({ ...overview, config: aiReviewer.config() });
3201
+ }
3202
+ catch (err) {
3203
+ return resp.status(400).type('application/json').send({ error: err.message });
3204
+ }
3205
+ });
3206
+ // Runs one poll pass immediately instead of waiting for the interval.
3207
+ app.post('/api/azure-devops/ai-review/poll', async (_req, resp) => {
3208
+ try {
3209
+ const reviewed = await aiReviewer.poll();
3210
+ return resp.type('application/json').send({ reviewed });
3211
+ }
3212
+ catch (err) {
3213
+ return resp.status(400).type('application/json').send({ error: err.message });
3214
+ }
3215
+ });
3216
+ app.get('/api/azure-devops/pullrequests/:id/ai-review', async (req, resp) => {
3217
+ try {
3218
+ const state = await aiReviewer.state(Number(req.params?.id));
3219
+ return resp.type('application/json').send({ state });
3220
+ }
3221
+ catch (err) {
3222
+ return resp.status(400).type('application/json').send({ error: err.message });
3223
+ }
3224
+ });
3225
+ // Reviews the pull request now; "force" re-reviews the whole diff even when
3226
+ // the merge source commit has not moved.
3227
+ app.post('/api/azure-devops/pullrequests/:id/ai-review', async (req, resp) => {
3228
+ try {
3229
+ const state = await aiReviewer.review(Number(req.params?.id), {
3230
+ force: req.body?.force === true,
2684
3231
  });
3232
+ if (state.status === 'error') {
3233
+ return resp.status(400).type('application/json').send({ error: state.error, state });
3234
+ }
3235
+ return resp.type('application/json').send({ state });
3236
+ }
3237
+ catch (err) {
3238
+ return resp.status(400).type('application/json').send({ error: err.message });
3239
+ }
3240
+ });
3241
+ // Posts the selected findings to Azure DevOps as comment threads.
3242
+ app.post('/api/azure-devops/pullrequests/:id/ai-review/post', async (req, resp) => {
3243
+ try {
3244
+ const findingIds = (Array.isArray(req.body?.findingIds) ? req.body.findingIds : []).map((id) => String(id));
3245
+ const state = await aiReviewer.post(Number(req.params?.id), findingIds);
3246
+ return resp.type('application/json').send({ state });
3247
+ }
3248
+ catch (err) {
3249
+ return resp.status(400).type('application/json').send({ error: err.message });
3250
+ }
3251
+ });
3252
+ // Dismisses findings; a dismissed point is never raised again for this PR.
3253
+ app.post('/api/azure-devops/pullrequests/:id/ai-review/dismiss', async (req, resp) => {
3254
+ try {
3255
+ const findingIds = (Array.isArray(req.body?.findingIds) ? req.body.findingIds : []).map((id) => String(id));
3256
+ const state = await aiReviewer.dismiss(Number(req.params?.id), findingIds);
3257
+ return resp.type('application/json').send({ state });
3258
+ }
3259
+ catch (err) {
3260
+ return resp.status(400).type('application/json').send({ error: err.message });
3261
+ }
3262
+ });
3263
+ // Drops everything remembered about a pull request, including dismissals.
3264
+ app.delete('/api/azure-devops/pullrequests/:id/ai-review', async (req, resp) => {
3265
+ try {
3266
+ await aiReviewer.forget(Number(req.params?.id));
3267
+ return resp.type('application/json').send({ ok: true });
2685
3268
  }
2686
3269
  catch (err) {
2687
3270
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2858,8 +3441,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2858
3441
  // ==================== Working Tree Actions ====================
2859
3442
  app.post('/api/reset', async (_req, resp) => {
2860
3443
  try {
2861
- await git.raw(['reset', '--hard', 'HEAD']);
2862
- return resp.type('application/json').send({ ok: true });
3444
+ await mutate(() => git.raw(['reset', '--hard', 'HEAD']));
3445
+ return mutationResult(resp, []);
2863
3446
  }
2864
3447
  catch (err) {
2865
3448
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2867,8 +3450,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2867
3450
  });
2868
3451
  app.post('/api/clean', async (_req, resp) => {
2869
3452
  try {
2870
- await git.raw(['clean', '-fd']);
2871
- return resp.type('application/json').send({ ok: true });
3453
+ await mutate(() => git.raw(['clean', '-fd']));
3454
+ return mutationResult(resp, []);
2872
3455
  }
2873
3456
  catch (err) {
2874
3457
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -2877,7 +3460,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2877
3460
  const address = await app.listen({ port, ...(host ? { host } : {}) });
2878
3461
  return {
2879
3462
  address,
2880
- close: () => app.close(),
3463
+ aiReview: aiReviewer,
3464
+ close: async () => {
3465
+ aiReviewer.stop();
3466
+ await app.close();
3467
+ },
2881
3468
  updateSettings: (settings) => {
2882
3469
  azureDevOpsUrl = settings.azureDevOpsUrl;
2883
3470
  prBranchNameTemplate = settings.prBranchNameTemplate;
@@ -2894,6 +3481,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
2894
3481
  issueRegex = settings.issueRegex;
2895
3482
  issueUrl = settings.issueUrl;
2896
3483
  allowMerge = settings.allowMerge;
3484
+ aiReview = settings.aiReview;
3485
+ void refreshAiReviewConfig();
2897
3486
  },
2898
3487
  };
2899
3488
  }