guito 0.6.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -0
- package/README.md +2 -0
- package/bin/avatars.js +35 -0
- package/bin/guito-server.js +1149 -78
- package/bin/repository-state.js +25 -0
- 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-SN64JYAQ.js +0 -6
package/bin/guito-server.js
CHANGED
|
@@ -2,16 +2,51 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { execFile, spawn } from 'node:child_process';
|
|
3
3
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { userInfo } from 'node:os';
|
|
5
6
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
6
7
|
import fastify from 'fastify';
|
|
7
8
|
import fastifyStatic from '@fastify/static';
|
|
8
9
|
import fastifyCors from '@fastify/cors';
|
|
9
10
|
import fastifyCompress from '@fastify/compress';
|
|
10
11
|
import { simpleGit } from 'simple-git';
|
|
12
|
+
import { createTwoFilesPatch } from 'diff';
|
|
11
13
|
import { promisify } from 'node:util';
|
|
12
14
|
import { workingTree } from './working-tree.js';
|
|
15
|
+
import { avatarCache } from './avatars.js';
|
|
16
|
+
import { repositoryState } from './repository-state.js';
|
|
13
17
|
const execFileAsync = promisify(execFile);
|
|
14
|
-
const AZURE_API_VERSION = '
|
|
18
|
+
const AZURE_API_VERSION = '5.0';
|
|
19
|
+
export const DEFAULT_PR_BRANCH_NAME_TEMPLATE = 'pr/${randomstring}';
|
|
20
|
+
const PR_BRANCH_TEMPLATE_VARIABLES = [
|
|
21
|
+
'username',
|
|
22
|
+
'randomstring',
|
|
23
|
+
'branch',
|
|
24
|
+
'targetbranch',
|
|
25
|
+
'title',
|
|
26
|
+
'repository',
|
|
27
|
+
'date',
|
|
28
|
+
'time',
|
|
29
|
+
'timestamp',
|
|
30
|
+
];
|
|
31
|
+
/** Azure DevOps comment thread statuses, by their numeric REST value. */
|
|
32
|
+
const AZURE_THREAD_STATUSES = {
|
|
33
|
+
unknown: 0,
|
|
34
|
+
active: 1,
|
|
35
|
+
wontFix: 3,
|
|
36
|
+
fixed: 4,
|
|
37
|
+
closed: 5,
|
|
38
|
+
byDesign: 6,
|
|
39
|
+
pending: 7,
|
|
40
|
+
};
|
|
41
|
+
const azureThreadStatusName = (value) => Object.entries(AZURE_THREAD_STATUSES).find(([, numeric]) => numeric === value)?.[0] ?? 'unknown';
|
|
42
|
+
/** Pull request reviewer votes, by their numeric REST value. */
|
|
43
|
+
const AZURE_VOTES = {
|
|
44
|
+
reset: 0,
|
|
45
|
+
approveWithSuggestions: 5,
|
|
46
|
+
approve: 10,
|
|
47
|
+
waitForAuthor: -5,
|
|
48
|
+
reject: -10,
|
|
49
|
+
};
|
|
15
50
|
/**
|
|
16
51
|
* Extracts {projectPath, repo} from an Azure DevOps git remote URL.
|
|
17
52
|
* Supports https (with optional credentials) and ssh (scp-style and ssh://)
|
|
@@ -74,11 +109,19 @@ export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
|
|
|
74
109
|
.join('/');
|
|
75
110
|
try {
|
|
76
111
|
const parsed = new URL(base);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
112
|
+
const decode = (path) => path
|
|
113
|
+
.split('/')
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.map((segment) => decodeURIComponent(segment));
|
|
116
|
+
const baseParts = decode(parsed.pathname);
|
|
117
|
+
const remoteParts = info.projectPath.split('/').filter(Boolean);
|
|
118
|
+
let overlap = Math.min(baseParts.length, remoteParts.length);
|
|
119
|
+
while (overlap &&
|
|
120
|
+
!baseParts
|
|
121
|
+
.slice(-overlap)
|
|
122
|
+
.every((part, index) => part.toLowerCase() === remoteParts[index].toLowerCase()))
|
|
123
|
+
overlap--;
|
|
124
|
+
return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join('/')}`;
|
|
82
125
|
}
|
|
83
126
|
catch {
|
|
84
127
|
return `${base}/${encodePath(info.projectPath)}`;
|
|
@@ -94,18 +137,18 @@ export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
|
|
|
94
137
|
if (!info) {
|
|
95
138
|
return null;
|
|
96
139
|
}
|
|
97
|
-
return `${prefix}/
|
|
140
|
+
return `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
98
141
|
}
|
|
99
142
|
/**
|
|
100
143
|
* Default Azure DevOps HTTP runner: shells out to the Windows built-in
|
|
101
|
-
* curl.exe with
|
|
102
|
-
*
|
|
144
|
+
* curl.exe with Negotiate and an empty username, which authenticates with
|
|
145
|
+
* the current Windows session (SSPI single sign-on). Do not also request
|
|
146
|
+
* --ntlm: some curl builds omit that option while still supporting Negotiate.
|
|
103
147
|
*/
|
|
104
148
|
function defaultAzureRequest(method, url, body) {
|
|
105
149
|
return new Promise((resolveRequest, rejectRequest) => {
|
|
106
150
|
const args = [
|
|
107
151
|
'-sS',
|
|
108
|
-
'--ntlm',
|
|
109
152
|
'--negotiate',
|
|
110
153
|
'-u',
|
|
111
154
|
':',
|
|
@@ -116,7 +159,7 @@ function defaultAzureRequest(method, url, body) {
|
|
|
116
159
|
'-X',
|
|
117
160
|
method,
|
|
118
161
|
];
|
|
119
|
-
if (method === 'POST') {
|
|
162
|
+
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
|
|
120
163
|
args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
|
|
121
164
|
}
|
|
122
165
|
args.push(url);
|
|
@@ -161,6 +204,22 @@ const randomBranchSuffix = () => {
|
|
|
161
204
|
}
|
|
162
205
|
return suffix;
|
|
163
206
|
};
|
|
207
|
+
const safeBranchTemplateValue = (value) => value
|
|
208
|
+
.trim()
|
|
209
|
+
.replace(/[\x00-\x20\x7f~^:?*\[\\]+/g, '-')
|
|
210
|
+
.replace(/\.{2,}/g, '-')
|
|
211
|
+
.replace(/-+/g, '-')
|
|
212
|
+
.replace(/^[./-]+|[./-]+$/g, '') || 'unknown';
|
|
213
|
+
/** Expands a configured automatic PR branch name and rejects unknown variables. */
|
|
214
|
+
export const formatPrBranchName = (template, values) => {
|
|
215
|
+
const source = template.trim() || DEFAULT_PR_BRANCH_NAME_TEMPLATE;
|
|
216
|
+
return source.replace(/\$\{([^}]+)\}/g, (_match, variable) => {
|
|
217
|
+
if (!PR_BRANCH_TEMPLATE_VARIABLES.includes(variable)) {
|
|
218
|
+
throw new Error(`Unknown pull request branch variable \${${variable}}. Supported variables: ${PR_BRANCH_TEMPLATE_VARIABLES.map((name) => `\${${name}}`).join(', ')}.`);
|
|
219
|
+
}
|
|
220
|
+
return safeBranchTemplateValue(values[variable]);
|
|
221
|
+
});
|
|
222
|
+
};
|
|
164
223
|
/** Extracts Azure DevOps' human-readable error message from a REST response. */
|
|
165
224
|
const azureErrorMessage = (result) => {
|
|
166
225
|
try {
|
|
@@ -170,11 +229,21 @@ const azureErrorMessage = (result) => {
|
|
|
170
229
|
return result.body;
|
|
171
230
|
}
|
|
172
231
|
};
|
|
173
|
-
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, azureRequestImpl, }) {
|
|
232
|
+
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, prBranchNameTemplate, autoReload, diffViewer, showGraph, showStashes, showTags, showRemoteBranches, issueRegex, issueUrl, fileListView, searchMode, searchCaseSensitive, azureRequestImpl, avatarFetchImpl, onLog, }) {
|
|
174
233
|
// Initialize server
|
|
175
234
|
const app = fastify({
|
|
176
235
|
logger: false,
|
|
177
236
|
});
|
|
237
|
+
if (onLog) {
|
|
238
|
+
// Report error responses to the host (e.g. the VS Code output channel).
|
|
239
|
+
app.addHook('onSend', async (request, reply, payload) => {
|
|
240
|
+
if (reply.statusCode >= 400) {
|
|
241
|
+
const body = typeof payload === 'string' ? payload.slice(0, 500) : '';
|
|
242
|
+
onLog(`${request.method} ${request.url} -> ${reply.statusCode}${body ? ` ${body}` : ''}`);
|
|
243
|
+
}
|
|
244
|
+
return payload;
|
|
245
|
+
});
|
|
246
|
+
}
|
|
178
247
|
// Register cors
|
|
179
248
|
await app.register(fastifyCors);
|
|
180
249
|
// Compress JSON responses (commit history payloads are highly repetitive)
|
|
@@ -207,6 +276,27 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
207
276
|
}
|
|
208
277
|
// Initialize git lib
|
|
209
278
|
const git = simpleGit(repositoryPath);
|
|
279
|
+
const avatars = avatarCache(avatarFetchImpl);
|
|
280
|
+
app.get('/api/avatar', async (req, reply) => {
|
|
281
|
+
const email = String(req.query?.email ?? '').trim();
|
|
282
|
+
if (!email || email.length > 320)
|
|
283
|
+
return reply.code(400).send({ error: 'Valid email required' });
|
|
284
|
+
const image = await avatars(email);
|
|
285
|
+
reply.header('Cache-Control', 'private, max-age=3600');
|
|
286
|
+
return image ? reply.type(image.contentType).send(image.data) : reply.code(204).send();
|
|
287
|
+
});
|
|
288
|
+
let stateRequest;
|
|
289
|
+
app.get('/api/repository-state', async (_req, reply) => {
|
|
290
|
+
try {
|
|
291
|
+
stateRequest ?? (stateRequest = repositoryState(git, repositoryPath).finally(() => {
|
|
292
|
+
stateRequest = undefined;
|
|
293
|
+
}));
|
|
294
|
+
return await stateRequest;
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
return reply.code(400).send({ error: err.message });
|
|
298
|
+
}
|
|
299
|
+
});
|
|
210
300
|
// ==================== Diff parsing ====================
|
|
211
301
|
function parseUnifiedDiff(rawDiff) {
|
|
212
302
|
const files = [];
|
|
@@ -322,8 +412,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
322
412
|
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
323
413
|
};
|
|
324
414
|
const azureRequest = azureRequestImpl ?? defaultAzureRequest;
|
|
325
|
-
const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
|
|
326
415
|
const effectiveAzureUrl = async () => {
|
|
416
|
+
const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
|
|
327
417
|
if (configuredAzureUrl) {
|
|
328
418
|
return { url: configuredAzureUrl, source: 'vscode' };
|
|
329
419
|
}
|
|
@@ -350,11 +440,165 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
350
440
|
}
|
|
351
441
|
return { prefix, remoteUrl };
|
|
352
442
|
};
|
|
443
|
+
/** Human identity of the authenticated Windows/Azure user; cached per run. */
|
|
444
|
+
let cachedAzureMe = null;
|
|
445
|
+
const azureMe = async () => {
|
|
446
|
+
if (cachedAzureMe) {
|
|
447
|
+
return cachedAzureMe;
|
|
448
|
+
}
|
|
449
|
+
const { prefix } = await azureProjectContext();
|
|
450
|
+
// The prefix is "{collection-or-org}/{project}"; connectionData is
|
|
451
|
+
// collection scoped.
|
|
452
|
+
const collection = prefix.slice(0, prefix.lastIndexOf('/'));
|
|
453
|
+
const result = await azureRequest('GET', `${collection}/_apis/connectionData?q=1&api-version=${AZURE_API_VERSION}`, '');
|
|
454
|
+
if (result.status < 200 || result.status >= 300) {
|
|
455
|
+
throw new Error(azureErrorMessage(result) ||
|
|
456
|
+
`Azure DevOps returned HTTP ${result.status} while resolving the current user.`);
|
|
457
|
+
}
|
|
458
|
+
const user = (JSON.parse(result.body) ?? {}).authenticatedUser;
|
|
459
|
+
const id = String(user?.id ?? '');
|
|
460
|
+
if (!id) {
|
|
461
|
+
throw new Error('Azure DevOps did not report an authenticated user.');
|
|
462
|
+
}
|
|
463
|
+
cachedAzureMe = {
|
|
464
|
+
id,
|
|
465
|
+
name: String(user?.providerDisplayName ?? ''),
|
|
466
|
+
email: String(user?.properties?.Mail?.$value ?? user?.properties?.Account?.$value ?? ''),
|
|
467
|
+
};
|
|
468
|
+
return cachedAzureMe;
|
|
469
|
+
};
|
|
470
|
+
/** Web UI URL of a pull request, used by the "Open in Azure DevOps" links. */
|
|
471
|
+
const azurePrWebUrl = (prefix, remoteUrl, id) => {
|
|
472
|
+
const repo = parseAzureRemoteUrl(remoteUrl)?.repo ?? '';
|
|
473
|
+
return `${prefix}/_git/${encodeURIComponent(repo)}/pullrequest/${id}`;
|
|
474
|
+
};
|
|
475
|
+
/** Maps one Azure DevOps pull request record to the shape the UI consumes. */
|
|
476
|
+
const mapAzurePullRequest = (pr, prefix, remoteUrl, me) => {
|
|
477
|
+
const reviewers = (Array.isArray(pr?.reviewers) ? pr.reviewers : []).map((entry) => ({
|
|
478
|
+
id: String(entry?.id ?? ''),
|
|
479
|
+
name: String(entry?.displayName ?? ''),
|
|
480
|
+
email: String(entry?.uniqueName ?? ''),
|
|
481
|
+
vote: Number(entry?.vote ?? 0),
|
|
482
|
+
isRequired: entry?.isRequired === true,
|
|
483
|
+
}));
|
|
484
|
+
const mine = me ? reviewers.find((reviewer) => reviewer.id === me.id) : undefined;
|
|
485
|
+
return {
|
|
486
|
+
id: Number(pr?.pullRequestId ?? 0),
|
|
487
|
+
title: String(pr?.title ?? ''),
|
|
488
|
+
isDraft: pr?.isDraft === true,
|
|
489
|
+
author: {
|
|
490
|
+
id: String(pr?.createdBy?.id ?? ''),
|
|
491
|
+
name: String(pr?.createdBy?.displayName ?? ''),
|
|
492
|
+
email: String(pr?.createdBy?.uniqueName ?? ''),
|
|
493
|
+
},
|
|
494
|
+
createdAt: String(pr?.creationDate ?? ''),
|
|
495
|
+
sourceBranch: String(pr?.sourceRefName ?? '').replace(/^refs\/heads\//, ''),
|
|
496
|
+
targetBranch: String(pr?.targetRefName ?? '').replace(/^refs\/heads\//, ''),
|
|
497
|
+
status: String(pr?.status ?? 'active'),
|
|
498
|
+
webUrl: String(pr?._links?.web?.href || '') || azurePrWebUrl(prefix, remoteUrl, Number(pr?.pullRequestId ?? 0)),
|
|
499
|
+
reviewers,
|
|
500
|
+
myVote: mine ? Number(mine.vote ?? 0) : 0,
|
|
501
|
+
requiresMe: mine?.isRequired === true,
|
|
502
|
+
};
|
|
503
|
+
};
|
|
504
|
+
/** REST base of one pull request (".../pullrequests/{id}") plus repo context. */
|
|
505
|
+
const azurePrApiBase = async (id) => {
|
|
506
|
+
if (!Number.isInteger(id) || id <= 0) {
|
|
507
|
+
throw new Error('A pull request id is required.');
|
|
508
|
+
}
|
|
509
|
+
const { prefix, remoteUrl } = await azureProjectContext();
|
|
510
|
+
const repo = parseAzureRemoteUrl(remoteUrl)?.repo;
|
|
511
|
+
if (!repo) {
|
|
512
|
+
throw new Error(`The origin remote does not point to an Azure DevOps project: ${remoteUrl}`);
|
|
513
|
+
}
|
|
514
|
+
return {
|
|
515
|
+
prefix,
|
|
516
|
+
remoteUrl,
|
|
517
|
+
repo,
|
|
518
|
+
base: `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests/${id}`,
|
|
519
|
+
};
|
|
520
|
+
};
|
|
521
|
+
/** Runs one Azure request and turns non-2xx replies into user-facing errors. */
|
|
522
|
+
const azureJson = async (method, url, body = '') => {
|
|
523
|
+
const result = await azureRequest(method, url, body);
|
|
524
|
+
if (result.status < 200 || result.status >= 300) {
|
|
525
|
+
throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
|
|
526
|
+
}
|
|
527
|
+
return (result.body ? JSON.parse(result.body) : {});
|
|
528
|
+
};
|
|
529
|
+
/** Merges the VS Code settings with the server-side settings file. */
|
|
530
|
+
const effectiveSettings = async () => {
|
|
531
|
+
const [file, azure] = await Promise.all([readSettings(), effectiveAzureUrl()]);
|
|
532
|
+
const fileAutoReload = typeof file.autoReload === 'boolean' ? file.autoReload : undefined;
|
|
533
|
+
const fileShowGraph = typeof file.showGraph === 'boolean' ? file.showGraph : undefined;
|
|
534
|
+
const fileShowStashes = typeof file.showStashes === 'boolean' ? file.showStashes : undefined;
|
|
535
|
+
const fileShowTags = typeof file.showTags === 'boolean' ? file.showTags : undefined;
|
|
536
|
+
const fileShowRemoteBranches = typeof file.showRemoteBranches === 'boolean' ? file.showRemoteBranches : undefined;
|
|
537
|
+
const fileFileListView = file.fileListView === 'tree' || file.fileListView === 'flat' ? file.fileListView : undefined;
|
|
538
|
+
const fileSearchMode = file.searchMode === 'filter' || file.searchMode === 'navigate' ? file.searchMode : undefined;
|
|
539
|
+
const fileSearchCaseSensitive = typeof file.searchCaseSensitive === 'boolean' ? file.searchCaseSensitive : undefined;
|
|
540
|
+
const filePrBranchNameTemplate = typeof file.prBranchNameTemplate === 'string' && file.prBranchNameTemplate.trim()
|
|
541
|
+
? file.prBranchNameTemplate.trim()
|
|
542
|
+
: undefined;
|
|
543
|
+
const localIssue = file.issueLinking;
|
|
544
|
+
let issueLinking = null;
|
|
545
|
+
if (issueRegex?.trim() && issueUrl?.trim()) {
|
|
546
|
+
issueLinking = { regex: issueRegex.trim(), url: issueUrl.trim(), useGlobally: true };
|
|
547
|
+
}
|
|
548
|
+
else if (typeof localIssue?.regex === 'string' && typeof localIssue.url === 'string') {
|
|
549
|
+
issueLinking = { regex: localIssue.regex, url: localIssue.url, useGlobally: false };
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
const [regex, url] = await Promise.all([
|
|
553
|
+
git.raw(['config', '--global', '--get', 'guito.issueRegex']).catch(() => ''),
|
|
554
|
+
git.raw(['config', '--global', '--get', 'guito.issueUrl']).catch(() => ''),
|
|
555
|
+
]);
|
|
556
|
+
if (regex.trim() && url.trim()) {
|
|
557
|
+
issueLinking = { regex: regex.trim(), url: url.trim(), useGlobally: true };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return {
|
|
561
|
+
azureDevOpsUrl: azure.url,
|
|
562
|
+
prBranchNameTemplate: prBranchNameTemplate?.trim() ||
|
|
563
|
+
filePrBranchNameTemplate ||
|
|
564
|
+
DEFAULT_PR_BRANCH_NAME_TEMPLATE,
|
|
565
|
+
source: azure.source,
|
|
566
|
+
autoReload: typeof autoReload === 'boolean' ? autoReload : (fileAutoReload ?? true),
|
|
567
|
+
diffViewer: diffViewer === 'vscode' ? 'vscode' : 'guito',
|
|
568
|
+
showGraph: typeof showGraph === 'boolean' ? showGraph : (fileShowGraph ?? true),
|
|
569
|
+
showStashes: typeof showStashes === 'boolean' ? showStashes : (fileShowStashes ?? true),
|
|
570
|
+
showTags: typeof showTags === 'boolean' ? showTags : (fileShowTags ?? true),
|
|
571
|
+
showRemoteBranches: typeof showRemoteBranches === 'boolean'
|
|
572
|
+
? showRemoteBranches
|
|
573
|
+
: (fileShowRemoteBranches ?? true),
|
|
574
|
+
fileListView: fileListView ?? fileFileListView ?? 'flat',
|
|
575
|
+
searchMode: searchMode ?? fileSearchMode ?? 'navigate',
|
|
576
|
+
searchCaseSensitive: typeof searchCaseSensitive === 'boolean'
|
|
577
|
+
? searchCaseSensitive
|
|
578
|
+
: (fileSearchCaseSensitive ?? false),
|
|
579
|
+
issueLinking,
|
|
580
|
+
};
|
|
581
|
+
};
|
|
582
|
+
const configuredIdentity = async () => {
|
|
583
|
+
const config = await git.listConfig();
|
|
584
|
+
return {
|
|
585
|
+
name: String(config.all['user.name'] ?? '').trim(),
|
|
586
|
+
email: String(config.all['user.email'] ?? '').trim(),
|
|
587
|
+
};
|
|
588
|
+
};
|
|
589
|
+
const authorIdentity = (name, email, identity) => ({
|
|
590
|
+
author_name: identity.name && identity.email && email.trim().toLowerCase() === identity.email.toLowerCase()
|
|
591
|
+
? identity.name
|
|
592
|
+
: name,
|
|
593
|
+
author_email: email,
|
|
594
|
+
});
|
|
353
595
|
// ==================== Repository ====================
|
|
354
596
|
app.get('/api/repo', async (_req, resp) => {
|
|
355
597
|
try {
|
|
356
598
|
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
357
|
-
return resp
|
|
599
|
+
return resp
|
|
600
|
+
.type('application/json')
|
|
601
|
+
.send({ root, name: basename(root), identity: await configuredIdentity() });
|
|
358
602
|
}
|
|
359
603
|
catch (err) {
|
|
360
604
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -389,12 +633,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
389
633
|
if (Number.isFinite(skip) && skip > 0) {
|
|
390
634
|
options['--skip'] = String(skip);
|
|
391
635
|
}
|
|
392
|
-
const [log, count] = await Promise.all([
|
|
636
|
+
const [log, count, identity] = await Promise.all([
|
|
393
637
|
git.log(options),
|
|
394
638
|
git.raw(['rev-list', '--count', '--all']),
|
|
639
|
+
configuredIdentity(),
|
|
395
640
|
]);
|
|
396
641
|
const commits = log.all.map((commit) => ({
|
|
397
642
|
...commit,
|
|
643
|
+
...authorIdentity(commit.author_name, commit.author_email, identity),
|
|
398
644
|
parents: String(commit.parents ?? '')
|
|
399
645
|
.split(' ')
|
|
400
646
|
.filter(Boolean),
|
|
@@ -417,28 +663,42 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
417
663
|
// matches over the loaded history.
|
|
418
664
|
app.get('/api/commits/search', async (req, resp) => {
|
|
419
665
|
const query = String((req.query ?? {}).query ?? '').trim();
|
|
666
|
+
const caseSensitive = String((req.query ?? {}).caseSensitive ?? '') === '1';
|
|
420
667
|
if (!query) {
|
|
421
|
-
return resp.type('application/json').send({ hashes: [] });
|
|
668
|
+
return resp.type('application/json').send({ hashes: [], indices: {} });
|
|
422
669
|
}
|
|
423
670
|
try {
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
'
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
'--format=%H',
|
|
671
|
+
const grepArgs = ['log', '--all'];
|
|
672
|
+
if (!caseSensitive)
|
|
673
|
+
grepArgs.push('-i');
|
|
674
|
+
grepArgs.push('--fixed-strings', `--grep=${query}`, '--format=%H');
|
|
675
|
+
const [raw, history] = await Promise.all([
|
|
676
|
+
git.raw(grepArgs),
|
|
677
|
+
git.raw(['log', '--all', '--format=%H']),
|
|
431
678
|
]);
|
|
679
|
+
const hashes = raw
|
|
680
|
+
.split('\n')
|
|
681
|
+
.map((hash) => hash.trim())
|
|
682
|
+
.filter(Boolean);
|
|
683
|
+
const matches = new Set(hashes);
|
|
684
|
+
// Use the same unfiltered Git log order as the paged history endpoint.
|
|
685
|
+
const indices = {};
|
|
686
|
+
history
|
|
687
|
+
.split('\n')
|
|
688
|
+
.map((hash) => hash.trim())
|
|
689
|
+
.filter(Boolean)
|
|
690
|
+
.forEach((hash, index) => {
|
|
691
|
+
if (matches.has(hash))
|
|
692
|
+
indices[hash] = index;
|
|
693
|
+
});
|
|
432
694
|
return resp.type('application/json').send({
|
|
433
|
-
hashes
|
|
434
|
-
|
|
435
|
-
.map((hash) => hash.trim())
|
|
436
|
-
.filter(Boolean),
|
|
695
|
+
hashes,
|
|
696
|
+
indices,
|
|
437
697
|
});
|
|
438
698
|
}
|
|
439
699
|
catch (err) {
|
|
440
700
|
if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
|
|
441
|
-
return resp.type('application/json').send({ hashes: [] });
|
|
701
|
+
return resp.type('application/json').send({ hashes: [], indices: {} });
|
|
442
702
|
}
|
|
443
703
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
444
704
|
}
|
|
@@ -617,6 +877,100 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
617
877
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
618
878
|
}
|
|
619
879
|
});
|
|
880
|
+
// ==================== Worktrees ====================
|
|
881
|
+
app.get('/api/worktrees', async (_req, resp) => {
|
|
882
|
+
try {
|
|
883
|
+
const raw = await git.raw(['worktree', 'list', '--porcelain']);
|
|
884
|
+
// Path comparison is case-insensitive on Windows so the served worktree
|
|
885
|
+
// is detected regardless of drive-letter casing.
|
|
886
|
+
const samePath = (a, b) => process.platform === 'win32'
|
|
887
|
+
? resolve(a).toLowerCase() === resolve(b).toLowerCase()
|
|
888
|
+
: resolve(a) === resolve(b);
|
|
889
|
+
const worktrees = raw
|
|
890
|
+
.split(/\n\s*\n/)
|
|
891
|
+
.map((block) => block.trim())
|
|
892
|
+
.filter(Boolean)
|
|
893
|
+
.map((block) => {
|
|
894
|
+
const entry = {
|
|
895
|
+
path: '',
|
|
896
|
+
head: '',
|
|
897
|
+
branch: '',
|
|
898
|
+
bare: false,
|
|
899
|
+
detached: false,
|
|
900
|
+
current: false,
|
|
901
|
+
};
|
|
902
|
+
for (const line of block.split('\n').map((l) => l.trim())) {
|
|
903
|
+
if (line.startsWith('worktree '))
|
|
904
|
+
entry.path = line.slice('worktree '.length);
|
|
905
|
+
else if (line.startsWith('HEAD '))
|
|
906
|
+
entry.head = line.slice('HEAD '.length);
|
|
907
|
+
else if (line.startsWith('branch '))
|
|
908
|
+
entry.branch = line.slice('branch '.length).replace(/^refs\/heads\//, '');
|
|
909
|
+
else if (line === 'bare')
|
|
910
|
+
entry.bare = true;
|
|
911
|
+
else if (line === 'detached')
|
|
912
|
+
entry.detached = true;
|
|
913
|
+
}
|
|
914
|
+
entry.current = !!entry.path && samePath(entry.path, repositoryPath);
|
|
915
|
+
return entry;
|
|
916
|
+
});
|
|
917
|
+
return resp.type('application/json').send(worktrees);
|
|
918
|
+
}
|
|
919
|
+
catch (err) {
|
|
920
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
921
|
+
}
|
|
922
|
+
});
|
|
923
|
+
app.post('/api/worktrees', async (req, resp) => {
|
|
924
|
+
try {
|
|
925
|
+
const path = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
|
926
|
+
const branch = typeof req.body?.branch === 'string' ? req.body.branch.trim() : '';
|
|
927
|
+
if (!path || !isAbsolute(path)) {
|
|
928
|
+
throw new Error('Choose an absolute folder path for the worktree.');
|
|
929
|
+
}
|
|
930
|
+
if (!branch) {
|
|
931
|
+
throw new Error('Choose a local branch for the worktree.');
|
|
932
|
+
}
|
|
933
|
+
// Resolve the branch as a local ref before passing it to Git. Besides a
|
|
934
|
+
// clearer error, this prevents a request from treating an option as a ref.
|
|
935
|
+
await git.raw(['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]);
|
|
936
|
+
await mutate(() => git.raw(['worktree', 'add', '--', resolve(path), branch]).then(() => { }));
|
|
937
|
+
return resp.type('application/json').send({ success: true });
|
|
938
|
+
}
|
|
939
|
+
catch (err) {
|
|
940
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
app.post('/api/worktrees/remove', async (req, resp) => {
|
|
944
|
+
try {
|
|
945
|
+
const path = typeof req.body?.path === 'string' ? req.body.path.trim() : '';
|
|
946
|
+
if (!path || !isAbsolute(path)) {
|
|
947
|
+
throw new Error('A valid worktree path is required.');
|
|
948
|
+
}
|
|
949
|
+
const target = resolve(path);
|
|
950
|
+
const samePath = (a, b) => process.platform === 'win32'
|
|
951
|
+
? resolve(a).toLowerCase() === resolve(b).toLowerCase()
|
|
952
|
+
: resolve(a) === resolve(b);
|
|
953
|
+
if (samePath(target, repositoryPath)) {
|
|
954
|
+
throw new Error('The worktree currently open in Guito cannot be removed.');
|
|
955
|
+
}
|
|
956
|
+
// Only registered worktrees may be removed. Do not use --force: Git must
|
|
957
|
+
// protect uncommitted or untracked files in the selected worktree.
|
|
958
|
+
const registered = (await git.raw(['worktree', 'list', '--porcelain']))
|
|
959
|
+
.split(/\n\s*\n/)
|
|
960
|
+
.some((block) => {
|
|
961
|
+
const line = block.split('\n').find((entry) => entry.startsWith('worktree '));
|
|
962
|
+
return !!line && samePath(line.slice('worktree '.length).trim(), target);
|
|
963
|
+
});
|
|
964
|
+
if (!registered) {
|
|
965
|
+
throw new Error('The selected folder is not a registered worktree.');
|
|
966
|
+
}
|
|
967
|
+
await mutate(() => git.raw(['worktree', 'remove', '--', target]).then(() => { }));
|
|
968
|
+
return resp.type('application/json').send({ success: true });
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
972
|
+
}
|
|
973
|
+
});
|
|
620
974
|
app.post('/api/checkout', async (req, resp) => {
|
|
621
975
|
try {
|
|
622
976
|
const { ref } = req.body;
|
|
@@ -682,8 +1036,26 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
682
1036
|
// ==================== Stash ====================
|
|
683
1037
|
app.get('/api/stash/list', async (_req, resp) => {
|
|
684
1038
|
try {
|
|
685
|
-
|
|
686
|
-
|
|
1039
|
+
// Extra fields (date, author) let the commit table render stash rows;
|
|
1040
|
+
// they are NUL-separated because messages cannot contain NUL.
|
|
1041
|
+
const raw = await git.raw([
|
|
1042
|
+
'stash',
|
|
1043
|
+
'list',
|
|
1044
|
+
'--format=%H%x00%aI%x00%aN%x00%aE%x00%s',
|
|
1045
|
+
]);
|
|
1046
|
+
const all = raw
|
|
1047
|
+
.trim()
|
|
1048
|
+
.split('\n')
|
|
1049
|
+
.filter(Boolean)
|
|
1050
|
+
.map((line) => {
|
|
1051
|
+
const [hash, date, authorName, authorEmail, message] = line.split('\u0000');
|
|
1052
|
+
return { hash, date, author_name: authorName, author_email: authorEmail, message };
|
|
1053
|
+
});
|
|
1054
|
+
return resp.type('application/json').send({
|
|
1055
|
+
all,
|
|
1056
|
+
latest: all[0] ?? null,
|
|
1057
|
+
total: all.length,
|
|
1058
|
+
});
|
|
687
1059
|
}
|
|
688
1060
|
catch (err) {
|
|
689
1061
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -754,9 +1126,21 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
754
1126
|
}
|
|
755
1127
|
});
|
|
756
1128
|
// ==================== Tags ====================
|
|
1129
|
+
// All tags with the commit each points at (annotated tags are peeled, so the
|
|
1130
|
+
// client can always look the tagged commit up by hash). Sorted by name.
|
|
757
1131
|
app.get('/api/tags', async (_req, resp) => {
|
|
758
1132
|
try {
|
|
759
|
-
const
|
|
1133
|
+
const raw = await git.raw([
|
|
1134
|
+
'tag',
|
|
1135
|
+
'--list',
|
|
1136
|
+
'--sort=refname',
|
|
1137
|
+
'--format=%(refname:short)%09%(*objectname)%09%(objectname)',
|
|
1138
|
+
]);
|
|
1139
|
+
const tags = raw
|
|
1140
|
+
.split('\n')
|
|
1141
|
+
.map((line) => line.split('\t'))
|
|
1142
|
+
.filter(([name]) => Boolean(name.trim()))
|
|
1143
|
+
.map(([name, peeled, direct]) => ({ name, hash: peeled || direct }));
|
|
760
1144
|
return resp.type('application/json').send(tags);
|
|
761
1145
|
}
|
|
762
1146
|
catch (err) {
|
|
@@ -802,9 +1186,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
802
1186
|
}
|
|
803
1187
|
});
|
|
804
1188
|
// ==================== Remote Operations ====================
|
|
805
|
-
app.get('/api/fetch', async (
|
|
1189
|
+
app.get('/api/fetch', async (req, resp) => {
|
|
806
1190
|
try {
|
|
807
|
-
|
|
1191
|
+
const prune = ['1', 'true', 'yes'].includes(String(req.query?.prune ?? '').toLowerCase());
|
|
1192
|
+
await git.fetch(prune ? { '--prune': null } : {});
|
|
808
1193
|
return resp.type('application/json').send({ success: true });
|
|
809
1194
|
}
|
|
810
1195
|
catch (err) {
|
|
@@ -851,13 +1236,92 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
851
1236
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
852
1237
|
}
|
|
853
1238
|
});
|
|
1239
|
+
const listRemotes = async () => {
|
|
1240
|
+
const names = (await git.raw(['remote']))
|
|
1241
|
+
.split(/\r?\n/)
|
|
1242
|
+
.map((name) => name.trim())
|
|
1243
|
+
.filter(Boolean);
|
|
1244
|
+
return Promise.all(names.map(async (name) => {
|
|
1245
|
+
const fetchUrl = (await git.raw(['remote', 'get-url', name])).trim();
|
|
1246
|
+
const pushUrl = (await git.raw(['remote', 'get-url', '--push', name]).catch(() => fetchUrl)).trim();
|
|
1247
|
+
return { name, fetchUrl, pushUrl };
|
|
1248
|
+
}));
|
|
1249
|
+
};
|
|
1250
|
+
app.get('/api/remotes', async (_req, resp) => {
|
|
1251
|
+
try {
|
|
1252
|
+
return resp.type('application/json').send(await listRemotes());
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
return resp.status(400).send({ error: err.message });
|
|
1256
|
+
}
|
|
1257
|
+
});
|
|
1258
|
+
app.post('/api/remotes', async (req, resp) => {
|
|
1259
|
+
try {
|
|
1260
|
+
const name = String(req.body?.name ?? '').trim();
|
|
1261
|
+
const originalName = String(req.body?.originalName ?? '').trim();
|
|
1262
|
+
const fetchUrl = String(req.body?.fetchUrl ?? '').trim();
|
|
1263
|
+
const pushUrl = String(req.body?.pushUrl ?? '').trim() || fetchUrl;
|
|
1264
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name) || !fetchUrl) {
|
|
1265
|
+
return resp.status(400).send({ error: 'A valid remote name and fetch URL are required.' });
|
|
1266
|
+
}
|
|
1267
|
+
if (!originalName) {
|
|
1268
|
+
await git.raw(['remote', 'add', name, fetchUrl]);
|
|
1269
|
+
}
|
|
1270
|
+
else {
|
|
1271
|
+
if (originalName !== name)
|
|
1272
|
+
await git.raw(['remote', 'rename', originalName, name]);
|
|
1273
|
+
await git.raw(['remote', 'set-url', name, fetchUrl]);
|
|
1274
|
+
}
|
|
1275
|
+
await git.raw(['remote', 'set-url', '--push', name, pushUrl]);
|
|
1276
|
+
return resp.send(await listRemotes());
|
|
1277
|
+
}
|
|
1278
|
+
catch (err) {
|
|
1279
|
+
return resp.status(400).send({ error: err.message });
|
|
1280
|
+
}
|
|
1281
|
+
});
|
|
1282
|
+
app.delete('/api/remotes/:name', async (req, resp) => {
|
|
1283
|
+
try {
|
|
1284
|
+
const name = String(req.params?.name ?? '').trim();
|
|
1285
|
+
if (!/^[A-Za-z0-9._-]+$/.test(name)) {
|
|
1286
|
+
return resp.status(400).send({ error: 'Invalid remote name.' });
|
|
1287
|
+
}
|
|
1288
|
+
await git.raw(['remote', 'remove', name]);
|
|
1289
|
+
return resp.send(await listRemotes());
|
|
1290
|
+
}
|
|
1291
|
+
catch (err) {
|
|
1292
|
+
return resp.status(400).send({ error: err.message });
|
|
1293
|
+
}
|
|
1294
|
+
});
|
|
1295
|
+
app.post('/api/identity', async (req, resp) => {
|
|
1296
|
+
try {
|
|
1297
|
+
const name = String(req.body?.name ?? '').trim();
|
|
1298
|
+
const email = String(req.body?.email ?? '').trim();
|
|
1299
|
+
if (!name || !email) {
|
|
1300
|
+
return resp.status(400).send({ error: 'User name and email are required.' });
|
|
1301
|
+
}
|
|
1302
|
+
await git.raw(['config', '--local', 'user.name', name]);
|
|
1303
|
+
await git.raw(['config', '--local', 'user.email', email]);
|
|
1304
|
+
return resp.send(await configuredIdentity());
|
|
1305
|
+
}
|
|
1306
|
+
catch (err) {
|
|
1307
|
+
return resp.status(400).send({ error: err.message });
|
|
1308
|
+
}
|
|
1309
|
+
});
|
|
1310
|
+
app.delete('/api/identity', async (_req, resp) => {
|
|
1311
|
+
try {
|
|
1312
|
+
await git.raw(['config', '--local', '--unset-all', 'user.name']).catch(() => '');
|
|
1313
|
+
await git.raw(['config', '--local', '--unset-all', 'user.email']).catch(() => '');
|
|
1314
|
+
return resp.send(await configuredIdentity());
|
|
1315
|
+
}
|
|
1316
|
+
catch (err) {
|
|
1317
|
+
return resp.status(400).send({ error: err.message });
|
|
1318
|
+
}
|
|
1319
|
+
});
|
|
854
1320
|
// ==================== Settings ====================
|
|
855
1321
|
app.get('/api/settings', async (_req, resp) => {
|
|
856
1322
|
try {
|
|
857
|
-
const
|
|
858
|
-
return resp
|
|
859
|
-
.type('application/json')
|
|
860
|
-
.send({ azureDevOpsUrl: effective.url, source: effective.source });
|
|
1323
|
+
const settings = await effectiveSettings();
|
|
1324
|
+
return resp.type('application/json').send(settings);
|
|
861
1325
|
}
|
|
862
1326
|
catch (err) {
|
|
863
1327
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -865,20 +1329,105 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
865
1329
|
});
|
|
866
1330
|
app.post('/api/settings', async (req, resp) => {
|
|
867
1331
|
try {
|
|
868
|
-
const
|
|
869
|
-
if (url && !/^https?:\/\//i.test(url)) {
|
|
870
|
-
return resp
|
|
871
|
-
.status(400)
|
|
872
|
-
.type('application/json')
|
|
873
|
-
.send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
|
|
874
|
-
}
|
|
1332
|
+
const body = req.body ?? {};
|
|
875
1333
|
const settings = await readSettings();
|
|
876
|
-
|
|
1334
|
+
if ('azureDevOpsUrl' in body) {
|
|
1335
|
+
const url = String(body.azureDevOpsUrl ?? '').trim();
|
|
1336
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
1337
|
+
return resp
|
|
1338
|
+
.status(400)
|
|
1339
|
+
.type('application/json')
|
|
1340
|
+
.send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
|
|
1341
|
+
}
|
|
1342
|
+
settings.azureDevOpsUrl = url;
|
|
1343
|
+
}
|
|
1344
|
+
if ('prBranchNameTemplate' in body) {
|
|
1345
|
+
const template = String(body.prBranchNameTemplate ?? '').trim();
|
|
1346
|
+
if (template.length > 200) {
|
|
1347
|
+
return resp
|
|
1348
|
+
.status(400)
|
|
1349
|
+
.send({ error: 'The pull request branch template must be 200 characters or fewer.' });
|
|
1350
|
+
}
|
|
1351
|
+
try {
|
|
1352
|
+
formatPrBranchName(template, {
|
|
1353
|
+
username: 'username',
|
|
1354
|
+
randomstring: 'randomstring',
|
|
1355
|
+
branch: 'branch',
|
|
1356
|
+
targetbranch: 'targetbranch',
|
|
1357
|
+
title: 'title',
|
|
1358
|
+
repository: 'repository',
|
|
1359
|
+
date: '2026-09-12',
|
|
1360
|
+
time: '120000',
|
|
1361
|
+
timestamp: '1789214400000',
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
return resp.status(400).send({ error: error.message });
|
|
1366
|
+
}
|
|
1367
|
+
settings.prBranchNameTemplate = template || DEFAULT_PR_BRANCH_NAME_TEMPLATE;
|
|
1368
|
+
}
|
|
1369
|
+
if (typeof body.showGraph === 'boolean') {
|
|
1370
|
+
settings.showGraph = body.showGraph;
|
|
1371
|
+
}
|
|
1372
|
+
if (typeof body.autoReload === 'boolean') {
|
|
1373
|
+
settings.autoReload = body.autoReload;
|
|
1374
|
+
}
|
|
1375
|
+
if (typeof body.showStashes === 'boolean') {
|
|
1376
|
+
settings.showStashes = body.showStashes;
|
|
1377
|
+
}
|
|
1378
|
+
if (typeof body.showTags === 'boolean') {
|
|
1379
|
+
settings.showTags = body.showTags;
|
|
1380
|
+
}
|
|
1381
|
+
if (typeof body.showRemoteBranches === 'boolean') {
|
|
1382
|
+
settings.showRemoteBranches = body.showRemoteBranches;
|
|
1383
|
+
}
|
|
1384
|
+
if (body.fileListView === 'tree' || body.fileListView === 'flat') {
|
|
1385
|
+
settings.fileListView = body.fileListView;
|
|
1386
|
+
}
|
|
1387
|
+
if (body.searchMode === 'filter' || body.searchMode === 'navigate') {
|
|
1388
|
+
settings.searchMode = body.searchMode;
|
|
1389
|
+
}
|
|
1390
|
+
if (typeof body.searchCaseSensitive === 'boolean') {
|
|
1391
|
+
settings.searchCaseSensitive = body.searchCaseSensitive;
|
|
1392
|
+
}
|
|
1393
|
+
if ('issueLinking' in body) {
|
|
1394
|
+
if (body.issueLinking === null) {
|
|
1395
|
+
if (body.issueLinkingGlobal) {
|
|
1396
|
+
await git.raw(['config', '--global', '--unset-all', 'guito.issueRegex']).catch(() => '');
|
|
1397
|
+
await git.raw(['config', '--global', '--unset-all', 'guito.issueUrl']).catch(() => '');
|
|
1398
|
+
}
|
|
1399
|
+
else {
|
|
1400
|
+
delete settings.issueLinking;
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
else {
|
|
1404
|
+
const regex = String(body.issueLinking?.regex ?? '').trim();
|
|
1405
|
+
const url = String(body.issueLinking?.url ?? '').trim();
|
|
1406
|
+
if (!regex || !url) {
|
|
1407
|
+
return resp.status(400).send({ error: 'Issue regex and URL are required.' });
|
|
1408
|
+
}
|
|
1409
|
+
try {
|
|
1410
|
+
new RegExp(regex);
|
|
1411
|
+
}
|
|
1412
|
+
catch {
|
|
1413
|
+
return resp.status(400).send({ error: 'Issue regex is not a valid regular expression.' });
|
|
1414
|
+
}
|
|
1415
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
1416
|
+
return resp.status(400).send({ error: 'Issue URL must start with http:// or https://.' });
|
|
1417
|
+
}
|
|
1418
|
+
if (body.issueLinking.useGlobally) {
|
|
1419
|
+
await git.raw(['config', '--global', 'guito.issueRegex', regex]);
|
|
1420
|
+
await git.raw(['config', '--global', 'guito.issueUrl', url]);
|
|
1421
|
+
delete settings.issueLinking;
|
|
1422
|
+
}
|
|
1423
|
+
else {
|
|
1424
|
+
settings.issueLinking = { regex, url };
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
877
1428
|
await writeSettings(settings);
|
|
878
|
-
const effective = await
|
|
879
|
-
return resp
|
|
880
|
-
.type('application/json')
|
|
881
|
-
.send({ azureDevOpsUrl: effective.url, source: effective.source });
|
|
1429
|
+
const effective = await effectiveSettings();
|
|
1430
|
+
return resp.type('application/json').send(effective);
|
|
882
1431
|
}
|
|
883
1432
|
catch (err) {
|
|
884
1433
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -899,12 +1448,49 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
899
1448
|
.type('application/json')
|
|
900
1449
|
.send({ error: 'Unable to build the Azure DevOps pull request URL.' });
|
|
901
1450
|
}
|
|
902
|
-
const prUrl = `${prefix}/
|
|
1451
|
+
const prUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
903
1452
|
let source = String(sourceBranch ?? '').trim();
|
|
904
1453
|
if (newBranch) {
|
|
905
1454
|
// Create a remote-only branch from the current HEAD; the local
|
|
906
1455
|
// repository and the checked-out branch stay untouched.
|
|
907
|
-
const
|
|
1456
|
+
const now = new Date();
|
|
1457
|
+
const currentSettings = await effectiveSettings();
|
|
1458
|
+
let computerUsername = process.env.USERNAME || process.env.USER || '';
|
|
1459
|
+
try {
|
|
1460
|
+
computerUsername = userInfo().username || computerUsername;
|
|
1461
|
+
}
|
|
1462
|
+
catch {
|
|
1463
|
+
// Environment variables are a sufficient fallback in restricted hosts.
|
|
1464
|
+
}
|
|
1465
|
+
const name = formatPrBranchName(currentSettings.prBranchNameTemplate, {
|
|
1466
|
+
username: computerUsername,
|
|
1467
|
+
randomstring: randomBranchSuffix(),
|
|
1468
|
+
branch: (await git.revparse(['--abbrev-ref', 'HEAD'])).trim(),
|
|
1469
|
+
targetbranch: String(targetBranch)
|
|
1470
|
+
.replace(/^refs\/heads\//, '')
|
|
1471
|
+
.replace(/^origin\//, ''),
|
|
1472
|
+
title: String(title ?? '').trim() || 'pull-request',
|
|
1473
|
+
repository: basename(await repoRoot()),
|
|
1474
|
+
date: now.toISOString().slice(0, 10),
|
|
1475
|
+
time: now.toISOString().slice(11, 19).replace(/:/g, ''),
|
|
1476
|
+
timestamp: String(now.getTime()),
|
|
1477
|
+
});
|
|
1478
|
+
try {
|
|
1479
|
+
await git.raw(['check-ref-format', '--branch', name]);
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
return resp.status(400).send({
|
|
1483
|
+
error: `The pull request branch template produced an invalid Git branch name: ${name}`,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
const existingBranch = await git
|
|
1487
|
+
.raw(['ls-remote', '--heads', 'origin', `refs/heads/${name}`])
|
|
1488
|
+
.catch(() => '');
|
|
1489
|
+
if (existingBranch.trim()) {
|
|
1490
|
+
return resp.status(400).send({
|
|
1491
|
+
error: `The pull request branch already exists on origin: ${name}`,
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
908
1494
|
await git.push(['origin', `HEAD:refs/heads/${name}`]);
|
|
909
1495
|
source = name;
|
|
910
1496
|
}
|
|
@@ -936,8 +1522,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
936
1522
|
}
|
|
937
1523
|
}
|
|
938
1524
|
const payload = {
|
|
1525
|
+
isDraft: req.body?.isDraft === true,
|
|
939
1526
|
sourceRefName: `refs/heads/${source}`,
|
|
940
|
-
targetRefName: `refs/heads/${targetBranch
|
|
1527
|
+
targetRefName: `refs/heads/${String(targetBranch)
|
|
1528
|
+
.replace(/^refs\/heads\//, '')
|
|
1529
|
+
.replace(/^origin\//, '')}`,
|
|
941
1530
|
};
|
|
942
1531
|
const trimmedTitle = String(title ?? '').trim();
|
|
943
1532
|
const trimmedDescription = String(description ?? '').trim();
|
|
@@ -959,7 +1548,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
959
1548
|
.map((id) => Number(id))
|
|
960
1549
|
.filter((id) => Number.isInteger(id) && id > 0);
|
|
961
1550
|
if (payloadWorkItems.length) {
|
|
962
|
-
payload.
|
|
1551
|
+
payload.workItemRefs = payloadWorkItems.map((id) => ({ id: String(id) }));
|
|
963
1552
|
}
|
|
964
1553
|
const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
|
|
965
1554
|
if (result.status < 200 || result.status >= 300) {
|
|
@@ -978,7 +1567,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
978
1567
|
const warnings = [];
|
|
979
1568
|
if (labelNames.length && created.pullRequestId) {
|
|
980
1569
|
// Built from the prefix: prUrl already carries the api-version query.
|
|
981
|
-
const labelsUrl = `${prefix}/
|
|
1570
|
+
const labelsUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}` +
|
|
982
1571
|
`/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
|
|
983
1572
|
for (const name of labelNames) {
|
|
984
1573
|
try {
|
|
@@ -1004,8 +1593,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1004
1593
|
}
|
|
1005
1594
|
});
|
|
1006
1595
|
// ==================== Azure DevOps PR metadata ====================
|
|
1007
|
-
//
|
|
1008
|
-
// web UI itself uses; undocumented but stable across TFS 2018+).
|
|
1596
|
+
// Collection-scoped identity IDs can be passed directly as PR reviewers.
|
|
1009
1597
|
app.get('/api/azure-devops/reviewers', async (req, resp) => {
|
|
1010
1598
|
try {
|
|
1011
1599
|
const query = String((req.query ?? {}).query ?? '').trim();
|
|
@@ -1013,9 +1601,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1013
1601
|
return resp.type('application/json').send({ reviewers: [] });
|
|
1014
1602
|
}
|
|
1015
1603
|
const { prefix } = await azureProjectContext();
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1604
|
+
const collection = prefix.slice(0, prefix.lastIndexOf('/'));
|
|
1605
|
+
const identityBase = collection.replace('https://dev.azure.com/', 'https://vssps.dev.azure.com/');
|
|
1606
|
+
const url = `${identityBase}/_apis/identities?api-version=5.0` +
|
|
1607
|
+
`&searchFilter=General&filterValue=${encodeURIComponent(query)}&queryMembership=None`;
|
|
1019
1608
|
const result = await azureRequest('GET', url, '');
|
|
1020
1609
|
if (result.status < 200 || result.status >= 300) {
|
|
1021
1610
|
return resp
|
|
@@ -1026,12 +1615,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1026
1615
|
});
|
|
1027
1616
|
}
|
|
1028
1617
|
const data = JSON.parse(result.body);
|
|
1029
|
-
const list = Array.isArray(data) ? data : (data.
|
|
1618
|
+
const list = Array.isArray(data) ? data : (data.value ?? data.identities ?? []);
|
|
1030
1619
|
const reviewers = list
|
|
1031
1620
|
.map((entry) => ({
|
|
1032
1621
|
id: String(entry?.id ?? entry?.identity?.id ?? ''),
|
|
1033
|
-
label: String(entry?.
|
|
1034
|
-
|
|
1622
|
+
label: String(entry?.customDisplayName ||
|
|
1623
|
+
entry?.providerDisplayName ||
|
|
1624
|
+
entry?.label ||
|
|
1625
|
+
entry?.displayName ||
|
|
1626
|
+
entry?.name ||
|
|
1627
|
+
''),
|
|
1628
|
+
description: entry?.properties?.Mail?.$value ??
|
|
1629
|
+
entry?.properties?.Account?.$value ??
|
|
1630
|
+
entry?.description ??
|
|
1631
|
+
undefined,
|
|
1035
1632
|
}))
|
|
1036
1633
|
.filter((entry) => entry.id && entry.label);
|
|
1037
1634
|
return resp.type('application/json').send({ reviewers });
|
|
@@ -1044,19 +1641,21 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1044
1641
|
// then fetches title/state for the first matches.
|
|
1045
1642
|
app.get('/api/azure-devops/workitems', async (req, resp) => {
|
|
1046
1643
|
try {
|
|
1047
|
-
const query = String((req.query ?? {}).query ?? '')
|
|
1644
|
+
const query = String((req.query ?? {}).query ?? '')
|
|
1645
|
+
.trim()
|
|
1646
|
+
.replace(/^#(?=\d+$)/, '');
|
|
1048
1647
|
if (!query) {
|
|
1049
1648
|
return resp.type('application/json').send({ workItems: [] });
|
|
1050
1649
|
}
|
|
1051
1650
|
const { prefix } = await azureProjectContext();
|
|
1052
1651
|
const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
|
|
1053
1652
|
if (/^\d+$/.test(query)) {
|
|
1054
|
-
clauses.push(`[System.Id] =
|
|
1653
|
+
clauses.push(`[System.Id] = ${Number(query)}`);
|
|
1055
1654
|
}
|
|
1056
1655
|
const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
|
|
1057
|
-
`WHERE ${clauses.map((clause) => `(${clause})`).join(' OR ')} ` +
|
|
1656
|
+
`WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(' OR ')}) ` +
|
|
1058
1657
|
'ORDER BY [System.ChangedDate] DESC';
|
|
1059
|
-
const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql
|
|
1658
|
+
const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
|
|
1060
1659
|
if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
|
|
1061
1660
|
return resp
|
|
1062
1661
|
.status(400)
|
|
@@ -1093,23 +1692,481 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1093
1692
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1094
1693
|
}
|
|
1095
1694
|
});
|
|
1096
|
-
//
|
|
1695
|
+
// Existing pull request labels for the tag autocomplete.
|
|
1097
1696
|
app.get('/api/azure-devops/tags', async (_req, resp) => {
|
|
1098
1697
|
try {
|
|
1099
|
-
const { prefix } = await azureProjectContext();
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1698
|
+
const { prefix, remoteUrl } = await azureProjectContext();
|
|
1699
|
+
const repo = parseAzureRemoteUrl(remoteUrl).repo;
|
|
1700
|
+
// PR labels are distinct from Azure Boards work item tags.
|
|
1701
|
+
const names = new Set();
|
|
1702
|
+
for (let skip = 0;; skip += 100) {
|
|
1703
|
+
const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
|
|
1704
|
+
`?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`, '');
|
|
1705
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1706
|
+
throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
|
|
1707
|
+
}
|
|
1708
|
+
const data = JSON.parse(result.body);
|
|
1709
|
+
const requests = Array.isArray(data) ? data : (data.value ?? []);
|
|
1710
|
+
// Some Server releases omit labels from the PR list response.
|
|
1711
|
+
// Read those resources explicitly, with at most five requests in flight.
|
|
1712
|
+
for (let offset = 0; offset < requests.length; offset += 5) {
|
|
1713
|
+
const labels = await Promise.all(requests.slice(offset, offset + 5).map(async (request) => {
|
|
1714
|
+
if (Array.isArray(request.labels))
|
|
1715
|
+
return request.labels;
|
|
1716
|
+
if (!Number.isInteger(request.pullRequestId))
|
|
1717
|
+
return [];
|
|
1718
|
+
const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}` +
|
|
1719
|
+
`/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`, '');
|
|
1720
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1721
|
+
throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
|
|
1722
|
+
}
|
|
1723
|
+
const data = JSON.parse(result.body);
|
|
1724
|
+
return Array.isArray(data) ? data : (data.value ?? []);
|
|
1725
|
+
}));
|
|
1726
|
+
for (const label of labels.flat()) {
|
|
1727
|
+
if (typeof label.name === 'string' && label.name.trim())
|
|
1728
|
+
names.add(label.name.trim());
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
if (requests.length < 100)
|
|
1732
|
+
break;
|
|
1733
|
+
}
|
|
1734
|
+
const tags = [...names].sort((a, b) => a.localeCompare(b));
|
|
1735
|
+
return resp.type('application/json').send({ tags });
|
|
1736
|
+
}
|
|
1737
|
+
catch (err) {
|
|
1738
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
// ==================== Azure DevOps pull request review ====================
|
|
1742
|
+
// Everything the PR sidebar list and the PR detail dialog need: identity,
|
|
1743
|
+
// listing, editing, votes, reviewers, auto-complete, completion, comment
|
|
1744
|
+
// threads, and per-file diffs.
|
|
1745
|
+
app.get('/api/azure-devops/me', async (_req, resp) => {
|
|
1746
|
+
try {
|
|
1747
|
+
return resp.type('application/json').send(await azureMe());
|
|
1748
|
+
}
|
|
1749
|
+
catch (err) {
|
|
1750
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1751
|
+
}
|
|
1752
|
+
});
|
|
1753
|
+
// Pull requests the current user created or reviews ("mine"), merged and
|
|
1754
|
+
// de-duplicated. Status defaults to active.
|
|
1755
|
+
app.get('/api/azure-devops/pullrequests', async (req, resp) => {
|
|
1756
|
+
try {
|
|
1757
|
+
const status = String(req.query?.status ?? 'active').trim() || 'active';
|
|
1758
|
+
const { prefix, remoteUrl } = await azureProjectContext();
|
|
1759
|
+
const repo = parseAzureRemoteUrl(remoteUrl).repo;
|
|
1760
|
+
const me = await azureMe();
|
|
1761
|
+
const collect = async (criteria) => {
|
|
1762
|
+
const found = new Map();
|
|
1763
|
+
for (let skip = 0;; skip += 100) {
|
|
1764
|
+
const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
|
|
1765
|
+
`?searchCriteria.status=${encodeURIComponent(status)}&searchCriteria.${criteria}` +
|
|
1766
|
+
`&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`;
|
|
1767
|
+
const data = await azureJson('GET', url);
|
|
1768
|
+
const requests = Array.isArray(data?.value) ? data.value : [];
|
|
1769
|
+
for (const request of requests) {
|
|
1770
|
+
const id = Number(request?.pullRequestId);
|
|
1771
|
+
if (Number.isInteger(id) && id > 0 && !found.has(id)) {
|
|
1772
|
+
found.set(id, request);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
if (requests.length < 100)
|
|
1776
|
+
break;
|
|
1777
|
+
}
|
|
1778
|
+
return found;
|
|
1779
|
+
};
|
|
1780
|
+
const [created, assigned] = await Promise.all([
|
|
1781
|
+
collect(`creatorId=${encodeURIComponent(me.id)}`),
|
|
1782
|
+
collect(`reviewerId=${encodeURIComponent(me.id)}`),
|
|
1783
|
+
]);
|
|
1784
|
+
const merged = new Map([...created, ...assigned]);
|
|
1785
|
+
const pullRequests = [...merged.values()]
|
|
1786
|
+
.map((request) => mapAzurePullRequest(request, prefix, remoteUrl, me))
|
|
1787
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
1788
|
+
return resp.type('application/json').send({ pullRequests });
|
|
1789
|
+
}
|
|
1790
|
+
catch (err) {
|
|
1791
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1794
|
+
app.get('/api/azure-devops/pullrequests/:id', async (req, resp) => {
|
|
1795
|
+
try {
|
|
1796
|
+
const { base, prefix, remoteUrl } = await azurePrApiBase(Number(req.params?.id));
|
|
1797
|
+
const [pr, me] = await Promise.all([
|
|
1798
|
+
azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`),
|
|
1799
|
+
azureMe(),
|
|
1800
|
+
]);
|
|
1801
|
+
const summary = mapAzurePullRequest(pr, prefix, remoteUrl, me);
|
|
1802
|
+
return resp.type('application/json').send({
|
|
1803
|
+
...summary,
|
|
1804
|
+
description: String(pr?.description ?? ''),
|
|
1805
|
+
autoCompleteSetBy: pr?.autoCompleteSetBy
|
|
1806
|
+
? {
|
|
1807
|
+
id: String(pr.autoCompleteSetBy.id ?? ''),
|
|
1808
|
+
name: String(pr.autoCompleteSetBy.displayName ?? ''),
|
|
1809
|
+
}
|
|
1810
|
+
: null,
|
|
1811
|
+
completionOptions: pr?.completionOptions ?? null,
|
|
1812
|
+
lastMergeSourceCommit: String(pr?.lastMergeSourceCommit?.commitId ?? ''),
|
|
1813
|
+
lastMergeTargetCommit: String(pr?.lastMergeTargetCommit?.commitId ?? ''),
|
|
1814
|
+
labels: (Array.isArray(pr?.labels) ? pr.labels : [])
|
|
1815
|
+
.map((label) => String(label?.name ?? ''))
|
|
1816
|
+
.filter(Boolean),
|
|
1817
|
+
});
|
|
1818
|
+
}
|
|
1819
|
+
catch (err) {
|
|
1820
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
// Edits title/description or flips the draft flag ("publish"/"convert to draft").
|
|
1824
|
+
app.patch('/api/azure-devops/pullrequests/:id', async (req, resp) => {
|
|
1825
|
+
try {
|
|
1826
|
+
const { title, description, isDraft } = req.body ?? {};
|
|
1827
|
+
const payload = {};
|
|
1828
|
+
if (title !== undefined)
|
|
1829
|
+
payload.title = String(title);
|
|
1830
|
+
if (description !== undefined)
|
|
1831
|
+
payload.description = String(description);
|
|
1832
|
+
if (isDraft !== undefined)
|
|
1833
|
+
payload.isDraft = isDraft === true;
|
|
1834
|
+
if (!Object.keys(payload).length) {
|
|
1835
|
+
return resp.status(400).type('application/json').send({ error: 'Nothing to update.' });
|
|
1836
|
+
}
|
|
1837
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1838
|
+
await azureJson('PATCH', `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
|
|
1839
|
+
return resp.type('application/json').send({ ok: true });
|
|
1840
|
+
}
|
|
1841
|
+
catch (err) {
|
|
1842
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1843
|
+
}
|
|
1844
|
+
});
|
|
1845
|
+
// Records the current user's vote on the pull request.
|
|
1846
|
+
app.post('/api/azure-devops/pullrequests/:id/vote', async (req, resp) => {
|
|
1847
|
+
try {
|
|
1848
|
+
const vote = String(req.body?.vote ?? '');
|
|
1849
|
+
if (!(vote in AZURE_VOTES)) {
|
|
1102
1850
|
return resp
|
|
1103
1851
|
.status(400)
|
|
1104
1852
|
.type('application/json')
|
|
1105
|
-
.send({
|
|
1106
|
-
|
|
1853
|
+
.send({ error: `Unknown vote "${vote}".` });
|
|
1854
|
+
}
|
|
1855
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1856
|
+
const me = await azureMe();
|
|
1857
|
+
await azureJson('PUT', `${base}/reviewers/${encodeURIComponent(me.id)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ vote: AZURE_VOTES[vote] }));
|
|
1858
|
+
return resp.type('application/json').send({ ok: true });
|
|
1859
|
+
}
|
|
1860
|
+
catch (err) {
|
|
1861
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1862
|
+
}
|
|
1863
|
+
});
|
|
1864
|
+
// Adds or updates a reviewer (optionally required), or removes one.
|
|
1865
|
+
app.post('/api/azure-devops/pullrequests/:id/reviewers', async (req, resp) => {
|
|
1866
|
+
try {
|
|
1867
|
+
const id = String(req.body?.id ?? '');
|
|
1868
|
+
if (!id) {
|
|
1869
|
+
return resp.status(400).type('application/json').send({ error: 'Reviewer id required.' });
|
|
1870
|
+
}
|
|
1871
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1872
|
+
const reviewerUrl = `${base}/reviewers/${encodeURIComponent(id)}?api-version=${AZURE_API_VERSION}`;
|
|
1873
|
+
if (req.body?.remove === true) {
|
|
1874
|
+
await azureJson('DELETE', reviewerUrl);
|
|
1875
|
+
return resp.type('application/json').send({ ok: true });
|
|
1876
|
+
}
|
|
1877
|
+
// The PUT replaces the reviewer record, so an existing vote is passed
|
|
1878
|
+
// through to survive an isRequired toggle.
|
|
1879
|
+
const payload = { isRequired: req.body?.required === true };
|
|
1880
|
+
if (req.body?.vote !== undefined && Number.isFinite(Number(req.body.vote))) {
|
|
1881
|
+
payload.vote = Number(req.body.vote);
|
|
1882
|
+
}
|
|
1883
|
+
await azureJson('PUT', reviewerUrl, JSON.stringify(payload));
|
|
1884
|
+
return resp.type('application/json').send({ ok: true });
|
|
1885
|
+
}
|
|
1886
|
+
catch (err) {
|
|
1887
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1888
|
+
}
|
|
1889
|
+
});
|
|
1890
|
+
/** Maps dialog completion options onto the Azure DevOps payload shape. */
|
|
1891
|
+
const completionOptionsOf = (body) => {
|
|
1892
|
+
const strategies = ['noFastForward', 'squash', 'rebase', 'rebaseMerge'];
|
|
1893
|
+
const mergeStrategy = String(body?.mergeStrategy ?? '');
|
|
1894
|
+
const options = {};
|
|
1895
|
+
if (strategies.includes(mergeStrategy))
|
|
1896
|
+
options.mergeStrategy = mergeStrategy;
|
|
1897
|
+
if (body?.deleteSourceBranch !== undefined) {
|
|
1898
|
+
options.deleteSourceBranch = body.deleteSourceBranch === true;
|
|
1899
|
+
}
|
|
1900
|
+
const completeWorkItems = body?.completeWorkItems === true;
|
|
1901
|
+
if (completeWorkItems) {
|
|
1902
|
+
options.completeWorkItems = true;
|
|
1903
|
+
// Transitioning requires completion of linked work items.
|
|
1904
|
+
if (body?.transitionWorkItems === true)
|
|
1905
|
+
options.transitionWorkItems = true;
|
|
1906
|
+
}
|
|
1907
|
+
return Object.keys(options).length ? options : null;
|
|
1908
|
+
};
|
|
1909
|
+
// Sets or clears auto-complete with optional completion options.
|
|
1910
|
+
app.post('/api/azure-devops/pullrequests/:id/autocomplete', async (req, resp) => {
|
|
1911
|
+
try {
|
|
1912
|
+
const enabled = req.body?.enabled === true;
|
|
1913
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1914
|
+
const buildPayload = (identity) => {
|
|
1915
|
+
const payload = { autoCompleteSetBy: identity };
|
|
1916
|
+
if (enabled) {
|
|
1917
|
+
const options = completionOptionsOf(req.body);
|
|
1918
|
+
if (options)
|
|
1919
|
+
payload.completionOptions = options;
|
|
1920
|
+
}
|
|
1921
|
+
return JSON.stringify(payload);
|
|
1922
|
+
};
|
|
1923
|
+
const url = `${base}?api-version=${AZURE_API_VERSION}`;
|
|
1924
|
+
const me = enabled ? await azureMe() : null;
|
|
1925
|
+
// Azure DevOps clears auto-complete for an empty identity; some Server
|
|
1926
|
+
// releases only accept an explicit null, so retry that shape on failure.
|
|
1927
|
+
try {
|
|
1928
|
+
await azureJson('PATCH', url, buildPayload(enabled && me ? { id: me.id } : { id: '' }));
|
|
1929
|
+
}
|
|
1930
|
+
catch (firstError) {
|
|
1931
|
+
if (enabled)
|
|
1932
|
+
throw firstError;
|
|
1933
|
+
await azureJson('PATCH', url, buildPayload(null));
|
|
1934
|
+
}
|
|
1935
|
+
return resp.type('application/json').send({ ok: true });
|
|
1936
|
+
}
|
|
1937
|
+
catch (err) {
|
|
1938
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1939
|
+
}
|
|
1940
|
+
});
|
|
1941
|
+
// Completes the pull request (merge). Requires the merge source commit for
|
|
1942
|
+
// optimistic concurrency, exactly like the Azure DevOps web dialog.
|
|
1943
|
+
app.post('/api/azure-devops/pullrequests/:id/complete', async (req, resp) => {
|
|
1944
|
+
try {
|
|
1945
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1946
|
+
const pr = await azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`);
|
|
1947
|
+
const commitId = String(pr?.lastMergeSourceCommit?.commitId ?? '');
|
|
1948
|
+
if (!commitId) {
|
|
1949
|
+
throw new Error('The pull request has no merge commit to complete yet.');
|
|
1950
|
+
}
|
|
1951
|
+
const payload = {
|
|
1952
|
+
status: 'completed',
|
|
1953
|
+
lastMergeSourceCommit: { commitId },
|
|
1954
|
+
};
|
|
1955
|
+
const options = completionOptionsOf(req.body);
|
|
1956
|
+
if (options)
|
|
1957
|
+
payload.completionOptions = options;
|
|
1958
|
+
await azureJson('PATCH', `${base}?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
|
|
1959
|
+
return resp.type('application/json').send({ ok: true });
|
|
1960
|
+
}
|
|
1961
|
+
catch (err) {
|
|
1962
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1963
|
+
}
|
|
1964
|
+
});
|
|
1965
|
+
app.get('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
|
|
1966
|
+
try {
|
|
1967
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
1968
|
+
const data = await azureJson('GET', `${base}/threads?api-version=${AZURE_API_VERSION}`);
|
|
1969
|
+
const threads = (Array.isArray(data?.value) ? data.value : [])
|
|
1970
|
+
.filter((thread) => thread?.isDeleted !== true)
|
|
1971
|
+
.map((thread) => {
|
|
1972
|
+
const context = thread?.threadContext ?? {};
|
|
1973
|
+
const line = context.rightFileStart?.line ?? context.leftFileStart?.line ?? null;
|
|
1974
|
+
return {
|
|
1975
|
+
id: Number(thread?.id ?? 0),
|
|
1976
|
+
status: azureThreadStatusName(Number(thread?.status ?? 0)),
|
|
1977
|
+
isDeleted: false,
|
|
1978
|
+
filePath: typeof context.filePath === 'string' ? context.filePath.replace(/^\//, '') : null,
|
|
1979
|
+
line: Number.isInteger(line) ? line : null,
|
|
1980
|
+
side: context.rightFileStart ? 'right' : context.leftFileStart ? 'left' : null,
|
|
1981
|
+
comments: (Array.isArray(thread?.comments) ? thread.comments : [])
|
|
1982
|
+
.filter((comment) => comment?.isDeleted !== true)
|
|
1983
|
+
.map((comment) => ({
|
|
1984
|
+
id: Number(comment?.id ?? 0),
|
|
1985
|
+
author: {
|
|
1986
|
+
name: String(comment?.author?.displayName ?? ''),
|
|
1987
|
+
email: String(comment?.author?.uniqueName ?? ''),
|
|
1988
|
+
},
|
|
1989
|
+
content: String(comment?.content ?? ''),
|
|
1990
|
+
createdAt: String(comment?.publishedDate ?? ''),
|
|
1991
|
+
isDeleted: false,
|
|
1992
|
+
})),
|
|
1993
|
+
};
|
|
1994
|
+
});
|
|
1995
|
+
return resp.type('application/json').send({ threads });
|
|
1996
|
+
}
|
|
1997
|
+
catch (err) {
|
|
1998
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
// Adds a comment: a reply to a thread, a general PR comment, or an inline
|
|
2002
|
+
// comment anchored to a file line (thread context).
|
|
2003
|
+
app.post('/api/azure-devops/pullrequests/:id/threads', async (req, resp) => {
|
|
2004
|
+
try {
|
|
2005
|
+
const content = String(req.body?.content ?? '').trim();
|
|
2006
|
+
if (!content) {
|
|
2007
|
+
return resp.status(400).type('application/json').send({ error: 'Comment text required.' });
|
|
2008
|
+
}
|
|
2009
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2010
|
+
const threadId = Number(req.body?.threadId ?? 0);
|
|
2011
|
+
if (Number.isInteger(threadId) && threadId > 0) {
|
|
2012
|
+
await azureJson('POST', `${base}/threads/${threadId}/comments?api-version=${AZURE_API_VERSION}`, JSON.stringify({ content }));
|
|
2013
|
+
return resp.type('application/json').send({ ok: true });
|
|
2014
|
+
}
|
|
2015
|
+
const payload = {
|
|
2016
|
+
status: AZURE_THREAD_STATUSES.active,
|
|
2017
|
+
comments: [{ content, parentCommentId: 0 }],
|
|
2018
|
+
};
|
|
2019
|
+
const filePath = String(req.body?.filePath ?? '').trim();
|
|
2020
|
+
const line = Number(req.body?.line ?? 0);
|
|
2021
|
+
const left = req.body?.side === 'left';
|
|
2022
|
+
if (filePath && Number.isInteger(line) && line > 0) {
|
|
2023
|
+
// Azure anchors a whole-line comment from offset 1 to "end of line".
|
|
2024
|
+
payload.threadContext = {
|
|
2025
|
+
filePath: `/${filePath.replace(/^\//, '')}`,
|
|
2026
|
+
[left ? 'leftFileStart' : 'rightFileStart']: { line, offset: 1 },
|
|
2027
|
+
[left ? 'leftFileEnd' : 'rightFileEnd']: { line, offset: 2147483647 },
|
|
2028
|
+
};
|
|
2029
|
+
}
|
|
2030
|
+
await azureJson('POST', `${base}/threads?api-version=${AZURE_API_VERSION}`, JSON.stringify(payload));
|
|
2031
|
+
return resp.type('application/json').send({ ok: true });
|
|
2032
|
+
}
|
|
2033
|
+
catch (err) {
|
|
2034
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
// Resolves, reactivates or closes a comment thread.
|
|
2038
|
+
app.post('/api/azure-devops/pullrequests/:id/threads/:threadId/status', async (req, resp) => {
|
|
2039
|
+
try {
|
|
2040
|
+
const status = String(req.body?.status ?? '');
|
|
2041
|
+
if (!(status in AZURE_THREAD_STATUSES) || status === 'unknown') {
|
|
2042
|
+
return resp
|
|
2043
|
+
.status(400)
|
|
2044
|
+
.type('application/json')
|
|
2045
|
+
.send({ error: `Unknown thread status "${status}".` });
|
|
2046
|
+
}
|
|
2047
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2048
|
+
await azureJson('PATCH', `${base}/threads/${Number(req.params?.threadId)}?api-version=${AZURE_API_VERSION}`, JSON.stringify({ status: AZURE_THREAD_STATUSES[status] }));
|
|
2049
|
+
return resp.type('application/json').send({ ok: true });
|
|
2050
|
+
}
|
|
2051
|
+
catch (err) {
|
|
2052
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
// Changed files of the latest PR iteration (names and change types only;
|
|
2056
|
+
// content is fetched per file on demand by /file-diff).
|
|
2057
|
+
app.get('/api/azure-devops/pullrequests/:id/changes', async (req, resp) => {
|
|
2058
|
+
try {
|
|
2059
|
+
const { base } = await azurePrApiBase(Number(req.params?.id));
|
|
2060
|
+
const iterations = await azureJson('GET', `${base}/iterations?api-version=${AZURE_API_VERSION}`);
|
|
2061
|
+
const list = Array.isArray(iterations?.value) ? iterations.value : [];
|
|
2062
|
+
const latest = list.length
|
|
2063
|
+
? list.reduce((newest, entry) => String(entry?.createdDate ?? '') > String(newest?.createdDate ?? '') ? entry : newest)
|
|
2064
|
+
: null;
|
|
2065
|
+
if (!latest) {
|
|
2066
|
+
return resp.type('application/json').send({ files: [] });
|
|
2067
|
+
}
|
|
2068
|
+
const data = await azureJson('GET', `${base}/iterations/${latest.id}/changes?api-version=${AZURE_API_VERSION}`);
|
|
2069
|
+
const changeStatus = {
|
|
2070
|
+
add: 'added',
|
|
2071
|
+
edit: 'modified',
|
|
2072
|
+
rename: 'renamed',
|
|
2073
|
+
delete: 'deleted',
|
|
2074
|
+
undelete: 'added',
|
|
2075
|
+
};
|
|
2076
|
+
const files = (Array.isArray(data?.value) ? data.value : []).map((entry) => {
|
|
2077
|
+
const path = String(entry?.item?.path ?? '').replace(/^\//, '');
|
|
2078
|
+
const changeType = changeStatus[String(entry?.changeType ?? '')] ?? 'modified';
|
|
2079
|
+
const oldPath = changeType === 'renamed'
|
|
2080
|
+
? String(entry?.sourceServerItem ?? '').replace(/^\//, '')
|
|
2081
|
+
: '';
|
|
2082
|
+
return { path, oldPath, changeType };
|
|
2083
|
+
});
|
|
2084
|
+
return resp.type('application/json').send({ files });
|
|
2085
|
+
}
|
|
2086
|
+
catch (err) {
|
|
2087
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
2088
|
+
}
|
|
2089
|
+
});
|
|
2090
|
+
// Unified diff of one PR file, computed from the Azure item contents at the
|
|
2091
|
+
// PR merge commits. source/target are optional overrides; default comes from
|
|
2092
|
+
// the pull request detail.
|
|
2093
|
+
app.get('/api/azure-devops/pullrequests/:id/file-diff', async (req, resp) => {
|
|
2094
|
+
try {
|
|
2095
|
+
const path = String(req.query?.path ?? '').replace(/^\//, '');
|
|
2096
|
+
if (!path) {
|
|
2097
|
+
return resp.status(400).type('application/json').send({ error: 'path required' });
|
|
2098
|
+
}
|
|
2099
|
+
const oldPath = String(req.query?.oldPath ?? '').replace(/^\//, '');
|
|
2100
|
+
const { base, prefix, repo } = await azurePrApiBase(Number(req.params?.id));
|
|
2101
|
+
let source = String(req.query?.source ?? '');
|
|
2102
|
+
let target = String(req.query?.target ?? '');
|
|
2103
|
+
if (!source || !target) {
|
|
2104
|
+
const pr = await azureJson('GET', `${base}?api-version=${AZURE_API_VERSION}`);
|
|
2105
|
+
source = source || String(pr?.lastMergeSourceCommit?.commitId ?? '');
|
|
2106
|
+
target = target || String(pr?.lastMergeTargetCommit?.commitId ?? '');
|
|
2107
|
+
}
|
|
2108
|
+
if (!source || !target) {
|
|
2109
|
+
throw new Error('The pull request does not have merge commits to diff yet.');
|
|
2110
|
+
}
|
|
2111
|
+
const fetchContent = async (itemPath, commit) => {
|
|
2112
|
+
if (!itemPath)
|
|
2113
|
+
return { content: '', binary: false };
|
|
2114
|
+
const url = `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/items` +
|
|
2115
|
+
`?path=${encodeURIComponent(`/${itemPath}`)}` +
|
|
2116
|
+
`&versionDescriptor.version=${encodeURIComponent(commit)}` +
|
|
2117
|
+
`&versionDescriptor.versionType=commit&includeContent=true` +
|
|
2118
|
+
`&api-version=${AZURE_API_VERSION}`;
|
|
2119
|
+
const result = await azureRequest('GET', url, '');
|
|
2120
|
+
// 404/203: the file does not exist at that commit (added/deleted file).
|
|
2121
|
+
if (result.status === 404 || result.status === 203) {
|
|
2122
|
+
return { content: '', binary: false };
|
|
2123
|
+
}
|
|
2124
|
+
if (result.status < 200 || result.status >= 300) {
|
|
2125
|
+
throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
|
|
2126
|
+
}
|
|
2127
|
+
const data = JSON.parse(result.body);
|
|
2128
|
+
return {
|
|
2129
|
+
content: typeof data?.content === 'string' ? data.content : '',
|
|
2130
|
+
binary: data?.isBinary === true,
|
|
2131
|
+
};
|
|
2132
|
+
};
|
|
2133
|
+
const [oldFile, newFile] = await Promise.all([
|
|
2134
|
+
fetchContent(oldPath || path, target),
|
|
2135
|
+
fetchContent(path, source),
|
|
2136
|
+
]);
|
|
2137
|
+
if (oldFile.binary || newFile.binary) {
|
|
2138
|
+
return resp.type('application/json').send({
|
|
2139
|
+
path,
|
|
2140
|
+
oldPath,
|
|
2141
|
+
status: 'binary',
|
|
2142
|
+
binary: true,
|
|
2143
|
+
additions: 0,
|
|
2144
|
+
deletions: 0,
|
|
2145
|
+
lines: [],
|
|
1107
2146
|
});
|
|
1108
2147
|
}
|
|
1109
|
-
const
|
|
1110
|
-
.
|
|
1111
|
-
|
|
1112
|
-
|
|
2148
|
+
const status = String(req.query?.changeType ?? '') ||
|
|
2149
|
+
(!oldFile.content ? 'added' : !newFile.content ? 'deleted' : 'modified');
|
|
2150
|
+
// jsdiff omits the "diff --git" header parseUnifiedDiff expects, and
|
|
2151
|
+
// prefixing it lets the same parser serve both local and PR diffs.
|
|
2152
|
+
const patch = createTwoFilesPatch(`a/${oldPath || path}`, `b/${path}`, oldFile.content, newFile.content);
|
|
2153
|
+
const raw = `diff --git a/${oldPath || path} b/${path}\n${patch.replace(/^=+\n/, '')}`;
|
|
2154
|
+
// The patch's trailing newline would surface as a phantom context row.
|
|
2155
|
+
const [file] = parseUnifiedDiff(raw.replace(/\n+$/, ''));
|
|
2156
|
+
const maxLines = 10000;
|
|
2157
|
+
const lines = (file?.lines ?? []).slice(0, maxLines);
|
|
2158
|
+
if ((file?.lines ?? []).length > maxLines) {
|
|
2159
|
+
lines.push({ type: 'hunk', text: 'Diff truncated (file too large).' });
|
|
2160
|
+
}
|
|
2161
|
+
return resp.type('application/json').send({
|
|
2162
|
+
path,
|
|
2163
|
+
oldPath,
|
|
2164
|
+
status,
|
|
2165
|
+
binary: false,
|
|
2166
|
+
additions: file?.additions ?? 0,
|
|
2167
|
+
deletions: file?.deletions ?? 0,
|
|
2168
|
+
lines,
|
|
2169
|
+
});
|
|
1113
2170
|
}
|
|
1114
2171
|
catch (err) {
|
|
1115
2172
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -1139,8 +2196,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1139
2196
|
message,
|
|
1140
2197
|
refs: '',
|
|
1141
2198
|
body: body ?? '',
|
|
1142
|
-
|
|
1143
|
-
author_email: authorEmail,
|
|
2199
|
+
...authorIdentity(authorName, authorEmail, await configuredIdentity()),
|
|
1144
2200
|
parents: String(parents ?? '')
|
|
1145
2201
|
.split(' ')
|
|
1146
2202
|
.filter(Boolean),
|
|
@@ -1288,5 +2344,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1288
2344
|
return {
|
|
1289
2345
|
address,
|
|
1290
2346
|
close: () => app.close(),
|
|
2347
|
+
updateSettings: (settings) => {
|
|
2348
|
+
azureDevOpsUrl = settings.azureDevOpsUrl;
|
|
2349
|
+
prBranchNameTemplate = settings.prBranchNameTemplate;
|
|
2350
|
+
autoReload = settings.autoReload;
|
|
2351
|
+
diffViewer = settings.diffViewer;
|
|
2352
|
+
showGraph = settings.showGraph;
|
|
2353
|
+
showStashes = settings.showStashes;
|
|
2354
|
+
fileListView = settings.fileListView;
|
|
2355
|
+
searchMode = settings.searchMode;
|
|
2356
|
+
searchCaseSensitive = settings.searchCaseSensitive;
|
|
2357
|
+
showTags = settings.showTags;
|
|
2358
|
+
showRemoteBranches = settings.showRemoteBranches;
|
|
2359
|
+
issueRegex = settings.issueRegex;
|
|
2360
|
+
issueUrl = settings.issueUrl;
|
|
2361
|
+
},
|
|
1291
2362
|
};
|
|
1292
2363
|
}
|