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