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