guito 0.6.1 → 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,16 +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';
15
+ import { avatarCache } from './avatars.js';
16
+ import { repositoryState } from './repository-state.js';
13
17
  const execFileAsync = promisify(execFile);
14
- const AZURE_API_VERSION = '4.1';
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
+ };
15
50
  /**
16
51
  * Extracts {projectPath, repo} from an Azure DevOps git remote URL.
17
52
  * Supports https (with optional credentials) and ssh (scp-style and ssh://)
@@ -74,11 +109,19 @@ export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
74
109
  .join('/');
75
110
  try {
76
111
  const parsed = new URL(base);
77
- const basePath = parsed.pathname.replace(/^\/+|\/+$/g, '');
78
- if (basePath && info.projectPath.toLowerCase().startsWith(basePath.toLowerCase())) {
79
- return `${parsed.origin}/${encodePath(info.projectPath)}`;
80
- }
81
- return `${parsed.origin}${basePath ? `/${encodePath(basePath)}` : ''}/${encodePath(info.projectPath)}`;
112
+ const decode = (path) => path
113
+ .split('/')
114
+ .filter(Boolean)
115
+ .map((segment) => decodeURIComponent(segment));
116
+ const baseParts = decode(parsed.pathname);
117
+ const remoteParts = info.projectPath.split('/').filter(Boolean);
118
+ let overlap = Math.min(baseParts.length, remoteParts.length);
119
+ while (overlap &&
120
+ !baseParts
121
+ .slice(-overlap)
122
+ .every((part, index) => part.toLowerCase() === remoteParts[index].toLowerCase()))
123
+ overlap--;
124
+ return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join('/')}`;
82
125
  }
83
126
  catch {
84
127
  return `${base}/${encodePath(info.projectPath)}`;
@@ -94,18 +137,18 @@ export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
94
137
  if (!info) {
95
138
  return null;
96
139
  }
97
- return `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
140
+ return `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
98
141
  }
99
142
  /**
100
143
  * Default Azure DevOps HTTP runner: shells out to the Windows built-in
101
- * curl.exe with NTLM + Negotiate and an empty username, which authenticates
102
- * 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.
103
147
  */
104
148
  function defaultAzureRequest(method, url, body) {
105
149
  return new Promise((resolveRequest, rejectRequest) => {
106
150
  const args = [
107
151
  '-sS',
108
- '--ntlm',
109
152
  '--negotiate',
110
153
  '-u',
111
154
  ':',
@@ -116,7 +159,7 @@ function defaultAzureRequest(method, url, body) {
116
159
  '-X',
117
160
  method,
118
161
  ];
119
- if (method === 'POST') {
162
+ if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
120
163
  args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
121
164
  }
122
165
  args.push(url);
@@ -161,6 +204,22 @@ const randomBranchSuffix = () => {
161
204
  }
162
205
  return suffix;
163
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
+ };
164
223
  /** Extracts Azure DevOps' human-readable error message from a REST response. */
165
224
  const azureErrorMessage = (result) => {
166
225
  try {
@@ -170,11 +229,21 @@ const azureErrorMessage = (result) => {
170
229
  return result.body;
171
230
  }
172
231
  };
173
- export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, azureRequestImpl, }) {
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, }) {
174
233
  // Initialize server
175
234
  const app = fastify({
176
235
  logger: false,
177
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
+ }
178
247
  // Register cors
179
248
  await app.register(fastifyCors);
180
249
  // Compress JSON responses (commit history payloads are highly repetitive)
@@ -207,6 +276,27 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
207
276
  }
208
277
  // Initialize git lib
209
278
  const git = simpleGit(repositoryPath);
279
+ const avatars = avatarCache(avatarFetchImpl);
280
+ app.get('/api/avatar', async (req, reply) => {
281
+ const email = String(req.query?.email ?? '').trim();
282
+ if (!email || email.length > 320)
283
+ return reply.code(400).send({ error: 'Valid email required' });
284
+ const image = await avatars(email);
285
+ reply.header('Cache-Control', 'private, max-age=3600');
286
+ return image ? reply.type(image.contentType).send(image.data) : reply.code(204).send();
287
+ });
288
+ let stateRequest;
289
+ app.get('/api/repository-state', async (_req, reply) => {
290
+ try {
291
+ stateRequest ?? (stateRequest = repositoryState(git, repositoryPath).finally(() => {
292
+ stateRequest = undefined;
293
+ }));
294
+ return await stateRequest;
295
+ }
296
+ catch (err) {
297
+ return reply.code(400).send({ error: err.message });
298
+ }
299
+ });
210
300
  // ==================== Diff parsing ====================
211
301
  function parseUnifiedDiff(rawDiff) {
212
302
  const files = [];
@@ -322,8 +412,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
322
412
  await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
323
413
  };
324
414
  const azureRequest = azureRequestImpl ?? defaultAzureRequest;
325
- const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
326
415
  const effectiveAzureUrl = async () => {
416
+ const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
327
417
  if (configuredAzureUrl) {
328
418
  return { url: configuredAzureUrl, source: 'vscode' };
329
419
  }
@@ -350,11 +440,165 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
350
440
  }
351
441
  return { prefix, remoteUrl };
352
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
+ };
529
+ /** Merges the VS Code settings with the server-side settings file. */
530
+ const effectiveSettings = async () => {
531
+ const [file, azure] = await Promise.all([readSettings(), effectiveAzureUrl()]);
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
+ }
560
+ return {
561
+ azureDevOpsUrl: azure.url,
562
+ prBranchNameTemplate: prBranchNameTemplate?.trim() ||
563
+ filePrBranchNameTemplate ||
564
+ DEFAULT_PR_BRANCH_NAME_TEMPLATE,
565
+ source: azure.source,
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,
580
+ };
581
+ };
582
+ const configuredIdentity = async () => {
583
+ const config = await git.listConfig();
584
+ return {
585
+ name: String(config.all['user.name'] ?? '').trim(),
586
+ email: String(config.all['user.email'] ?? '').trim(),
587
+ };
588
+ };
589
+ const authorIdentity = (name, email, identity) => ({
590
+ author_name: identity.name && identity.email && email.trim().toLowerCase() === identity.email.toLowerCase()
591
+ ? identity.name
592
+ : name,
593
+ author_email: email,
594
+ });
353
595
  // ==================== Repository ====================
354
596
  app.get('/api/repo', async (_req, resp) => {
355
597
  try {
356
598
  const root = (await git.revparse(['--show-toplevel'])).trim();
357
- return resp.type('application/json').send({ root, name: basename(root) });
599
+ return resp
600
+ .type('application/json')
601
+ .send({ root, name: basename(root), identity: await configuredIdentity() });
358
602
  }
359
603
  catch (err) {
360
604
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -389,12 +633,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
389
633
  if (Number.isFinite(skip) && skip > 0) {
390
634
  options['--skip'] = String(skip);
391
635
  }
392
- const [log, count] = await Promise.all([
636
+ const [log, count, identity] = await Promise.all([
393
637
  git.log(options),
394
638
  git.raw(['rev-list', '--count', '--all']),
639
+ configuredIdentity(),
395
640
  ]);
396
641
  const commits = log.all.map((commit) => ({
397
642
  ...commit,
643
+ ...authorIdentity(commit.author_name, commit.author_email, identity),
398
644
  parents: String(commit.parents ?? '')
399
645
  .split(' ')
400
646
  .filter(Boolean),
@@ -417,28 +663,42 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
417
663
  // matches over the loaded history.
418
664
  app.get('/api/commits/search', async (req, resp) => {
419
665
  const query = String((req.query ?? {}).query ?? '').trim();
666
+ const caseSensitive = String((req.query ?? {}).caseSensitive ?? '') === '1';
420
667
  if (!query) {
421
- return resp.type('application/json').send({ hashes: [] });
668
+ return resp.type('application/json').send({ hashes: [], indices: {} });
422
669
  }
423
670
  try {
424
- const raw = await git.raw([
425
- 'log',
426
- '--all',
427
- '-i',
428
- '--fixed-strings',
429
- `--grep=${query}`,
430
- '--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']),
431
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
+ });
432
694
  return resp.type('application/json').send({
433
- hashes: raw
434
- .split('\n')
435
- .map((hash) => hash.trim())
436
- .filter(Boolean),
695
+ hashes,
696
+ indices,
437
697
  });
438
698
  }
439
699
  catch (err) {
440
700
  if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
441
- return resp.type('application/json').send({ hashes: [] });
701
+ return resp.type('application/json').send({ hashes: [], indices: {} });
442
702
  }
443
703
  return resp.status(400).type('application/json').send({ error: err.message });
444
704
  }
@@ -617,6 +877,100 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
617
877
  return resp.status(400).type('application/json').send({ error: err.message });
618
878
  }
619
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
+ });
620
974
  app.post('/api/checkout', async (req, resp) => {
621
975
  try {
622
976
  const { ref } = req.body;
@@ -682,8 +1036,26 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
682
1036
  // ==================== Stash ====================
683
1037
  app.get('/api/stash/list', async (_req, resp) => {
684
1038
  try {
685
- const stashList = await git.stashList();
686
- 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
+ });
687
1059
  }
688
1060
  catch (err) {
689
1061
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -754,9 +1126,21 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
754
1126
  }
755
1127
  });
756
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.
757
1131
  app.get('/api/tags', async (_req, resp) => {
758
1132
  try {
759
- 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 }));
760
1144
  return resp.type('application/json').send(tags);
761
1145
  }
762
1146
  catch (err) {
@@ -802,9 +1186,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
802
1186
  }
803
1187
  });
804
1188
  // ==================== Remote Operations ====================
805
- app.get('/api/fetch', async (_req, resp) => {
1189
+ app.get('/api/fetch', async (req, resp) => {
806
1190
  try {
807
- await git.fetch();
1191
+ const prune = ['1', 'true', 'yes'].includes(String(req.query?.prune ?? '').toLowerCase());
1192
+ await git.fetch(prune ? { '--prune': null } : {});
808
1193
  return resp.type('application/json').send({ success: true });
809
1194
  }
810
1195
  catch (err) {
@@ -851,13 +1236,92 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
851
1236
  return resp.status(400).type('application/json').send({ error: err.message });
852
1237
  }
853
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
+ });
854
1320
  // ==================== Settings ====================
855
1321
  app.get('/api/settings', async (_req, resp) => {
856
1322
  try {
857
- const effective = await effectiveAzureUrl();
858
- return resp
859
- .type('application/json')
860
- .send({ azureDevOpsUrl: effective.url, source: effective.source });
1323
+ const settings = await effectiveSettings();
1324
+ return resp.type('application/json').send(settings);
861
1325
  }
862
1326
  catch (err) {
863
1327
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -865,20 +1329,105 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
865
1329
  });
866
1330
  app.post('/api/settings', async (req, resp) => {
867
1331
  try {
868
- const url = String(req.body?.azureDevOpsUrl ?? '').trim();
869
- if (url && !/^https?:\/\//i.test(url)) {
870
- return resp
871
- .status(400)
872
- .type('application/json')
873
- .send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
874
- }
1332
+ const body = req.body ?? {};
875
1333
  const settings = await readSettings();
876
- 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
+ }
877
1428
  await writeSettings(settings);
878
- const effective = await effectiveAzureUrl();
879
- return resp
880
- .type('application/json')
881
- .send({ azureDevOpsUrl: effective.url, source: effective.source });
1429
+ const effective = await effectiveSettings();
1430
+ return resp.type('application/json').send(effective);
882
1431
  }
883
1432
  catch (err) {
884
1433
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -899,12 +1448,49 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
899
1448
  .type('application/json')
900
1449
  .send({ error: 'Unable to build the Azure DevOps pull request URL.' });
901
1450
  }
902
- const prUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
1451
+ const prUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
903
1452
  let source = String(sourceBranch ?? '').trim();
904
1453
  if (newBranch) {
905
1454
  // Create a remote-only branch from the current HEAD; the local
906
1455
  // repository and the checked-out branch stay untouched.
907
- 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
+ }
908
1494
  await git.push(['origin', `HEAD:refs/heads/${name}`]);
909
1495
  source = name;
910
1496
  }
@@ -936,8 +1522,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
936
1522
  }
937
1523
  }
938
1524
  const payload = {
1525
+ isDraft: req.body?.isDraft === true,
939
1526
  sourceRefName: `refs/heads/${source}`,
940
- targetRefName: `refs/heads/${targetBranch}`,
1527
+ targetRefName: `refs/heads/${String(targetBranch)
1528
+ .replace(/^refs\/heads\//, '')
1529
+ .replace(/^origin\//, '')}`,
941
1530
  };
942
1531
  const trimmedTitle = String(title ?? '').trim();
943
1532
  const trimmedDescription = String(description ?? '').trim();
@@ -959,7 +1548,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
959
1548
  .map((id) => Number(id))
960
1549
  .filter((id) => Number.isInteger(id) && id > 0);
961
1550
  if (payloadWorkItems.length) {
962
- payload.workItems = payloadWorkItems.map((id) => ({ id }));
1551
+ payload.workItemRefs = payloadWorkItems.map((id) => ({ id: String(id) }));
963
1552
  }
964
1553
  const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
965
1554
  if (result.status < 200 || result.status >= 300) {
@@ -978,7 +1567,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
978
1567
  const warnings = [];
979
1568
  if (labelNames.length && created.pullRequestId) {
980
1569
  // Built from the prefix: prUrl already carries the api-version query.
981
- const labelsUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}` +
1570
+ const labelsUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}` +
982
1571
  `/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
983
1572
  for (const name of labelNames) {
984
1573
  try {
@@ -1004,8 +1593,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1004
1593
  }
1005
1594
  });
1006
1595
  // ==================== Azure DevOps PR metadata ====================
1007
- // Identity search for the reviewer pickers (the endpoint the Azure DevOps
1008
- // web UI itself uses; undocumented but stable across TFS 2018+).
1596
+ // Collection-scoped identity IDs can be passed directly as PR reviewers.
1009
1597
  app.get('/api/azure-devops/reviewers', async (req, resp) => {
1010
1598
  try {
1011
1599
  const query = String((req.query ?? {}).query ?? '').trim();
@@ -1013,9 +1601,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1013
1601
  return resp.type('application/json').send({ reviewers: [] });
1014
1602
  }
1015
1603
  const { prefix } = await azureProjectContext();
1016
- const url = `${prefix}/_apis/identitypicker/identities?api-version=${AZURE_API_VERSION}` +
1017
- '&identityTypes=msa-uuid,umd&queryType=nameAndEmail&searchFilter=' +
1018
- `&showImage=False&filterValue=${encodeURIComponent(query)}`;
1604
+ const collection = prefix.slice(0, prefix.lastIndexOf('/'));
1605
+ const identityBase = collection.replace('https://dev.azure.com/', 'https://vssps.dev.azure.com/');
1606
+ const url = `${identityBase}/_apis/identities?api-version=5.0` +
1607
+ `&searchFilter=General&filterValue=${encodeURIComponent(query)}&queryMembership=None`;
1019
1608
  const result = await azureRequest('GET', url, '');
1020
1609
  if (result.status < 200 || result.status >= 300) {
1021
1610
  return resp
@@ -1026,12 +1615,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1026
1615
  });
1027
1616
  }
1028
1617
  const data = JSON.parse(result.body);
1029
- const list = Array.isArray(data) ? data : (data.results ?? data.identities ?? []);
1618
+ const list = Array.isArray(data) ? data : (data.value ?? data.identities ?? []);
1030
1619
  const reviewers = list
1031
1620
  .map((entry) => ({
1032
1621
  id: String(entry?.id ?? entry?.identity?.id ?? ''),
1033
- label: String(entry?.label ?? entry?.displayName ?? entry?.name ?? ''),
1034
- description: entry?.description ? String(entry.description) : undefined,
1622
+ label: String(entry?.customDisplayName ||
1623
+ entry?.providerDisplayName ||
1624
+ entry?.label ||
1625
+ entry?.displayName ||
1626
+ entry?.name ||
1627
+ ''),
1628
+ description: entry?.properties?.Mail?.$value ??
1629
+ entry?.properties?.Account?.$value ??
1630
+ entry?.description ??
1631
+ undefined,
1035
1632
  }))
1036
1633
  .filter((entry) => entry.id && entry.label);
1037
1634
  return resp.type('application/json').send({ reviewers });
@@ -1044,19 +1641,21 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1044
1641
  // then fetches title/state for the first matches.
1045
1642
  app.get('/api/azure-devops/workitems', async (req, resp) => {
1046
1643
  try {
1047
- const query = String((req.query ?? {}).query ?? '').trim();
1644
+ const query = String((req.query ?? {}).query ?? '')
1645
+ .trim()
1646
+ .replace(/^#(?=\d+$)/, '');
1048
1647
  if (!query) {
1049
1648
  return resp.type('application/json').send({ workItems: [] });
1050
1649
  }
1051
1650
  const { prefix } = await azureProjectContext();
1052
1651
  const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
1053
1652
  if (/^\d+$/.test(query)) {
1054
- clauses.push(`[System.Id] = '${query}'`);
1653
+ clauses.push(`[System.Id] = ${Number(query)}`);
1055
1654
  }
1056
1655
  const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
1057
- `WHERE ${clauses.map((clause) => `(${clause})`).join(' OR ')} ` +
1656
+ `WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(' OR ')}) ` +
1058
1657
  'ORDER BY [System.ChangedDate] DESC';
1059
- const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
1658
+ const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
1060
1659
  if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
1061
1660
  return resp
1062
1661
  .status(400)
@@ -1093,23 +1692,481 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1093
1692
  return resp.status(400).type('application/json').send({ error: err.message });
1094
1693
  }
1095
1694
  });
1096
- // Work item tag names for the tag autocomplete.
1695
+ // Existing pull request labels for the tag autocomplete.
1097
1696
  app.get('/api/azure-devops/tags', async (_req, resp) => {
1098
1697
  try {
1099
- const { prefix } = await azureProjectContext();
1100
- const result = await azureRequest('GET', `${prefix}/_apis/wit/tags?api-version=${AZURE_API_VERSION}`, '');
1101
- if (result.status < 200 || result.status >= 300) {
1698
+ const { prefix, remoteUrl } = await azureProjectContext();
1699
+ const repo = parseAzureRemoteUrl(remoteUrl).repo;
1700
+ // PR labels are distinct from Azure Boards work item tags.
1701
+ const names = new Set();
1702
+ for (let skip = 0;; skip += 100) {
1703
+ const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
1704
+ `?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`, '');
1705
+ if (result.status < 200 || result.status >= 300) {
1706
+ throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
1707
+ }
1708
+ const data = JSON.parse(result.body);
1709
+ const requests = Array.isArray(data) ? data : (data.value ?? []);
1710
+ // Some Server releases omit labels from the PR list response.
1711
+ // Read those resources explicitly, with at most five requests in flight.
1712
+ for (let offset = 0; offset < requests.length; offset += 5) {
1713
+ const labels = await Promise.all(requests.slice(offset, offset + 5).map(async (request) => {
1714
+ if (Array.isArray(request.labels))
1715
+ return request.labels;
1716
+ if (!Number.isInteger(request.pullRequestId))
1717
+ return [];
1718
+ const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}` +
1719
+ `/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`, '');
1720
+ if (result.status < 200 || result.status >= 300) {
1721
+ throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
1722
+ }
1723
+ const data = JSON.parse(result.body);
1724
+ return Array.isArray(data) ? data : (data.value ?? []);
1725
+ }));
1726
+ for (const label of labels.flat()) {
1727
+ if (typeof label.name === 'string' && label.name.trim())
1728
+ names.add(label.name.trim());
1729
+ }
1730
+ }
1731
+ if (requests.length < 100)
1732
+ break;
1733
+ }
1734
+ const tags = [...names].sort((a, b) => a.localeCompare(b));
1735
+ return resp.type('application/json').send({ tags });
1736
+ }
1737
+ catch (err) {
1738
+ return resp.status(400).type('application/json').send({ error: err.message });
1739
+ }
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)) {
1102
1850
  return resp
1103
1851
  .status(400)
1104
1852
  .type('application/json')
1105
- .send({
1106
- error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
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: [],
1107
2146
  });
1108
2147
  }
1109
- const tags = (JSON.parse(result.body).value ?? [])
1110
- .map((tag) => String(tag?.name ?? '').trim())
1111
- .filter(Boolean);
1112
- return resp.type('application/json').send({ tags });
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
+ });
1113
2170
  }
1114
2171
  catch (err) {
1115
2172
  return resp.status(400).type('application/json').send({ error: err.message });
@@ -1139,8 +2196,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1139
2196
  message,
1140
2197
  refs: '',
1141
2198
  body: body ?? '',
1142
- author_name: authorName,
1143
- author_email: authorEmail,
2199
+ ...authorIdentity(authorName, authorEmail, await configuredIdentity()),
1144
2200
  parents: String(parents ?? '')
1145
2201
  .split(' ')
1146
2202
  .filter(Boolean),
@@ -1288,5 +2344,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1288
2344
  return {
1289
2345
  address,
1290
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
+ },
1291
2362
  };
1292
2363
  }