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.
- package/CHANGELOG.md +27 -0
- package/CONTRIBUTION.md +10 -10
- package/README.md +31 -1
- package/bin/ai-review.js +709 -0
- package/bin/guito-server.js +708 -119
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-TJBW4QCI.js +67 -0
- package/guito-1.0.6.vsix +0 -0
- package/package.json +1 -1
- package/bin/ui/browser/main-DE53IE7Q.js +0 -67
package/bin/guito-server.js
CHANGED
|
@@ -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
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
808
|
-
|
|
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
|
-
|
|
817
|
-
|
|
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}`,
|
|
827
|
-
return resp
|
|
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
|
|
909
|
-
|
|
910
|
-
|
|
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
|
|
919
|
-
|
|
920
|
-
|
|
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
|
|
929
|
-
|
|
930
|
-
|
|
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
|
|
939
|
-
|
|
940
|
-
|
|
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
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
|
1084
|
-
|
|
1085
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
|
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
|
|
1159
|
-
|
|
1160
|
-
|
|
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
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
|
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
|
|
1221
|
-
if (
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
1246
|
-
if (!name)
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
await git.push(remote
|
|
1250
|
-
return resp
|
|
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
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
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
|
-
|
|
1872
|
-
.map((request) =>
|
|
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
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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
|
|
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
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
-
|
|
2507
|
+
throw new Error('Comment text required.');
|
|
2114
2508
|
}
|
|
2115
|
-
const { base } = await azurePrApiBase(
|
|
2116
|
-
const threadId = Number(
|
|
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
|
|
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(
|
|
2126
|
-
const line = Number(
|
|
2127
|
-
const requestedEndLine = Number(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
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
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
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
|
-
|
|
3013
|
+
throw new Error('path required');
|
|
2593
3014
|
}
|
|
2594
|
-
const oldPath = String(
|
|
2595
|
-
const { base, prefix, repo } = await azurePrApiBase(
|
|
2596
|
-
let source = String(
|
|
2597
|
-
let target = String(
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
}
|