guito 0.6.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,18 +2,51 @@ import { existsSync } from 'node:fs';
2
2
  import { execFile, spawn } from 'node:child_process';
3
3
  import { readFile, rm, writeFile } from 'node:fs/promises';
4
4
  import { randomBytes } from 'node:crypto';
5
+ import { userInfo } from 'node:os';
5
6
  import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
7
  import fastify from 'fastify';
7
8
  import fastifyStatic from '@fastify/static';
8
9
  import fastifyCors from '@fastify/cors';
9
10
  import fastifyCompress from '@fastify/compress';
10
11
  import { simpleGit } from 'simple-git';
12
+ import { createTwoFilesPatch } from 'diff';
11
13
  import { promisify } from 'node:util';
12
14
  import { workingTree } from './working-tree.js';
13
15
  import { avatarCache } from './avatars.js';
14
16
  import { repositoryState } from './repository-state.js';
15
17
  const execFileAsync = promisify(execFile);
16
18
  const AZURE_API_VERSION = '5.0';
19
+ export const DEFAULT_PR_BRANCH_NAME_TEMPLATE = 'pr/${randomstring}';
20
+ const PR_BRANCH_TEMPLATE_VARIABLES = [
21
+ 'username',
22
+ 'randomstring',
23
+ 'branch',
24
+ 'targetbranch',
25
+ 'title',
26
+ 'repository',
27
+ 'date',
28
+ 'time',
29
+ 'timestamp',
30
+ ];
31
+ /** Azure DevOps comment thread statuses, by their numeric REST value. */
32
+ const AZURE_THREAD_STATUSES = {
33
+ unknown: 0,
34
+ active: 1,
35
+ wontFix: 3,
36
+ fixed: 4,
37
+ closed: 5,
38
+ byDesign: 6,
39
+ pending: 7,
40
+ };
41
+ const azureThreadStatusName = (value) => Object.entries(AZURE_THREAD_STATUSES).find(([, numeric]) => numeric === value)?.[0] ?? 'unknown';
42
+ /** Pull request reviewer votes, by their numeric REST value. */
43
+ const AZURE_VOTES = {
44
+ reset: 0,
45
+ approveWithSuggestions: 5,
46
+ approve: 10,
47
+ waitForAuthor: -5,
48
+ reject: -10,
49
+ };
17
50
  /**
18
51
  * Extracts {projectPath, repo} from an Azure DevOps git remote URL.
19
52
  * Supports https (with optional credentials) and ssh (scp-style and ssh://)
@@ -108,14 +141,14 @@ export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
108
141
  }
109
142
  /**
110
143
  * Default Azure DevOps HTTP runner: shells out to the Windows built-in
111
- * curl.exe with NTLM + Negotiate and an empty username, which authenticates
112
- * with the current Windows session (SSPI single sign-on).
144
+ * curl.exe with Negotiate and an empty username, which authenticates with
145
+ * the current Windows session (SSPI single sign-on). Do not also request
146
+ * --ntlm: some curl builds omit that option while still supporting Negotiate.
113
147
  */
114
148
  function defaultAzureRequest(method, url, body) {
115
149
  return new Promise((resolveRequest, rejectRequest) => {
116
150
  const args = [
117
151
  '-sS',
118
- '--ntlm',
119
152
  '--negotiate',
120
153
  '-u',
121
154
  ':',
@@ -126,7 +159,7 @@ function defaultAzureRequest(method, url, body) {
126
159
  '-X',
127
160
  method,
128
161
  ];
129
- if (method === 'POST') {
162
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
130
163
  args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
131
164
  }
132
165
  args.push(url);
@@ -171,6 +204,22 @@ const randomBranchSuffix = () => {
171
204
  }
172
205
  return suffix;
173
206
  };
207
+ const safeBranchTemplateValue = (value) => value
208
+ .trim()
209
+ .replace(/[\x00-\x20\x7f~^:?*\[\\]+/g, '-')
210
+ .replace(/\.{2,}/g, '-')
211
+ .replace(/-+/g, '-')
212
+ .replace(/^[./-]+|[./-]+$/g, '') || 'unknown';
213
+ /** Expands a configured automatic PR branch name and rejects unknown variables. */
214
+ export const formatPrBranchName = (template, values) => {
215
+ const source = template.trim() || DEFAULT_PR_BRANCH_NAME_TEMPLATE;
216
+ return source.replace(/\$\{([^}]+)\}/g, (_match, variable) => {
217
+ if (!PR_BRANCH_TEMPLATE_VARIABLES.includes(variable)) {
218
+ throw new Error(`Unknown pull request branch variable \${${variable}}. Supported variables: ${PR_BRANCH_TEMPLATE_VARIABLES.map((name) => `\${${name}}`).join(', ')}.`);
219
+ }
220
+ return safeBranchTemplateValue(values[variable]);
221
+ });
222
+ };
174
223
  /** Extracts Azure DevOps' human-readable error message from a REST response. */
175
224
  const azureErrorMessage = (result) => {
176
225
  try {
@@ -180,11 +229,21 @@ const azureErrorMessage = (result) => {
180
229
  return result.body;
181
230
  }
182
231
  };
183
- export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, autoReload, azureRequestImpl, avatarFetchImpl, }) {
232
+ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, fileListView, searchMode, searchCaseSensitive, azureRequestImpl, avatarFetchImpl, onLog, }) {
184
233
  // Initialize server
185
234
  const app = fastify({
186
235
  logger: false,
187
236
  });
237
+ if (onLog) {
238
+ // Report error responses to the host (e.g. the VS Code output channel).
239
+ app.addHook('onSend', async (request, reply, payload) => {
240
+ if (reply.statusCode >= 400) {
241
+ const body = typeof payload === 'string' ? payload.slice(0, 500) : '';
242
+ onLog(`${request.method} ${request.url} -> ${reply.statusCode}${body ? ` ${body}` : ''}`);
243
+ }
244
+ return payload;
245
+ });
246
+ }
188
247
  // Register cors
189
248
  await app.register(fastifyCors);
190
249
  // Compress JSON responses (commit history payloads are highly repetitive)
@@ -353,8 +412,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
353
412
  await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
354
413
  };
355
414
  const azureRequest = azureRequestImpl ?? defaultAzureRequest;
356
- const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
357
415
  const effectiveAzureUrl = async () => {
416
+ const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
358
417
  if (configuredAzureUrl) {
359
418
  return { url: configuredAzureUrl, source: 'vscode' };
360
419
  }
@@ -381,14 +440,143 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
381
440
  }
382
441
  return { prefix, remoteUrl };
383
442
  };
443
+ /** Human identity of the authenticated Windows/Azure user; cached per run. */
444
+ let cachedAzureMe = null;
445
+ const azureMe = async () => {
446
+ if (cachedAzureMe) {
447
+ return cachedAzureMe;
448
+ }
449
+ const { prefix } = await azureProjectContext();
450
+ // The prefix is "{collection-or-org}/{project}"; connectionData is
451
+ // collection scoped.
452
+ const collection = prefix.slice(0, prefix.lastIndexOf('/'));
453
+ const result = await azureRequest('GET', `${collection}/_apis/connectionData?q=1&api-version=${AZURE_API_VERSION}`, '');
454
+ if (result.status < 200 || result.status >= 300) {
455
+ throw new Error(azureErrorMessage(result) ||
456
+ `Azure DevOps returned HTTP ${result.status} while resolving the current user.`);
457
+ }
458
+ const user = (JSON.parse(result.body) ?? {}).authenticatedUser;
459
+ const id = String(user?.id ?? '');
460
+ if (!id) {
461
+ throw new Error('Azure DevOps did not report an authenticated user.');
462
+ }
463
+ cachedAzureMe = {
464
+ id,
465
+ name: String(user?.providerDisplayName ?? ''),
466
+ email: String(user?.properties?.Mail?.$value ?? user?.properties?.Account?.$value ?? ''),
467
+ };
468
+ return cachedAzureMe;
469
+ };
470
+ /** Web UI URL of a pull request, used by the "Open in Azure DevOps" links. */
471
+ const azurePrWebUrl = (prefix, remoteUrl, id) => {
472
+ const repo = parseAzureRemoteUrl(remoteUrl)?.repo ?? '';
473
+ return `${prefix}/_git/${encodeURIComponent(repo)}/pullrequest/${id}`;
474
+ };
475
+ /** Maps one Azure DevOps pull request record to the shape the UI consumes. */
476
+ const mapAzurePullRequest = (pr, prefix, remoteUrl, me) => {
477
+ const reviewers = (Array.isArray(pr?.reviewers) ? pr.reviewers : []).map((entry) => ({
478
+ id: String(entry?.id ?? ''),
479
+ name: String(entry?.displayName ?? ''),
480
+ email: String(entry?.uniqueName ?? ''),
481
+ vote: Number(entry?.vote ?? 0),
482
+ isRequired: entry?.isRequired === true,
483
+ }));
484
+ const mine = me ? reviewers.find((reviewer) => reviewer.id === me.id) : undefined;
485
+ return {
486
+ id: Number(pr?.pullRequestId ?? 0),
487
+ title: String(pr?.title ?? ''),
488
+ isDraft: pr?.isDraft === true,
489
+ author: {
490
+ id: String(pr?.createdBy?.id ?? ''),
491
+ name: String(pr?.createdBy?.displayName ?? ''),
492
+ email: String(pr?.createdBy?.uniqueName ?? ''),
493
+ },
494
+ createdAt: String(pr?.creationDate ?? ''),
495
+ sourceBranch: String(pr?.sourceRefName ?? '').replace(/^refs\/heads\//, ''),
496
+ targetBranch: String(pr?.targetRefName ?? '').replace(/^refs\/heads\//, ''),
497
+ status: String(pr?.status ?? 'active'),
498
+ webUrl: String(pr?._links?.web?.href || '') || azurePrWebUrl(prefix, remoteUrl, Number(pr?.pullRequestId ?? 0)),
499
+ reviewers,
500
+ myVote: mine ? Number(mine.vote ?? 0) : 0,
501
+ requiresMe: mine?.isRequired === true,
502
+ };
503
+ };
504
+ /** REST base of one pull request (".../pullrequests/{id}") plus repo context. */
505
+ const azurePrApiBase = async (id) => {
506
+ if (!Number.isInteger(id) || id <= 0) {
507
+ throw new Error('A pull request id is required.');
508
+ }
509
+ const { prefix, remoteUrl } = await azureProjectContext();
510
+ const repo = parseAzureRemoteUrl(remoteUrl)?.repo;
511
+ if (!repo) {
512
+ throw new Error(`The origin remote does not point to an Azure DevOps project: ${remoteUrl}`);
513
+ }
514
+ return {
515
+ prefix,
516
+ remoteUrl,
517
+ repo,
518
+ base: `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests/${id}`,
519
+ };
520
+ };
521
+ /** Runs one Azure request and turns non-2xx replies into user-facing errors. */
522
+ const azureJson = async (method, url, body = '') => {
523
+ const result = await azureRequest(method, url, body);
524
+ if (result.status < 200 || result.status >= 300) {
525
+ throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
526
+ }
527
+ return (result.body ? JSON.parse(result.body) : {});
528
+ };
384
529
  /** Merges the VS Code settings with the server-side settings file. */
385
530
  const effectiveSettings = async () => {
386
531
  const [file, azure] = await Promise.all([readSettings(), effectiveAzureUrl()]);
387
532
  const fileAutoReload = typeof file.autoReload === 'boolean' ? file.autoReload : undefined;
533
+ const fileShowGraph = typeof file.showGraph === 'boolean' ? file.showGraph : undefined;
534
+ const fileShowStashes = typeof file.showStashes === 'boolean' ? file.showStashes : undefined;
535
+ const fileShowTags = typeof file.showTags === 'boolean' ? file.showTags : undefined;
536
+ const fileShowRemoteBranches = typeof file.showRemoteBranches === 'boolean' ? file.showRemoteBranches : undefined;
537
+ const fileFileListView = file.fileListView === 'tree' || file.fileListView === 'flat' ? file.fileListView : undefined;
538
+ const fileSearchMode = file.searchMode === 'filter' || file.searchMode === 'navigate' ? file.searchMode : undefined;
539
+ const fileSearchCaseSensitive = typeof file.searchCaseSensitive === 'boolean' ? file.searchCaseSensitive : undefined;
540
+ const filePrBranchNameTemplate = typeof file.prBranchNameTemplate === 'string' && file.prBranchNameTemplate.trim()
541
+ ? file.prBranchNameTemplate.trim()
542
+ : undefined;
543
+ const localIssue = file.issueLinking;
544
+ let issueLinking = null;
545
+ if (issueRegex?.trim() && issueUrl?.trim()) {
546
+ issueLinking = { regex: issueRegex.trim(), url: issueUrl.trim(), useGlobally: true };
547
+ }
548
+ else if (typeof localIssue?.regex === 'string' && typeof localIssue.url === 'string') {
549
+ issueLinking = { regex: localIssue.regex, url: localIssue.url, useGlobally: false };
550
+ }
551
+ else {
552
+ const [regex, url] = await Promise.all([
553
+ git.raw(['config', '--global', '--get', 'guito.issueRegex']).catch(() => ''),
554
+ git.raw(['config', '--global', '--get', 'guito.issueUrl']).catch(() => ''),
555
+ ]);
556
+ if (regex.trim() && url.trim()) {
557
+ issueLinking = { regex: regex.trim(), url: url.trim(), useGlobally: true };
558
+ }
559
+ }
388
560
  return {
389
561
  azureDevOpsUrl: azure.url,
562
+ prBranchNameTemplate: prBranchNameTemplate?.trim() ||
563
+ filePrBranchNameTemplate ||
564
+ DEFAULT_PR_BRANCH_NAME_TEMPLATE,
390
565
  source: azure.source,
391
566
  autoReload: typeof autoReload === 'boolean' ? autoReload : (fileAutoReload ?? true),
567
+ diffViewer: diffViewer === 'vscode' ? 'vscode' : 'guito',
568
+ showGraph: typeof showGraph === 'boolean' ? showGraph : (fileShowGraph ?? true),
569
+ showStashes: typeof showStashes === 'boolean' ? showStashes : (fileShowStashes ?? true),
570
+ showTags: typeof showTags === 'boolean' ? showTags : (fileShowTags ?? true),
571
+ showRemoteBranches: typeof showRemoteBranches === 'boolean'
572
+ ? showRemoteBranches
573
+ : (fileShowRemoteBranches ?? true),
574
+ fileListView: fileListView ?? fileFileListView ?? 'flat',
575
+ searchMode: searchMode ?? fileSearchMode ?? 'navigate',
576
+ searchCaseSensitive: typeof searchCaseSensitive === 'boolean'
577
+ ? searchCaseSensitive
578
+ : (fileSearchCaseSensitive ?? false),
579
+ issueLinking,
392
580
  };
393
581
  };
394
582
  const configuredIdentity = async () => {
@@ -475,28 +663,42 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
475
663
  // matches over the loaded history.
476
664
  app.get('/api/commits/search', async (req, resp) => {
477
665
  const query = String((req.query ?? {}).query ?? '').trim();
666
+ const caseSensitive = String((req.query ?? {}).caseSensitive ?? '') === '1';
478
667
  if (!query) {
479
- return resp.type('application/json').send({ hashes: [] });
668
+ return resp.type('application/json').send({ hashes: [], indices: {} });
480
669
  }
481
670
  try {
482
- const raw = await git.raw([
483
- 'log',
484
- '--all',
485
- '-i',
486
- '--fixed-strings',
487
- `--grep=${query}`,
488
- '--format=%H',
671
+ const grepArgs = ['log', '--all'];
672
+ if (!caseSensitive)
673
+ grepArgs.push('-i');
674
+ grepArgs.push('--fixed-strings', `--grep=${query}`, '--format=%H');
675
+ const [raw, history] = await Promise.all([
676
+ git.raw(grepArgs),
677
+ git.raw(['log', '--all', '--format=%H']),
489
678
  ]);
679
+ const hashes = raw
680
+ .split('\n')
681
+ .map((hash) => hash.trim())
682
+ .filter(Boolean);
683
+ const matches = new Set(hashes);
684
+ // Use the same unfiltered Git log order as the paged history endpoint.
685
+ const indices = {};
686
+ history
687
+ .split('\n')
688
+ .map((hash) => hash.trim())
689
+ .filter(Boolean)
690
+ .forEach((hash, index) => {
691
+ if (matches.has(hash))
692
+ indices[hash] = index;
693
+ });
490
694
  return resp.type('application/json').send({
491
- hashes: raw
492
- .split('\n')
493
- .map((hash) => hash.trim())
494
- .filter(Boolean),
695
+ hashes,
696
+ indices,
495
697
  });
496
698
  }
497
699
  catch (err) {
498
700
  if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
499
- return resp.type('application/json').send({ hashes: [] });
701
+ return resp.type('application/json').send({ hashes: [], indices: {} });
500
702
  }
501
703
  return resp.status(400).type('application/json').send({ error: err.message });
502
704
  }
@@ -675,6 +877,100 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
675
877
  return resp.status(400).type('application/json').send({ error: err.message });
676
878
  }
677
879
  });
880
+ // ==================== Worktrees ====================
881
+ app.get('/api/worktrees', async (_req, resp) => {
882
+ try {
883
+ const raw = await git.raw(['worktree', 'list', '--porcelain']);
884
+ // Path comparison is case-insensitive on Windows so the served worktree
885
+ // is detected regardless of drive-letter casing.
886
+ const samePath = (a, b) => process.platform === 'win32'
887
+ ? resolve(a).toLowerCase() === resolve(b).toLowerCase()
888
+ : resolve(a) === resolve(b);
889
+ const worktrees = raw
890
+ .split(/\n\s*\n/)
891
+ .map((block) => block.trim())
892
+ .filter(Boolean)
893
+ .map((block) => {
894
+ const entry = {
895
+ path: '',
896
+ head: '',
897
+ branch: '',
898
+ bare: false,
899
+ detached: false,
900
+ current: false,
901
+ };
902
+ for (const line of block.split('\n').map((l) => l.trim())) {
903
+ if (line.startsWith('worktree '))
904
+ entry.path = line.slice('worktree '.length);
905
+ else if (line.startsWith('HEAD '))
906
+ entry.head = line.slice('HEAD '.length);
907
+ else if (line.startsWith('branch '))
908
+ entry.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
909
+ else if (line === 'bare')
910
+ entry.bare = true;
911
+ else if (line === 'detached')
912
+ entry.detached = true;
913
+ }
914
+ entry.current = !!entry.path && samePath(entry.path, repositoryPath);
915
+ return entry;
916
+ });
917
+ return resp.type('application/json').send(worktrees);
918
+ }
919
+ catch (err) {
920
+ return resp.status(400).type('application/json').send({ error: err.message });
921
+ }
922
+ });
923
+ app.post('/api/worktrees', async (req, resp) => {
924
+ try {
925
+ const path = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
926
+ const branch = typeof req.body?.branch === 'string' ? req.body.branch.trim() : '';
927
+ if (!path || !isAbsolute(path)) {
928
+ throw new Error('Choose an absolute folder path for the worktree.');
929
+ }
930
+ if (!branch) {
931
+ throw new Error('Choose a local branch for the worktree.');
932
+ }
933
+ // Resolve the branch as a local ref before passing it to Git. Besides a
934
+ // clearer error, this prevents a request from treating an option as a ref.
935
+ await git.raw(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
936
+ await mutate(() => git.raw(['worktree', 'add', '--', resolve(path), branch]).then(() => { }));
937
+ return resp.type('application/json').send({ success: true });
938
+ }
939
+ catch (err) {
940
+ return resp.status(400).type('application/json').send({ error: err.message });
941
+ }
942
+ });
943
+ app.post('/api/worktrees/remove', async (req, resp) => {
944
+ try {
945
+ const path = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
946
+ if (!path || !isAbsolute(path)) {
947
+ throw new Error('A valid worktree path is required.');
948
+ }
949
+ const target = resolve(path);
950
+ const samePath = (a, b) => process.platform === 'win32'
951
+ ? resolve(a).toLowerCase() === resolve(b).toLowerCase()
952
+ : resolve(a) === resolve(b);
953
+ if (samePath(target, repositoryPath)) {
954
+ throw new Error('The worktree currently open in Guito cannot be removed.');
955
+ }
956
+ // Only registered worktrees may be removed. Do not use --force: Git must
957
+ // protect uncommitted or untracked files in the selected worktree.
958
+ const registered = (await git.raw(['worktree', 'list', '--porcelain']))
959
+ .split(/\n\s*\n/)
960
+ .some((block) => {
961
+ const line = block.split('\n').find((entry) => entry.startsWith('worktree '));
962
+ return !!line && samePath(line.slice('worktree '.length).trim(), target);
963
+ });
964
+ if (!registered) {
965
+ throw new Error('The selected folder is not a registered worktree.');
966
+ }
967
+ await mutate(() => git.raw(['worktree', 'remove', '--', target]).then(() => { }));
968
+ return resp.type('application/json').send({ success: true });
969
+ }
970
+ catch (err) {
971
+ return resp.status(400).type('application/json').send({ error: err.message });
972
+ }
973
+ });
678
974
  app.post('/api/checkout', async (req, resp) => {
679
975
  try {
680
976
  const { ref } = req.body;
@@ -740,8 +1036,26 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
740
1036
  // ==================== Stash ====================
741
1037
  app.get('/api/stash/list', async (_req, resp) => {
742
1038
  try {
743
- const stashList = await git.stashList();
744
- return resp.type('application/json').send(stashList);
1039
+ // Extra fields (date, author) let the commit table render stash rows;
1040
+ // they are NUL-separated because messages cannot contain NUL.
1041
+ const raw = await git.raw([
1042
+ 'stash',
1043
+ 'list',
1044
+ '--format=%H%x00%aI%x00%aN%x00%aE%x00%s',
1045
+ ]);
1046
+ const all = raw
1047
+ .trim()
1048
+ .split('\n')
1049
+ .filter(Boolean)
1050
+ .map((line) => {
1051
+ const [hash, date, authorName, authorEmail, message] = line.split('\u0000');
1052
+ return { hash, date, author_name: authorName, author_email: authorEmail, message };
1053
+ });
1054
+ return resp.type('application/json').send({
1055
+ all,
1056
+ latest: all[0] ?? null,
1057
+ total: all.length,
1058
+ });
745
1059
  }
746
1060
  catch (err) {
747
1061
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -812,9 +1126,21 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
812
1126
  }
813
1127
  });
814
1128
  // ==================== Tags ====================
1129
+ // All tags with the commit each points at (annotated tags are peeled, so the
1130
+ // client can always look the tagged commit up by hash). Sorted by name.
815
1131
  app.get('/api/tags', async (_req, resp) => {
816
1132
  try {
817
- const tags = await git.tags();
1133
+ const raw = await git.raw([
1134
+ 'tag',
1135
+ '--list',
1136
+ '--sort=refname',
1137
+ '--format=%(refname:short)%09%(*objectname)%09%(objectname)',
1138
+ ]);
1139
+ const tags = raw
1140
+ .split('\n')
1141
+ .map((line) => line.split('\t'))
1142
+ .filter(([name]) => Boolean(name.trim()))
1143
+ .map(([name, peeled, direct]) => ({ name, hash: peeled || direct }));
818
1144
  return resp.type('application/json').send(tags);
819
1145
  }
820
1146
  catch (err) {
@@ -860,9 +1186,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
860
1186
  }
861
1187
  });
862
1188
  // ==================== Remote Operations ====================
863
- app.get('/api/fetch', async (_req, resp) => {
1189
+ app.get('/api/fetch', async (req, resp) => {
864
1190
  try {
865
- await git.fetch();
1191
+ const prune = ['1', 'true', 'yes'].includes(String(req.query?.prune ?? '').toLowerCase());
1192
+ await git.fetch(prune ? { '--prune': null } : {});
866
1193
  return resp.type('application/json').send({ success: true });
867
1194
  }
868
1195
  catch (err) {
@@ -909,6 +1236,87 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
909
1236
  return resp.status(400).type('application/json').send({ error: err.message });
910
1237
  }
911
1238
  });
1239
+ const listRemotes = async () => {
1240
+ const names = (await git.raw(['remote']))
1241
+ .split(/\r?\n/)
1242
+ .map((name) => name.trim())
1243
+ .filter(Boolean);
1244
+ return Promise.all(names.map(async (name) => {
1245
+ const fetchUrl = (await git.raw(['remote', 'get-url', name])).trim();
1246
+ const pushUrl = (await git.raw(['remote', 'get-url', '--push', name]).catch(() => fetchUrl)).trim();
1247
+ return { name, fetchUrl, pushUrl };
1248
+ }));
1249
+ };
1250
+ app.get('/api/remotes', async (_req, resp) => {
1251
+ try {
1252
+ return resp.type('application/json').send(await listRemotes());
1253
+ }
1254
+ catch (err) {
1255
+ return resp.status(400).send({ error: err.message });
1256
+ }
1257
+ });
1258
+ app.post('/api/remotes', async (req, resp) => {
1259
+ try {
1260
+ const name = String(req.body?.name ?? '').trim();
1261
+ const originalName = String(req.body?.originalName ?? '').trim();
1262
+ const fetchUrl = String(req.body?.fetchUrl ?? '').trim();
1263
+ const pushUrl = String(req.body?.pushUrl ?? '').trim() || fetchUrl;
1264
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || !fetchUrl) {
1265
+ return resp.status(400).send({ error: 'A valid remote name and fetch URL are required.' });
1266
+ }
1267
+ if (!originalName) {
1268
+ await git.raw(['remote', 'add', name, fetchUrl]);
1269
+ }
1270
+ else {
1271
+ if (originalName !== name)
1272
+ await git.raw(['remote', 'rename', originalName, name]);
1273
+ await git.raw(['remote', 'set-url', name, fetchUrl]);
1274
+ }
1275
+ await git.raw(['remote', 'set-url', '--push', name, pushUrl]);
1276
+ return resp.send(await listRemotes());
1277
+ }
1278
+ catch (err) {
1279
+ return resp.status(400).send({ error: err.message });
1280
+ }
1281
+ });
1282
+ app.delete('/api/remotes/:name', async (req, resp) => {
1283
+ try {
1284
+ const name = String(req.params?.name ?? '').trim();
1285
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
1286
+ return resp.status(400).send({ error: 'Invalid remote name.' });
1287
+ }
1288
+ await git.raw(['remote', 'remove', name]);
1289
+ return resp.send(await listRemotes());
1290
+ }
1291
+ catch (err) {
1292
+ return resp.status(400).send({ error: err.message });
1293
+ }
1294
+ });
1295
+ app.post('/api/identity', async (req, resp) => {
1296
+ try {
1297
+ const name = String(req.body?.name ?? '').trim();
1298
+ const email = String(req.body?.email ?? '').trim();
1299
+ if (!name || !email) {
1300
+ return resp.status(400).send({ error: 'User name and email are required.' });
1301
+ }
1302
+ await git.raw(['config', '--local', 'user.name', name]);
1303
+ await git.raw(['config', '--local', 'user.email', email]);
1304
+ return resp.send(await configuredIdentity());
1305
+ }
1306
+ catch (err) {
1307
+ return resp.status(400).send({ error: err.message });
1308
+ }
1309
+ });
1310
+ app.delete('/api/identity', async (_req, resp) => {
1311
+ try {
1312
+ await git.raw(['config', '--local', '--unset-all', 'user.name']).catch(() => '');
1313
+ await git.raw(['config', '--local', '--unset-all', 'user.email']).catch(() => '');
1314
+ return resp.send(await configuredIdentity());
1315
+ }
1316
+ catch (err) {
1317
+ return resp.status(400).send({ error: err.message });
1318
+ }
1319
+ });
912
1320
  // ==================== Settings ====================
913
1321
  app.get('/api/settings', async (_req, resp) => {
914
1322
  try {
@@ -921,15 +1329,102 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
921
1329
  });
922
1330
  app.post('/api/settings', async (req, resp) => {
923
1331
  try {
924
- const url = String(req.body?.azureDevOpsUrl ?? '').trim();
925
- if (url && !/^https?:\/\//i.test(url)) {
926
- return resp
927
- .status(400)
928
- .type('application/json')
929
- .send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
930
- }
1332
+ const body = req.body ?? {};
931
1333
  const settings = await readSettings();
932
- settings.azureDevOpsUrl = url;
1334
+ if ('azureDevOpsUrl' in body) {
1335
+ const url = String(body.azureDevOpsUrl ?? '').trim();
1336
+ if (url && !/^https?:\/\//i.test(url)) {
1337
+ return resp
1338
+ .status(400)
1339
+ .type('application/json')
1340
+ .send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
1341
+ }
1342
+ settings.azureDevOpsUrl = url;
1343
+ }
1344
+ if ('prBranchNameTemplate' in body) {
1345
+ const template = String(body.prBranchNameTemplate ?? '').trim();
1346
+ if (template.length > 200) {
1347
+ return resp
1348
+ .status(400)
1349
+ .send({ error: 'The pull request branch template must be 200 characters or fewer.' });
1350
+ }
1351
+ try {
1352
+ formatPrBranchName(template, {
1353
+ username: 'username',
1354
+ randomstring: 'randomstring',
1355
+ branch: 'branch',
1356
+ targetbranch: 'targetbranch',
1357
+ title: 'title',
1358
+ repository: 'repository',
1359
+ date: '2026-09-12',
1360
+ time: '120000',
1361
+ timestamp: '1789214400000',
1362
+ });
1363
+ }
1364
+ catch (error) {
1365
+ return resp.status(400).send({ error: error.message });
1366
+ }
1367
+ settings.prBranchNameTemplate = template || DEFAULT_PR_BRANCH_NAME_TEMPLATE;
1368
+ }
1369
+ if (typeof body.showGraph === 'boolean') {
1370
+ settings.showGraph = body.showGraph;
1371
+ }
1372
+ if (typeof body.autoReload === 'boolean') {
1373
+ settings.autoReload = body.autoReload;
1374
+ }
1375
+ if (typeof body.showStashes === 'boolean') {
1376
+ settings.showStashes = body.showStashes;
1377
+ }
1378
+ if (typeof body.showTags === 'boolean') {
1379
+ settings.showTags = body.showTags;
1380
+ }
1381
+ if (typeof body.showRemoteBranches === 'boolean') {
1382
+ settings.showRemoteBranches = body.showRemoteBranches;
1383
+ }
1384
+ if (body.fileListView === 'tree' || body.fileListView === 'flat') {
1385
+ settings.fileListView = body.fileListView;
1386
+ }
1387
+ if (body.searchMode === 'filter' || body.searchMode === 'navigate') {
1388
+ settings.searchMode = body.searchMode;
1389
+ }
1390
+ if (typeof body.searchCaseSensitive === 'boolean') {
1391
+ settings.searchCaseSensitive = body.searchCaseSensitive;
1392
+ }
1393
+ if ('issueLinking' in body) {
1394
+ if (body.issueLinking === null) {
1395
+ if (body.issueLinkingGlobal) {
1396
+ await git.raw(['config', '--global', '--unset-all', 'guito.issueRegex']).catch(() => '');
1397
+ await git.raw(['config', '--global', '--unset-all', 'guito.issueUrl']).catch(() => '');
1398
+ }
1399
+ else {
1400
+ delete settings.issueLinking;
1401
+ }
1402
+ }
1403
+ else {
1404
+ const regex = String(body.issueLinking?.regex ?? '').trim();
1405
+ const url = String(body.issueLinking?.url ?? '').trim();
1406
+ if (!regex || !url) {
1407
+ return resp.status(400).send({ error: 'Issue regex and URL are required.' });
1408
+ }
1409
+ try {
1410
+ new RegExp(regex);
1411
+ }
1412
+ catch {
1413
+ return resp.status(400).send({ error: 'Issue regex is not a valid regular expression.' });
1414
+ }
1415
+ if (!/^https?:\/\//i.test(url)) {
1416
+ return resp.status(400).send({ error: 'Issue URL must start with http:// or https://.' });
1417
+ }
1418
+ if (body.issueLinking.useGlobally) {
1419
+ await git.raw(['config', '--global', 'guito.issueRegex', regex]);
1420
+ await git.raw(['config', '--global', 'guito.issueUrl', url]);
1421
+ delete settings.issueLinking;
1422
+ }
1423
+ else {
1424
+ settings.issueLinking = { regex, url };
1425
+ }
1426
+ }
1427
+ }
933
1428
  await writeSettings(settings);
934
1429
  const effective = await effectiveSettings();
935
1430
  return resp.type('application/json').send(effective);
@@ -958,7 +1453,44 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
958
1453
  if (newBranch) {
959
1454
  // Create a remote-only branch from the current HEAD; the local
960
1455
  // repository and the checked-out branch stay untouched.
961
- const name = `pr/${randomBranchSuffix()}`;
1456
+ const now = new Date();
1457
+ const currentSettings = await effectiveSettings();
1458
+ let computerUsername = process.env.USERNAME || process.env.USER || '';
1459
+ try {
1460
+ computerUsername = userInfo().username || computerUsername;
1461
+ }
1462
+ catch {
1463
+ // Environment variables are a sufficient fallback in restricted hosts.
1464
+ }
1465
+ const name = formatPrBranchName(currentSettings.prBranchNameTemplate, {
1466
+ username: computerUsername,
1467
+ randomstring: randomBranchSuffix(),
1468
+ branch: (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(),
1469
+ targetbranch: String(targetBranch)
1470
+ .replace(/^refs\/heads\//, '')
1471
+ .replace(/^origin\//, ''),
1472
+ title: String(title ?? '').trim() || 'pull-request',
1473
+ repository: basename(await repoRoot()),
1474
+ date: now.toISOString().slice(0, 10),
1475
+ time: now.toISOString().slice(11, 19).replace(/:/g, ''),
1476
+ timestamp: String(now.getTime()),
1477
+ });
1478
+ try {
1479
+ await git.raw(['check-ref-format', '--branch', name]);
1480
+ }
1481
+ catch {
1482
+ return resp.status(400).send({
1483
+ error: `The pull request branch template produced an invalid Git branch name: ${name}`,
1484
+ });
1485
+ }
1486
+ const existingBranch = await git
1487
+ .raw(['ls-remote', '--heads', 'origin', `refs/heads/${name}`])
1488
+ .catch(() => '');
1489
+ if (existingBranch.trim()) {
1490
+ return resp.status(400).send({
1491
+ error: `The pull request branch already exists on origin: ${name}`,
1492
+ });
1493
+ }
962
1494
  await git.push(['origin', `HEAD:refs/heads/${name}`]);
963
1495
  source = name;
964
1496
  }
@@ -1206,6 +1738,440 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1206
1738
  return resp.status(400).type('application/json').send({ error: err.message });
1207
1739
  }
1208
1740
  });
1741
+ // ==================== Azure DevOps pull request review ====================
1742
+ // Everything the PR sidebar list and the PR detail dialog need: identity,
1743
+ // listing, editing, votes, reviewers, auto-complete, completion, comment
1744
+ // threads, and per-file diffs.
1745
+ app.get('/api/azure-devops/me', async (_req, resp) => {
1746
+ try {
1747
+ return resp.type('application/json').send(await azureMe());
1748
+ }
1749
+ catch (err) {
1750
+ return resp.status(400).type('application/json').send({ error: err.message });
1751
+ }
1752
+ });
1753
+ // Pull requests the current user created or reviews ("mine"), merged and
1754
+ // de-duplicated. Status defaults to active.
1755
+ app.get('/api/azure-devops/pullrequests', async (req, resp) => {
1756
+ try {
1757
+ const status = String(req.query?.status ?? 'active').trim() || 'active';
1758
+ const { prefix, remoteUrl } = await azureProjectContext();
1759
+ const repo = parseAzureRemoteUrl(remoteUrl).repo;
1760
+ const me = await azureMe();
1761
+ const collect = async (criteria) => {
1762
+ const found = new Map();
1763
+ for (let skip = 0;; skip += 100) {
1764
+ const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
1765
+ `?searchCriteria.status=${encodeURIComponent(status)}&searchCriteria.${criteria}` +
1766
+ `&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`;
1767
+ const data = await azureJson('GET', url);
1768
+ const requests = Array.isArray(data?.value) ? data.value : [];
1769
+ for (const request of requests) {
1770
+ const id = Number(request?.pullRequestId);
1771
+ if (Number.isInteger(id) && id > 0 && !found.has(id)) {
1772
+ found.set(id, request);
1773
+ }
1774
+ }
1775
+ if (requests.length < 100)
1776
+ break;
1777
+ }
1778
+ return found;
1779
+ };
1780
+ const [created, assigned] = await Promise.all([
1781
+ collect(`creatorId=${encodeURIComponent(me.id)}`),
1782
+ collect(`reviewerId=${encodeURIComponent(me.id)}`),
1783
+ ]);
1784
+ const merged = new Map([...created, ...assigned]);
1785
+ const pullRequests = [...merged.values()]
1786
+ .map((request) => mapAzurePullRequest(request, prefix, remoteUrl, me))
1787
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
1788
+ return resp.type('application/json').send({ pullRequests });
1789
+ }
1790
+ catch (err) {
1791
+ return resp.status(400).type('application/json').send({ error: err.message });
1792
+ }
1793
+ });
1794
+ app.get('/api/azure-devops/pullrequests/:id', async (req, resp) => {
1795
+ try {
1796
+ const { base, prefix, remoteUrl } = await azurePrApiBase(Number(req.params?.id));
1797
+ const [pr, me] = await Promise.all([
1798
+ azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`),
1799
+ azureMe(),
1800
+ ]);
1801
+ const summary = mapAzurePullRequest(pr, prefix, remoteUrl, me);
1802
+ return resp.type('application/json').send({
1803
+ ...summary,
1804
+ description: String(pr?.description ?? ''),
1805
+ autoCompleteSetBy: pr?.autoCompleteSetBy
1806
+ ? {
1807
+ id: String(pr.autoCompleteSetBy.id ?? ''),
1808
+ name: String(pr.autoCompleteSetBy.displayName ?? ''),
1809
+ }
1810
+ : null,
1811
+ completionOptions: pr?.completionOptions ?? null,
1812
+ lastMergeSourceCommit: String(pr?.lastMergeSourceCommit?.commitId ?? ''),
1813
+ lastMergeTargetCommit: String(pr?.lastMergeTargetCommit?.commitId ?? ''),
1814
+ labels: (Array.isArray(pr?.labels) ? pr.labels : [])
1815
+ .map((label) => String(label?.name ?? ''))
1816
+ .filter(Boolean),
1817
+ });
1818
+ }
1819
+ catch (err) {
1820
+ return resp.status(400).type('application/json').send({ error: err.message });
1821
+ }
1822
+ });
1823
+ // Edits title/description or flips the draft flag ("publish"/"convert to draft").
1824
+ app.patch('/api/azure-devops/pullrequests/:id', async (req, resp) => {
1825
+ try {
1826
+ const { title, description, isDraft } = req.body ?? {};
1827
+ const payload = {};
1828
+ if (title !== undefined)
1829
+ payload.title = String(title);
1830
+ if (description !== undefined)
1831
+ payload.description = String(description);
1832
+ if (isDraft !== undefined)
1833
+ payload.isDraft = isDraft === true;
1834
+ if (!Object.keys(payload).length) {
1835
+ return resp.status(400).type('application/json').send({ error: 'Nothing to update.' });
1836
+ }
1837
+ const { base } = await azurePrApiBase(Number(req.params?.id));
1838
+ await azureJson('PATCH', `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
1839
+ return resp.type('application/json').send({ ok: true });
1840
+ }
1841
+ catch (err) {
1842
+ return resp.status(400).type('application/json').send({ error: err.message });
1843
+ }
1844
+ });
1845
+ // Records the current user's vote on the pull request.
1846
+ app.post('/api/azure-devops/pullrequests/:id/vote', async (req, resp) => {
1847
+ try {
1848
+ const vote = String(req.body?.vote ?? '');
1849
+ if (!(vote in AZURE_VOTES)) {
1850
+ return resp
1851
+ .status(400)
1852
+ .type('application/json')
1853
+ .send({ error: `Unknown vote "${vote}".` });
1854
+ }
1855
+ const { base } = await azurePrApiBase(Number(req.params?.id));
1856
+ const me = await azureMe();
1857
+ await azureJson('PUT', `${base}/reviewers/${encodeURIComponent(me.id)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ vote: AZURE_VOTES[vote] }));
1858
+ return resp.type('application/json').send({ ok: true });
1859
+ }
1860
+ catch (err) {
1861
+ return resp.status(400).type('application/json').send({ error: err.message });
1862
+ }
1863
+ });
1864
+ // Adds or updates a reviewer (optionally required), or removes one.
1865
+ app.post('/api/azure-devops/pullrequests/:id/reviewers', async (req, resp) => {
1866
+ try {
1867
+ const id = String(req.body?.id ?? '');
1868
+ if (!id) {
1869
+ return resp.status(400).type('application/json').send({ error: 'Reviewer id required.' });
1870
+ }
1871
+ const { base } = await azurePrApiBase(Number(req.params?.id));
1872
+ const reviewerUrl = `${base}/reviewers/${encodeURIComponent(id)}?api-version=${AZURE_API_VERSION}`;
1873
+ if (req.body?.remove === true) {
1874
+ await azureJson('DELETE', reviewerUrl);
1875
+ return resp.type('application/json').send({ ok: true });
1876
+ }
1877
+ // The PUT replaces the reviewer record, so an existing vote is passed
1878
+ // through to survive an isRequired toggle.
1879
+ const payload = { isRequired: req.body?.required === true };
1880
+ if (req.body?.vote !== undefined && Number.isFinite(Number(req.body.vote))) {
1881
+ payload.vote = Number(req.body.vote);
1882
+ }
1883
+ await azureJson('PUT', reviewerUrl, JSON.stringify(payload));
1884
+ return resp.type('application/json').send({ ok: true });
1885
+ }
1886
+ catch (err) {
1887
+ return resp.status(400).type('application/json').send({ error: err.message });
1888
+ }
1889
+ });
1890
+ /** Maps dialog completion options onto the Azure DevOps payload shape. */
1891
+ const completionOptionsOf = (body) => {
1892
+ const strategies = ['noFastForward', 'squash', 'rebase', 'rebaseMerge'];
1893
+ const mergeStrategy = String(body?.mergeStrategy ?? '');
1894
+ const options = {};
1895
+ if (strategies.includes(mergeStrategy))
1896
+ options.mergeStrategy = mergeStrategy;
1897
+ if (body?.deleteSourceBranch !== undefined) {
1898
+ options.deleteSourceBranch = body.deleteSourceBranch === true;
1899
+ }
1900
+ const completeWorkItems = body?.completeWorkItems === true;
1901
+ if (completeWorkItems) {
1902
+ options.completeWorkItems = true;
1903
+ // Transitioning requires completion of linked work items.
1904
+ if (body?.transitionWorkItems === true)
1905
+ options.transitionWorkItems = true;
1906
+ }
1907
+ return Object.keys(options).length ? options : null;
1908
+ };
1909
+ // Sets or clears auto-complete with optional completion options.
1910
+ app.post('/api/azure-devops/pullrequests/:id/autocomplete', async (req, resp) => {
1911
+ try {
1912
+ const enabled = req.body?.enabled === true;
1913
+ const { base } = await azurePrApiBase(Number(req.params?.id));
1914
+ const buildPayload = (identity) => {
1915
+ const payload = { autoCompleteSetBy: identity };
1916
+ if (enabled) {
1917
+ const options = completionOptionsOf(req.body);
1918
+ if (options)
1919
+ payload.completionOptions = options;
1920
+ }
1921
+ return JSON.stringify(payload);
1922
+ };
1923
+ const url = `${base}?api-version=${AZURE_API_VERSION}`;
1924
+ const me = enabled ? await azureMe() : null;
1925
+ // Azure DevOps clears auto-complete for an empty identity; some Server
1926
+ // releases only accept an explicit null, so retry that shape on failure.
1927
+ try {
1928
+ await azureJson('PATCH', url, buildPayload(enabled && me ? { id: me.id } : { id: '' }));
1929
+ }
1930
+ catch (firstError) {
1931
+ if (enabled)
1932
+ throw firstError;
1933
+ await azureJson('PATCH', url, buildPayload(null));
1934
+ }
1935
+ return resp.type('application/json').send({ ok: true });
1936
+ }
1937
+ catch (err) {
1938
+ return resp.status(400).type('application/json').send({ error: err.message });
1939
+ }
1940
+ });
1941
+ // Completes the pull request (merge). Requires the merge source commit for
1942
+ // optimistic concurrency, exactly like the Azure DevOps web dialog.
1943
+ app.post('/api/azure-devops/pullrequests/:id/complete', async (req, resp) => {
1944
+ try {
1945
+ const { base } = await azurePrApiBase(Number(req.params?.id));
1946
+ const pr = await azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`);
1947
+ const commitId = String(pr?.lastMergeSourceCommit?.commitId ?? '');
1948
+ if (!commitId) {
1949
+ throw new Error('The pull request has no merge commit to complete yet.');
1950
+ }
1951
+ const payload = {
1952
+ status: 'completed',
1953
+ lastMergeSourceCommit: { commitId },
1954
+ };
1955
+ const options = completionOptionsOf(req.body);
1956
+ if (options)
1957
+ payload.completionOptions = options;
1958
+ await azureJson('PATCH', `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
1959
+ return resp.type('application/json').send({ ok: true });
1960
+ }
1961
+ catch (err) {
1962
+ return resp.status(400).type('application/json').send({ error: err.message });
1963
+ }
1964
+ });
1965
+ app.get('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
1966
+ try {
1967
+ const { base } = await azurePrApiBase(Number(req.params?.id));
1968
+ const data = await azureJson('GET', `${base}/threads?api-version=${AZURE_API_VERSION}`);
1969
+ const threads = (Array.isArray(data?.value) ? data.value : [])
1970
+ .filter((thread) => thread?.isDeleted !== true)
1971
+ .map((thread) => {
1972
+ const context = thread?.threadContext ?? {};
1973
+ const line = context.rightFileStart?.line ?? context.leftFileStart?.line ?? null;
1974
+ return {
1975
+ id: Number(thread?.id ?? 0),
1976
+ status: azureThreadStatusName(Number(thread?.status ?? 0)),
1977
+ isDeleted: false,
1978
+ filePath: typeof context.filePath === 'string' ? context.filePath.replace(/^\//, '') : null,
1979
+ line: Number.isInteger(line) ? line : null,
1980
+ side: context.rightFileStart ? 'right' : context.leftFileStart ? 'left' : null,
1981
+ comments: (Array.isArray(thread?.comments) ? thread.comments : [])
1982
+ .filter((comment) => comment?.isDeleted !== true)
1983
+ .map((comment) => ({
1984
+ id: Number(comment?.id ?? 0),
1985
+ author: {
1986
+ name: String(comment?.author?.displayName ?? ''),
1987
+ email: String(comment?.author?.uniqueName ?? ''),
1988
+ },
1989
+ content: String(comment?.content ?? ''),
1990
+ createdAt: String(comment?.publishedDate ?? ''),
1991
+ isDeleted: false,
1992
+ })),
1993
+ };
1994
+ });
1995
+ return resp.type('application/json').send({ threads });
1996
+ }
1997
+ catch (err) {
1998
+ return resp.status(400).type('application/json').send({ error: err.message });
1999
+ }
2000
+ });
2001
+ // Adds a comment: a reply to a thread, a general PR comment, or an inline
2002
+ // comment anchored to a file line (thread context).
2003
+ app.post('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
2004
+ try {
2005
+ const content = String(req.body?.content ?? '').trim();
2006
+ if (!content) {
2007
+ return resp.status(400).type('application/json').send({ error: 'Comment text required.' });
2008
+ }
2009
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2010
+ const threadId = Number(req.body?.threadId ?? 0);
2011
+ if (Number.isInteger(threadId) && threadId > 0) {
2012
+ await azureJson('POST', `${base}/threads/${threadId}/comments?api-version=${AZURE_API_VERSION}`, JSON.stringify({ content }));
2013
+ return resp.type('application/json').send({ ok: true });
2014
+ }
2015
+ const payload = {
2016
+ status: AZURE_THREAD_STATUSES.active,
2017
+ comments: [{ content, parentCommentId: 0 }],
2018
+ };
2019
+ const filePath = String(req.body?.filePath ?? '').trim();
2020
+ const line = Number(req.body?.line ?? 0);
2021
+ const left = req.body?.side === 'left';
2022
+ if (filePath && Number.isInteger(line) && line > 0) {
2023
+ // Azure anchors a whole-line comment from offset 1 to "end of line".
2024
+ payload.threadContext = {
2025
+ filePath: `/${filePath.replace(/^\//, '')}`,
2026
+ [left ? 'leftFileStart' : 'rightFileStart']: { line, offset: 1 },
2027
+ [left ? 'leftFileEnd' : 'rightFileEnd']: { line, offset: 2147483647 },
2028
+ };
2029
+ }
2030
+ await azureJson('POST', `${base}/threads?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
2031
+ return resp.type('application/json').send({ ok: true });
2032
+ }
2033
+ catch (err) {
2034
+ return resp.status(400).type('application/json').send({ error: err.message });
2035
+ }
2036
+ });
2037
+ // Resolves, reactivates or closes a comment thread.
2038
+ app.post('/api/azure-devops/pullrequests/:id/threads/:threadId/status', async (req, resp) => {
2039
+ try {
2040
+ const status = String(req.body?.status ?? '');
2041
+ if (!(status in AZURE_THREAD_STATUSES) || status === 'unknown') {
2042
+ return resp
2043
+ .status(400)
2044
+ .type('application/json')
2045
+ .send({ error: `Unknown thread status "${status}".` });
2046
+ }
2047
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2048
+ await azureJson('PATCH', `${base}/threads/${Number(req.params?.threadId)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ status: AZURE_THREAD_STATUSES[status] }));
2049
+ return resp.type('application/json').send({ ok: true });
2050
+ }
2051
+ catch (err) {
2052
+ return resp.status(400).type('application/json').send({ error: err.message });
2053
+ }
2054
+ });
2055
+ // Changed files of the latest PR iteration (names and change types only;
2056
+ // content is fetched per file on demand by /file-diff).
2057
+ app.get('/api/azure-devops/pullrequests/:id/changes', async (req, resp) => {
2058
+ try {
2059
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2060
+ const iterations = await azureJson('GET', `${base}/iterations?api-version=${AZURE_API_VERSION}`);
2061
+ const list = Array.isArray(iterations?.value) ? iterations.value : [];
2062
+ const latest = list.length
2063
+ ? list.reduce((newest, entry) => String(entry?.createdDate ?? '') > String(newest?.createdDate ?? '') ? entry : newest)
2064
+ : null;
2065
+ if (!latest) {
2066
+ return resp.type('application/json').send({ files: [] });
2067
+ }
2068
+ const data = await azureJson('GET', `${base}/iterations/${latest.id}/changes?api-version=${AZURE_API_VERSION}`);
2069
+ const changeStatus = {
2070
+ add: 'added',
2071
+ edit: 'modified',
2072
+ rename: 'renamed',
2073
+ delete: 'deleted',
2074
+ undelete: 'added',
2075
+ };
2076
+ const files = (Array.isArray(data?.value) ? data.value : []).map((entry) => {
2077
+ const path = String(entry?.item?.path ?? '').replace(/^\//, '');
2078
+ const changeType = changeStatus[String(entry?.changeType ?? '')] ?? 'modified';
2079
+ const oldPath = changeType === 'renamed'
2080
+ ? String(entry?.sourceServerItem ?? '').replace(/^\//, '')
2081
+ : '';
2082
+ return { path, oldPath, changeType };
2083
+ });
2084
+ return resp.type('application/json').send({ files });
2085
+ }
2086
+ catch (err) {
2087
+ return resp.status(400).type('application/json').send({ error: err.message });
2088
+ }
2089
+ });
2090
+ // Unified diff of one PR file, computed from the Azure item contents at the
2091
+ // PR merge commits. source/target are optional overrides; default comes from
2092
+ // the pull request detail.
2093
+ app.get('/api/azure-devops/pullrequests/:id/file-diff', async (req, resp) => {
2094
+ try {
2095
+ const path = String(req.query?.path ?? '').replace(/^\//, '');
2096
+ if (!path) {
2097
+ return resp.status(400).type('application/json').send({ error: 'path required' });
2098
+ }
2099
+ const oldPath = String(req.query?.oldPath ?? '').replace(/^\//, '');
2100
+ const { base, prefix, repo } = await azurePrApiBase(Number(req.params?.id));
2101
+ let source = String(req.query?.source ?? '');
2102
+ let target = String(req.query?.target ?? '');
2103
+ if (!source || !target) {
2104
+ const pr = await azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`);
2105
+ source = source || String(pr?.lastMergeSourceCommit?.commitId ?? '');
2106
+ target = target || String(pr?.lastMergeTargetCommit?.commitId ?? '');
2107
+ }
2108
+ if (!source || !target) {
2109
+ throw new Error('The pull request does not have merge commits to diff yet.');
2110
+ }
2111
+ const fetchContent = async (itemPath, commit) => {
2112
+ if (!itemPath)
2113
+ return { content: '', binary: false };
2114
+ const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/items` +
2115
+ `?path=${encodeURIComponent(`/${itemPath}`)}` +
2116
+ `&versionDescriptor.version=${encodeURIComponent(commit)}` +
2117
+ `&versionDescriptor.versionType=commit&includeContent=true` +
2118
+ `&api-version=${AZURE_API_VERSION}`;
2119
+ const result = await azureRequest('GET', url, '');
2120
+ // 404/203: the file does not exist at that commit (added/deleted file).
2121
+ if (result.status === 404 || result.status === 203) {
2122
+ return { content: '', binary: false };
2123
+ }
2124
+ if (result.status < 200 || result.status >= 300) {
2125
+ throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
2126
+ }
2127
+ const data = JSON.parse(result.body);
2128
+ return {
2129
+ content: typeof data?.content === 'string' ? data.content : '',
2130
+ binary: data?.isBinary === true,
2131
+ };
2132
+ };
2133
+ const [oldFile, newFile] = await Promise.all([
2134
+ fetchContent(oldPath || path, target),
2135
+ fetchContent(path, source),
2136
+ ]);
2137
+ if (oldFile.binary || newFile.binary) {
2138
+ return resp.type('application/json').send({
2139
+ path,
2140
+ oldPath,
2141
+ status: 'binary',
2142
+ binary: true,
2143
+ additions: 0,
2144
+ deletions: 0,
2145
+ lines: [],
2146
+ });
2147
+ }
2148
+ const status = String(req.query?.changeType ?? '') ||
2149
+ (!oldFile.content ? 'added' : !newFile.content ? 'deleted' : 'modified');
2150
+ // jsdiff omits the "diff --git" header parseUnifiedDiff expects, and
2151
+ // prefixing it lets the same parser serve both local and PR diffs.
2152
+ const patch = createTwoFilesPatch(`a/${oldPath || path}`, `b/${path}`, oldFile.content, newFile.content);
2153
+ const raw = `diff --git a/${oldPath || path} b/${path}\n${patch.replace(/^=+\n/, '')}`;
2154
+ // The patch's trailing newline would surface as a phantom context row.
2155
+ const [file] = parseUnifiedDiff(raw.replace(/\n+$/, ''));
2156
+ const maxLines = 10000;
2157
+ const lines = (file?.lines ?? []).slice(0, maxLines);
2158
+ if ((file?.lines ?? []).length > maxLines) {
2159
+ lines.push({ type: 'hunk', text: 'Diff truncated (file too large).' });
2160
+ }
2161
+ return resp.type('application/json').send({
2162
+ path,
2163
+ oldPath,
2164
+ status,
2165
+ binary: false,
2166
+ additions: file?.additions ?? 0,
2167
+ deletions: file?.deletions ?? 0,
2168
+ lines,
2169
+ });
2170
+ }
2171
+ catch (err) {
2172
+ return resp.status(400).type('application/json').send({ error: err.message });
2173
+ }
2174
+ });
1209
2175
  // ==================== Commit Detail ====================
1210
2176
  app.post('/api/commit/detail', async (req, resp) => {
1211
2177
  try {
@@ -1378,5 +2344,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1378
2344
  return {
1379
2345
  address,
1380
2346
  close: () => app.close(),
2347
+ updateSettings: (settings) => {
2348
+ azureDevOpsUrl = settings.azureDevOpsUrl;
2349
+ prBranchNameTemplate = settings.prBranchNameTemplate;
2350
+ autoReload = settings.autoReload;
2351
+ diffViewer = settings.diffViewer;
2352
+ showGraph = settings.showGraph;
2353
+ showStashes = settings.showStashes;
2354
+ fileListView = settings.fileListView;
2355
+ searchMode = settings.searchMode;
2356
+ searchCaseSensitive = settings.searchCaseSensitive;
2357
+ showTags = settings.showTags;
2358
+ showRemoteBranches = settings.showRemoteBranches;
2359
+ issueRegex = settings.issueRegex;
2360
+ issueUrl = settings.issueUrl;
2361
+ },
1381
2362
  };
1382
2363
  }