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