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