guito 0.6.2 → 1.0.1

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.
@@ -1,26 +1,59 @@
1
- import { existsSync } from 'node:fs';
2
- import { execFile, spawn } from 'node:child_process';
3
- import { readFile, rm, writeFile } from 'node:fs/promises';
4
- import { randomBytes } from 'node:crypto';
5
- import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
6
- import fastify from 'fastify';
7
- import fastifyStatic from '@fastify/static';
8
- import fastifyCors from '@fastify/cors';
9
- import fastifyCompress from '@fastify/compress';
10
- import { simpleGit } from 'simple-git';
11
- import { promisify } from 'node:util';
12
- import { workingTree } from './working-tree.js';
13
- import { avatarCache } from './avatars.js';
14
- import { repositoryState } from './repository-state.js';
1
+ import { existsSync } from "node:fs";
2
+ import { execFile, spawn } from "node:child_process";
3
+ import { readFile, rm, writeFile } from "node:fs/promises";
4
+ import { randomBytes } from "node:crypto";
5
+ import { userInfo } from "node:os";
6
+ import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
7
+ import fastify from "fastify";
8
+ import fastifyStatic from "@fastify/static";
9
+ import fastifyCors from "@fastify/cors";
10
+ import fastifyCompress from "@fastify/compress";
11
+ import { simpleGit } from "simple-git";
12
+ import { createTwoFilesPatch } from "diff";
13
+ import { promisify } from "node:util";
14
+ import { workingTree } from "./working-tree.js";
15
+ import { avatarCache } from "./avatars.js";
16
+ import { repositoryState } from "./repository-state.js";
15
17
  const execFileAsync = promisify(execFile);
16
- const AZURE_API_VERSION = '5.0';
18
+ const AZURE_API_VERSION = "5.0-preview";
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://)
20
53
  * forms, a trailing .git, and URL-encoded segments such as %20.
21
54
  */
22
55
  export function parseAzureRemoteUrl(remoteUrl) {
23
- const value = (remoteUrl ?? '').trim();
56
+ const value = (remoteUrl ?? "").trim();
24
57
  if (!value) {
25
58
  return null;
26
59
  }
@@ -37,19 +70,19 @@ export function parseAzureRemoteUrl(remoteUrl) {
37
70
  }
38
71
  path = scpMatch[1];
39
72
  }
40
- path = path.replace(/\.git\/?$/i, '');
73
+ path = path.replace(/\.git\/?$/i, "");
41
74
  try {
42
75
  path = decodeURIComponent(path);
43
76
  }
44
77
  catch {
45
78
  // Keep the raw path when it contains invalid percent-escapes.
46
79
  }
47
- const gitIndex = path.toLowerCase().lastIndexOf('/_git/');
80
+ const gitIndex = path.toLowerCase().lastIndexOf("/_git/");
48
81
  if (gitIndex < 0) {
49
82
  return null;
50
83
  }
51
- const projectPath = path.slice(0, gitIndex).replace(/^\/+|\/+$/g, '');
52
- const repo = path.slice(gitIndex + '/_git/'.length).replace(/^\/+|\/+$/g, '');
84
+ const projectPath = path.slice(0, gitIndex).replace(/^\/+|\/+$/g, "");
85
+ const repo = path.slice(gitIndex + "/_git/".length).replace(/^\/+|\/+$/g, "");
53
86
  if (!projectPath || !repo) {
54
87
  return null;
55
88
  }
@@ -61,7 +94,7 @@ export function parseAzureRemoteUrl(remoteUrl) {
61
94
  * prefix of the remote path it is not duplicated.
62
95
  */
63
96
  export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
64
- const base = (baseUrl ?? '').trim().replace(/\/+$/, '');
97
+ const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
65
98
  if (!base) {
66
99
  return null;
67
100
  }
@@ -70,25 +103,25 @@ export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
70
103
  return null;
71
104
  }
72
105
  const encodePath = (value) => value
73
- .split('/')
106
+ .split("/")
74
107
  .filter(Boolean)
75
108
  .map((segment) => encodeURIComponent(segment))
76
- .join('/');
109
+ .join("/");
77
110
  try {
78
111
  const parsed = new URL(base);
79
112
  const decode = (path) => path
80
- .split('/')
113
+ .split("/")
81
114
  .filter(Boolean)
82
115
  .map((segment) => decodeURIComponent(segment));
83
116
  const baseParts = decode(parsed.pathname);
84
- const remoteParts = info.projectPath.split('/').filter(Boolean);
117
+ const remoteParts = info.projectPath.split("/").filter(Boolean);
85
118
  let overlap = Math.min(baseParts.length, remoteParts.length);
86
119
  while (overlap &&
87
120
  !baseParts
88
121
  .slice(-overlap)
89
122
  .every((part, index) => part.toLowerCase() === remoteParts[index].toLowerCase()))
90
123
  overlap--;
91
- return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join('/')}`;
124
+ return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join("/")}`;
92
125
  }
93
126
  catch {
94
127
  return `${base}/${encodePath(info.projectPath)}`;
@@ -108,126 +141,158 @@ 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
- function defaultAzureRequest(method, url, body) {
148
+ function defaultAzureRequest(method, url, body, contentType = "application/json") {
115
149
  return new Promise((resolveRequest, rejectRequest) => {
116
150
  const args = [
117
- '-sS',
118
- '--ntlm',
119
- '--negotiate',
120
- '-u',
121
- ':',
122
- '--max-time',
123
- '60',
124
- '-w',
125
- '\n%{http_code}',
126
- '-X',
151
+ "-sS",
152
+ "--negotiate",
153
+ "-u",
154
+ ":",
155
+ "--max-time",
156
+ "60",
157
+ "-w",
158
+ "\n%{http_code}",
159
+ "-X",
127
160
  method,
128
161
  ];
129
- if (method === 'POST') {
130
- args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
162
+ if (method === "POST" || method === "PUT" || method === "PATCH") {
163
+ args.push("-H", `Content-Type: ${contentType}`, "--data-binary", "@-");
131
164
  }
132
165
  args.push(url);
133
- const child = spawn('curl.exe', args, { windowsHide: true });
134
- let stdout = '';
135
- let stderr = '';
136
- child.stdout.on('data', (chunk) => {
166
+ const child = spawn("curl.exe", args, { windowsHide: true });
167
+ let stdout = "";
168
+ let stderr = "";
169
+ child.stdout.on("data", (chunk) => {
137
170
  stdout += chunk;
138
171
  });
139
- child.stderr.on('data', (chunk) => {
172
+ child.stderr.on("data", (chunk) => {
140
173
  stderr += chunk;
141
174
  });
142
175
  // EPIPE when curl exits before reading the whole body; the close handler
143
176
  // reports the real failure.
144
- child.stdin.on('error', () => { });
145
- child.on('error', (err) => {
177
+ child.stdin.on("error", () => { });
178
+ child.on("error", (err) => {
146
179
  rejectRequest(new Error(`Failed to run curl.exe (Windows integrated auth requires Windows): ${err.message}`));
147
180
  });
148
- child.on('close', (code) => {
181
+ child.on("close", (code) => {
149
182
  if (code !== 0) {
150
183
  rejectRequest(new Error(stderr.trim() || `curl.exe exited with code ${code}.`));
151
184
  return;
152
185
  }
153
186
  // -w appends "\n<http_code>" after the response body.
154
- const separator = stdout.lastIndexOf('\n');
187
+ const separator = stdout.lastIndexOf("\n");
155
188
  const status = Number.parseInt(stdout.slice(separator + 1), 10);
156
189
  if (separator < 0 || !Number.isFinite(status)) {
157
- rejectRequest(new Error('Unexpected curl.exe output (missing HTTP status).'));
190
+ rejectRequest(new Error("Unexpected curl.exe output (missing HTTP status)."));
158
191
  return;
159
192
  }
160
193
  resolveRequest({ status, body: stdout.slice(0, separator) });
161
194
  });
162
- child.stdin.end(body, 'utf8');
195
+ child.stdin.end(body, "utf8");
163
196
  });
164
197
  }
165
198
  const randomBranchSuffix = () => {
166
- const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
199
+ const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
167
200
  const bytes = randomBytes(6);
168
- let suffix = '';
201
+ let suffix = "";
169
202
  for (const byte of bytes) {
170
203
  suffix += alphabet[byte % alphabet.length];
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 {
177
- return String(JSON.parse(result.body)?.message ?? '');
226
+ return String(JSON.parse(result.body)?.message ?? "");
178
227
  }
179
228
  catch {
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, allowMerge, 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)
191
250
  await app.register(fastifyCompress);
192
251
  if (apiToken) {
193
- app.addHook('onRequest', async (request, reply) => {
194
- const pathname = request.url.split('?', 1)[0];
195
- if (!pathname.startsWith('/api/')) {
252
+ app.addHook("onRequest", async (request, reply) => {
253
+ const pathname = request.url.split("?", 1)[0];
254
+ if (!pathname.startsWith("/api/")) {
196
255
  return;
197
256
  }
198
- const headerToken = request.headers['x-guito-token'];
199
- const queryToken = request.query?.guitoToken;
200
- const archiveToken = pathname === '/api/archive' ? queryToken : undefined;
257
+ const headerToken = request.headers["x-guito-token"];
258
+ const queryToken = request.query
259
+ ?.guitoToken;
260
+ const archiveToken = pathname === "/api/archive" ? queryToken : undefined;
201
261
  if (headerToken !== apiToken && archiveToken !== apiToken) {
202
- await reply.status(401).type('application/json').send({ error: 'unauthorized' });
262
+ await reply
263
+ .status(401)
264
+ .type("application/json")
265
+ .send({ error: "unauthorized" });
203
266
  }
204
267
  });
205
268
  }
206
269
  // Register static file provider (supports both `ui/` and `ui/browser/` layouts)
207
- const staticRoot = existsSync(join(uiRoot, 'browser', 'index.html'))
208
- ? join(uiRoot, 'browser')
270
+ const staticRoot = existsSync(join(uiRoot, "browser", "index.html"))
271
+ ? join(uiRoot, "browser")
209
272
  : uiRoot;
210
273
  if (existsSync(staticRoot)) {
211
274
  await app.register(fastifyStatic, { root: staticRoot });
212
275
  // Serve UI
213
- app.get('/', (_req, resp) => resp.sendFile('index.html'));
276
+ app.get("/", (_req, resp) => resp.sendFile("index.html"));
214
277
  }
215
278
  else {
216
- app.get('/', (_req, resp) => resp.send('Guito API is running. Build the UI with `npm run build:ui`.'));
279
+ app.get("/", (_req, resp) => resp.send("Guito API is running. Build the UI with `npm run build:ui`."));
217
280
  }
218
281
  // Initialize git lib
219
282
  const git = simpleGit(repositoryPath);
220
283
  const avatars = avatarCache(avatarFetchImpl);
221
- app.get('/api/avatar', async (req, reply) => {
222
- const email = String(req.query?.email ?? '').trim();
284
+ app.get("/api/avatar", async (req, reply) => {
285
+ const email = String(req.query?.email ?? "").trim();
223
286
  if (!email || email.length > 320)
224
- return reply.code(400).send({ error: 'Valid email required' });
287
+ return reply.code(400).send({ error: "Valid email required" });
225
288
  const image = await avatars(email);
226
- reply.header('Cache-Control', 'private, max-age=3600');
227
- return image ? reply.type(image.contentType).send(image.data) : reply.code(204).send();
289
+ reply.header("Cache-Control", "private, max-age=3600");
290
+ return image
291
+ ? reply.type(image.contentType).send(image.data)
292
+ : reply.code(204).send();
228
293
  });
229
294
  let stateRequest;
230
- app.get('/api/repository-state', async (_req, reply) => {
295
+ app.get("/api/repository-state", async (_req, reply) => {
231
296
  try {
232
297
  stateRequest ?? (stateRequest = repositoryState(git, repositoryPath).finally(() => {
233
298
  stateRequest = undefined;
@@ -241,16 +306,16 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
241
306
  // ==================== Diff parsing ====================
242
307
  function parseUnifiedDiff(rawDiff) {
243
308
  const files = [];
244
- const lines = rawDiff.split('\n');
309
+ const lines = rawDiff.split("\n");
245
310
  let current = null;
246
311
  let inHunk = false;
247
312
  let oldLine = 0;
248
313
  let newLine = 0;
249
314
  const startFile = (line) => {
250
315
  current = {
251
- path: '',
252
- oldPath: '',
253
- status: 'modified',
316
+ path: "",
317
+ oldPath: "",
318
+ status: "modified",
254
319
  lines: [],
255
320
  additions: 0,
256
321
  deletions: 0,
@@ -263,7 +328,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
263
328
  inHunk = false;
264
329
  };
265
330
  for (const line of lines) {
266
- if (line.startsWith('diff --git ')) {
331
+ if (line.startsWith("diff --git ")) {
267
332
  if (current)
268
333
  files.push(current);
269
334
  startFile(line);
@@ -271,45 +336,54 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
271
336
  }
272
337
  if (!current)
273
338
  continue;
274
- if (line.startsWith('@@')) {
339
+ if (line.startsWith("@@")) {
275
340
  inHunk = true;
276
341
  const match = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
277
342
  if (match) {
278
343
  oldLine = parseInt(match[1], 10);
279
344
  newLine = parseInt(match[2], 10);
280
345
  }
281
- current.lines.push({ type: 'hunk', text: line });
346
+ current.lines.push({ type: "hunk", text: line });
282
347
  continue;
283
348
  }
284
349
  if (!inHunk) {
285
350
  // File meta headers (mode, index, rename, binary...)
286
- if (line.startsWith('new file mode'))
287
- current.status = 'added';
288
- else if (line.startsWith('deleted file mode'))
289
- current.status = 'deleted';
290
- else if (line.startsWith('rename from '))
291
- current.status = 'renamed';
292
- else if (line.startsWith('rename to '))
293
- current.path = line.slice('rename to '.length);
294
- else if (line.startsWith('Binary files') || line.startsWith('GIT binary patch'))
295
- current.status = 'binary';
351
+ if (line.startsWith("new file mode"))
352
+ current.status = "added";
353
+ else if (line.startsWith("deleted file mode"))
354
+ current.status = "deleted";
355
+ else if (line.startsWith("rename from "))
356
+ current.status = "renamed";
357
+ else if (line.startsWith("rename to "))
358
+ current.path = line.slice("rename to ".length);
359
+ else if (line.startsWith("Binary files") ||
360
+ line.startsWith("GIT binary patch"))
361
+ current.status = "binary";
296
362
  continue;
297
363
  }
298
- if (line.startsWith('\\')) {
364
+ if (line.startsWith("\\")) {
299
365
  // ""
300
366
  continue;
301
367
  }
302
- if (line.startsWith('+')) {
303
- current.lines.push({ type: 'add', newLine: newLine++, text: line.slice(1) });
368
+ if (line.startsWith("+")) {
369
+ current.lines.push({
370
+ type: "add",
371
+ newLine: newLine++,
372
+ text: line.slice(1),
373
+ });
304
374
  current.additions++;
305
375
  }
306
- else if (line.startsWith('-')) {
307
- current.lines.push({ type: 'del', oldLine: oldLine++, text: line.slice(1) });
376
+ else if (line.startsWith("-")) {
377
+ current.lines.push({
378
+ type: "del",
379
+ oldLine: oldLine++,
380
+ text: line.slice(1),
381
+ });
308
382
  current.deletions++;
309
383
  }
310
384
  else {
311
385
  current.lines.push({
312
- type: 'context',
386
+ type: "context",
313
387
  oldLine: oldLine++,
314
388
  newLine: newLine++,
315
389
  text: line.slice(1),
@@ -328,49 +402,51 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
328
402
  mutationQueue = result.catch(() => { });
329
403
  return result;
330
404
  };
331
- const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
405
+ const repoRoot = async () => (await git.revparse(["--show-toplevel"])).trim();
332
406
  // ==================== Azure DevOps settings ====================
333
407
  // Settings live in the repository's git directory so they are per-repo and
334
408
  // never committed. The VS Code extension passes its own setting via
335
409
  // azureDevOpsUrl, which wins over the file.
336
- let settingsPath = '';
410
+ let settingsPath = "";
337
411
  try {
338
- const gitDir = (await git.revparse(['--absolute-git-dir'])).trim();
339
- settingsPath = join(gitDir, 'guito-settings.json');
412
+ const gitDir = (await git.revparse(["--absolute-git-dir"])).trim();
413
+ settingsPath = join(gitDir, "guito-settings.json");
340
414
  }
341
415
  catch {
342
- settingsPath = join(repositoryPath, '.git', 'guito-settings.json');
416
+ settingsPath = join(repositoryPath, ".git", "guito-settings.json");
343
417
  }
344
418
  const readSettings = async () => {
345
419
  try {
346
- return JSON.parse(await readFile(settingsPath, 'utf8'));
420
+ return JSON.parse(await readFile(settingsPath, "utf8"));
347
421
  }
348
422
  catch {
349
423
  return {};
350
424
  }
351
425
  };
352
426
  const writeSettings = async (settings) => {
353
- await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
427
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
354
428
  };
355
429
  const azureRequest = azureRequestImpl ?? defaultAzureRequest;
356
- const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
357
430
  const effectiveAzureUrl = async () => {
431
+ const configuredAzureUrl = azureDevOpsUrl?.trim() ?? "";
358
432
  if (configuredAzureUrl) {
359
- return { url: configuredAzureUrl, source: 'vscode' };
433
+ return { url: configuredAzureUrl, source: "vscode" };
360
434
  }
361
435
  const settings = await readSettings();
362
- const url = typeof settings.azureDevOpsUrl === 'string' ? settings.azureDevOpsUrl.trim() : '';
363
- return { url, source: url ? 'file' : '' };
436
+ const url = typeof settings.azureDevOpsUrl === "string"
437
+ ? settings.azureDevOpsUrl.trim()
438
+ : "";
439
+ return { url, source: url ? "file" : "" };
364
440
  };
365
441
  /** Resolves the Azure DevOps project prefix or throws a user-facing error. */
366
442
  const azureProjectContext = async () => {
367
443
  const effective = await effectiveAzureUrl();
368
444
  if (!effective.url) {
369
- throw new Error('Azure DevOps URL is not configured. Set it in Settings (gear icon) or the guito.azureDevOpsUrl VS Code setting.');
445
+ throw new Error("Azure DevOps URL is not configured. Set it in Settings (gear icon) or the guito.azureDevOpsUrl VS Code setting.");
370
446
  }
371
- let remoteUrl = '';
447
+ let remoteUrl = "";
372
448
  try {
373
- remoteUrl = (await git.remote(['get-url', 'origin'])).trim();
449
+ remoteUrl = (await git.remote(["get-url", "origin"])).trim();
374
450
  }
375
451
  catch {
376
452
  throw new Error('No "origin" remote is configured for this repository.');
@@ -381,47 +457,261 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
381
457
  }
382
458
  return { prefix, remoteUrl };
383
459
  };
460
+ /** Human identity of the authenticated Windows/Azure user; cached per run. */
461
+ let cachedAzureMe = null;
462
+ const azureMe = async () => {
463
+ if (cachedAzureMe) {
464
+ return cachedAzureMe;
465
+ }
466
+ const { prefix } = await azureProjectContext();
467
+ // The prefix is "{collection-or-org}/{project}"; connectionData is
468
+ // collection scoped.
469
+ const collection = prefix.slice(0, prefix.lastIndexOf("/"));
470
+ const result = await azureRequest("GET", `${collection}/_apis/connectionData?q=1&api-version=${AZURE_API_VERSION}`, "");
471
+ if (result.status < 200 || result.status >= 300) {
472
+ throw new Error(azureErrorMessage(result) ||
473
+ `Azure DevOps returned HTTP ${result.status} while resolving the current user.`);
474
+ }
475
+ const user = (JSON.parse(result.body) ?? {}).authenticatedUser;
476
+ const id = String(user?.id ?? "");
477
+ if (!id) {
478
+ throw new Error("Azure DevOps did not report an authenticated user.");
479
+ }
480
+ cachedAzureMe = {
481
+ id,
482
+ name: String(user?.providerDisplayName ?? ""),
483
+ email: String(user?.properties?.Mail?.$value ??
484
+ user?.properties?.Account?.$value ??
485
+ ""),
486
+ };
487
+ return cachedAzureMe;
488
+ };
489
+ /** Web UI URL of a pull request, used by the "Open in Azure DevOps" links. */
490
+ const azurePrWebUrl = (prefix, remoteUrl, id) => {
491
+ const repo = parseAzureRemoteUrl(remoteUrl)?.repo ?? "";
492
+ return `${prefix}/_git/${encodeURIComponent(repo)}/pullrequest/${id}`;
493
+ };
494
+ /** Normalizes the Azure pull request merge state for the dialog. */
495
+ const azureMergeStatus = (pr) => {
496
+ const raw = String(pr?.mergeStatus ?? "notSet");
497
+ return [
498
+ "notSet",
499
+ "queued",
500
+ "conflicts",
501
+ "succeeded",
502
+ "rejectedByPolicy",
503
+ "failure",
504
+ ].includes(raw)
505
+ ? raw
506
+ : "notSet";
507
+ };
508
+ /** Maps one Azure DevOps pull request record to the shape the UI consumes. */
509
+ const mapAzurePullRequest = (pr, prefix, remoteUrl, me) => {
510
+ const reviewers = (Array.isArray(pr?.reviewers) ? pr.reviewers : []).map((entry) => ({
511
+ id: String(entry?.id ?? ""),
512
+ name: String(entry?.displayName ?? ""),
513
+ email: String(entry?.uniqueName ?? ""),
514
+ vote: Number(entry?.vote ?? 0),
515
+ isRequired: entry?.isRequired === true,
516
+ }));
517
+ const mine = me
518
+ ? reviewers.find((reviewer) => reviewer.id === me.id)
519
+ : undefined;
520
+ return {
521
+ id: Number(pr?.pullRequestId ?? 0),
522
+ title: String(pr?.title ?? ""),
523
+ isDraft: pr?.isDraft === true,
524
+ author: {
525
+ id: String(pr?.createdBy?.id ?? ""),
526
+ name: String(pr?.createdBy?.displayName ?? ""),
527
+ email: String(pr?.createdBy?.uniqueName ?? ""),
528
+ },
529
+ createdAt: String(pr?.creationDate ?? ""),
530
+ sourceBranch: String(pr?.sourceRefName ?? "").replace(/^refs\/heads\//, ""),
531
+ targetBranch: String(pr?.targetRefName ?? "").replace(/^refs\/heads\//, ""),
532
+ status: String(pr?.status ?? "active"),
533
+ webUrl: String(pr?._links?.web?.href || "") ||
534
+ azurePrWebUrl(prefix, remoteUrl, Number(pr?.pullRequestId ?? 0)),
535
+ reviewers,
536
+ myVote: mine ? Number(mine.vote ?? 0) : 0,
537
+ requiresMe: mine?.isRequired === true,
538
+ };
539
+ };
540
+ /** REST base of one pull request (".../pullrequests/{id}") plus repo context. */
541
+ const azurePrApiBase = async (id) => {
542
+ if (!Number.isInteger(id) || id <= 0) {
543
+ throw new Error("A pull request id is required.");
544
+ }
545
+ const { prefix, remoteUrl } = await azureProjectContext();
546
+ const repo = parseAzureRemoteUrl(remoteUrl)?.repo;
547
+ if (!repo) {
548
+ throw new Error(`The origin remote does not point to an Azure DevOps project: ${remoteUrl}`);
549
+ }
550
+ return {
551
+ prefix,
552
+ remoteUrl,
553
+ repo,
554
+ base: `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests/${id}`,
555
+ };
556
+ };
557
+ /** Runs one Azure request and turns non-2xx replies into user-facing errors. */
558
+ const azureJson = async (method, url, body = "", contentType = "application/json") => {
559
+ const result = await azureRequest(method, url, body, contentType);
560
+ if (result.status < 200 || result.status >= 300) {
561
+ throw new Error(azureErrorMessage(result) ||
562
+ `Azure DevOps returned HTTP ${result.status}.`);
563
+ }
564
+ return (result.body ? JSON.parse(result.body) : {});
565
+ };
566
+ /** vstfs artifact URI of a pull request; work item ArtifactLink relations point at it. */
567
+ const azurePrArtifactUrl = (projectId, repositoryId, id) => `vstfs:///Git/PullRequestId/${encodeURIComponent(projectId)}%2F${encodeURIComponent(repositoryId)}%2F${id}`;
568
+ /** Reads one work item's relations from the WIT API. */
569
+ const azureWorkItemRelations = async (prefix, workItemId) => {
570
+ const item = await azureJson("GET", `${prefix}/_apis/wit/workitems/${workItemId}?$expand=relations&api-version=${AZURE_API_VERSION}`);
571
+ return Array.isArray(item?.relations) ? item.relations : [];
572
+ };
573
+ /** Patches a work item's relations (the WIT API wants the patch content type). */
574
+ const azurePatchWorkItemRelations = async (prefix, workItemId, patch) => {
575
+ await azureJson("PATCH", `${prefix}/_apis/wit/workitems/${workItemId}?api-version=${AZURE_API_VERSION}`, JSON.stringify(patch), "application/json-patch+json");
576
+ };
577
+ /** Project prefix plus artifact URI of one pull request (work item links). */
578
+ const azurePrArtifactOf = async (id) => {
579
+ const { base, prefix } = await azurePrApiBase(id);
580
+ const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
581
+ const projectId = String(pr?.repository?.project?.id ?? "");
582
+ const repositoryId = String(pr?.repository?.id ?? "");
583
+ if (!projectId || !repositoryId) {
584
+ throw new Error("Azure DevOps did not report the pull request's project and repository.");
585
+ }
586
+ return {
587
+ prefix,
588
+ artifactUrl: azurePrArtifactUrl(projectId, repositoryId, id),
589
+ };
590
+ };
384
591
  /** Merges the VS Code settings with the server-side settings file. */
385
592
  const effectiveSettings = async () => {
386
- const [file, azure] = await Promise.all([readSettings(), effectiveAzureUrl()]);
387
- const fileAutoReload = typeof file.autoReload === 'boolean' ? file.autoReload : undefined;
593
+ const [file, azure] = await Promise.all([
594
+ readSettings(),
595
+ effectiveAzureUrl(),
596
+ ]);
597
+ const fileAutoReload = typeof file.autoReload === "boolean" ? file.autoReload : undefined;
598
+ const fileShowGraph = typeof file.showGraph === "boolean" ? file.showGraph : undefined;
599
+ const fileShowStashes = typeof file.showStashes === "boolean" ? file.showStashes : undefined;
600
+ const fileShowTags = typeof file.showTags === "boolean" ? file.showTags : undefined;
601
+ const fileShowRemoteBranches = typeof file.showRemoteBranches === "boolean"
602
+ ? file.showRemoteBranches
603
+ : undefined;
604
+ const fileFileListView = file.fileListView === "tree" || file.fileListView === "flat"
605
+ ? file.fileListView
606
+ : undefined;
607
+ const fileSearchMode = file.searchMode === "filter" || file.searchMode === "navigate"
608
+ ? file.searchMode
609
+ : undefined;
610
+ const fileAllowMerge = typeof file.allowMerge === "boolean" ? file.allowMerge : undefined;
611
+ const fileSearchCaseSensitive = typeof file.searchCaseSensitive === "boolean"
612
+ ? file.searchCaseSensitive
613
+ : undefined;
614
+ const filePrBranchNameTemplate = typeof file.prBranchNameTemplate === "string" &&
615
+ file.prBranchNameTemplate.trim()
616
+ ? file.prBranchNameTemplate.trim()
617
+ : undefined;
618
+ const localIssue = file.issueLinking;
619
+ let issueLinking = null;
620
+ if (issueRegex?.trim() && issueUrl?.trim()) {
621
+ issueLinking = {
622
+ regex: issueRegex.trim(),
623
+ url: issueUrl.trim(),
624
+ useGlobally: true,
625
+ };
626
+ }
627
+ else if (typeof localIssue?.regex === "string" &&
628
+ typeof localIssue.url === "string") {
629
+ issueLinking = {
630
+ regex: localIssue.regex,
631
+ url: localIssue.url,
632
+ useGlobally: false,
633
+ };
634
+ }
635
+ else {
636
+ const [regex, url] = await Promise.all([
637
+ git
638
+ .raw(["config", "--global", "--get", "guito.issueRegex"])
639
+ .catch(() => ""),
640
+ git
641
+ .raw(["config", "--global", "--get", "guito.issueUrl"])
642
+ .catch(() => ""),
643
+ ]);
644
+ if (regex.trim() && url.trim()) {
645
+ issueLinking = {
646
+ regex: regex.trim(),
647
+ url: url.trim(),
648
+ useGlobally: true,
649
+ };
650
+ }
651
+ }
388
652
  return {
389
653
  azureDevOpsUrl: azure.url,
654
+ prBranchNameTemplate: prBranchNameTemplate?.trim() ||
655
+ filePrBranchNameTemplate ||
656
+ DEFAULT_PR_BRANCH_NAME_TEMPLATE,
390
657
  source: azure.source,
391
- autoReload: typeof autoReload === 'boolean' ? autoReload : (fileAutoReload ?? true),
658
+ autoReload: typeof autoReload === "boolean" ? autoReload : (fileAutoReload ?? true),
659
+ diffViewer: diffViewer === "vscode" ? "vscode" : "guito",
660
+ showGraph: typeof showGraph === "boolean" ? showGraph : (fileShowGraph ?? true),
661
+ showStashes: typeof showStashes === "boolean"
662
+ ? showStashes
663
+ : (fileShowStashes ?? false),
664
+ showTags: typeof showTags === "boolean" ? showTags : (fileShowTags ?? true),
665
+ showRemoteBranches: typeof showRemoteBranches === "boolean"
666
+ ? showRemoteBranches
667
+ : (fileShowRemoteBranches ?? true),
668
+ fileListView: fileListView ?? fileFileListView ?? "flat",
669
+ searchMode: searchMode ?? fileSearchMode ?? "navigate",
670
+ searchCaseSensitive: typeof searchCaseSensitive === "boolean"
671
+ ? searchCaseSensitive
672
+ : (fileSearchCaseSensitive ?? false),
673
+ allowMerge: typeof allowMerge === "boolean" ? allowMerge : (fileAllowMerge ?? true),
674
+ issueLinking,
392
675
  };
393
676
  };
394
677
  const configuredIdentity = async () => {
395
678
  const config = await git.listConfig();
396
679
  return {
397
- name: String(config.all['user.name'] ?? '').trim(),
398
- email: String(config.all['user.email'] ?? '').trim(),
680
+ name: String(config.all["user.name"] ?? "").trim(),
681
+ email: String(config.all["user.email"] ?? "").trim(),
399
682
  };
400
683
  };
401
684
  const authorIdentity = (name, email, identity) => ({
402
- author_name: identity.name && identity.email && email.trim().toLowerCase() === identity.email.toLowerCase()
685
+ author_name: identity.name &&
686
+ identity.email &&
687
+ email.trim().toLowerCase() === identity.email.toLowerCase()
403
688
  ? identity.name
404
689
  : name,
405
690
  author_email: email,
406
691
  });
407
692
  // ==================== Repository ====================
408
- app.get('/api/repo', async (_req, resp) => {
693
+ app.get("/api/repo", async (_req, resp) => {
409
694
  try {
410
- const root = (await git.revparse(['--show-toplevel'])).trim();
411
- return resp
412
- .type('application/json')
413
- .send({ root, name: basename(root), identity: await configuredIdentity() });
695
+ const root = (await git.revparse(["--show-toplevel"])).trim();
696
+ return resp.type("application/json").send({
697
+ root,
698
+ name: basename(root),
699
+ identity: await configuredIdentity(),
700
+ });
414
701
  }
415
702
  catch (err) {
416
- return resp.status(400).type('application/json').send({ error: err.message });
703
+ return resp
704
+ .status(400)
705
+ .type("application/json")
706
+ .send({ error: err.message });
417
707
  }
418
708
  });
419
709
  // ==================== Commits ====================
420
- app.get('/api/commits', async (req, resp) => {
710
+ app.get("/api/commits", async (req, resp) => {
421
711
  try {
422
712
  const query = (req.query ?? {});
423
- const limit = Number.parseInt(query.limit ?? '', 10);
424
- const skip = Number.parseInt(query.skip ?? '', 10);
713
+ const limit = Number.parseInt(query.limit ?? "", 10);
714
+ const skip = Number.parseInt(query.skip ?? "", 10);
425
715
  // Build simple-git options conditionally: an explicit key with an
426
716
  // undefined value would still be emitted as a bare command flag.
427
717
  // The commit body is intentionally omitted: it roughly doubles the
@@ -429,546 +719,1072 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
429
719
  // on demand by /api/commit/detail instead.
430
720
  const options = {
431
721
  format: {
432
- hash: '%H',
433
- date: '%aI',
434
- message: '%s',
435
- refs: '%D',
436
- author_name: '%aN',
437
- author_email: '%aE',
438
- parents: '%P',
722
+ hash: "%H",
723
+ date: "%aI",
724
+ message: "%s",
725
+ refs: "%D",
726
+ author_name: "%aN",
727
+ author_email: "%aE",
728
+ parents: "%P",
439
729
  },
440
- '--all': null,
730
+ "--all": null,
441
731
  };
442
732
  if (Number.isFinite(limit) && limit > 0) {
443
733
  options.maxCount = limit;
444
734
  }
445
735
  if (Number.isFinite(skip) && skip > 0) {
446
- options['--skip'] = String(skip);
736
+ options["--skip"] = String(skip);
447
737
  }
448
738
  const [log, count, identity] = await Promise.all([
449
739
  git.log(options),
450
- git.raw(['rev-list', '--count', '--all']),
740
+ git.raw(["rev-list", "--count", "--all"]),
451
741
  configuredIdentity(),
452
742
  ]);
453
743
  const commits = log.all.map((commit) => ({
454
744
  ...commit,
455
745
  ...authorIdentity(commit.author_name, commit.author_email, identity),
456
- parents: String(commit.parents ?? '')
457
- .split(' ')
746
+ parents: String(commit.parents ?? "")
747
+ .split(" ")
458
748
  .filter(Boolean),
459
749
  }));
460
750
  return resp
461
- .type('application/json')
751
+ .type("application/json")
462
752
  .send({ commits, total: Number.parseInt(count, 10) || 0 });
463
753
  }
464
754
  catch (err) {
465
755
  // A repository without any commits has no history to list.
466
- if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
467
- return resp.type('application/json').send({ commits: [], total: 0 });
756
+ if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? "")) {
757
+ return resp.type("application/json").send({ commits: [], total: 0 });
468
758
  }
469
- return resp.status(400).type('application/json').send({ error: err.message });
759
+ return resp
760
+ .status(400)
761
+ .type("application/json")
762
+ .send({ error: err.message });
470
763
  }
471
764
  });
472
765
  // Full-text commit search. The list payload omits commit bodies, so body
473
766
  // matching runs here (git --grep covers subject and body together) and the
474
767
  // client unions the resulting hashes with its local subject/author/hash
475
768
  // matches over the loaded history.
476
- app.get('/api/commits/search', async (req, resp) => {
477
- const query = String((req.query ?? {}).query ?? '').trim();
769
+ app.get("/api/commits/search", async (req, resp) => {
770
+ const query = String((req.query ?? {}).query ?? "").trim();
771
+ const caseSensitive = String((req.query ?? {}).caseSensitive ?? "") === "1";
478
772
  if (!query) {
479
- return resp.type('application/json').send({ hashes: [] });
773
+ return resp.type("application/json").send({ hashes: [], indices: {} });
480
774
  }
481
775
  try {
482
- const raw = await git.raw([
483
- 'log',
484
- '--all',
485
- '-i',
486
- '--fixed-strings',
487
- `--grep=${query}`,
488
- '--format=%H',
776
+ const grepArgs = ["log", "--all"];
777
+ if (!caseSensitive)
778
+ grepArgs.push("-i");
779
+ grepArgs.push("--fixed-strings", `--grep=${query}`, "--format=%H");
780
+ const [raw, history] = await Promise.all([
781
+ git.raw(grepArgs),
782
+ git.raw(["log", "--all", "--format=%H"]),
489
783
  ]);
490
- return resp.type('application/json').send({
491
- hashes: raw
492
- .split('\n')
493
- .map((hash) => hash.trim())
494
- .filter(Boolean),
784
+ const hashes = raw
785
+ .split("\n")
786
+ .map((hash) => hash.trim())
787
+ .filter(Boolean);
788
+ const matches = new Set(hashes);
789
+ // Use the same unfiltered Git log order as the paged history endpoint.
790
+ const indices = {};
791
+ history
792
+ .split("\n")
793
+ .map((hash) => hash.trim())
794
+ .filter(Boolean)
795
+ .forEach((hash, index) => {
796
+ if (matches.has(hash))
797
+ indices[hash] = index;
798
+ });
799
+ return resp.type("application/json").send({
800
+ hashes,
801
+ indices,
495
802
  });
496
803
  }
497
804
  catch (err) {
498
- if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
499
- return resp.type('application/json').send({ hashes: [] });
805
+ if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? "")) {
806
+ return resp.type("application/json").send({ hashes: [], indices: {} });
500
807
  }
501
- return resp.status(400).type('application/json').send({ error: err.message });
808
+ return resp
809
+ .status(400)
810
+ .type("application/json")
811
+ .send({ error: err.message });
502
812
  }
503
813
  });
504
- app.post('/api/commit', async (req, resp) => {
814
+ app.post("/api/commit", async (req, resp) => {
505
815
  try {
506
816
  const { message, description } = req.body ?? {};
507
817
  await mutate(() => working.commit(message, description));
508
- return resp.type('application/json').send({ success: true });
818
+ return resp.type("application/json").send({ success: true });
509
819
  }
510
820
  catch (err) {
511
- return resp.status(400).type('application/json').send({ error: err.message });
821
+ return resp
822
+ .status(400)
823
+ .type("application/json")
824
+ .send({ error: err.message });
512
825
  }
513
826
  });
514
- app.post('/api/amend', async (req, resp) => {
827
+ app.post("/api/amend", async (req, resp) => {
515
828
  try {
516
829
  const { message, description } = req.body;
517
830
  await git.commit([message, ...(description ? [description] : [])], {
518
- '--amend': null,
519
- '--no-edit': null,
831
+ "--amend": null,
832
+ "--no-edit": null,
520
833
  });
521
- return resp.type('application/json').send({ success: true });
834
+ return resp.type("application/json").send({ success: true });
522
835
  }
523
836
  catch (err) {
524
- return resp.status(400).type('application/json').send({ error: err.message });
837
+ return resp
838
+ .status(400)
839
+ .type("application/json")
840
+ .send({ error: err.message });
525
841
  }
526
842
  });
527
- app.post('/api/revert', async (req, resp) => {
843
+ app.post("/api/revert", async (req, resp) => {
528
844
  try {
529
845
  const { commit } = req.body;
530
846
  await git.revert(commit);
531
- return resp.type('application/json').send({ success: true });
847
+ return resp.type("application/json").send({ success: true });
532
848
  }
533
849
  catch (err) {
534
- return resp.status(400).type('application/json').send({ error: err.message });
850
+ return resp
851
+ .status(400)
852
+ .type("application/json")
853
+ .send({ error: err.message });
535
854
  }
536
855
  });
537
- app.post('/api/cherry-pick', async (req, resp) => {
856
+ app.post("/api/cherry-pick", async (req, resp) => {
538
857
  try {
539
- await git.raw(['cherry-pick', req.body.commit]);
540
- return resp.type('application/json').send({ success: true });
858
+ await git.raw(["cherry-pick", req.body.commit]);
859
+ return resp.type("application/json").send({ success: true });
541
860
  }
542
861
  catch (err) {
543
- return resp.status(400).type('application/json').send({ error: err.message });
862
+ return resp
863
+ .status(400)
864
+ .type("application/json")
865
+ .send({ error: err.message });
544
866
  }
545
867
  });
546
- app.post('/api/commit/drop', async (req, resp) => {
868
+ app.post("/api/commit/drop", async (req, resp) => {
547
869
  try {
548
- await git.raw(['reset', '--hard', `${req.body.commit}^`]);
549
- return resp.type('application/json').send({ success: true });
870
+ await git.raw(["reset", "--hard", `${req.body.commit}^`]);
871
+ return resp.type("application/json").send({ success: true });
550
872
  }
551
873
  catch (err) {
552
- return resp.status(400).type('application/json').send({ error: err.message });
874
+ return resp
875
+ .status(400)
876
+ .type("application/json")
877
+ .send({ error: err.message });
553
878
  }
554
879
  });
555
- app.post('/api/reset-commit', async (req, resp) => {
880
+ app.post("/api/reset-commit", async (req, resp) => {
556
881
  try {
557
- const mode = ['soft', 'mixed', 'hard'].includes(req.body?.mode) ? req.body.mode : 'hard';
558
- await git.raw(['reset', `--${mode}`, req.body.commit]);
559
- return resp.type('application/json').send({ success: true });
882
+ const mode = ["soft", "mixed", "hard"].includes(req.body?.mode)
883
+ ? req.body.mode
884
+ : "hard";
885
+ await git.raw(["reset", `--${mode}`, req.body.commit]);
886
+ return resp.type("application/json").send({ success: true });
560
887
  }
561
888
  catch (err) {
562
- return resp.status(400).type('application/json').send({ error: err.message });
889
+ return resp
890
+ .status(400)
891
+ .type("application/json")
892
+ .send({ error: err.message });
563
893
  }
564
894
  });
565
895
  // ==================== Staging ====================
566
- app.get('/api/status', async (_req, resp) => {
896
+ app.get("/api/status", async (_req, resp) => {
567
897
  try {
568
898
  const status = await git.status();
569
- return resp.type('application/json').send(status);
899
+ return resp.type("application/json").send(status);
570
900
  }
571
901
  catch (err) {
572
- return resp.status(400).type('application/json').send({ error: err.message });
902
+ return resp
903
+ .status(400)
904
+ .type("application/json")
905
+ .send({ error: err.message });
573
906
  }
574
907
  });
575
- app.post('/api/stage', async (req, resp) => {
908
+ app.post("/api/stage", async (req, resp) => {
576
909
  try {
577
910
  await mutate(() => working.stage(req.body?.files));
578
- return resp.type('application/json').send({ success: true });
911
+ return resp.type("application/json").send({ success: true });
579
912
  }
580
913
  catch (err) {
581
- return resp.status(400).type('application/json').send({ error: err.message });
914
+ return resp
915
+ .status(400)
916
+ .type("application/json")
917
+ .send({ error: err.message });
582
918
  }
583
919
  });
584
- app.post('/api/unstage', async (req, resp) => {
920
+ app.post("/api/unstage", async (req, resp) => {
585
921
  try {
586
922
  await mutate(() => working.unstage(req.body?.files));
587
- return resp.type('application/json').send({ success: true });
923
+ return resp.type("application/json").send({ success: true });
588
924
  }
589
925
  catch (err) {
590
- return resp.status(400).type('application/json').send({ error: err.message });
926
+ return resp
927
+ .status(400)
928
+ .type("application/json")
929
+ .send({ error: err.message });
591
930
  }
592
931
  });
593
- app.post('/api/stage-lines', async (req, resp) => {
932
+ app.post("/api/stage-lines", async (req, resp) => {
594
933
  try {
595
934
  const { file, patch } = req.body;
596
935
  // Stage specific lines using git add -p equivalent (patch mode)
597
- await git.raw('add --patch', { input: patch });
598
- return resp.type('application/json').send({ success: true });
936
+ await git.raw("add --patch", { input: patch });
937
+ return resp.type("application/json").send({ success: true });
599
938
  }
600
939
  catch (err) {
601
- return resp.status(400).type('application/json').send({ error: err.message });
940
+ return resp
941
+ .status(400)
942
+ .type("application/json")
943
+ .send({ error: err.message });
602
944
  }
603
945
  });
604
946
  // ==================== Branches ====================
605
- app.get('/api/branches/all', async (_req, resp) => {
947
+ app.get("/api/branches/all", async (_req, resp) => {
606
948
  try {
607
949
  const raw = await git.raw([
608
- 'for-each-ref',
609
- '--format=%(refname)%09%(objectname)%09%(HEAD)',
610
- 'refs/heads',
611
- 'refs/remotes',
950
+ "for-each-ref",
951
+ "--format=%(refname)%09%(objectname)%09%(HEAD)",
952
+ "refs/heads",
953
+ "refs/remotes",
612
954
  ]);
613
955
  const branches = raw
614
- .split('\n')
956
+ .split("\n")
615
957
  .map((line) => line.trim())
616
958
  .filter(Boolean)
617
959
  .map((line) => {
618
- const [refname, commit, headMarker] = line.split('\t');
619
- const remote = refname.startsWith('refs/remotes/');
620
- const name = refname.replace(/^refs\/(heads|remotes)\//, '');
621
- return { name, commit, current: headMarker === '*', remote };
960
+ const [refname, commit, headMarker] = line.split("\t");
961
+ const remote = refname.startsWith("refs/remotes/");
962
+ const name = refname.replace(/^refs\/(heads|remotes)\//, "");
963
+ return { name, commit, current: headMarker === "*", remote };
622
964
  });
623
- return resp.type('application/json').send(branches);
965
+ return resp.type("application/json").send(branches);
624
966
  }
625
967
  catch (err) {
626
- return resp.status(400).type('application/json').send({ error: err.message });
968
+ return resp
969
+ .status(400)
970
+ .type("application/json")
971
+ .send({ error: err.message });
627
972
  }
628
973
  });
629
- app.get('/api/branches', async (_req, resp) => {
974
+ app.get("/api/branches", async (_req, resp) => {
630
975
  try {
631
976
  const branchSummary = await git.branchLocal();
632
- return resp.type('application/json').send(branchSummary);
977
+ return resp.type("application/json").send(branchSummary);
633
978
  }
634
979
  catch (err) {
635
- return resp.status(400).type('application/json').send({ error: err.message });
980
+ return resp
981
+ .status(400)
982
+ .type("application/json")
983
+ .send({ error: err.message });
636
984
  }
637
985
  });
638
- app.post('/api/branch/create', async (req, resp) => {
986
+ app.post("/api/branch/create", async (req, resp) => {
639
987
  try {
640
988
  const { name, startPoint } = req.body;
641
989
  await git.branch([...(startPoint ? [name, startPoint] : [name])]);
642
- return resp.type('application/json').send({ success: true });
990
+ return resp.type("application/json").send({ success: true });
643
991
  }
644
992
  catch (err) {
645
- return resp.status(400).type('application/json').send({ error: err.message });
993
+ return resp
994
+ .status(400)
995
+ .type("application/json")
996
+ .send({ error: err.message });
646
997
  }
647
998
  });
648
- app.post('/api/branch/delete', async (req, resp) => {
999
+ app.post("/api/branch/delete", async (req, resp) => {
649
1000
  try {
650
1001
  const { name, force } = req.body;
651
1002
  await git.deleteLocalBranch(name, force);
652
- return resp.type('application/json').send({ success: true });
1003
+ return resp.type("application/json").send({ success: true });
653
1004
  }
654
1005
  catch (err) {
655
- return resp.status(400).type('application/json').send({ error: err.message });
1006
+ return resp
1007
+ .status(400)
1008
+ .type("application/json")
1009
+ .send({ error: err.message });
656
1010
  }
657
1011
  });
658
- app.post('/api/branch/delete-remote', async (req, resp) => {
1012
+ app.post("/api/branch/delete-remote", async (req, resp) => {
659
1013
  try {
660
1014
  const { remote, branch } = req.body;
661
- await git.push([remote, '--delete', branch]);
662
- return resp.type('application/json').send({ success: true });
1015
+ await git.push([remote, "--delete", branch]);
1016
+ return resp.type("application/json").send({ success: true });
663
1017
  }
664
1018
  catch (err) {
665
- return resp.status(400).type('application/json').send({ error: err.message });
1019
+ return resp
1020
+ .status(400)
1021
+ .type("application/json")
1022
+ .send({ error: err.message });
666
1023
  }
667
1024
  });
668
- app.post('/api/branch/rename', async (req, resp) => {
1025
+ app.post("/api/branch/rename", async (req, resp) => {
669
1026
  try {
670
1027
  const { oldName, newName } = req.body;
671
- await git.branch(['-m', oldName, newName]);
672
- return resp.type('application/json').send({ success: true });
1028
+ await git.branch(["-m", oldName, newName]);
1029
+ return resp.type("application/json").send({ success: true });
1030
+ }
1031
+ catch (err) {
1032
+ return resp
1033
+ .status(400)
1034
+ .type("application/json")
1035
+ .send({ error: err.message });
1036
+ }
1037
+ });
1038
+ // ==================== Worktrees ====================
1039
+ app.get("/api/worktrees", async (_req, resp) => {
1040
+ try {
1041
+ const raw = await git.raw(["worktree", "list", "--porcelain"]);
1042
+ // Path comparison is case-insensitive on Windows so the served worktree
1043
+ // is detected regardless of drive-letter casing.
1044
+ const samePath = (a, b) => process.platform === "win32"
1045
+ ? resolve(a).toLowerCase() === resolve(b).toLowerCase()
1046
+ : resolve(a) === resolve(b);
1047
+ const worktrees = raw
1048
+ .split(/\n\s*\n/)
1049
+ .map((block) => block.trim())
1050
+ .filter(Boolean)
1051
+ .map((block) => {
1052
+ const entry = {
1053
+ path: "",
1054
+ head: "",
1055
+ branch: "",
1056
+ bare: false,
1057
+ detached: false,
1058
+ current: false,
1059
+ };
1060
+ for (const line of block.split("\n").map((l) => l.trim())) {
1061
+ if (line.startsWith("worktree "))
1062
+ entry.path = line.slice("worktree ".length);
1063
+ else if (line.startsWith("HEAD "))
1064
+ entry.head = line.slice("HEAD ".length);
1065
+ else if (line.startsWith("branch "))
1066
+ entry.branch = line
1067
+ .slice("branch ".length)
1068
+ .replace(/^refs\/heads\//, "");
1069
+ else if (line === "bare")
1070
+ entry.bare = true;
1071
+ else if (line === "detached")
1072
+ entry.detached = true;
1073
+ }
1074
+ entry.current = !!entry.path && samePath(entry.path, repositoryPath);
1075
+ return entry;
1076
+ });
1077
+ return resp.type("application/json").send(worktrees);
673
1078
  }
674
1079
  catch (err) {
675
- return resp.status(400).type('application/json').send({ error: err.message });
1080
+ return resp
1081
+ .status(400)
1082
+ .type("application/json")
1083
+ .send({ error: err.message });
676
1084
  }
677
1085
  });
678
- app.post('/api/checkout', async (req, resp) => {
1086
+ app.post("/api/worktrees", async (req, resp) => {
1087
+ try {
1088
+ const path = typeof req.body?.path === "string" ? req.body.path.trim() : "";
1089
+ const branch = typeof req.body?.branch === "string" ? req.body.branch.trim() : "";
1090
+ if (!path || !isAbsolute(path)) {
1091
+ throw new Error("Choose an absolute folder path for the worktree.");
1092
+ }
1093
+ if (!branch) {
1094
+ throw new Error("Choose a local branch for the worktree.");
1095
+ }
1096
+ // Resolve the branch as a local ref before passing it to Git. Besides a
1097
+ // clearer error, this prevents a request from treating an option as a ref.
1098
+ await git.raw([
1099
+ "show-ref",
1100
+ "--verify",
1101
+ "--quiet",
1102
+ `refs/heads/${branch}`,
1103
+ ]);
1104
+ await mutate(() => git
1105
+ .raw(["worktree", "add", "--", resolve(path), branch])
1106
+ .then(() => { }));
1107
+ return resp.type("application/json").send({ success: true });
1108
+ }
1109
+ catch (err) {
1110
+ return resp
1111
+ .status(400)
1112
+ .type("application/json")
1113
+ .send({ error: err.message });
1114
+ }
1115
+ });
1116
+ app.post("/api/worktrees/remove", async (req, resp) => {
1117
+ try {
1118
+ const path = typeof req.body?.path === "string" ? req.body.path.trim() : "";
1119
+ if (!path || !isAbsolute(path)) {
1120
+ throw new Error("A valid worktree path is required.");
1121
+ }
1122
+ const target = resolve(path);
1123
+ const samePath = (a, b) => process.platform === "win32"
1124
+ ? resolve(a).toLowerCase() === resolve(b).toLowerCase()
1125
+ : resolve(a) === resolve(b);
1126
+ if (samePath(target, repositoryPath)) {
1127
+ throw new Error("The worktree currently open in Guito cannot be removed.");
1128
+ }
1129
+ // Only registered worktrees may be removed. Do not use --force: Git must
1130
+ // protect uncommitted or untracked files in the selected worktree.
1131
+ const registered = (await git.raw(["worktree", "list", "--porcelain"]))
1132
+ .split(/\n\s*\n/)
1133
+ .some((block) => {
1134
+ const line = block
1135
+ .split("\n")
1136
+ .find((entry) => entry.startsWith("worktree "));
1137
+ return (!!line && samePath(line.slice("worktree ".length).trim(), target));
1138
+ });
1139
+ if (!registered) {
1140
+ throw new Error("The selected folder is not a registered worktree.");
1141
+ }
1142
+ await mutate(() => git.raw(["worktree", "remove", "--", target]).then(() => { }));
1143
+ return resp.type("application/json").send({ success: true });
1144
+ }
1145
+ catch (err) {
1146
+ return resp
1147
+ .status(400)
1148
+ .type("application/json")
1149
+ .send({ error: err.message });
1150
+ }
1151
+ });
1152
+ app.post("/api/checkout", async (req, resp) => {
679
1153
  try {
680
1154
  const { ref } = req.body;
681
1155
  await git.checkout(ref);
682
- return resp.type('application/json').send({ success: true });
1156
+ return resp.type("application/json").send({ success: true });
683
1157
  }
684
1158
  catch (err) {
685
- return resp.status(400).type('application/json').send({ error: err.message });
1159
+ return resp
1160
+ .status(400)
1161
+ .type("application/json")
1162
+ .send({ error: err.message });
686
1163
  }
687
1164
  });
688
1165
  // ==================== Merge & Rebase ====================
689
- app.post('/api/merge', async (req, resp) => {
1166
+ app.post("/api/merge", async (req, resp) => {
690
1167
  try {
691
1168
  const { branch } = req.body;
692
1169
  await git.merge([branch]);
693
- return resp.type('application/json').send({ success: true });
1170
+ return resp.type("application/json").send({ success: true });
694
1171
  }
695
1172
  catch (err) {
696
- return resp.status(400).type('application/json').send({ error: err.message });
1173
+ return resp
1174
+ .status(400)
1175
+ .type("application/json")
1176
+ .send({ error: err.message });
697
1177
  }
698
1178
  });
699
- app.get('/api/archive', async (req, resp) => {
1179
+ app.get("/api/archive", async (req, resp) => {
700
1180
  try {
701
- const ref = String(req.query?.ref ?? 'HEAD');
1181
+ const ref = String(req.query?.ref ?? "HEAD");
702
1182
  if (!/^[0-9a-fA-F]{7,40}$/.test(ref) && !/^[A-Za-z0-9._/-]+$/.test(ref)) {
703
- return resp.status(400).type('application/json').send({ error: 'invalid ref' });
1183
+ return resp
1184
+ .status(400)
1185
+ .type("application/json")
1186
+ .send({ error: "invalid ref" });
704
1187
  }
705
- const result = await execFileAsync('git', ['archive', '--format=zip', ref], {
1188
+ const result = await execFileAsync("git", ["archive", "--format=zip", ref], {
706
1189
  cwd: repositoryPath,
707
- encoding: 'buffer',
1190
+ encoding: "buffer",
708
1191
  maxBuffer: 100 * 1024 * 1024,
709
1192
  });
710
1193
  return resp
711
- .header('Content-Disposition', `attachment; filename="guito-${ref.slice(0, 8)}.zip"`)
712
- .type('application/zip')
1194
+ .header("Content-Disposition", `attachment; filename="guito-${ref.slice(0, 8)}.zip"`)
1195
+ .type("application/zip")
713
1196
  .send(result.stdout);
714
1197
  }
715
1198
  catch (err) {
716
- return resp.status(400).type('application/json').send({ error: err.message });
1199
+ return resp
1200
+ .status(400)
1201
+ .type("application/json")
1202
+ .send({ error: err.message });
717
1203
  }
718
1204
  });
719
- app.post('/api/rebase', async (req, resp) => {
1205
+ app.post("/api/rebase", async (req, resp) => {
720
1206
  try {
721
1207
  const { branch } = req.body;
722
1208
  await git.rebase([branch]);
723
- return resp.type('application/json').send({ success: true });
1209
+ return resp.type("application/json").send({ success: true });
724
1210
  }
725
1211
  catch (err) {
726
- return resp.status(400).type('application/json').send({ error: err.message });
1212
+ return resp
1213
+ .status(400)
1214
+ .type("application/json")
1215
+ .send({ error: err.message });
727
1216
  }
728
1217
  });
729
- app.post('/api/squash', async (req, resp) => {
1218
+ app.post("/api/squash", async (req, resp) => {
730
1219
  try {
731
1220
  const { commits } = req.body;
732
1221
  // Squash: rebase -i with squash operation
733
- await git.rebase(['-i', 'HEAD~' + commits]);
734
- return resp.type('application/json').send({ success: true });
1222
+ await git.rebase(["-i", "HEAD~" + commits]);
1223
+ return resp.type("application/json").send({ success: true });
735
1224
  }
736
1225
  catch (err) {
737
- return resp.status(400).type('application/json').send({ error: err.message });
1226
+ return resp
1227
+ .status(400)
1228
+ .type("application/json")
1229
+ .send({ error: err.message });
738
1230
  }
739
1231
  });
740
1232
  // ==================== Stash ====================
741
- app.get('/api/stash/list', async (_req, resp) => {
1233
+ app.get("/api/stash/list", async (_req, resp) => {
742
1234
  try {
743
- const stashList = await git.stashList();
744
- return resp.type('application/json').send(stashList);
1235
+ // Extra fields (date, author) let the commit table render stash rows;
1236
+ // they are NUL-separated because messages cannot contain NUL.
1237
+ const raw = await git.raw([
1238
+ "stash",
1239
+ "list",
1240
+ "--format=%H%x00%aI%x00%aN%x00%aE%x00%s",
1241
+ ]);
1242
+ const all = raw
1243
+ .trim()
1244
+ .split("\n")
1245
+ .filter(Boolean)
1246
+ .map((line) => {
1247
+ const [hash, date, authorName, authorEmail, message] = line.split("\u0000");
1248
+ return {
1249
+ hash,
1250
+ date,
1251
+ author_name: authorName,
1252
+ author_email: authorEmail,
1253
+ message,
1254
+ };
1255
+ });
1256
+ return resp.type("application/json").send({
1257
+ all,
1258
+ latest: all[0] ?? null,
1259
+ total: all.length,
1260
+ });
745
1261
  }
746
1262
  catch (err) {
747
- return resp.status(400).type('application/json').send({ error: err.message });
1263
+ return resp
1264
+ .status(400)
1265
+ .type("application/json")
1266
+ .send({ error: err.message });
748
1267
  }
749
1268
  });
750
- app.post('/api/stash/save', async (req, resp) => {
1269
+ app.post("/api/stash/save", async (req, resp) => {
751
1270
  try {
752
1271
  const { message, scope } = req.body ?? {};
753
1272
  // 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
754
1273
  // (--keep-index leaves staged changes staged); 'all' → index + working tree.
755
1274
  // Untracked files ride along for 'all'/'unstaged' since the panel lists them as unstaged.
756
- const args = ['push'];
757
- if (scope === 'staged') {
758
- args.push('--staged');
1275
+ const args = ["push"];
1276
+ if (scope === "staged") {
1277
+ args.push("--staged");
759
1278
  }
760
1279
  else {
761
- if (scope === 'unstaged')
762
- args.push('--keep-index');
763
- args.push('-u');
1280
+ if (scope === "unstaged")
1281
+ args.push("--keep-index");
1282
+ args.push("-u");
764
1283
  }
765
1284
  if (message)
766
- args.push('-m', message);
1285
+ args.push("-m", message);
767
1286
  await git.stash(args);
768
- return resp.type('application/json').send({ success: true });
1287
+ return resp.type("application/json").send({ success: true });
769
1288
  }
770
1289
  catch (err) {
771
- return resp.status(400).type('application/json').send({ error: err.message });
1290
+ return resp
1291
+ .status(400)
1292
+ .type("application/json")
1293
+ .send({ error: err.message });
772
1294
  }
773
1295
  });
774
- app.post('/api/stash/apply', async (req, resp) => {
1296
+ app.post("/api/stash/apply", async (req, resp) => {
775
1297
  try {
776
1298
  const { index } = req.body;
777
- await git.stash(['apply', `stash@{${index}}`]);
778
- return resp.type('application/json').send({ success: true });
1299
+ await git.stash(["apply", `stash@{${index}}`]);
1300
+ return resp.type("application/json").send({ success: true });
779
1301
  }
780
1302
  catch (err) {
781
- return resp.status(400).type('application/json').send({ error: err.message });
1303
+ return resp
1304
+ .status(400)
1305
+ .type("application/json")
1306
+ .send({ error: err.message });
782
1307
  }
783
1308
  });
784
- app.post('/api/stash/pop', async (req, resp) => {
1309
+ app.post("/api/stash/pop", async (req, resp) => {
785
1310
  try {
786
1311
  const { index } = req.body;
787
- await git.stash(['pop', `stash@{${index}}`]);
788
- return resp.type('application/json').send({ success: true });
1312
+ await git.stash(["pop", `stash@{${index}}`]);
1313
+ return resp.type("application/json").send({ success: true });
789
1314
  }
790
1315
  catch (err) {
791
- return resp.status(400).type('application/json').send({ error: err.message });
1316
+ return resp
1317
+ .status(400)
1318
+ .type("application/json")
1319
+ .send({ error: err.message });
792
1320
  }
793
1321
  });
794
- app.post('/api/stash/drop', async (req, resp) => {
1322
+ app.post("/api/stash/drop", async (req, resp) => {
795
1323
  try {
796
1324
  const { index } = req.body;
797
- await git.stash(['drop', `stash@{${index}}`]);
798
- return resp.type('application/json').send({ success: true });
1325
+ await git.stash(["drop", `stash@{${index}}`]);
1326
+ return resp.type("application/json").send({ success: true });
799
1327
  }
800
1328
  catch (err) {
801
- return resp.status(400).type('application/json').send({ error: err.message });
1329
+ return resp
1330
+ .status(400)
1331
+ .type("application/json")
1332
+ .send({ error: err.message });
802
1333
  }
803
1334
  });
804
- app.post('/api/stash/show', async (req, resp) => {
1335
+ app.post("/api/stash/show", async (req, resp) => {
805
1336
  try {
806
1337
  const { index } = req.body;
807
- const show = await git.stash(['show', '-p', `stash@{${index}}`]);
808
- return resp.type('application/json').send({ preview: show });
1338
+ const show = await git.stash(["show", "-p", `stash@{${index}}`]);
1339
+ return resp.type("application/json").send({ preview: show });
809
1340
  }
810
1341
  catch (err) {
811
- return resp.status(400).type('application/json').send({ error: err.message });
1342
+ return resp
1343
+ .status(400)
1344
+ .type("application/json")
1345
+ .send({ error: err.message });
812
1346
  }
813
1347
  });
814
1348
  // ==================== Tags ====================
815
- app.get('/api/tags', async (_req, resp) => {
1349
+ // All tags with the commit each points at (annotated tags are peeled, so the
1350
+ // client can always look the tagged commit up by hash). Sorted by name.
1351
+ app.get("/api/tags", async (_req, resp) => {
816
1352
  try {
817
- const tags = await git.tags();
818
- return resp.type('application/json').send(tags);
1353
+ const raw = await git.raw([
1354
+ "tag",
1355
+ "--list",
1356
+ "--sort=refname",
1357
+ "--format=%(refname:short)%09%(*objectname)%09%(objectname)",
1358
+ ]);
1359
+ const tags = raw
1360
+ .split("\n")
1361
+ .map((line) => line.split("\t"))
1362
+ .filter(([name]) => Boolean(name.trim()))
1363
+ .map(([name, peeled, direct]) => ({ name, hash: peeled || direct }));
1364
+ return resp.type("application/json").send(tags);
819
1365
  }
820
1366
  catch (err) {
821
- return resp.status(400).type('application/json').send({ error: err.message });
1367
+ return resp
1368
+ .status(400)
1369
+ .type("application/json")
1370
+ .send({ error: err.message });
822
1371
  }
823
1372
  });
824
- app.post('/api/tag/create', async (req, resp) => {
1373
+ app.post("/api/tag/create", async (req, resp) => {
825
1374
  try {
826
1375
  const { name, message, commit } = req.body;
827
1376
  if (message) {
828
- await git.tag(['-a', name, '-m', message, ...(commit ? [commit] : [])]);
1377
+ await git.tag(["-a", name, "-m", message, ...(commit ? [commit] : [])]);
829
1378
  }
830
1379
  else {
831
1380
  await git.tag([name, ...(commit ? [commit] : [])]);
832
1381
  }
833
- return resp.type('application/json').send({ success: true });
1382
+ return resp.type("application/json").send({ success: true });
834
1383
  }
835
1384
  catch (err) {
836
- return resp.status(400).type('application/json').send({ error: err.message });
1385
+ return resp
1386
+ .status(400)
1387
+ .type("application/json")
1388
+ .send({ error: err.message });
837
1389
  }
838
1390
  });
839
- app.post('/api/tag/delete', async (req, resp) => {
1391
+ app.post("/api/tag/delete", async (req, resp) => {
840
1392
  try {
841
1393
  const { name } = req.body;
842
- await git.tag(['-d', name]);
843
- return resp.type('application/json').send({ success: true });
1394
+ await git.tag(["-d", name]);
1395
+ return resp.type("application/json").send({ success: true });
844
1396
  }
845
1397
  catch (err) {
846
- return resp.status(400).type('application/json').send({ error: err.message });
1398
+ return resp
1399
+ .status(400)
1400
+ .type("application/json")
1401
+ .send({ error: err.message });
847
1402
  }
848
1403
  });
849
- app.post('/api/tag/push', async (req, resp) => {
1404
+ app.post("/api/tag/push", async (req, resp) => {
850
1405
  try {
851
1406
  const { name, remote } = req.body;
852
1407
  if (!name) {
853
- return resp.status(400).type('application/json').send({ error: 'name required' });
1408
+ return resp
1409
+ .status(400)
1410
+ .type("application/json")
1411
+ .send({ error: "name required" });
854
1412
  }
855
- await git.push(remote || 'origin', `refs/tags/${name}`);
856
- return resp.type('application/json').send({ success: true });
1413
+ await git.push(remote || "origin", `refs/tags/${name}`);
1414
+ return resp.type("application/json").send({ success: true });
857
1415
  }
858
1416
  catch (err) {
859
- return resp.status(400).type('application/json').send({ error: err.message });
1417
+ return resp
1418
+ .status(400)
1419
+ .type("application/json")
1420
+ .send({ error: err.message });
860
1421
  }
861
1422
  });
862
1423
  // ==================== Remote Operations ====================
863
- app.get('/api/fetch', async (_req, resp) => {
1424
+ app.get("/api/fetch", async (req, resp) => {
864
1425
  try {
865
- await git.fetch();
866
- return resp.type('application/json').send({ success: true });
1426
+ const prune = ["1", "true", "yes"].includes(String(req.query?.prune ?? "").toLowerCase());
1427
+ await git.fetch(prune ? { "--prune": null } : {});
1428
+ return resp.type("application/json").send({ success: true });
867
1429
  }
868
1430
  catch (err) {
869
- return resp.status(400).type('application/json').send({ error: err.message });
1431
+ return resp
1432
+ .status(400)
1433
+ .type("application/json")
1434
+ .send({ error: err.message });
870
1435
  }
871
1436
  });
872
- app.post('/api/pull', async (req, resp) => {
1437
+ app.post("/api/pull", async (req, resp) => {
873
1438
  try {
874
1439
  const { remote, branch, rebase } = req.body;
875
- await git.pull(remote || 'origin', branch || undefined, rebase ? { '--rebase': null } : {});
876
- return resp.type('application/json').send({ success: true });
1440
+ await git.pull(remote || "origin", branch || undefined, rebase ? { "--rebase": null } : {});
1441
+ return resp.type("application/json").send({ success: true });
877
1442
  }
878
1443
  catch (err) {
879
- return resp.status(400).type('application/json').send({ error: err.message });
1444
+ return resp
1445
+ .status(400)
1446
+ .type("application/json")
1447
+ .send({ error: err.message });
880
1448
  }
881
1449
  });
882
- app.post('/api/push', async (req, resp) => {
1450
+ app.post("/api/push", async (req, resp) => {
883
1451
  try {
884
1452
  const { remote, branch, force } = req.body;
885
- await git.push(remote || 'origin', branch || undefined, force ? { '-f': null } : {});
886
- return resp.type('application/json').send({ success: true });
1453
+ await git.push(remote || "origin", branch || undefined, force ? { "-f": null } : {});
1454
+ return resp.type("application/json").send({ success: true });
887
1455
  }
888
1456
  catch (err) {
889
- return resp.status(400).type('application/json').send({ error: err.message });
1457
+ return resp
1458
+ .status(400)
1459
+ .type("application/json")
1460
+ .send({ error: err.message });
890
1461
  }
891
1462
  });
892
- app.post('/api/sync', async (_req, resp) => {
1463
+ app.post("/api/sync", async (_req, resp) => {
893
1464
  try {
894
- await git.pull('origin', undefined, {});
895
- await git.push('origin', undefined, {});
896
- return resp.type('application/json').send({ success: true });
1465
+ await git.pull("origin", undefined, {});
1466
+ await git.push("origin", undefined, {});
1467
+ return resp.type("application/json").send({ success: true });
897
1468
  }
898
1469
  catch (err) {
899
- return resp.status(400).type('application/json').send({ error: err.message });
1470
+ return resp
1471
+ .status(400)
1472
+ .type("application/json")
1473
+ .send({ error: err.message });
900
1474
  }
901
1475
  });
902
- app.post('/api/prune', async (req, resp) => {
1476
+ app.post("/api/prune", async (req, resp) => {
903
1477
  try {
904
1478
  const { remote } = req.body;
905
- await git.remote(['prune', remote || 'origin']);
906
- return resp.type('application/json').send({ success: true });
1479
+ await git.remote(["prune", remote || "origin"]);
1480
+ return resp.type("application/json").send({ success: true });
907
1481
  }
908
1482
  catch (err) {
909
- return resp.status(400).type('application/json').send({ error: err.message });
1483
+ return resp
1484
+ .status(400)
1485
+ .type("application/json")
1486
+ .send({ error: err.message });
910
1487
  }
911
1488
  });
912
- // ==================== Settings ====================
913
- app.get('/api/settings', async (_req, resp) => {
1489
+ const listRemotes = async () => {
1490
+ const names = (await git.raw(["remote"]))
1491
+ .split(/\r?\n/)
1492
+ .map((name) => name.trim())
1493
+ .filter(Boolean);
1494
+ return Promise.all(names.map(async (name) => {
1495
+ const fetchUrl = (await git.raw(["remote", "get-url", name])).trim();
1496
+ const pushUrl = (await git
1497
+ .raw(["remote", "get-url", "--push", name])
1498
+ .catch(() => fetchUrl)).trim();
1499
+ return { name, fetchUrl, pushUrl };
1500
+ }));
1501
+ };
1502
+ app.get("/api/remotes", async (_req, resp) => {
914
1503
  try {
915
- const settings = await effectiveSettings();
916
- return resp.type('application/json').send(settings);
1504
+ return resp.type("application/json").send(await listRemotes());
917
1505
  }
918
1506
  catch (err) {
919
- return resp.status(400).type('application/json').send({ error: err.message });
1507
+ return resp.status(400).send({ error: err.message });
920
1508
  }
921
1509
  });
922
- app.post('/api/settings', async (req, resp) => {
1510
+ app.post("/api/remotes", async (req, resp) => {
923
1511
  try {
924
- const url = String(req.body?.azureDevOpsUrl ?? '').trim();
925
- if (url && !/^https?:\/\//i.test(url)) {
1512
+ const name = String(req.body?.name ?? "").trim();
1513
+ const originalName = String(req.body?.originalName ?? "").trim();
1514
+ const fetchUrl = String(req.body?.fetchUrl ?? "").trim();
1515
+ const pushUrl = String(req.body?.pushUrl ?? "").trim() || fetchUrl;
1516
+ if (!/^[A-Za-z0-9._-]+$/.test(name) || !fetchUrl) {
926
1517
  return resp
927
1518
  .status(400)
928
- .type('application/json')
929
- .send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
1519
+ .send({ error: "A valid remote name and fetch URL are required." });
1520
+ }
1521
+ if (!originalName) {
1522
+ await git.raw(["remote", "add", name, fetchUrl]);
1523
+ }
1524
+ else {
1525
+ if (originalName !== name)
1526
+ await git.raw(["remote", "rename", originalName, name]);
1527
+ await git.raw(["remote", "set-url", name, fetchUrl]);
930
1528
  }
1529
+ await git.raw(["remote", "set-url", "--push", name, pushUrl]);
1530
+ return resp.send(await listRemotes());
1531
+ }
1532
+ catch (err) {
1533
+ return resp.status(400).send({ error: err.message });
1534
+ }
1535
+ });
1536
+ app.delete("/api/remotes/:name", async (req, resp) => {
1537
+ try {
1538
+ const name = String(req.params?.name ?? "").trim();
1539
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
1540
+ return resp.status(400).send({ error: "Invalid remote name." });
1541
+ }
1542
+ await git.raw(["remote", "remove", name]);
1543
+ return resp.send(await listRemotes());
1544
+ }
1545
+ catch (err) {
1546
+ return resp.status(400).send({ error: err.message });
1547
+ }
1548
+ });
1549
+ app.post("/api/identity", async (req, resp) => {
1550
+ try {
1551
+ const name = String(req.body?.name ?? "").trim();
1552
+ const email = String(req.body?.email ?? "").trim();
1553
+ if (!name || !email) {
1554
+ return resp
1555
+ .status(400)
1556
+ .send({ error: "User name and email are required." });
1557
+ }
1558
+ await git.raw(["config", "--local", "user.name", name]);
1559
+ await git.raw(["config", "--local", "user.email", email]);
1560
+ return resp.send(await configuredIdentity());
1561
+ }
1562
+ catch (err) {
1563
+ return resp.status(400).send({ error: err.message });
1564
+ }
1565
+ });
1566
+ app.delete("/api/identity", async (_req, resp) => {
1567
+ try {
1568
+ await git
1569
+ .raw(["config", "--local", "--unset-all", "user.name"])
1570
+ .catch(() => "");
1571
+ await git
1572
+ .raw(["config", "--local", "--unset-all", "user.email"])
1573
+ .catch(() => "");
1574
+ return resp.send(await configuredIdentity());
1575
+ }
1576
+ catch (err) {
1577
+ return resp.status(400).send({ error: err.message });
1578
+ }
1579
+ });
1580
+ // ==================== Settings ====================
1581
+ app.get("/api/settings", async (_req, resp) => {
1582
+ try {
1583
+ const settings = await effectiveSettings();
1584
+ return resp.type("application/json").send(settings);
1585
+ }
1586
+ catch (err) {
1587
+ return resp
1588
+ .status(400)
1589
+ .type("application/json")
1590
+ .send({ error: err.message });
1591
+ }
1592
+ });
1593
+ app.post("/api/settings", async (req, resp) => {
1594
+ try {
1595
+ const body = req.body ?? {};
931
1596
  const settings = await readSettings();
932
- settings.azureDevOpsUrl = url;
1597
+ if ("azureDevOpsUrl" in body) {
1598
+ const url = String(body.azureDevOpsUrl ?? "").trim();
1599
+ if (url && !/^https?:\/\//i.test(url)) {
1600
+ return resp.status(400).type("application/json").send({
1601
+ error: "The Azure DevOps URL must start with http:// or https://.",
1602
+ });
1603
+ }
1604
+ settings.azureDevOpsUrl = url;
1605
+ }
1606
+ if ("prBranchNameTemplate" in body) {
1607
+ const template = String(body.prBranchNameTemplate ?? "").trim();
1608
+ if (template.length > 200) {
1609
+ return resp.status(400).send({
1610
+ error: "The pull request branch template must be 200 characters or fewer.",
1611
+ });
1612
+ }
1613
+ try {
1614
+ formatPrBranchName(template, {
1615
+ username: "username",
1616
+ randomstring: "randomstring",
1617
+ branch: "branch",
1618
+ targetbranch: "targetbranch",
1619
+ title: "title",
1620
+ repository: "repository",
1621
+ date: "2026-09-12",
1622
+ time: "120000",
1623
+ timestamp: "1789214400000",
1624
+ });
1625
+ }
1626
+ catch (error) {
1627
+ return resp.status(400).send({ error: error.message });
1628
+ }
1629
+ settings.prBranchNameTemplate =
1630
+ template || DEFAULT_PR_BRANCH_NAME_TEMPLATE;
1631
+ }
1632
+ if (typeof body.showGraph === "boolean") {
1633
+ settings.showGraph = body.showGraph;
1634
+ }
1635
+ if (typeof body.autoReload === "boolean") {
1636
+ settings.autoReload = body.autoReload;
1637
+ }
1638
+ if (typeof body.showStashes === "boolean") {
1639
+ settings.showStashes = body.showStashes;
1640
+ }
1641
+ if (typeof body.showTags === "boolean") {
1642
+ settings.showTags = body.showTags;
1643
+ }
1644
+ if (typeof body.showRemoteBranches === "boolean") {
1645
+ settings.showRemoteBranches = body.showRemoteBranches;
1646
+ }
1647
+ if (body.fileListView === "tree" || body.fileListView === "flat") {
1648
+ settings.fileListView = body.fileListView;
1649
+ }
1650
+ if (body.searchMode === "filter" || body.searchMode === "navigate") {
1651
+ settings.searchMode = body.searchMode;
1652
+ }
1653
+ if (typeof body.searchCaseSensitive === "boolean") {
1654
+ settings.searchCaseSensitive = body.searchCaseSensitive;
1655
+ }
1656
+ if (typeof body.allowMerge === "boolean") {
1657
+ settings.allowMerge = body.allowMerge;
1658
+ }
1659
+ if ("issueLinking" in body) {
1660
+ if (body.issueLinking === null) {
1661
+ if (body.issueLinkingGlobal) {
1662
+ await git
1663
+ .raw(["config", "--global", "--unset-all", "guito.issueRegex"])
1664
+ .catch(() => "");
1665
+ await git
1666
+ .raw(["config", "--global", "--unset-all", "guito.issueUrl"])
1667
+ .catch(() => "");
1668
+ }
1669
+ else {
1670
+ delete settings.issueLinking;
1671
+ }
1672
+ }
1673
+ else {
1674
+ const regex = String(body.issueLinking?.regex ?? "").trim();
1675
+ const url = String(body.issueLinking?.url ?? "").trim();
1676
+ if (!regex || !url) {
1677
+ return resp
1678
+ .status(400)
1679
+ .send({ error: "Issue regex and URL are required." });
1680
+ }
1681
+ try {
1682
+ new RegExp(regex);
1683
+ }
1684
+ catch {
1685
+ return resp.status(400).send({
1686
+ error: "Issue regex is not a valid regular expression.",
1687
+ });
1688
+ }
1689
+ if (!/^https?:\/\//i.test(url)) {
1690
+ return resp.status(400).send({
1691
+ error: "Issue URL must start with http:// or https://.",
1692
+ });
1693
+ }
1694
+ if (body.issueLinking.useGlobally) {
1695
+ await git.raw(["config", "--global", "guito.issueRegex", regex]);
1696
+ await git.raw(["config", "--global", "guito.issueUrl", url]);
1697
+ delete settings.issueLinking;
1698
+ }
1699
+ else {
1700
+ settings.issueLinking = { regex, url };
1701
+ }
1702
+ }
1703
+ }
933
1704
  await writeSettings(settings);
934
1705
  const effective = await effectiveSettings();
935
- return resp.type('application/json').send(effective);
1706
+ return resp.type("application/json").send(effective);
936
1707
  }
937
1708
  catch (err) {
938
- return resp.status(400).type('application/json').send({ error: err.message });
1709
+ return resp
1710
+ .status(400)
1711
+ .type("application/json")
1712
+ .send({ error: err.message });
939
1713
  }
940
1714
  });
941
1715
  // ==================== Azure DevOps Pull Requests ====================
942
- app.post('/api/azure-devops/pullrequest', async (req, resp) => {
1716
+ app.post("/api/azure-devops/pullrequest", async (req, resp) => {
943
1717
  try {
944
1718
  const { sourceBranch, targetBranch, title, description, newBranch } = req.body ?? {};
945
1719
  if (!targetBranch) {
946
- return resp.status(400).type('application/json').send({ error: 'targetBranch required' });
1720
+ return resp
1721
+ .status(400)
1722
+ .type("application/json")
1723
+ .send({ error: "targetBranch required" });
947
1724
  }
948
1725
  const { prefix, remoteUrl } = await azureProjectContext();
949
1726
  const info = parseAzureRemoteUrl(remoteUrl);
950
1727
  if (!info) {
951
- return resp
952
- .status(400)
953
- .type('application/json')
954
- .send({ error: 'Unable to build the Azure DevOps pull request URL.' });
1728
+ return resp.status(400).type("application/json").send({
1729
+ error: "Unable to build the Azure DevOps pull request URL.",
1730
+ });
955
1731
  }
956
1732
  const prUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
957
- let source = String(sourceBranch ?? '').trim();
1733
+ let source = String(sourceBranch ?? "").trim();
958
1734
  if (newBranch) {
959
1735
  // Create a remote-only branch from the current HEAD; the local
960
1736
  // repository and the checked-out branch stay untouched.
961
- const name = `pr/${randomBranchSuffix()}`;
962
- await git.push(['origin', `HEAD:refs/heads/${name}`]);
1737
+ const now = new Date();
1738
+ const currentSettings = await effectiveSettings();
1739
+ let computerUsername = process.env.USERNAME || process.env.USER || "";
1740
+ try {
1741
+ computerUsername = userInfo().username || computerUsername;
1742
+ }
1743
+ catch {
1744
+ // Environment variables are a sufficient fallback in restricted hosts.
1745
+ }
1746
+ const name = formatPrBranchName(currentSettings.prBranchNameTemplate, {
1747
+ username: computerUsername,
1748
+ randomstring: randomBranchSuffix(),
1749
+ branch: (await git.revparse(["--abbrev-ref", "HEAD"])).trim(),
1750
+ targetbranch: String(targetBranch)
1751
+ .replace(/^refs\/heads\//, "")
1752
+ .replace(/^origin\//, ""),
1753
+ title: String(title ?? "").trim() || "pull-request",
1754
+ repository: basename(await repoRoot()),
1755
+ date: now.toISOString().slice(0, 10),
1756
+ time: now.toISOString().slice(11, 19).replace(/:/g, ""),
1757
+ timestamp: String(now.getTime()),
1758
+ });
1759
+ try {
1760
+ await git.raw(["check-ref-format", "--branch", name]);
1761
+ }
1762
+ catch {
1763
+ return resp.status(400).send({
1764
+ error: `The pull request branch template produced an invalid Git branch name: ${name}`,
1765
+ });
1766
+ }
1767
+ const existingBranch = await git
1768
+ .raw(["ls-remote", "--heads", "origin", `refs/heads/${name}`])
1769
+ .catch(() => "");
1770
+ if (existingBranch.trim()) {
1771
+ return resp.status(400).send({
1772
+ error: `The pull request branch already exists on origin: ${name}`,
1773
+ });
1774
+ }
1775
+ await git.push(["origin", `HEAD:refs/heads/${name}`]);
963
1776
  source = name;
964
1777
  }
965
1778
  else {
966
1779
  if (!source) {
967
- return resp.status(400).type('application/json').send({ error: 'sourceBranch required' });
1780
+ return resp
1781
+ .status(400)
1782
+ .type("application/json")
1783
+ .send({ error: "sourceBranch required" });
968
1784
  }
969
- if (source.startsWith('origin/')) {
1785
+ if (source.startsWith("origin/")) {
970
1786
  // Remote-tracking branch: the PR source is the branch name on the remote.
971
- source = source.slice('origin/'.length);
1787
+ source = source.slice("origin/".length);
972
1788
  }
973
1789
  else {
974
1790
  // Publish the branch first when the remote does not have it yet.
@@ -977,15 +1793,15 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
977
1793
  // rev-parse --verify --quiet does not fail reliably through
978
1794
  // simple-git, so list the tracking refs instead.
979
1795
  const trackingRefs = await git.raw([
980
- 'for-each-ref',
981
- 'refs/remotes/origin',
982
- '--format=%(refname)',
1796
+ "for-each-ref",
1797
+ "refs/remotes/origin",
1798
+ "--format=%(refname)",
983
1799
  ]);
984
1800
  const published = trackingRefs
985
- .split('\n')
1801
+ .split("\n")
986
1802
  .some((line) => line.trim() === `refs/remotes/origin/${source}`);
987
1803
  if (!published) {
988
- await git.push(['origin', source]);
1804
+ await git.push(["origin", source]);
989
1805
  }
990
1806
  }
991
1807
  }
@@ -993,44 +1809,54 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
993
1809
  isDraft: req.body?.isDraft === true,
994
1810
  sourceRefName: `refs/heads/${source}`,
995
1811
  targetRefName: `refs/heads/${String(targetBranch)
996
- .replace(/^refs\/heads\//, '')
997
- .replace(/^origin\//, '')}`,
1812
+ .replace(/^refs\/heads\//, "")
1813
+ .replace(/^origin\//, "")}`,
998
1814
  };
999
- const trimmedTitle = String(title ?? '').trim();
1000
- const trimmedDescription = String(description ?? '').trim();
1815
+ const trimmedTitle = String(title ?? "").trim();
1816
+ const trimmedDescription = String(description ?? "").trim();
1001
1817
  if (trimmedTitle) {
1002
1818
  payload.title = trimmedTitle;
1003
1819
  }
1004
1820
  if (trimmedDescription) {
1005
1821
  payload.description = trimmedDescription;
1006
1822
  }
1007
- const reviewerList = Array.isArray(req.body?.reviewers) ? req.body.reviewers : [];
1823
+ const reviewerList = Array.isArray(req.body?.reviewers)
1824
+ ? req.body.reviewers
1825
+ : [];
1008
1826
  const payloadReviewers = reviewerList
1009
- .map((entry) => ({ id: String(entry?.id ?? ''), isRequired: !!entry?.required }))
1827
+ .map((entry) => ({
1828
+ id: String(entry?.id ?? ""),
1829
+ isRequired: !!entry?.required,
1830
+ }))
1010
1831
  .filter((entry) => entry.id);
1011
1832
  if (payloadReviewers.length) {
1012
1833
  payload.reviewers = payloadReviewers;
1013
1834
  }
1014
- const workItemList = Array.isArray(req.body?.workItems) ? req.body.workItems : [];
1835
+ const workItemList = Array.isArray(req.body?.workItems)
1836
+ ? req.body.workItems
1837
+ : [];
1015
1838
  const payloadWorkItems = workItemList
1016
1839
  .map((id) => Number(id))
1017
1840
  .filter((id) => Number.isInteger(id) && id > 0);
1018
1841
  if (payloadWorkItems.length) {
1019
- payload.workItemRefs = payloadWorkItems.map((id) => ({ id: String(id) }));
1842
+ payload.workItemRefs = payloadWorkItems.map((id) => ({
1843
+ id: String(id),
1844
+ }));
1020
1845
  }
1021
- const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
1846
+ const result = await azureRequest("POST", prUrl, JSON.stringify(payload));
1022
1847
  if (result.status < 200 || result.status >= 300) {
1023
1848
  return resp
1024
1849
  .status(400)
1025
- .type('application/json')
1850
+ .type("application/json")
1026
1851
  .send({
1027
- error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
1852
+ error: azureErrorMessage(result) ||
1853
+ `Azure DevOps returned HTTP ${result.status}.`,
1028
1854
  });
1029
1855
  }
1030
1856
  const created = JSON.parse(result.body);
1031
1857
  // Tags (labels) are a separate resource; add them after the PR exists.
1032
1858
  const labelNames = (Array.isArray(req.body?.labels) ? req.body.labels : [])
1033
- .map((name) => String(name ?? '').trim())
1859
+ .map((name) => String(name ?? "").trim())
1034
1860
  .filter(Boolean);
1035
1861
  const warnings = [];
1036
1862
  if (labelNames.length && created.pullRequestId) {
@@ -1039,7 +1865,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1039
1865
  `/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
1040
1866
  for (const name of labelNames) {
1041
1867
  try {
1042
- const labelResult = await azureRequest('POST', labelsUrl, JSON.stringify({ name }));
1868
+ const labelResult = await azureRequest("POST", labelsUrl, JSON.stringify({ name }));
1043
1869
  if (labelResult.status < 200 || labelResult.status >= 300) {
1044
1870
  warnings.push(`Could not add tag "${name}" (HTTP ${labelResult.status}).`);
1045
1871
  }
@@ -1049,87 +1875,99 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1049
1875
  }
1050
1876
  }
1051
1877
  }
1052
- return resp.type('application/json').send({
1878
+ return resp.type("application/json").send({
1053
1879
  id: created.pullRequestId,
1054
- url: created._links?.web?.href ?? '',
1880
+ url: created._links?.web?.href ?? "",
1055
1881
  branch: source,
1056
1882
  ...(warnings.length ? { warnings } : {}),
1057
1883
  });
1058
1884
  }
1059
1885
  catch (err) {
1060
- return resp.status(400).type('application/json').send({ error: err.message });
1886
+ return resp
1887
+ .status(400)
1888
+ .type("application/json")
1889
+ .send({ error: err.message });
1061
1890
  }
1062
1891
  });
1063
1892
  // ==================== Azure DevOps PR metadata ====================
1064
1893
  // Collection-scoped identity IDs can be passed directly as PR reviewers.
1065
- app.get('/api/azure-devops/reviewers', async (req, resp) => {
1894
+ app.get("/api/azure-devops/reviewers", async (req, resp) => {
1066
1895
  try {
1067
- const query = String((req.query ?? {}).query ?? '').trim();
1896
+ const query = String((req.query ?? {}).query ?? "").trim();
1068
1897
  if (!query) {
1069
- return resp.type('application/json').send({ reviewers: [] });
1898
+ return resp.type("application/json").send({ reviewers: [] });
1070
1899
  }
1071
1900
  const { prefix } = await azureProjectContext();
1072
- const collection = prefix.slice(0, prefix.lastIndexOf('/'));
1073
- const identityBase = collection.replace('https://dev.azure.com/', 'https://vssps.dev.azure.com/');
1074
- const url = `${identityBase}/_apis/identities?api-version=5.0` +
1901
+ const collection = prefix.slice(0, prefix.lastIndexOf("/"));
1902
+ const identityBase = collection.replace("https://dev.azure.com/", "https://vssps.dev.azure.com/");
1903
+ const url = `${identityBase}/_apis/identities?api-version=5.0-preview` +
1075
1904
  `&searchFilter=General&filterValue=${encodeURIComponent(query)}&queryMembership=None`;
1076
- const result = await azureRequest('GET', url, '');
1905
+ const result = await azureRequest("GET", url, "");
1077
1906
  if (result.status < 200 || result.status >= 300) {
1078
1907
  return resp
1079
1908
  .status(400)
1080
- .type('application/json')
1909
+ .type("application/json")
1081
1910
  .send({
1082
- error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
1911
+ error: azureErrorMessage(result) ||
1912
+ `Azure DevOps returned HTTP ${result.status}.`,
1083
1913
  });
1084
1914
  }
1085
1915
  const data = JSON.parse(result.body);
1086
- const list = Array.isArray(data) ? data : (data.value ?? data.identities ?? []);
1916
+ const list = Array.isArray(data)
1917
+ ? data
1918
+ : (data.value ?? data.identities ?? []);
1087
1919
  const reviewers = list
1088
1920
  .map((entry) => ({
1089
- id: String(entry?.id ?? entry?.identity?.id ?? ''),
1921
+ id: String(entry?.id ?? entry?.identity?.id ?? ""),
1090
1922
  label: String(entry?.customDisplayName ||
1091
1923
  entry?.providerDisplayName ||
1092
1924
  entry?.label ||
1093
1925
  entry?.displayName ||
1094
1926
  entry?.name ||
1095
- ''),
1927
+ ""),
1096
1928
  description: entry?.properties?.Mail?.$value ??
1097
1929
  entry?.properties?.Account?.$value ??
1098
1930
  entry?.description ??
1099
1931
  undefined,
1100
1932
  }))
1101
1933
  .filter((entry) => entry.id && entry.label);
1102
- return resp.type('application/json').send({ reviewers });
1934
+ return resp.type("application/json").send({ reviewers });
1103
1935
  }
1104
1936
  catch (err) {
1105
- return resp.status(400).type('application/json').send({ error: err.message });
1937
+ return resp
1938
+ .status(400)
1939
+ .type("application/json")
1940
+ .send({ error: err.message });
1106
1941
  }
1107
1942
  });
1108
1943
  // Work item search by id or title: a WIQL query returns ids, a batch read
1109
1944
  // then fetches title/state for the first matches.
1110
- app.get('/api/azure-devops/workitems', async (req, resp) => {
1945
+ app.get("/api/azure-devops/workitems", async (req, resp) => {
1111
1946
  try {
1112
- const query = String((req.query ?? {}).query ?? '')
1947
+ const query = String((req.query ?? {}).query ?? "")
1113
1948
  .trim()
1114
- .replace(/^#(?=\d+$)/, '');
1949
+ .replace(/^#(?=\d+$)/, "");
1115
1950
  if (!query) {
1116
- return resp.type('application/json').send({ workItems: [] });
1951
+ return resp.type("application/json").send({ workItems: [] });
1117
1952
  }
1118
1953
  const { prefix } = await azureProjectContext();
1119
- const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
1954
+ const clauses = [
1955
+ `[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`,
1956
+ ];
1120
1957
  if (/^\d+$/.test(query)) {
1121
1958
  clauses.push(`[System.Id] = ${Number(query)}`);
1122
1959
  }
1123
- const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
1124
- `WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(' OR ')}) ` +
1125
- 'ORDER BY [System.ChangedDate] DESC';
1126
- const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
1960
+ const wiql = "SELECT [System.Id], [System.Title], [System.State] FROM WorkItems " +
1961
+ `WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(" OR ")}) ` +
1962
+ "ORDER BY [System.ChangedDate] DESC";
1963
+ const wiqlResult = await azureRequest("POST", `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
1127
1964
  if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
1128
1965
  return resp
1129
1966
  .status(400)
1130
- .type('application/json')
1967
+ .type("application/json")
1131
1968
  .send({
1132
- error: azureErrorMessage(wiqlResult) || `Azure DevOps returned HTTP ${wiqlResult.status}.`,
1969
+ error: azureErrorMessage(wiqlResult) ||
1970
+ `Azure DevOps returned HTTP ${wiqlResult.status}.`,
1133
1971
  });
1134
1972
  }
1135
1973
  const ids = (JSON.parse(wiqlResult.body).workItems ?? [])
@@ -1137,41 +1975,46 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1137
1975
  .filter((id) => Number.isInteger(id) && id > 0)
1138
1976
  .slice(0, 20);
1139
1977
  if (!ids.length) {
1140
- return resp.type('application/json').send({ workItems: [] });
1978
+ return resp.type("application/json").send({ workItems: [] });
1141
1979
  }
1142
- const batchResult = await azureRequest('GET', `${prefix}/_apis/wit/workitems?ids=${ids.join(',')}` +
1143
- `&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, '');
1980
+ const batchResult = await azureRequest("GET", `${prefix}/_apis/wit/workitems?ids=${ids.join(",")}` +
1981
+ `&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, "");
1144
1982
  if (batchResult.status < 200 || batchResult.status >= 300) {
1145
1983
  return resp
1146
1984
  .status(400)
1147
- .type('application/json')
1985
+ .type("application/json")
1148
1986
  .send({
1149
- error: azureErrorMessage(batchResult) || `Azure DevOps returned HTTP ${batchResult.status}.`,
1987
+ error: azureErrorMessage(batchResult) ||
1988
+ `Azure DevOps returned HTTP ${batchResult.status}.`,
1150
1989
  });
1151
1990
  }
1152
1991
  const workItems = (JSON.parse(batchResult.body).value ?? []).map((item) => ({
1153
1992
  id: Number(item?.id),
1154
- title: String(item?.fields?.['System.Title'] ?? ''),
1155
- state: String(item?.fields?.['System.State'] ?? ''),
1993
+ title: String(item?.fields?.["System.Title"] ?? ""),
1994
+ state: String(item?.fields?.["System.State"] ?? ""),
1156
1995
  }));
1157
- return resp.type('application/json').send({ workItems });
1996
+ return resp.type("application/json").send({ workItems });
1158
1997
  }
1159
1998
  catch (err) {
1160
- return resp.status(400).type('application/json').send({ error: err.message });
1999
+ return resp
2000
+ .status(400)
2001
+ .type("application/json")
2002
+ .send({ error: err.message });
1161
2003
  }
1162
2004
  });
1163
2005
  // Existing pull request labels for the tag autocomplete.
1164
- app.get('/api/azure-devops/tags', async (_req, resp) => {
2006
+ app.get("/api/azure-devops/tags", async (_req, resp) => {
1165
2007
  try {
1166
2008
  const { prefix, remoteUrl } = await azureProjectContext();
1167
2009
  const repo = parseAzureRemoteUrl(remoteUrl).repo;
1168
2010
  // PR labels are distinct from Azure Boards work item tags.
1169
2011
  const names = new Set();
1170
2012
  for (let skip = 0;; skip += 100) {
1171
- const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
1172
- `?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`, '');
2013
+ const result = await azureRequest("GET", `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
2014
+ `?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`, "");
1173
2015
  if (result.status < 200 || result.status >= 300) {
1174
- throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
2016
+ throw new Error(azureErrorMessage(result) ||
2017
+ `Azure DevOps returned HTTP ${result.status}.`);
1175
2018
  }
1176
2019
  const data = JSON.parse(result.body);
1177
2020
  const requests = Array.isArray(data) ? data : (data.value ?? []);
@@ -1183,16 +2026,17 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1183
2026
  return request.labels;
1184
2027
  if (!Number.isInteger(request.pullRequestId))
1185
2028
  return [];
1186
- const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}` +
1187
- `/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`, '');
2029
+ const result = await azureRequest("GET", `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}` +
2030
+ `/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`, "");
1188
2031
  if (result.status < 200 || result.status >= 300) {
1189
- throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
2032
+ throw new Error(azureErrorMessage(result) ||
2033
+ `Azure DevOps returned HTTP ${result.status}.`);
1190
2034
  }
1191
2035
  const data = JSON.parse(result.body);
1192
2036
  return Array.isArray(data) ? data : (data.value ?? []);
1193
2037
  }));
1194
2038
  for (const label of labels.flat()) {
1195
- if (typeof label.name === 'string' && label.name.trim())
2039
+ if (typeof label.name === "string" && label.name.trim())
1196
2040
  names.add(label.name.trim());
1197
2041
  }
1198
2042
  }
@@ -1200,130 +2044,1145 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1200
2044
  break;
1201
2045
  }
1202
2046
  const tags = [...names].sort((a, b) => a.localeCompare(b));
1203
- return resp.type('application/json').send({ tags });
2047
+ return resp.type("application/json").send({ tags });
2048
+ }
2049
+ catch (err) {
2050
+ return resp
2051
+ .status(400)
2052
+ .type("application/json")
2053
+ .send({ error: err.message });
2054
+ }
2055
+ });
2056
+ // ==================== Azure DevOps pull request review ====================
2057
+ // Everything the PR sidebar list and the PR detail dialog need: identity,
2058
+ // listing, editing, votes, reviewers, auto-complete, completion, comment
2059
+ // threads, and per-file diffs.
2060
+ app.get("/api/azure-devops/me", async (_req, resp) => {
2061
+ try {
2062
+ return resp.type("application/json").send(await azureMe());
2063
+ }
2064
+ catch (err) {
2065
+ return resp
2066
+ .status(400)
2067
+ .type("application/json")
2068
+ .send({ error: err.message });
2069
+ }
2070
+ });
2071
+ // Pull requests the current user created or reviews ("mine"), merged and
2072
+ // de-duplicated. Status defaults to active.
2073
+ app.get("/api/azure-devops/pullrequests", async (req, resp) => {
2074
+ try {
2075
+ const status = String(req.query?.status ?? "active").trim() || "active";
2076
+ const { prefix, remoteUrl } = await azureProjectContext();
2077
+ const repo = parseAzureRemoteUrl(remoteUrl).repo;
2078
+ const me = await azureMe();
2079
+ const collect = async (criteria) => {
2080
+ const found = new Map();
2081
+ for (let skip = 0;; skip += 100) {
2082
+ const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
2083
+ `?searchCriteria.status=${encodeURIComponent(status)}&searchCriteria.${criteria}` +
2084
+ `&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`;
2085
+ const data = await azureJson("GET", url);
2086
+ const requests = Array.isArray(data?.value) ? data.value : [];
2087
+ for (const request of requests) {
2088
+ const id = Number(request?.pullRequestId);
2089
+ if (Number.isInteger(id) && id > 0 && !found.has(id)) {
2090
+ found.set(id, request);
2091
+ }
2092
+ }
2093
+ if (requests.length < 100)
2094
+ break;
2095
+ }
2096
+ return found;
2097
+ };
2098
+ const [created, assigned] = await Promise.all([
2099
+ collect(`creatorId=${encodeURIComponent(me.id)}`),
2100
+ collect(`reviewerId=${encodeURIComponent(me.id)}`),
2101
+ ]);
2102
+ const merged = new Map([...created, ...assigned]);
2103
+ const pullRequests = [...merged.values()]
2104
+ .map((request) => mapAzurePullRequest(request, prefix, remoteUrl, me))
2105
+ .sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
2106
+ return resp.type("application/json").send({ pullRequests });
2107
+ }
2108
+ catch (err) {
2109
+ return resp
2110
+ .status(400)
2111
+ .type("application/json")
2112
+ .send({ error: err.message });
2113
+ }
2114
+ });
2115
+ app.get("/api/azure-devops/pullrequests/:id", async (req, resp) => {
2116
+ try {
2117
+ const { base, prefix, remoteUrl } = await azurePrApiBase(Number(req.params?.id));
2118
+ const [pr, me] = await Promise.all([
2119
+ azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`),
2120
+ azureMe(),
2121
+ ]);
2122
+ const summary = mapAzurePullRequest(pr, prefix, remoteUrl, me);
2123
+ // Some Server releases omit labels from the PR detail response; read
2124
+ // the labels resource explicitly in that case instead of reporting a
2125
+ // false empty tag list.
2126
+ let labels = (Array.isArray(pr?.labels) ? pr.labels : [])
2127
+ .map((label) => String(label?.name ?? ""))
2128
+ .filter(Boolean);
2129
+ if (!Array.isArray(pr?.labels)) {
2130
+ const labelsResult = await azureRequest("GET", `${base}/labels?api-version=${AZURE_API_VERSION}`, "");
2131
+ if (labelsResult.status < 200 || labelsResult.status >= 300) {
2132
+ throw new Error(azureErrorMessage(labelsResult) ||
2133
+ `Azure DevOps returned HTTP ${labelsResult.status} while reading pull request tags.`);
2134
+ }
2135
+ const labelData = JSON.parse(labelsResult.body);
2136
+ labels = (Array.isArray(labelData) ? labelData : (labelData.value ?? []))
2137
+ .map((label) => String(label?.name ?? ""))
2138
+ .filter(Boolean);
2139
+ }
2140
+ return resp.type("application/json").send({
2141
+ ...summary,
2142
+ description: String(pr?.description ?? ""),
2143
+ autoCompleteSetBy: pr?.autoCompleteSetBy
2144
+ ? {
2145
+ id: String(pr.autoCompleteSetBy.id ?? ""),
2146
+ name: String(pr.autoCompleteSetBy.displayName ?? ""),
2147
+ }
2148
+ : null,
2149
+ completionOptions: pr?.completionOptions ?? null,
2150
+ lastMergeSourceCommit: String(pr?.lastMergeSourceCommit?.commitId ?? ""),
2151
+ lastMergeTargetCommit: String(pr?.lastMergeTargetCommit?.commitId ?? ""),
2152
+ labels,
2153
+ mergeStatus: azureMergeStatus(pr),
2154
+ });
2155
+ }
2156
+ catch (err) {
2157
+ return resp
2158
+ .status(400)
2159
+ .type("application/json")
2160
+ .send({ error: err.message });
2161
+ }
2162
+ });
2163
+ // Edits title/description or flips the draft flag ("publish"/"convert to draft").
2164
+ app.patch("/api/azure-devops/pullrequests/:id", async (req, resp) => {
2165
+ try {
2166
+ const { title, description, isDraft } = req.body ?? {};
2167
+ const payload = {};
2168
+ if (title !== undefined)
2169
+ payload.title = String(title);
2170
+ if (description !== undefined)
2171
+ payload.description = String(description);
2172
+ if (isDraft !== undefined)
2173
+ payload.isDraft = isDraft === true;
2174
+ if (!Object.keys(payload).length) {
2175
+ return resp
2176
+ .status(400)
2177
+ .type("application/json")
2178
+ .send({ error: "Nothing to update." });
2179
+ }
2180
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2181
+ await azureJson("PATCH", `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
2182
+ return resp.type("application/json").send({ ok: true });
2183
+ }
2184
+ catch (err) {
2185
+ return resp
2186
+ .status(400)
2187
+ .type("application/json")
2188
+ .send({ error: err.message });
2189
+ }
2190
+ });
2191
+ // Records the current user's vote on the pull request.
2192
+ app.post("/api/azure-devops/pullrequests/:id/vote", async (req, resp) => {
2193
+ try {
2194
+ const vote = String(req.body?.vote ?? "");
2195
+ if (!(vote in AZURE_VOTES)) {
2196
+ return resp
2197
+ .status(400)
2198
+ .type("application/json")
2199
+ .send({ error: `Unknown vote "${vote}".` });
2200
+ }
2201
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2202
+ const me = await azureMe();
2203
+ await azureJson("PUT", `${base}/reviewers/${encodeURIComponent(me.id)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ vote: AZURE_VOTES[vote] }));
2204
+ return resp.type("application/json").send({ ok: true });
2205
+ }
2206
+ catch (err) {
2207
+ return resp
2208
+ .status(400)
2209
+ .type("application/json")
2210
+ .send({ error: err.message });
2211
+ }
2212
+ });
2213
+ // Adds or updates a reviewer (optionally required), or removes one.
2214
+ app.post("/api/azure-devops/pullrequests/:id/reviewers", async (req, resp) => {
2215
+ try {
2216
+ const id = String(req.body?.id ?? "");
2217
+ if (!id) {
2218
+ return resp
2219
+ .status(400)
2220
+ .type("application/json")
2221
+ .send({ error: "Reviewer id required." });
2222
+ }
2223
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2224
+ const reviewerUrl = `${base}/reviewers/${encodeURIComponent(id)}?api-version=${AZURE_API_VERSION}`;
2225
+ if (req.body?.remove === true) {
2226
+ await azureJson("DELETE", reviewerUrl);
2227
+ return resp.type("application/json").send({ ok: true });
2228
+ }
2229
+ // The PUT replaces the reviewer record, so an existing vote is passed
2230
+ // through to survive an isRequired toggle.
2231
+ const payload = {
2232
+ isRequired: req.body?.required === true,
2233
+ };
2234
+ if (req.body?.vote !== undefined &&
2235
+ Number.isFinite(Number(req.body.vote))) {
2236
+ payload.vote = Number(req.body.vote);
2237
+ }
2238
+ await azureJson("PUT", reviewerUrl, JSON.stringify(payload));
2239
+ return resp.type("application/json").send({ ok: true });
2240
+ }
2241
+ catch (err) {
2242
+ return resp
2243
+ .status(400)
2244
+ .type("application/json")
2245
+ .send({ error: err.message });
2246
+ }
2247
+ });
2248
+ /** Maps dialog completion options onto the Azure DevOps payload shape. */
2249
+ const completionOptionsOf = (body) => {
2250
+ const strategies = ["noFastForward", "squash", "rebase", "rebaseMerge"];
2251
+ const mergeStrategy = String(body?.mergeStrategy ?? "");
2252
+ const options = {};
2253
+ if (strategies.includes(mergeStrategy))
2254
+ options.mergeStrategy = mergeStrategy;
2255
+ if (body?.deleteSourceBranch !== undefined) {
2256
+ options.deleteSourceBranch = body.deleteSourceBranch === true;
2257
+ }
2258
+ const completeWorkItems = body?.completeWorkItems === true;
2259
+ if (completeWorkItems) {
2260
+ options.completeWorkItems = true;
2261
+ // Transitioning requires completion of linked work items.
2262
+ if (body?.transitionWorkItems === true)
2263
+ options.transitionWorkItems = true;
2264
+ }
2265
+ return Object.keys(options).length ? options : null;
2266
+ };
2267
+ // Sets or clears auto-complete with optional completion options.
2268
+ app.post("/api/azure-devops/pullrequests/:id/autocomplete", async (req, resp) => {
2269
+ try {
2270
+ const enabled = req.body?.enabled === true;
2271
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2272
+ const buildPayload = (identity) => {
2273
+ const payload = {
2274
+ autoCompleteSetBy: identity,
2275
+ };
2276
+ if (enabled) {
2277
+ const options = completionOptionsOf(req.body);
2278
+ if (options)
2279
+ payload.completionOptions = options;
2280
+ }
2281
+ return JSON.stringify(payload);
2282
+ };
2283
+ const url = `${base}?api-version=${AZURE_API_VERSION}`;
2284
+ const me = enabled ? await azureMe() : null;
2285
+ // Azure DevOps clears auto-complete for an empty identity; some Server
2286
+ // releases only accept an explicit null, so retry that shape on failure.
2287
+ try {
2288
+ await azureJson("PATCH", url, buildPayload(enabled && me ? { id: me.id } : { id: "" }));
2289
+ }
2290
+ catch (firstError) {
2291
+ if (enabled)
2292
+ throw firstError;
2293
+ await azureJson("PATCH", url, buildPayload(null));
2294
+ }
2295
+ return resp.type("application/json").send({ ok: true });
2296
+ }
2297
+ catch (err) {
2298
+ return resp
2299
+ .status(400)
2300
+ .type("application/json")
2301
+ .send({ error: err.message });
2302
+ }
2303
+ });
2304
+ // Completes the pull request (merge). Requires the merge source commit for
2305
+ // optimistic concurrency, exactly like the Azure DevOps web dialog.
2306
+ app.post("/api/azure-devops/pullrequests/:id/complete", async (req, resp) => {
2307
+ try {
2308
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2309
+ const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
2310
+ const commitId = String(pr?.lastMergeSourceCommit?.commitId ?? "");
2311
+ if (!commitId) {
2312
+ throw new Error("The pull request has no merge commit to complete yet.");
2313
+ }
2314
+ const payload = {
2315
+ status: "completed",
2316
+ lastMergeSourceCommit: { commitId },
2317
+ };
2318
+ const options = completionOptionsOf(req.body);
2319
+ if (options)
2320
+ payload.completionOptions = options;
2321
+ await azureJson("PATCH", `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
2322
+ return resp.type("application/json").send({ ok: true });
2323
+ }
2324
+ catch (err) {
2325
+ return resp
2326
+ .status(400)
2327
+ .type("application/json")
2328
+ .send({ error: err.message });
2329
+ }
2330
+ });
2331
+ app.get("/api/azure-devops/pullrequests/:id/threads", async (req, resp) => {
2332
+ try {
2333
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2334
+ const data = await azureJson("GET", `${base}/threads?api-version=${AZURE_API_VERSION}`);
2335
+ const threads = (Array.isArray(data?.value) ? data.value : [])
2336
+ .filter((thread) => thread?.isDeleted !== true)
2337
+ .map((thread) => {
2338
+ const context = thread?.threadContext ?? {};
2339
+ const line = context.rightFileStart?.line ??
2340
+ context.leftFileStart?.line ??
2341
+ null;
2342
+ return {
2343
+ id: Number(thread?.id ?? 0),
2344
+ status: azureThreadStatusName(Number(thread?.status ?? 0)),
2345
+ isDeleted: false,
2346
+ filePath: typeof context.filePath === "string"
2347
+ ? context.filePath.replace(/^\//, "")
2348
+ : null,
2349
+ line: Number.isInteger(line) ? line : null,
2350
+ side: context.rightFileStart
2351
+ ? "right"
2352
+ : context.leftFileStart
2353
+ ? "left"
2354
+ : null,
2355
+ comments: (Array.isArray(thread?.comments) ? thread.comments : [])
2356
+ .filter((comment) => comment?.isDeleted !== true)
2357
+ .map((comment) => ({
2358
+ id: Number(comment?.id ?? 0),
2359
+ author: {
2360
+ name: String(comment?.author?.displayName ?? ""),
2361
+ email: String(comment?.author?.uniqueName ?? ""),
2362
+ },
2363
+ content: String(comment?.content ?? ""),
2364
+ createdAt: String(comment?.publishedDate ?? ""),
2365
+ isDeleted: false,
2366
+ })),
2367
+ };
2368
+ });
2369
+ return resp.type("application/json").send({ threads });
2370
+ }
2371
+ catch (err) {
2372
+ return resp
2373
+ .status(400)
2374
+ .type("application/json")
2375
+ .send({ error: err.message });
2376
+ }
2377
+ });
2378
+ // Adds a comment: a reply to a thread, a general PR comment, or an inline
2379
+ // comment anchored to a file line (thread context).
2380
+ app.post("/api/azure-devops/pullrequests/:id/threads", async (req, resp) => {
2381
+ try {
2382
+ const content = String(req.body?.content ?? "").trim();
2383
+ if (!content) {
2384
+ return resp
2385
+ .status(400)
2386
+ .type("application/json")
2387
+ .send({ error: "Comment text required." });
2388
+ }
2389
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2390
+ const threadId = Number(req.body?.threadId ?? 0);
2391
+ if (Number.isInteger(threadId) && threadId > 0) {
2392
+ await azureJson("POST", `${base}/threads/${threadId}/comments?api-version=${AZURE_API_VERSION}`, JSON.stringify({ content }));
2393
+ return resp.type("application/json").send({ ok: true });
2394
+ }
2395
+ const payload = {
2396
+ status: AZURE_THREAD_STATUSES.active,
2397
+ comments: [{ content, parentCommentId: 0 }],
2398
+ };
2399
+ const filePath = String(req.body?.filePath ?? "").trim();
2400
+ const line = Number(req.body?.line ?? 0);
2401
+ const requestedEndLine = Number(req.body?.endLine ?? line);
2402
+ const endLine = Number.isInteger(requestedEndLine) && requestedEndLine >= line
2403
+ ? requestedEndLine
2404
+ : line;
2405
+ const left = req.body?.side === "left";
2406
+ if (filePath && Number.isInteger(line) && line > 0) {
2407
+ // Azure anchors a whole-line comment from offset 1 to "end of line".
2408
+ payload.threadContext = {
2409
+ filePath: `/${filePath.replace(/^\//, "")}`,
2410
+ [left ? "leftFileStart" : "rightFileStart"]: { line, offset: 1 },
2411
+ [left ? "leftFileEnd" : "rightFileEnd"]: {
2412
+ line: endLine,
2413
+ offset: 2147483647,
2414
+ },
2415
+ };
2416
+ }
2417
+ await azureJson("POST", `${base}/threads?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
2418
+ return resp.type("application/json").send({ ok: true });
2419
+ }
2420
+ catch (err) {
2421
+ return resp
2422
+ .status(400)
2423
+ .type("application/json")
2424
+ .send({ error: err.message });
2425
+ }
2426
+ });
2427
+ // Resolves, reactivates or closes a comment thread.
2428
+ app.post("/api/azure-devops/pullrequests/:id/threads/:threadId/status", async (req, resp) => {
2429
+ try {
2430
+ const status = String(req.body?.status ?? "");
2431
+ if (!(status in AZURE_THREAD_STATUSES) || status === "unknown") {
2432
+ return resp
2433
+ .status(400)
2434
+ .type("application/json")
2435
+ .send({ error: `Unknown thread status "${status}".` });
2436
+ }
2437
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2438
+ await azureJson("PATCH", `${base}/threads/${Number(req.params?.threadId)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ status: AZURE_THREAD_STATUSES[status] }));
2439
+ return resp.type("application/json").send({ ok: true });
2440
+ }
2441
+ catch (err) {
2442
+ return resp
2443
+ .status(400)
2444
+ .type("application/json")
2445
+ .send({ error: err.message });
2446
+ }
2447
+ });
2448
+ // Changed files of the latest PR iteration (names and change types only;
2449
+ // content is fetched per file on demand by /file-diff).
2450
+ app.get("/api/azure-devops/pullrequests/:id/changes", async (req, resp) => {
2451
+ try {
2452
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2453
+ const iterations = await azureJson("GET", `${base}/iterations?api-version=${AZURE_API_VERSION}`);
2454
+ const list = Array.isArray(iterations?.value) ? iterations.value : [];
2455
+ const latest = list.length
2456
+ ? list.reduce((newest, entry) => String(entry?.createdDate ?? "") >
2457
+ String(newest?.createdDate ?? "")
2458
+ ? entry
2459
+ : newest)
2460
+ : null;
2461
+ if (!latest) {
2462
+ return resp.type("application/json").send({ files: [] });
2463
+ }
2464
+ const changeStatus = {
2465
+ add: "added",
2466
+ edit: "modified",
2467
+ rename: "renamed",
2468
+ delete: "deleted",
2469
+ undelete: "added",
2470
+ };
2471
+ // Azure returns the changed files under "changeEntries" and pages
2472
+ // them (nextSkip); some releases use "value" instead. Follow the
2473
+ // paging until every page has been read, otherwise a PR with more
2474
+ // changes than one page would show none at all.
2475
+ const entries = [];
2476
+ let skip = 0;
2477
+ for (;;) {
2478
+ const data = await azureJson("GET", `${base}/iterations/${latest.id}/changes?$top=1000&$skip=${skip}&api-version=${AZURE_API_VERSION}`);
2479
+ const page = Array.isArray(data?.changeEntries)
2480
+ ? data.changeEntries
2481
+ : Array.isArray(data?.value)
2482
+ ? data.value
2483
+ : [];
2484
+ entries.push(...page);
2485
+ const nextSkip = Number(data?.nextSkip);
2486
+ if (Number.isInteger(nextSkip) && nextSkip > skip && page.length) {
2487
+ skip = nextSkip;
2488
+ continue;
2489
+ }
2490
+ if (page.length >= 1000) {
2491
+ skip += 1000;
2492
+ continue;
2493
+ }
2494
+ break;
2495
+ }
2496
+ const files = entries
2497
+ .filter((entry) => entry?.item?.isFolder !== true)
2498
+ .map((entry) => {
2499
+ const path = String(entry?.item?.path ?? "").replace(/^\//, "");
2500
+ const changeType = changeStatus[String(entry?.changeType ?? "")] ?? "modified";
2501
+ const oldPath = changeType === "renamed"
2502
+ ? String(entry?.sourceServerItem ?? "").replace(/^\//, "")
2503
+ : "";
2504
+ return { path, oldPath, changeType };
2505
+ })
2506
+ .filter((file) => Boolean(file.path));
2507
+ return resp.type("application/json").send({ files });
2508
+ }
2509
+ catch (err) {
2510
+ return resp
2511
+ .status(400)
2512
+ .type("application/json")
2513
+ .send({ error: err.message });
2514
+ }
2515
+ });
2516
+ /** Maps a policy evaluation status state onto the four UI states. */
2517
+ const azurePolicyState = (state) => {
2518
+ switch (String(state ?? "").toLowerCase()) {
2519
+ case "approved":
2520
+ return "succeeded";
2521
+ case "rejected":
2522
+ case "broken":
2523
+ return "failed";
2524
+ case "notapplicable":
2525
+ return "notApplicable";
2526
+ default:
2527
+ // pending, deferred, queued and unknown states all still gate merges.
2528
+ return "pending";
2529
+ }
2530
+ };
2531
+ /** Maps a native PR status state onto the four UI states. */
2532
+ const azureStatusState = (state) => {
2533
+ switch (String(state ?? "").toLowerCase()) {
2534
+ case "succeeded":
2535
+ return "succeeded";
2536
+ case "failed":
2537
+ case "error":
2538
+ return "failed";
2539
+ default:
2540
+ // notSet and pending: posted but undecided.
2541
+ return "pending";
2542
+ }
2543
+ };
2544
+ /** Reads every policy evaluation of one pull request (paged). */
2545
+ const fetchPolicyEvaluations = async (prefix, projectId, prId) => {
2546
+ if (!projectId || !prId) {
2547
+ throw new Error("The pull request does not expose its project identity.");
2548
+ }
2549
+ // The artifact id Azure keys policy evaluations by. The documented
2550
+ // template is "CodeReviewId"; the "CodeReviewIdentity" spelling is
2551
+ // rejected with an artifact-not-found 404 (verified against TFS).
2552
+ const artifactId = `vstfs:///CodeReview/CodeReviewId/${projectId}/${prId}`;
2553
+ const evaluations = [];
2554
+ let skip = 0;
2555
+ for (;;) {
2556
+ const data = await azureJson("GET", `${prefix}/_apis/policy/evaluations?artifactId=${encodeURIComponent(artifactId)}` +
2557
+ `&$skip=${skip}&$top=1000&api-version=5.0-preview.1`);
2558
+ // Versions disagree on the envelope: "evaluations" (preview shape), the
2559
+ // generic "count"/"value" list (seen live on TFS 5.0-preview.1) or a
2560
+ // bare array.
2561
+ let page = [];
2562
+ if (Array.isArray(data)) {
2563
+ page = data;
2564
+ }
2565
+ else if (Array.isArray(data?.evaluations)) {
2566
+ page = data.evaluations;
2567
+ }
2568
+ else if (Array.isArray(data?.value)) {
2569
+ page = data.value;
2570
+ }
2571
+ const before = evaluations.length;
2572
+ evaluations.push(...page);
2573
+ const total = Number(data?.count);
2574
+ const nextSkip = Number(data?.nextSkip);
2575
+ const moreByCount = Number.isInteger(total) && total > evaluations.length;
2576
+ const moreBySkip = Number.isInteger(nextSkip) && nextSkip > skip && page.length > 0;
2577
+ // Stop when complete, when a page added nothing new (the server ignored
2578
+ // our skip) or after a safety-capped number of pages.
2579
+ if (!(moreByCount || moreBySkip) || evaluations.length === before) {
2580
+ break;
2581
+ }
2582
+ skip =
2583
+ Number.isInteger(nextSkip) && nextSkip > skip
2584
+ ? nextSkip
2585
+ : evaluations.length;
2586
+ if (skip > 20000) {
2587
+ break;
2588
+ }
2589
+ }
2590
+ return evaluations;
2591
+ };
2592
+ /** Reads the native statuses posted directly on the pull request. */
2593
+ const fetchPullRequestStatuses = async (base) => {
2594
+ const data = await azureJson("GET", `${base}/statuses?api-version=5.0-preview.1`);
2595
+ return Array.isArray(data?.value) ? data.value : [];
2596
+ };
2597
+ /** Turns one policy evaluation into a check row (null when unnameable). */
2598
+ const policyCheckOf = (prefix, evaluation, index) => {
2599
+ const configuration = evaluation?.configuration ?? {};
2600
+ const type = configuration.type ?? {};
2601
+ const settings = configuration.settings ?? {};
2602
+ const context = evaluation?.context ?? {};
2603
+ const status = evaluation?.status;
2604
+ // 5.0-preview answers status as a plain state string; newer versions may
2605
+ // answer a { state, message } object. Support both.
2606
+ const state = typeof status === "string" ? status : status?.state;
2607
+ const message = typeof status === "string" ? "" : status?.message;
2608
+ const typeName = String(type?.displayName ?? "").trim();
2609
+ const isBuild = /build/i.test(typeName);
2610
+ const name = String(settings?.displayName ?? "").trim() ||
2611
+ (isBuild ? String(context?.buildDefinitionName ?? "").trim() : "") ||
2612
+ typeName ||
2613
+ "Policy";
2614
+ if (!name) {
2615
+ return null;
2616
+ }
2617
+ const gate = `${typeName} ${name}`;
2618
+ const buildId = Number(context?.buildId ?? settings?.buildId);
2619
+ const evaluationId = String(evaluation?.evaluationId ?? status?.evaluationId ?? "").trim();
2620
+ const check = {
2621
+ id: `policy:${evaluationId || index}`,
2622
+ name,
2623
+ kind: /build/i.test(gate)
2624
+ ? "build"
2625
+ : /reviewer/i.test(gate)
2626
+ ? "reviewer"
2627
+ : "policy",
2628
+ state: azurePolicyState(state),
2629
+ required: configuration?.isBlocking === true ||
2630
+ configuration?.isRequired === true,
2631
+ };
2632
+ const detail = String(message ?? "").trim();
2633
+ if (detail) {
2634
+ check.detail = detail;
2635
+ }
2636
+ if (context?.isExpired === true) {
2637
+ check.detail = detail ? `${detail} (build expired)` : "Build expired";
2638
+ }
2639
+ if (Number.isInteger(buildId) && buildId > 0) {
2640
+ check.url = `${prefix}/_build/results?buildId=${buildId}`;
2641
+ }
2642
+ // Only build gates can be re-run, and only through their evaluation id.
2643
+ if (check.kind === "build" && evaluationId) {
2644
+ check.evaluationId = evaluationId;
2645
+ }
2646
+ return check;
2647
+ };
2648
+ // Related work items of the pull request, resolved to title/state through
2649
+ // the work-item batch API. Unresolvable refs degrade to an id-only row.
2650
+ app.get("/api/azure-devops/pullrequests/:id/workitems", async (req, resp) => {
2651
+ try {
2652
+ const { base, prefix } = await azurePrApiBase(Number(req.params?.id));
2653
+ const refs = await azureJson("GET", `${base}/workitems?api-version=${AZURE_API_VERSION}`);
2654
+ const ids = [
2655
+ ...new Set((Array.isArray(refs?.value) ? refs.value : [])
2656
+ .map((entry) => Number(entry?.id))
2657
+ .filter((id) => Number.isInteger(id) && id > 0)),
2658
+ ];
2659
+ if (!ids.length) {
2660
+ return resp.type("application/json").send({ workItems: [] });
2661
+ }
2662
+ const batch = await azureRequest("GET", `${prefix}/_apis/wit/workitems?ids=${ids.join(",")}` +
2663
+ `&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, "");
2664
+ const details = new Map();
2665
+ if (batch.status >= 200 && batch.status < 300) {
2666
+ const data = JSON.parse(batch.body);
2667
+ for (const item of Array.isArray(data?.value) ? data.value : []) {
2668
+ details.set(String(item?.id ?? ""), {
2669
+ title: String(item?.fields?.["System.Title"] ?? ""),
2670
+ state: String(item?.fields?.["System.State"] ?? ""),
2671
+ });
2672
+ }
2673
+ }
2674
+ const workItems = ids.map((id) => ({
2675
+ id,
2676
+ title: details.get(String(id))?.title || `Work item ${id}`,
2677
+ state: details.get(String(id))?.state ?? "",
2678
+ url: `${prefix}/_workitems/edit/${id}`,
2679
+ }));
2680
+ return resp.type("application/json").send({ workItems });
2681
+ }
2682
+ catch (err) {
2683
+ return resp
2684
+ .status(400)
2685
+ .type("application/json")
2686
+ .send({ error: err.message });
2687
+ }
2688
+ });
2689
+ // Tags are the PR labels resource; the side panel adds and removes them
2690
+ // directly, so both directions are served here.
2691
+ app.post("/api/azure-devops/pullrequests/:id/labels", async (req, resp) => {
2692
+ try {
2693
+ const name = String(req.body?.name ?? "").trim();
2694
+ if (!name) {
2695
+ return resp
2696
+ .status(400)
2697
+ .type("application/json")
2698
+ .send({ error: "A tag name is required." });
2699
+ }
2700
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2701
+ await azureJson("POST", `${base}/labels?api-version=${AZURE_API_VERSION}`, JSON.stringify({ name }));
2702
+ return resp.type("application/json").send({ success: true });
2703
+ }
2704
+ catch (err) {
2705
+ return resp
2706
+ .status(400)
2707
+ .type("application/json")
2708
+ .send({ error: err.message });
2709
+ }
2710
+ });
2711
+ app.delete("/api/azure-devops/pullrequests/:id/labels/:name", async (req, resp) => {
2712
+ try {
2713
+ const name = String(req.params?.name ?? "");
2714
+ if (!name) {
2715
+ return resp
2716
+ .status(400)
2717
+ .type("application/json")
2718
+ .send({ error: "A tag name is required." });
2719
+ }
2720
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2721
+ await azureJson("DELETE", `${base}/labels/${encodeURIComponent(name)}?api-version=${AZURE_API_VERSION}`, "");
2722
+ return resp.type("application/json").send({ success: true });
2723
+ }
2724
+ catch (err) {
2725
+ return resp
2726
+ .status(400)
2727
+ .type("application/json")
2728
+ .send({ error: err.message });
2729
+ }
2730
+ });
2731
+ // Work items are linked from the work item side: the link lives on the work
2732
+ // item as an ArtifactLink relation pointing at the PR's artifact URI, so
2733
+ // linking and unlinking both patch the work item rather than the PR.
2734
+ app.post("/api/azure-devops/pullrequests/:id/workitems", async (req, resp) => {
2735
+ try {
2736
+ const workItemId = Number(req.body?.id);
2737
+ if (!Number.isInteger(workItemId) || workItemId <= 0) {
2738
+ return resp
2739
+ .status(400)
2740
+ .type("application/json")
2741
+ .send({ error: "A work item id is required." });
2742
+ }
2743
+ const { prefix, artifactUrl } = await azurePrArtifactOf(Number(req.params?.id));
2744
+ const alreadyLinked = (await azureWorkItemRelations(prefix, workItemId)).some((relation) => String(relation?.url ?? "").toLowerCase() ===
2745
+ artifactUrl.toLowerCase());
2746
+ if (!alreadyLinked) {
2747
+ await azurePatchWorkItemRelations(prefix, workItemId, [
2748
+ {
2749
+ op: "add",
2750
+ path: "/relations/-",
2751
+ value: {
2752
+ rel: "ArtifactLink",
2753
+ url: artifactUrl,
2754
+ attributes: { name: "Pull Request" },
2755
+ },
2756
+ },
2757
+ ]);
2758
+ }
2759
+ return resp.type("application/json").send({ success: true });
2760
+ }
2761
+ catch (err) {
2762
+ return resp
2763
+ .status(400)
2764
+ .type("application/json")
2765
+ .send({ error: err.message });
2766
+ }
2767
+ });
2768
+ app.delete("/api/azure-devops/pullrequests/:id/workitems/:workItemId", async (req, resp) => {
2769
+ try {
2770
+ const workItemId = Number(req.params?.workItemId);
2771
+ if (!Number.isInteger(workItemId) || workItemId <= 0) {
2772
+ return resp
2773
+ .status(400)
2774
+ .type("application/json")
2775
+ .send({ error: "A work item id is required." });
2776
+ }
2777
+ const { prefix, artifactUrl } = await azurePrArtifactOf(Number(req.params?.id));
2778
+ // Relations are removed by index, so confirm the link and its index in
2779
+ // the same read, then assert that url at the index before removing:
2780
+ // Azure applies the patch atomically, so a relation list that moved
2781
+ // underneath this call is rejected instead of removing a neighbour.
2782
+ const relations = await azureWorkItemRelations(prefix, workItemId);
2783
+ const index = relations.findIndex((relation) => String(relation?.url ?? "").toLowerCase() ===
2784
+ artifactUrl.toLowerCase());
2785
+ if (index >= 0) {
2786
+ await azurePatchWorkItemRelations(prefix, workItemId, [
2787
+ {
2788
+ op: "test",
2789
+ path: `/relations/${index}/url`,
2790
+ value: String(relations[index]?.url ?? ""),
2791
+ },
2792
+ { op: "remove", path: `/relations/${index}` },
2793
+ ]);
2794
+ }
2795
+ return resp.type("application/json").send({ success: true });
2796
+ }
2797
+ catch (err) {
2798
+ return resp
2799
+ .status(400)
2800
+ .type("application/json")
2801
+ .send({ error: err.message });
2802
+ }
2803
+ });
2804
+ // Merge gates of the pull request: Azure policy evaluations (builds,
2805
+ // required reviewers, comments, work items, ...) plus the native statuses
2806
+ // posted on the PR. One failing source degrades to a warning so the other
2807
+ // still reaches the dialog; only when both fail is the request an error.
2808
+ app.get("/api/azure-devops/pullrequests/:id/checks", async (req, resp) => {
2809
+ const describe = (error) => error instanceof Error ? error.message : String(error);
2810
+ try {
2811
+ const { base, prefix } = await azurePrApiBase(Number(req.params?.id));
2812
+ const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
2813
+ const warnings = [];
2814
+ const checks = new Map();
2815
+ const [evaluations, statuses] = await Promise.all([
2816
+ fetchPolicyEvaluations(prefix, String(pr?.repository?.project?.id ?? ""), Number(pr?.pullRequestId ?? 0)).catch((error) => ({ error })),
2817
+ fetchPullRequestStatuses(base).catch((error) => ({ error })),
2818
+ ]);
2819
+ const policyError = "error" in evaluations ? evaluations.error : null;
2820
+ const statusError = "error" in statuses ? statuses.error : null;
2821
+ if (policyError && statusError) {
2822
+ throw new Error(`Policy evaluations failed (${describe(policyError)}); pull request statuses failed (${describe(statusError)}).`);
2823
+ }
2824
+ if (policyError) {
2825
+ warnings.push(`Policy evaluations are unavailable (${describe(policyError)}). Showing pull request statuses only.`);
2826
+ }
2827
+ else {
2828
+ evaluations.forEach((evaluation, index) => {
2829
+ const check = policyCheckOf(prefix, evaluation, index);
2830
+ if (check) {
2831
+ checks.set(check.id, check);
2832
+ }
2833
+ });
2834
+ }
2835
+ if (statusError) {
2836
+ warnings.push(`Pull request statuses are unavailable (${describe(statusError)}). Showing policy evaluations only.`);
2837
+ }
2838
+ else {
2839
+ // Statuses repeat on re-post; keep only the latest per genre/name.
2840
+ const latestStatuses = new Map();
2841
+ for (const status of statuses) {
2842
+ const key = `${String(status?.context?.genre ?? "")}/${String(status?.context?.name ?? "")}`;
2843
+ const existing = latestStatuses.get(key);
2844
+ if (!existing ||
2845
+ String(status?.creationDate ?? "") >
2846
+ String(existing?.creationDate ?? "")) {
2847
+ latestStatuses.set(key, status);
2848
+ }
2849
+ }
2850
+ for (const [key, status] of latestStatuses) {
2851
+ const name = String(status?.context?.name ?? "Status");
2852
+ const row = {
2853
+ id: `status:${key}`,
2854
+ name,
2855
+ kind: "status",
2856
+ state: azureStatusState(status?.state),
2857
+ required: false,
2858
+ };
2859
+ const description = String(status?.description ?? "").trim();
2860
+ if (description) {
2861
+ row.detail = description;
2862
+ }
2863
+ const target = String(status?.targetUrl ?? "");
2864
+ if (target) {
2865
+ row.url = target;
2866
+ }
2867
+ checks.set(row.id, row);
2868
+ }
2869
+ }
2870
+ return resp
2871
+ .type("application/json")
2872
+ .send({ checks: [...checks.values()], warnings });
2873
+ }
2874
+ catch (err) {
2875
+ return resp
2876
+ .status(400)
2877
+ .type("application/json")
2878
+ .send({ error: err.message });
2879
+ }
2880
+ });
2881
+ // Re-queues the build behind one build policy evaluation, the same action
2882
+ // the Azure DevOps checks list offers. The evaluation id comes from the
2883
+ // checks endpoint above; Azure answers the refreshed evaluation.
2884
+ app.post("/api/azure-devops/pullrequests/:id/checks/:evaluationId/requeue", async (req, resp) => {
2885
+ try {
2886
+ const evaluationId = String(req.params?.evaluationId ?? "").trim();
2887
+ if (!evaluationId) {
2888
+ throw new Error("A policy evaluation id is required.");
2889
+ }
2890
+ const { prefix } = await azurePrApiBase(Number(req.params?.id));
2891
+ await azureJson("POST", `${prefix}/_apis/policy/evaluations/${encodeURIComponent(evaluationId)}` +
2892
+ `?api-version=5.0-preview.1`);
2893
+ return resp.type("application/json").send({ ok: true });
1204
2894
  }
1205
2895
  catch (err) {
1206
- return resp.status(400).type('application/json').send({ error: err.message });
2896
+ return resp
2897
+ .status(400)
2898
+ .type("application/json")
2899
+ .send({ error: err.message });
2900
+ }
2901
+ });
2902
+ // Abandons (closes) the pull request without merging.
2903
+ app.post("/api/azure-devops/pullrequests/:id/abandon", async (req, resp) => {
2904
+ try {
2905
+ const { base } = await azurePrApiBase(Number(req.params?.id));
2906
+ await azureJson("PATCH", `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ status: "abandoned" }));
2907
+ return resp.type("application/json").send({ ok: true });
2908
+ }
2909
+ catch (err) {
2910
+ return resp
2911
+ .status(400)
2912
+ .type("application/json")
2913
+ .send({ error: err.message });
2914
+ }
2915
+ });
2916
+ // Unified diff of one PR file, computed from the Azure item contents at the
2917
+ // PR merge commits. source/target are optional overrides; default comes from
2918
+ // the pull request detail.
2919
+ app.get("/api/azure-devops/pullrequests/:id/file-diff", async (req, resp) => {
2920
+ try {
2921
+ const path = String(req.query?.path ?? "").replace(/^\//, "");
2922
+ if (!path) {
2923
+ return resp
2924
+ .status(400)
2925
+ .type("application/json")
2926
+ .send({ error: "path required" });
2927
+ }
2928
+ const oldPath = String(req.query?.oldPath ?? "").replace(/^\//, "");
2929
+ const { base, prefix, repo } = await azurePrApiBase(Number(req.params?.id));
2930
+ let source = String(req.query?.source ?? "");
2931
+ let target = String(req.query?.target ?? "");
2932
+ if (!source || !target) {
2933
+ const pr = await azureJson("GET", `${base}?api-version=${AZURE_API_VERSION}`);
2934
+ source = source || String(pr?.lastMergeSourceCommit?.commitId ?? "");
2935
+ target = target || String(pr?.lastMergeTargetCommit?.commitId ?? "");
2936
+ }
2937
+ if (!source || !target) {
2938
+ throw new Error("The pull request does not have merge commits to diff yet.");
2939
+ }
2940
+ const fetchContent = async (itemPath, commit) => {
2941
+ if (!itemPath)
2942
+ return { content: "", binary: false };
2943
+ const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/items` +
2944
+ `?path=${encodeURIComponent(`/${itemPath}`)}` +
2945
+ `&versionDescriptor.version=${encodeURIComponent(commit)}` +
2946
+ `&versionDescriptor.versionType=commit&includeContent=true` +
2947
+ `&api-version=${AZURE_API_VERSION}`;
2948
+ const result = await azureRequest("GET", url, "");
2949
+ // 404/203: the file does not exist at that commit (added/deleted file).
2950
+ if (result.status === 404 || result.status === 203) {
2951
+ return { content: "", binary: false };
2952
+ }
2953
+ if (result.status < 200 || result.status >= 300) {
2954
+ throw new Error(azureErrorMessage(result) ||
2955
+ `Azure DevOps returned HTTP ${result.status}.`);
2956
+ }
2957
+ let data = null;
2958
+ try {
2959
+ data = JSON.parse(result.body);
2960
+ }
2961
+ catch {
2962
+ // Depending on the Azure deployment and file type, the Items API
2963
+ // may return the file body directly instead of a JSON envelope.
2964
+ }
2965
+ const hasContentEnvelope = data !== null &&
2966
+ typeof data === "object" &&
2967
+ (Object.prototype.hasOwnProperty.call(data, "content") ||
2968
+ Object.prototype.hasOwnProperty.call(data, "isBinary"));
2969
+ return {
2970
+ content: hasContentEnvelope
2971
+ ? typeof data.content === "string"
2972
+ ? data.content
2973
+ : ""
2974
+ : result.body,
2975
+ binary: hasContentEnvelope && data.isBinary === true,
2976
+ };
2977
+ };
2978
+ const [oldFile, newFile] = await Promise.all([
2979
+ fetchContent(oldPath || path, target),
2980
+ fetchContent(path, source),
2981
+ ]);
2982
+ if (oldFile.binary || newFile.binary) {
2983
+ return resp.type("application/json").send({
2984
+ path,
2985
+ oldPath,
2986
+ status: "binary",
2987
+ binary: true,
2988
+ additions: 0,
2989
+ deletions: 0,
2990
+ lines: [],
2991
+ });
2992
+ }
2993
+ const status = String(req.query?.changeType ?? "") ||
2994
+ (!oldFile.content
2995
+ ? "added"
2996
+ : !newFile.content
2997
+ ? "deleted"
2998
+ : "modified");
2999
+ // jsdiff omits the "diff --git" header parseUnifiedDiff expects, and
3000
+ // prefixing it lets the same parser serve both local and PR diffs.
3001
+ const patch = createTwoFilesPatch(`a/${oldPath || path}`, `b/${path}`, oldFile.content, newFile.content);
3002
+ const raw = `diff --git a/${oldPath || path} b/${path}\n${patch.replace(/^=+\n/, "")}`;
3003
+ // The patch's trailing newline would surface as a phantom context row.
3004
+ const [file] = parseUnifiedDiff(raw.replace(/\n+$/, ""));
3005
+ const maxLines = 10000;
3006
+ const lines = (file?.lines ?? []).slice(0, maxLines);
3007
+ if ((file?.lines ?? []).length > maxLines) {
3008
+ lines.push({
3009
+ type: "hunk",
3010
+ text: "Diff truncated (file too large).",
3011
+ });
3012
+ }
3013
+ return resp.type("application/json").send({
3014
+ path,
3015
+ oldPath,
3016
+ status,
3017
+ binary: false,
3018
+ additions: file?.additions ?? 0,
3019
+ deletions: file?.deletions ?? 0,
3020
+ originalContent: oldFile.content,
3021
+ modifiedContent: newFile.content,
3022
+ lines,
3023
+ });
3024
+ }
3025
+ catch (err) {
3026
+ return resp
3027
+ .status(400)
3028
+ .type("application/json")
3029
+ .send({ error: err.message });
1207
3030
  }
1208
3031
  });
1209
3032
  // ==================== Commit Detail ====================
1210
- app.post('/api/commit/detail', async (req, resp) => {
3033
+ app.post("/api/commit/detail", async (req, resp) => {
1211
3034
  try {
1212
3035
  const { hash } = req.body;
1213
3036
  if (!hash) {
1214
- return resp.status(400).type('application/json').send({ error: 'hash required' });
3037
+ return resp
3038
+ .status(400)
3039
+ .type("application/json")
3040
+ .send({ error: "hash required" });
1215
3041
  }
1216
3042
  // Fields are NUL-separated; %b may span lines and contain anything
1217
3043
  // except NUL, so it cannot be split naively by newline.
1218
3044
  const raw = await git.raw([
1219
- 'show',
1220
- '-s',
1221
- '--format=%H%x00%aI%x00%s%x00%b%x00%aN%x00%aE%x00%P',
3045
+ "show",
3046
+ "-s",
3047
+ "--format=%H%x00%aI%x00%s%x00%b%x00%aN%x00%aE%x00%P",
1222
3048
  hash,
1223
3049
  ]);
1224
- const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
1225
- .trimEnd()
1226
- .split('\u0000');
1227
- return resp.type('application/json').send({
3050
+ const [fullHash, date, message, body, authorName, authorEmail, parents] = raw.trimEnd().split("\u0000");
3051
+ return resp.type("application/json").send({
1228
3052
  hash: fullHash,
1229
3053
  date,
1230
3054
  message,
1231
- refs: '',
1232
- body: body ?? '',
3055
+ refs: "",
3056
+ body: body ?? "",
1233
3057
  ...authorIdentity(authorName, authorEmail, await configuredIdentity()),
1234
- parents: String(parents ?? '')
1235
- .split(' ')
3058
+ parents: String(parents ?? "")
3059
+ .split(" ")
1236
3060
  .filter(Boolean),
1237
3061
  });
1238
3062
  }
1239
3063
  catch (err) {
1240
- return resp.status(400).type('application/json').send({ error: err.message });
3064
+ return resp
3065
+ .status(400)
3066
+ .type("application/json")
3067
+ .send({ error: err.message });
1241
3068
  }
1242
3069
  });
1243
3070
  // ==================== Diff Viewer ====================
1244
- app.post('/api/commit/diff', async (req, resp) => {
3071
+ app.post("/api/commit/diff", async (req, resp) => {
1245
3072
  try {
1246
3073
  const { hash } = req.body;
1247
3074
  if (!hash) {
1248
- return resp.status(400).type('application/json').send({ error: 'hash required' });
3075
+ return resp
3076
+ .status(400)
3077
+ .type("application/json")
3078
+ .send({ error: "hash required" });
1249
3079
  }
1250
3080
  // Get the raw unified diff (diff against the first parent for merges)
1251
3081
  const rawDiff = await git.raw([
1252
- 'show',
1253
- '--no-color',
1254
- '--pretty=format:',
1255
- '--find-renames',
1256
- '--first-parent',
1257
- '-m',
3082
+ "show",
3083
+ "--no-color",
3084
+ "--pretty=format:",
3085
+ "--find-renames",
3086
+ "--first-parent",
3087
+ "-m",
1258
3088
  hash,
1259
3089
  ]);
1260
- return resp.type('application/json').send({ hash, files: parseUnifiedDiff(rawDiff) });
3090
+ return resp
3091
+ .type("application/json")
3092
+ .send({ hash, files: parseUnifiedDiff(rawDiff) });
1261
3093
  }
1262
3094
  catch (err) {
1263
- return resp.status(400).type('application/json').send({ error: err.message });
3095
+ return resp
3096
+ .status(400)
3097
+ .type("application/json")
3098
+ .send({ error: err.message });
1264
3099
  }
1265
3100
  });
1266
3101
  // ==================== Working Changes ====================
1267
- app.get('/api/working-changes', async (_req, resp) => {
3102
+ app.get("/api/working-changes", async (_req, resp) => {
1268
3103
  try {
1269
3104
  await mutationQueue;
1270
- return resp.type('application/json').send(await working.snapshot());
3105
+ return resp.type("application/json").send(await working.snapshot());
1271
3106
  }
1272
3107
  catch (err) {
1273
- return resp.status(400).type('application/json').send({ error: err.message });
3108
+ return resp
3109
+ .status(400)
3110
+ .type("application/json")
3111
+ .send({ error: err.message });
1274
3112
  }
1275
3113
  });
1276
3114
  // ==================== File Content ====================
1277
- app.post('/api/file-content', async (req, resp) => {
3115
+ app.post("/api/file-content", async (req, resp) => {
1278
3116
  try {
1279
3117
  const { path, ref } = req.body;
1280
3118
  if (!path || !ref) {
1281
- return resp.status(400).type('application/json').send({ error: 'path and ref required' });
3119
+ return resp
3120
+ .status(400)
3121
+ .type("application/json")
3122
+ .send({ error: "path and ref required" });
1282
3123
  }
1283
- if (ref === 'WORKING') {
3124
+ if (ref === "WORKING") {
1284
3125
  const root = await repoRoot();
1285
3126
  const abs = resolve(root, path);
1286
3127
  const rel = relative(root, abs);
1287
- if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
1288
- return resp.status(400).type('application/json').send({ error: 'invalid path' });
3128
+ if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
3129
+ return resp
3130
+ .status(400)
3131
+ .type("application/json")
3132
+ .send({ error: "invalid path" });
1289
3133
  }
1290
3134
  try {
1291
3135
  const buffer = await readFile(abs);
1292
3136
  if (buffer.includes(0)) {
1293
- return resp.type('application/json').send({ binary: true, content: '' });
3137
+ return resp
3138
+ .type("application/json")
3139
+ .send({ binary: true, content: "" });
1294
3140
  }
1295
- return resp.type('application/json').send({ content: buffer.toString('utf8') });
3141
+ return resp
3142
+ .type("application/json")
3143
+ .send({ content: buffer.toString("utf8") });
1296
3144
  }
1297
3145
  catch {
1298
3146
  // File no longer exists on disk (deleted).
1299
- return resp.type('application/json').send({ content: '' });
3147
+ return resp.type("application/json").send({ content: "" });
1300
3148
  }
1301
3149
  }
1302
- if (ref === 'EMPTY') {
1303
- return resp.type('application/json').send({ content: '' });
3150
+ if (ref === "EMPTY") {
3151
+ return resp.type("application/json").send({ content: "" });
1304
3152
  }
1305
3153
  try {
1306
- const content = await git.raw(['show', ref === 'INDEX' ? `:${path}` : `${ref}:${path}`]);
1307
- if (content.includes('\u0000')) {
1308
- return resp.type('application/json').send({ binary: true, content: '' });
3154
+ const content = await git.raw([
3155
+ "show",
3156
+ ref === "INDEX" ? `:${path}` : `${ref}:${path}`,
3157
+ ]);
3158
+ if (content.includes("\u0000")) {
3159
+ return resp
3160
+ .type("application/json")
3161
+ .send({ binary: true, content: "" });
1309
3162
  }
1310
- return resp.type('application/json').send({ content });
3163
+ return resp.type("application/json").send({ content });
1311
3164
  }
1312
3165
  catch {
1313
3166
  // Path did not exist at that revision (added file).
1314
- return resp.type('application/json').send({ content: '' });
3167
+ return resp.type("application/json").send({ content: "" });
1315
3168
  }
1316
3169
  }
1317
3170
  catch (err) {
1318
- return resp.status(400).type('application/json').send({ error: err.message });
3171
+ return resp
3172
+ .status(400)
3173
+ .type("application/json")
3174
+ .send({ error: err.message });
1319
3175
  }
1320
3176
  });
1321
3177
  // ==================== Discard ====================
1322
- app.post('/api/discard', async (req, resp) => {
3178
+ app.post("/api/discard", async (req, resp) => {
1323
3179
  try {
1324
3180
  const { files, mode } = req.body;
1325
3181
  if (!Array.isArray(files) || files.length === 0) {
1326
- return resp.status(400).type('application/json').send({ error: 'files required' });
3182
+ return resp
3183
+ .status(400)
3184
+ .type("application/json")
3185
+ .send({ error: "files required" });
1327
3186
  }
1328
3187
  const root = await repoRoot();
1329
3188
  const status = await git.status();
@@ -1331,52 +3190,77 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
1331
3190
  const tracked = files.filter((file) => !untracked.has(file));
1332
3191
  const removed = files.filter((file) => untracked.has(file));
1333
3192
  if (tracked.length > 0) {
1334
- if (mode === 'unstaged') {
3193
+ if (mode === "unstaged") {
1335
3194
  // Restore from the index so staged changes survive; unmerged (conflicted)
1336
3195
  // paths cannot be restored from the index and fall back to HEAD.
1337
3196
  const conflicted = new Set(status.conflicted ?? []);
1338
3197
  const restorable = tracked.filter((file) => !conflicted.has(file));
1339
3198
  const unmerged = tracked.filter((file) => conflicted.has(file));
1340
3199
  if (restorable.length > 0)
1341
- await git.raw(['checkout', '--', ...restorable]);
3200
+ await git.raw(["checkout", "--", ...restorable]);
1342
3201
  if (unmerged.length > 0)
1343
- await git.raw(['checkout', 'HEAD', '--', ...unmerged]);
3202
+ await git.raw(["checkout", "HEAD", "--", ...unmerged]);
1344
3203
  }
1345
3204
  else {
1346
- await git.raw(['checkout', 'HEAD', '--', ...tracked]);
3205
+ await git.raw(["checkout", "HEAD", "--", ...tracked]);
1347
3206
  }
1348
3207
  }
1349
3208
  for (const file of removed) {
1350
3209
  await rm(join(root, file), { force: true, recursive: true });
1351
3210
  }
1352
- return resp.type('application/json').send({ success: true });
3211
+ return resp.type("application/json").send({ success: true });
1353
3212
  }
1354
3213
  catch (err) {
1355
- return resp.status(400).type('application/json').send({ error: err.message });
3214
+ return resp
3215
+ .status(400)
3216
+ .type("application/json")
3217
+ .send({ error: err.message });
1356
3218
  }
1357
3219
  });
1358
3220
  // ==================== Working Tree Actions ====================
1359
- app.post('/api/reset', async (_req, resp) => {
3221
+ app.post("/api/reset", async (_req, resp) => {
1360
3222
  try {
1361
- await git.raw(['reset', '--hard', 'HEAD']);
1362
- return resp.type('application/json').send({ ok: true });
3223
+ await git.raw(["reset", "--hard", "HEAD"]);
3224
+ return resp.type("application/json").send({ ok: true });
1363
3225
  }
1364
3226
  catch (err) {
1365
- return resp.status(400).type('application/json').send({ error: err.message });
3227
+ return resp
3228
+ .status(400)
3229
+ .type("application/json")
3230
+ .send({ error: err.message });
1366
3231
  }
1367
3232
  });
1368
- app.post('/api/clean', async (_req, resp) => {
3233
+ app.post("/api/clean", async (_req, resp) => {
1369
3234
  try {
1370
- await git.raw(['clean', '-fd']);
1371
- return resp.type('application/json').send({ ok: true });
3235
+ await git.raw(["clean", "-fd"]);
3236
+ return resp.type("application/json").send({ ok: true });
1372
3237
  }
1373
3238
  catch (err) {
1374
- return resp.status(400).type('application/json').send({ error: err.message });
3239
+ return resp
3240
+ .status(400)
3241
+ .type("application/json")
3242
+ .send({ error: err.message });
1375
3243
  }
1376
3244
  });
1377
3245
  const address = await app.listen({ port, ...(host ? { host } : {}) });
1378
3246
  return {
1379
3247
  address,
1380
3248
  close: () => app.close(),
3249
+ updateSettings: (settings) => {
3250
+ azureDevOpsUrl = settings.azureDevOpsUrl;
3251
+ prBranchNameTemplate = settings.prBranchNameTemplate;
3252
+ autoReload = settings.autoReload;
3253
+ diffViewer = settings.diffViewer;
3254
+ showGraph = settings.showGraph;
3255
+ showStashes = settings.showStashes;
3256
+ fileListView = settings.fileListView;
3257
+ searchMode = settings.searchMode;
3258
+ searchCaseSensitive = settings.searchCaseSensitive;
3259
+ showTags = settings.showTags;
3260
+ showRemoteBranches = settings.showRemoteBranches;
3261
+ issueRegex = settings.issueRegex;
3262
+ issueUrl = settings.issueUrl;
3263
+ allowMerge = settings.allowMerge;
3264
+ },
1381
3265
  };
1382
3266
  }