guito 0.5.4 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/CONTRIBUTION.md +6 -0
- package/README.md +8 -1
- package/bin/guito-server.js +550 -74
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-SN64JYAQ.js +6 -0
- package/bin/working-tree.js +141 -0
- package/package.json +4 -2
- package/playwright.config.mjs +9 -0
- package/bin/ui/browser/main-3QYX4VGI.js +0 -6
package/bin/guito-server.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
3
|
-
import { readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
4
5
|
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
6
|
import fastify from 'fastify';
|
|
6
7
|
import fastifyStatic from '@fastify/static';
|
|
@@ -8,8 +9,168 @@ import fastifyCors from '@fastify/cors';
|
|
|
8
9
|
import fastifyCompress from '@fastify/compress';
|
|
9
10
|
import { simpleGit } from 'simple-git';
|
|
10
11
|
import { promisify } from 'node:util';
|
|
12
|
+
import { workingTree } from './working-tree.js';
|
|
11
13
|
const execFileAsync = promisify(execFile);
|
|
12
|
-
|
|
14
|
+
const AZURE_API_VERSION = '4.1';
|
|
15
|
+
/**
|
|
16
|
+
* Extracts {projectPath, repo} from an Azure DevOps git remote URL.
|
|
17
|
+
* Supports https (with optional credentials) and ssh (scp-style and ssh://)
|
|
18
|
+
* forms, a trailing .git, and URL-encoded segments such as %20.
|
|
19
|
+
*/
|
|
20
|
+
export function parseAzureRemoteUrl(remoteUrl) {
|
|
21
|
+
const value = (remoteUrl ?? '').trim();
|
|
22
|
+
if (!value) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
let path;
|
|
26
|
+
const urlMatch = value.match(/^[a-z][a-z0-9+.\-]*:\/\/[^/]*\/(.*)$/i);
|
|
27
|
+
if (urlMatch) {
|
|
28
|
+
path = urlMatch[1];
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
// scp-style ssh: git@host:Collection/Project/_git/Repo(.git)
|
|
32
|
+
const scpMatch = value.match(/^[^@/]+@[^:]+:(.+)$/);
|
|
33
|
+
if (!scpMatch) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
path = scpMatch[1];
|
|
37
|
+
}
|
|
38
|
+
path = path.replace(/\.git\/?$/i, '');
|
|
39
|
+
try {
|
|
40
|
+
path = decodeURIComponent(path);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Keep the raw path when it contains invalid percent-escapes.
|
|
44
|
+
}
|
|
45
|
+
const gitIndex = path.toLowerCase().lastIndexOf('/_git/');
|
|
46
|
+
if (gitIndex < 0) {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const projectPath = path.slice(0, gitIndex).replace(/^\/+|\/+$/g, '');
|
|
50
|
+
const repo = path.slice(gitIndex + '/_git/'.length).replace(/^\/+|\/+$/g, '');
|
|
51
|
+
if (!projectPath || !repo) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
return { projectPath, repo };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Builds the encoded "{origin}[/{collection}]/{project}" prefix for Azure
|
|
58
|
+
* DevOps REST calls. When the base URL already contains the collection
|
|
59
|
+
* prefix of the remote path it is not duplicated.
|
|
60
|
+
*/
|
|
61
|
+
export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
|
|
62
|
+
const base = (baseUrl ?? '').trim().replace(/\/+$/, '');
|
|
63
|
+
if (!base) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const info = parseAzureRemoteUrl(remoteUrl);
|
|
67
|
+
if (!info) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
const encodePath = (value) => value
|
|
71
|
+
.split('/')
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.map((segment) => encodeURIComponent(segment))
|
|
74
|
+
.join('/');
|
|
75
|
+
try {
|
|
76
|
+
const parsed = new URL(base);
|
|
77
|
+
const basePath = parsed.pathname.replace(/^\/+|\/+$/g, '');
|
|
78
|
+
if (basePath && info.projectPath.toLowerCase().startsWith(basePath.toLowerCase())) {
|
|
79
|
+
return `${parsed.origin}/${encodePath(info.projectPath)}`;
|
|
80
|
+
}
|
|
81
|
+
return `${parsed.origin}${basePath ? `/${encodePath(basePath)}` : ''}/${encodePath(info.projectPath)}`;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return `${base}/${encodePath(info.projectPath)}`;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Builds the pullrequests REST URL from the server base URL and origin remote. */
|
|
88
|
+
export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
|
|
89
|
+
const prefix = buildAzureProjectPrefix(baseUrl, remoteUrl);
|
|
90
|
+
if (!prefix) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
const info = parseAzureRemoteUrl(remoteUrl);
|
|
94
|
+
if (!info) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Default Azure DevOps HTTP runner: shells out to the Windows built-in
|
|
101
|
+
* curl.exe with NTLM + Negotiate and an empty username, which authenticates
|
|
102
|
+
* with the current Windows session (SSPI single sign-on).
|
|
103
|
+
*/
|
|
104
|
+
function defaultAzureRequest(method, url, body) {
|
|
105
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
106
|
+
const args = [
|
|
107
|
+
'-sS',
|
|
108
|
+
'--ntlm',
|
|
109
|
+
'--negotiate',
|
|
110
|
+
'-u',
|
|
111
|
+
':',
|
|
112
|
+
'--max-time',
|
|
113
|
+
'60',
|
|
114
|
+
'-w',
|
|
115
|
+
'\n%{http_code}',
|
|
116
|
+
'-X',
|
|
117
|
+
method,
|
|
118
|
+
];
|
|
119
|
+
if (method === 'POST') {
|
|
120
|
+
args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
|
|
121
|
+
}
|
|
122
|
+
args.push(url);
|
|
123
|
+
const child = spawn('curl.exe', args, { windowsHide: true });
|
|
124
|
+
let stdout = '';
|
|
125
|
+
let stderr = '';
|
|
126
|
+
child.stdout.on('data', (chunk) => {
|
|
127
|
+
stdout += chunk;
|
|
128
|
+
});
|
|
129
|
+
child.stderr.on('data', (chunk) => {
|
|
130
|
+
stderr += chunk;
|
|
131
|
+
});
|
|
132
|
+
// EPIPE when curl exits before reading the whole body; the close handler
|
|
133
|
+
// reports the real failure.
|
|
134
|
+
child.stdin.on('error', () => { });
|
|
135
|
+
child.on('error', (err) => {
|
|
136
|
+
rejectRequest(new Error(`Failed to run curl.exe (Windows integrated auth requires Windows): ${err.message}`));
|
|
137
|
+
});
|
|
138
|
+
child.on('close', (code) => {
|
|
139
|
+
if (code !== 0) {
|
|
140
|
+
rejectRequest(new Error(stderr.trim() || `curl.exe exited with code ${code}.`));
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
// -w appends "\n<http_code>" after the response body.
|
|
144
|
+
const separator = stdout.lastIndexOf('\n');
|
|
145
|
+
const status = Number.parseInt(stdout.slice(separator + 1), 10);
|
|
146
|
+
if (separator < 0 || !Number.isFinite(status)) {
|
|
147
|
+
rejectRequest(new Error('Unexpected curl.exe output (missing HTTP status).'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
resolveRequest({ status, body: stdout.slice(0, separator) });
|
|
151
|
+
});
|
|
152
|
+
child.stdin.end(body, 'utf8');
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const randomBranchSuffix = () => {
|
|
156
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
157
|
+
const bytes = randomBytes(6);
|
|
158
|
+
let suffix = '';
|
|
159
|
+
for (const byte of bytes) {
|
|
160
|
+
suffix += alphabet[byte % alphabet.length];
|
|
161
|
+
}
|
|
162
|
+
return suffix;
|
|
163
|
+
};
|
|
164
|
+
/** Extracts Azure DevOps' human-readable error message from a REST response. */
|
|
165
|
+
const azureErrorMessage = (result) => {
|
|
166
|
+
try {
|
|
167
|
+
return String(JSON.parse(result.body)?.message ?? '');
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return result.body;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, azureRequestImpl, }) {
|
|
13
174
|
// Initialize server
|
|
14
175
|
const app = fastify({
|
|
15
176
|
logger: false,
|
|
@@ -128,7 +289,67 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
128
289
|
files.push(current);
|
|
129
290
|
return files;
|
|
130
291
|
}
|
|
292
|
+
const working = workingTree(git, parseUnifiedDiff);
|
|
293
|
+
// Keep validation and the corresponding index mutation in one operation.
|
|
294
|
+
let mutationQueue = Promise.resolve();
|
|
295
|
+
const mutate = (action) => {
|
|
296
|
+
const result = mutationQueue.then(action);
|
|
297
|
+
mutationQueue = result.catch(() => { });
|
|
298
|
+
return result;
|
|
299
|
+
};
|
|
131
300
|
const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
|
|
301
|
+
// ==================== Azure DevOps settings ====================
|
|
302
|
+
// Settings live in the repository's git directory so they are per-repo and
|
|
303
|
+
// never committed. The VS Code extension passes its own setting via
|
|
304
|
+
// azureDevOpsUrl, which wins over the file.
|
|
305
|
+
let settingsPath = '';
|
|
306
|
+
try {
|
|
307
|
+
const gitDir = (await git.revparse(['--absolute-git-dir'])).trim();
|
|
308
|
+
settingsPath = join(gitDir, 'guito-settings.json');
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
settingsPath = join(repositoryPath, '.git', 'guito-settings.json');
|
|
312
|
+
}
|
|
313
|
+
const readSettings = async () => {
|
|
314
|
+
try {
|
|
315
|
+
return JSON.parse(await readFile(settingsPath, 'utf8'));
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return {};
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const writeSettings = async (settings) => {
|
|
322
|
+
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
323
|
+
};
|
|
324
|
+
const azureRequest = azureRequestImpl ?? defaultAzureRequest;
|
|
325
|
+
const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
|
|
326
|
+
const effectiveAzureUrl = async () => {
|
|
327
|
+
if (configuredAzureUrl) {
|
|
328
|
+
return { url: configuredAzureUrl, source: 'vscode' };
|
|
329
|
+
}
|
|
330
|
+
const settings = await readSettings();
|
|
331
|
+
const url = typeof settings.azureDevOpsUrl === 'string' ? settings.azureDevOpsUrl.trim() : '';
|
|
332
|
+
return { url, source: url ? 'file' : '' };
|
|
333
|
+
};
|
|
334
|
+
/** Resolves the Azure DevOps project prefix or throws a user-facing error. */
|
|
335
|
+
const azureProjectContext = async () => {
|
|
336
|
+
const effective = await effectiveAzureUrl();
|
|
337
|
+
if (!effective.url) {
|
|
338
|
+
throw new Error('Azure DevOps URL is not configured. Set it in Settings (gear icon) or the guito.azureDevOpsUrl VS Code setting.');
|
|
339
|
+
}
|
|
340
|
+
let remoteUrl = '';
|
|
341
|
+
try {
|
|
342
|
+
remoteUrl = (await git.remote(['get-url', 'origin'])).trim();
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
throw new Error('No "origin" remote is configured for this repository.');
|
|
346
|
+
}
|
|
347
|
+
const prefix = buildAzureProjectPrefix(effective.url, remoteUrl);
|
|
348
|
+
if (!prefix) {
|
|
349
|
+
throw new Error(`The origin remote does not point to an Azure DevOps project: ${remoteUrl}`);
|
|
350
|
+
}
|
|
351
|
+
return { prefix, remoteUrl };
|
|
352
|
+
};
|
|
132
353
|
// ==================== Repository ====================
|
|
133
354
|
app.get('/api/repo', async (_req, resp) => {
|
|
134
355
|
try {
|
|
@@ -208,9 +429,12 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
208
429
|
`--grep=${query}`,
|
|
209
430
|
'--format=%H',
|
|
210
431
|
]);
|
|
211
|
-
return resp
|
|
212
|
-
|
|
213
|
-
|
|
432
|
+
return resp.type('application/json').send({
|
|
433
|
+
hashes: raw
|
|
434
|
+
.split('\n')
|
|
435
|
+
.map((hash) => hash.trim())
|
|
436
|
+
.filter(Boolean),
|
|
437
|
+
});
|
|
214
438
|
}
|
|
215
439
|
catch (err) {
|
|
216
440
|
if (/does not have any commits yet|unknown revision|bad default revision/i.test(err.message ?? '')) {
|
|
@@ -221,8 +445,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
221
445
|
});
|
|
222
446
|
app.post('/api/commit', async (req, resp) => {
|
|
223
447
|
try {
|
|
224
|
-
const { message, description } = req.body;
|
|
225
|
-
await
|
|
448
|
+
const { message, description } = req.body ?? {};
|
|
449
|
+
await mutate(() => working.commit(message, description));
|
|
226
450
|
return resp.type('application/json').send({ success: true });
|
|
227
451
|
}
|
|
228
452
|
catch (err) {
|
|
@@ -272,7 +496,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
272
496
|
});
|
|
273
497
|
app.post('/api/reset-commit', async (req, resp) => {
|
|
274
498
|
try {
|
|
275
|
-
|
|
499
|
+
const mode = ['soft', 'mixed', 'hard'].includes(req.body?.mode) ? req.body.mode : 'hard';
|
|
500
|
+
await git.raw(['reset', `--${mode}`, req.body.commit]);
|
|
276
501
|
return resp.type('application/json').send({ success: true });
|
|
277
502
|
}
|
|
278
503
|
catch (err) {
|
|
@@ -291,10 +516,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
291
516
|
});
|
|
292
517
|
app.post('/api/stage', async (req, resp) => {
|
|
293
518
|
try {
|
|
294
|
-
|
|
295
|
-
if (Array.isArray(files) && files.length > 0) {
|
|
296
|
-
await git.add(files);
|
|
297
|
-
}
|
|
519
|
+
await mutate(() => working.stage(req.body?.files));
|
|
298
520
|
return resp.type('application/json').send({ success: true });
|
|
299
521
|
}
|
|
300
522
|
catch (err) {
|
|
@@ -303,10 +525,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
303
525
|
});
|
|
304
526
|
app.post('/api/unstage', async (req, resp) => {
|
|
305
527
|
try {
|
|
306
|
-
|
|
307
|
-
if (Array.isArray(files) && files.length > 0) {
|
|
308
|
-
await git.reset(['HEAD', ...files]);
|
|
309
|
-
}
|
|
528
|
+
await mutate(() => working.unstage(req.body?.files));
|
|
310
529
|
return resp.type('application/json').send({ success: true });
|
|
311
530
|
}
|
|
312
531
|
catch (err) {
|
|
@@ -472,8 +691,22 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
472
691
|
});
|
|
473
692
|
app.post('/api/stash/save', async (req, resp) => {
|
|
474
693
|
try {
|
|
475
|
-
const { message } = req.body;
|
|
476
|
-
|
|
694
|
+
const { message, scope } = req.body ?? {};
|
|
695
|
+
// 'staged' → index only (--staged, git ≥ 2.35); 'unstaged' → working tree only
|
|
696
|
+
// (--keep-index leaves staged changes staged); 'all' → index + working tree.
|
|
697
|
+
// Untracked files ride along for 'all'/'unstaged' since the panel lists them as unstaged.
|
|
698
|
+
const args = ['push'];
|
|
699
|
+
if (scope === 'staged') {
|
|
700
|
+
args.push('--staged');
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
if (scope === 'unstaged')
|
|
704
|
+
args.push('--keep-index');
|
|
705
|
+
args.push('-u');
|
|
706
|
+
}
|
|
707
|
+
if (message)
|
|
708
|
+
args.push('-m', message);
|
|
709
|
+
await git.stash(args);
|
|
477
710
|
return resp.type('application/json').send({ success: true });
|
|
478
711
|
}
|
|
479
712
|
catch (err) {
|
|
@@ -555,6 +788,19 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
555
788
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
556
789
|
}
|
|
557
790
|
});
|
|
791
|
+
app.post('/api/tag/push', async (req, resp) => {
|
|
792
|
+
try {
|
|
793
|
+
const { name, remote } = req.body;
|
|
794
|
+
if (!name) {
|
|
795
|
+
return resp.status(400).type('application/json').send({ error: 'name required' });
|
|
796
|
+
}
|
|
797
|
+
await git.push(remote || 'origin', `refs/tags/${name}`);
|
|
798
|
+
return resp.type('application/json').send({ success: true });
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
802
|
+
}
|
|
803
|
+
});
|
|
558
804
|
// ==================== Remote Operations ====================
|
|
559
805
|
app.get('/api/fetch', async (_req, resp) => {
|
|
560
806
|
try {
|
|
@@ -605,6 +851,270 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
605
851
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
606
852
|
}
|
|
607
853
|
});
|
|
854
|
+
// ==================== Settings ====================
|
|
855
|
+
app.get('/api/settings', async (_req, resp) => {
|
|
856
|
+
try {
|
|
857
|
+
const effective = await effectiveAzureUrl();
|
|
858
|
+
return resp
|
|
859
|
+
.type('application/json')
|
|
860
|
+
.send({ azureDevOpsUrl: effective.url, source: effective.source });
|
|
861
|
+
}
|
|
862
|
+
catch (err) {
|
|
863
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
app.post('/api/settings', async (req, resp) => {
|
|
867
|
+
try {
|
|
868
|
+
const url = String(req.body?.azureDevOpsUrl ?? '').trim();
|
|
869
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
870
|
+
return resp
|
|
871
|
+
.status(400)
|
|
872
|
+
.type('application/json')
|
|
873
|
+
.send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
|
|
874
|
+
}
|
|
875
|
+
const settings = await readSettings();
|
|
876
|
+
settings.azureDevOpsUrl = url;
|
|
877
|
+
await writeSettings(settings);
|
|
878
|
+
const effective = await effectiveAzureUrl();
|
|
879
|
+
return resp
|
|
880
|
+
.type('application/json')
|
|
881
|
+
.send({ azureDevOpsUrl: effective.url, source: effective.source });
|
|
882
|
+
}
|
|
883
|
+
catch (err) {
|
|
884
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
885
|
+
}
|
|
886
|
+
});
|
|
887
|
+
// ==================== Azure DevOps Pull Requests ====================
|
|
888
|
+
app.post('/api/azure-devops/pullrequest', async (req, resp) => {
|
|
889
|
+
try {
|
|
890
|
+
const { sourceBranch, targetBranch, title, description, newBranch } = req.body ?? {};
|
|
891
|
+
if (!targetBranch) {
|
|
892
|
+
return resp.status(400).type('application/json').send({ error: 'targetBranch required' });
|
|
893
|
+
}
|
|
894
|
+
const { prefix, remoteUrl } = await azureProjectContext();
|
|
895
|
+
const info = parseAzureRemoteUrl(remoteUrl);
|
|
896
|
+
if (!info) {
|
|
897
|
+
return resp
|
|
898
|
+
.status(400)
|
|
899
|
+
.type('application/json')
|
|
900
|
+
.send({ error: 'Unable to build the Azure DevOps pull request URL.' });
|
|
901
|
+
}
|
|
902
|
+
const prUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
903
|
+
let source = String(sourceBranch ?? '').trim();
|
|
904
|
+
if (newBranch) {
|
|
905
|
+
// Create a remote-only branch from the current HEAD; the local
|
|
906
|
+
// repository and the checked-out branch stay untouched.
|
|
907
|
+
const name = `pr/${randomBranchSuffix()}`;
|
|
908
|
+
await git.push(['origin', `HEAD:refs/heads/${name}`]);
|
|
909
|
+
source = name;
|
|
910
|
+
}
|
|
911
|
+
else {
|
|
912
|
+
if (!source) {
|
|
913
|
+
return resp.status(400).type('application/json').send({ error: 'sourceBranch required' });
|
|
914
|
+
}
|
|
915
|
+
if (source.startsWith('origin/')) {
|
|
916
|
+
// Remote-tracking branch: the PR source is the branch name on the remote.
|
|
917
|
+
source = source.slice('origin/'.length);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
// Publish the branch first when the remote does not have it yet.
|
|
921
|
+
// The remote-tracking ref is a local check; a redundant push is a
|
|
922
|
+
// harmless no-op ("Everything up-to-date").
|
|
923
|
+
// rev-parse --verify --quiet does not fail reliably through
|
|
924
|
+
// simple-git, so list the tracking refs instead.
|
|
925
|
+
const trackingRefs = await git.raw([
|
|
926
|
+
'for-each-ref',
|
|
927
|
+
'refs/remotes/origin',
|
|
928
|
+
'--format=%(refname)',
|
|
929
|
+
]);
|
|
930
|
+
const published = trackingRefs
|
|
931
|
+
.split('\n')
|
|
932
|
+
.some((line) => line.trim() === `refs/remotes/origin/${source}`);
|
|
933
|
+
if (!published) {
|
|
934
|
+
await git.push(['origin', source]);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
const payload = {
|
|
939
|
+
sourceRefName: `refs/heads/${source}`,
|
|
940
|
+
targetRefName: `refs/heads/${targetBranch}`,
|
|
941
|
+
};
|
|
942
|
+
const trimmedTitle = String(title ?? '').trim();
|
|
943
|
+
const trimmedDescription = String(description ?? '').trim();
|
|
944
|
+
if (trimmedTitle) {
|
|
945
|
+
payload.title = trimmedTitle;
|
|
946
|
+
}
|
|
947
|
+
if (trimmedDescription) {
|
|
948
|
+
payload.description = trimmedDescription;
|
|
949
|
+
}
|
|
950
|
+
const reviewerList = Array.isArray(req.body?.reviewers) ? req.body.reviewers : [];
|
|
951
|
+
const payloadReviewers = reviewerList
|
|
952
|
+
.map((entry) => ({ id: String(entry?.id ?? ''), isRequired: !!entry?.required }))
|
|
953
|
+
.filter((entry) => entry.id);
|
|
954
|
+
if (payloadReviewers.length) {
|
|
955
|
+
payload.reviewers = payloadReviewers;
|
|
956
|
+
}
|
|
957
|
+
const workItemList = Array.isArray(req.body?.workItems) ? req.body.workItems : [];
|
|
958
|
+
const payloadWorkItems = workItemList
|
|
959
|
+
.map((id) => Number(id))
|
|
960
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
961
|
+
if (payloadWorkItems.length) {
|
|
962
|
+
payload.workItems = payloadWorkItems.map((id) => ({ id }));
|
|
963
|
+
}
|
|
964
|
+
const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
|
|
965
|
+
if (result.status < 200 || result.status >= 300) {
|
|
966
|
+
return resp
|
|
967
|
+
.status(400)
|
|
968
|
+
.type('application/json')
|
|
969
|
+
.send({
|
|
970
|
+
error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
const created = JSON.parse(result.body);
|
|
974
|
+
// Tags (labels) are a separate resource; add them after the PR exists.
|
|
975
|
+
const labelNames = (Array.isArray(req.body?.labels) ? req.body.labels : [])
|
|
976
|
+
.map((name) => String(name ?? '').trim())
|
|
977
|
+
.filter(Boolean);
|
|
978
|
+
const warnings = [];
|
|
979
|
+
if (labelNames.length && created.pullRequestId) {
|
|
980
|
+
// Built from the prefix: prUrl already carries the api-version query.
|
|
981
|
+
const labelsUrl = `${prefix}/_git/${encodeURIComponent(info.repo)}` +
|
|
982
|
+
`/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
|
|
983
|
+
for (const name of labelNames) {
|
|
984
|
+
try {
|
|
985
|
+
const labelResult = await azureRequest('POST', labelsUrl, JSON.stringify({ name }));
|
|
986
|
+
if (labelResult.status < 200 || labelResult.status >= 300) {
|
|
987
|
+
warnings.push(`Could not add tag "${name}" (HTTP ${labelResult.status}).`);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
catch (err) {
|
|
991
|
+
warnings.push(`Could not add tag "${name}": ${err.message}`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
return resp.type('application/json').send({
|
|
996
|
+
id: created.pullRequestId,
|
|
997
|
+
url: created._links?.web?.href ?? '',
|
|
998
|
+
branch: source,
|
|
999
|
+
...(warnings.length ? { warnings } : {}),
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
catch (err) {
|
|
1003
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1004
|
+
}
|
|
1005
|
+
});
|
|
1006
|
+
// ==================== Azure DevOps PR metadata ====================
|
|
1007
|
+
// Identity search for the reviewer pickers (the endpoint the Azure DevOps
|
|
1008
|
+
// web UI itself uses; undocumented but stable across TFS 2018+).
|
|
1009
|
+
app.get('/api/azure-devops/reviewers', async (req, resp) => {
|
|
1010
|
+
try {
|
|
1011
|
+
const query = String((req.query ?? {}).query ?? '').trim();
|
|
1012
|
+
if (!query) {
|
|
1013
|
+
return resp.type('application/json').send({ reviewers: [] });
|
|
1014
|
+
}
|
|
1015
|
+
const { prefix } = await azureProjectContext();
|
|
1016
|
+
const url = `${prefix}/_apis/identitypicker/identities?api-version=${AZURE_API_VERSION}` +
|
|
1017
|
+
'&identityTypes=msa-uuid,umd&queryType=nameAndEmail&searchFilter=' +
|
|
1018
|
+
`&showImage=False&filterValue=${encodeURIComponent(query)}`;
|
|
1019
|
+
const result = await azureRequest('GET', url, '');
|
|
1020
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1021
|
+
return resp
|
|
1022
|
+
.status(400)
|
|
1023
|
+
.type('application/json')
|
|
1024
|
+
.send({
|
|
1025
|
+
error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
const data = JSON.parse(result.body);
|
|
1029
|
+
const list = Array.isArray(data) ? data : (data.results ?? data.identities ?? []);
|
|
1030
|
+
const reviewers = list
|
|
1031
|
+
.map((entry) => ({
|
|
1032
|
+
id: String(entry?.id ?? entry?.identity?.id ?? ''),
|
|
1033
|
+
label: String(entry?.label ?? entry?.displayName ?? entry?.name ?? ''),
|
|
1034
|
+
description: entry?.description ? String(entry.description) : undefined,
|
|
1035
|
+
}))
|
|
1036
|
+
.filter((entry) => entry.id && entry.label);
|
|
1037
|
+
return resp.type('application/json').send({ reviewers });
|
|
1038
|
+
}
|
|
1039
|
+
catch (err) {
|
|
1040
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1041
|
+
}
|
|
1042
|
+
});
|
|
1043
|
+
// Work item search by id or title: a WIQL query returns ids, a batch read
|
|
1044
|
+
// then fetches title/state for the first matches.
|
|
1045
|
+
app.get('/api/azure-devops/workitems', async (req, resp) => {
|
|
1046
|
+
try {
|
|
1047
|
+
const query = String((req.query ?? {}).query ?? '').trim();
|
|
1048
|
+
if (!query) {
|
|
1049
|
+
return resp.type('application/json').send({ workItems: [] });
|
|
1050
|
+
}
|
|
1051
|
+
const { prefix } = await azureProjectContext();
|
|
1052
|
+
const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
|
|
1053
|
+
if (/^\d+$/.test(query)) {
|
|
1054
|
+
clauses.push(`[System.Id] = '${query}'`);
|
|
1055
|
+
}
|
|
1056
|
+
const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
|
|
1057
|
+
`WHERE ${clauses.map((clause) => `(${clause})`).join(' OR ')} ` +
|
|
1058
|
+
'ORDER BY [System.ChangedDate] DESC';
|
|
1059
|
+
const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
|
|
1060
|
+
if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
|
|
1061
|
+
return resp
|
|
1062
|
+
.status(400)
|
|
1063
|
+
.type('application/json')
|
|
1064
|
+
.send({
|
|
1065
|
+
error: azureErrorMessage(wiqlResult) || `Azure DevOps returned HTTP ${wiqlResult.status}.`,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
const ids = (JSON.parse(wiqlResult.body).workItems ?? [])
|
|
1069
|
+
.map((entry) => Number(entry?.id))
|
|
1070
|
+
.filter((id) => Number.isInteger(id) && id > 0)
|
|
1071
|
+
.slice(0, 20);
|
|
1072
|
+
if (!ids.length) {
|
|
1073
|
+
return resp.type('application/json').send({ workItems: [] });
|
|
1074
|
+
}
|
|
1075
|
+
const batchResult = await azureRequest('GET', `${prefix}/_apis/wit/workitems?ids=${ids.join(',')}` +
|
|
1076
|
+
`&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, '');
|
|
1077
|
+
if (batchResult.status < 200 || batchResult.status >= 300) {
|
|
1078
|
+
return resp
|
|
1079
|
+
.status(400)
|
|
1080
|
+
.type('application/json')
|
|
1081
|
+
.send({
|
|
1082
|
+
error: azureErrorMessage(batchResult) || `Azure DevOps returned HTTP ${batchResult.status}.`,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
const workItems = (JSON.parse(batchResult.body).value ?? []).map((item) => ({
|
|
1086
|
+
id: Number(item?.id),
|
|
1087
|
+
title: String(item?.fields?.['System.Title'] ?? ''),
|
|
1088
|
+
state: String(item?.fields?.['System.State'] ?? ''),
|
|
1089
|
+
}));
|
|
1090
|
+
return resp.type('application/json').send({ workItems });
|
|
1091
|
+
}
|
|
1092
|
+
catch (err) {
|
|
1093
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1094
|
+
}
|
|
1095
|
+
});
|
|
1096
|
+
// Work item tag names for the tag autocomplete.
|
|
1097
|
+
app.get('/api/azure-devops/tags', async (_req, resp) => {
|
|
1098
|
+
try {
|
|
1099
|
+
const { prefix } = await azureProjectContext();
|
|
1100
|
+
const result = await azureRequest('GET', `${prefix}/_apis/wit/tags?api-version=${AZURE_API_VERSION}`, '');
|
|
1101
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1102
|
+
return resp
|
|
1103
|
+
.status(400)
|
|
1104
|
+
.type('application/json')
|
|
1105
|
+
.send({
|
|
1106
|
+
error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
const tags = (JSON.parse(result.body).value ?? [])
|
|
1110
|
+
.map((tag) => String(tag?.name ?? '').trim())
|
|
1111
|
+
.filter(Boolean);
|
|
1112
|
+
return resp.type('application/json').send({ tags });
|
|
1113
|
+
}
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1116
|
+
}
|
|
1117
|
+
});
|
|
608
1118
|
// ==================== Commit Detail ====================
|
|
609
1119
|
app.post('/api/commit/detail', async (req, resp) => {
|
|
610
1120
|
try {
|
|
@@ -620,7 +1130,9 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
620
1130
|
'--format=%H%x00%aI%x00%s%x00%b%x00%aN%x00%aE%x00%P',
|
|
621
1131
|
hash,
|
|
622
1132
|
]);
|
|
623
|
-
const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
|
|
1133
|
+
const [fullHash, date, message, body, authorName, authorEmail, parents] = raw
|
|
1134
|
+
.trimEnd()
|
|
1135
|
+
.split('\u0000');
|
|
624
1136
|
return resp.type('application/json').send({
|
|
625
1137
|
hash: fullHash,
|
|
626
1138
|
date,
|
|
@@ -664,57 +1176,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
664
1176
|
// ==================== Working Changes ====================
|
|
665
1177
|
app.get('/api/working-changes', async (_req, resp) => {
|
|
666
1178
|
try {
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
const rawDiff = await git.raw(['diff', 'HEAD', '--no-color', '--find-renames']);
|
|
670
|
-
const files = parseUnifiedDiff(rawDiff);
|
|
671
|
-
// Untracked files are not part of `git diff` — add them as new files.
|
|
672
|
-
const status = await git.status();
|
|
673
|
-
for (const file of status.not_added ?? []) {
|
|
674
|
-
let isBinary = false;
|
|
675
|
-
let content = '';
|
|
676
|
-
try {
|
|
677
|
-
const buffer = await readFile(join(root, file));
|
|
678
|
-
if (buffer.includes(0)) {
|
|
679
|
-
isBinary = true;
|
|
680
|
-
}
|
|
681
|
-
else {
|
|
682
|
-
content = buffer.toString('utf8');
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
catch {
|
|
686
|
-
content = '';
|
|
687
|
-
}
|
|
688
|
-
if (isBinary) {
|
|
689
|
-
files.push({
|
|
690
|
-
path: file,
|
|
691
|
-
oldPath: '',
|
|
692
|
-
status: 'binary',
|
|
693
|
-
lines: [],
|
|
694
|
-
additions: 0,
|
|
695
|
-
deletions: 0,
|
|
696
|
-
});
|
|
697
|
-
}
|
|
698
|
-
else {
|
|
699
|
-
const lines = content.length > 0 ? content.split('\n') : [];
|
|
700
|
-
files.push({
|
|
701
|
-
path: file,
|
|
702
|
-
oldPath: '',
|
|
703
|
-
status: 'added',
|
|
704
|
-
lines: lines.map((text, index) => ({ type: 'add', newLine: index + 1, text })),
|
|
705
|
-
additions: lines.length,
|
|
706
|
-
deletions: 0,
|
|
707
|
-
});
|
|
708
|
-
}
|
|
709
|
-
}
|
|
710
|
-
const stagedRaw = await git.raw(['diff', '--name-only', '--cached']);
|
|
711
|
-
const unstagedRaw = await git.raw(['diff', '--name-only']);
|
|
712
|
-
return resp.type('application/json').send({
|
|
713
|
-
files,
|
|
714
|
-
staged: stagedRaw.split('\n').filter(Boolean),
|
|
715
|
-
unstaged: unstagedRaw.split('\n').filter(Boolean),
|
|
716
|
-
untracked: status.not_added ?? [],
|
|
717
|
-
});
|
|
1179
|
+
await mutationQueue;
|
|
1180
|
+
return resp.type('application/json').send(await working.snapshot());
|
|
718
1181
|
}
|
|
719
1182
|
catch (err) {
|
|
720
1183
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -750,7 +1213,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
750
1213
|
return resp.type('application/json').send({ content: '' });
|
|
751
1214
|
}
|
|
752
1215
|
try {
|
|
753
|
-
const content = await git.raw(['show', `${ref}:${path}`]);
|
|
1216
|
+
const content = await git.raw(['show', ref === 'INDEX' ? `:${path}` : `${ref}:${path}`]);
|
|
754
1217
|
if (content.includes('\u0000')) {
|
|
755
1218
|
return resp.type('application/json').send({ binary: true, content: '' });
|
|
756
1219
|
}
|
|
@@ -768,7 +1231,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
768
1231
|
// ==================== Discard ====================
|
|
769
1232
|
app.post('/api/discard', async (req, resp) => {
|
|
770
1233
|
try {
|
|
771
|
-
const { files } = req.body;
|
|
1234
|
+
const { files, mode } = req.body;
|
|
772
1235
|
if (!Array.isArray(files) || files.length === 0) {
|
|
773
1236
|
return resp.status(400).type('application/json').send({ error: 'files required' });
|
|
774
1237
|
}
|
|
@@ -778,7 +1241,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
778
1241
|
const tracked = files.filter((file) => !untracked.has(file));
|
|
779
1242
|
const removed = files.filter((file) => untracked.has(file));
|
|
780
1243
|
if (tracked.length > 0) {
|
|
781
|
-
|
|
1244
|
+
if (mode === 'unstaged') {
|
|
1245
|
+
// Restore from the index so staged changes survive; unmerged (conflicted)
|
|
1246
|
+
// paths cannot be restored from the index and fall back to HEAD.
|
|
1247
|
+
const conflicted = new Set(status.conflicted ?? []);
|
|
1248
|
+
const restorable = tracked.filter((file) => !conflicted.has(file));
|
|
1249
|
+
const unmerged = tracked.filter((file) => conflicted.has(file));
|
|
1250
|
+
if (restorable.length > 0)
|
|
1251
|
+
await git.raw(['checkout', '--', ...restorable]);
|
|
1252
|
+
if (unmerged.length > 0)
|
|
1253
|
+
await git.raw(['checkout', 'HEAD', '--', ...unmerged]);
|
|
1254
|
+
}
|
|
1255
|
+
else {
|
|
1256
|
+
await git.raw(['checkout', 'HEAD', '--', ...tracked]);
|
|
1257
|
+
}
|
|
782
1258
|
}
|
|
783
1259
|
for (const file of removed) {
|
|
784
1260
|
await rm(join(root, file), { force: true, recursive: true });
|