guito 0.6.0 → 0.6.2
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 +37 -0
- package/bin/avatars.js +35 -0
- package/bin/guito-server.js +573 -7
- package/bin/repository-state.js +25 -0
- package/bin/ui/browser/index.html +1 -1
- package/bin/ui/browser/main-JDFRJ57Z.js +6 -0
- package/package.json +1 -1
- package/bin/ui/browser/main-GMXCXTX7.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';
|
|
@@ -9,8 +10,177 @@ import fastifyCompress from '@fastify/compress';
|
|
|
9
10
|
import { simpleGit } from 'simple-git';
|
|
10
11
|
import { promisify } from 'node:util';
|
|
11
12
|
import { workingTree } from './working-tree.js';
|
|
13
|
+
import { avatarCache } from './avatars.js';
|
|
14
|
+
import { repositoryState } from './repository-state.js';
|
|
12
15
|
const execFileAsync = promisify(execFile);
|
|
13
|
-
|
|
16
|
+
const AZURE_API_VERSION = '5.0';
|
|
17
|
+
/**
|
|
18
|
+
* Extracts {projectPath, repo} from an Azure DevOps git remote URL.
|
|
19
|
+
* Supports https (with optional credentials) and ssh (scp-style and ssh://)
|
|
20
|
+
* forms, a trailing .git, and URL-encoded segments such as %20.
|
|
21
|
+
*/
|
|
22
|
+
export function parseAzureRemoteUrl(remoteUrl) {
|
|
23
|
+
const value = (remoteUrl ?? '').trim();
|
|
24
|
+
if (!value) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
let path;
|
|
28
|
+
const urlMatch = value.match(/^[a-z][a-z0-9+.\-]*:\/\/[^/]*\/(.*)$/i);
|
|
29
|
+
if (urlMatch) {
|
|
30
|
+
path = urlMatch[1];
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
// scp-style ssh: git@host:Collection/Project/_git/Repo(.git)
|
|
34
|
+
const scpMatch = value.match(/^[^@/]+@[^:]+:(.+)$/);
|
|
35
|
+
if (!scpMatch) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
path = scpMatch[1];
|
|
39
|
+
}
|
|
40
|
+
path = path.replace(/\.git\/?$/i, '');
|
|
41
|
+
try {
|
|
42
|
+
path = decodeURIComponent(path);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Keep the raw path when it contains invalid percent-escapes.
|
|
46
|
+
}
|
|
47
|
+
const gitIndex = path.toLowerCase().lastIndexOf('/_git/');
|
|
48
|
+
if (gitIndex < 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const projectPath = path.slice(0, gitIndex).replace(/^\/+|\/+$/g, '');
|
|
52
|
+
const repo = path.slice(gitIndex + '/_git/'.length).replace(/^\/+|\/+$/g, '');
|
|
53
|
+
if (!projectPath || !repo) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
return { projectPath, repo };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Builds the encoded "{origin}[/{collection}]/{project}" prefix for Azure
|
|
60
|
+
* DevOps REST calls. When the base URL already contains the collection
|
|
61
|
+
* prefix of the remote path it is not duplicated.
|
|
62
|
+
*/
|
|
63
|
+
export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
|
|
64
|
+
const base = (baseUrl ?? '').trim().replace(/\/+$/, '');
|
|
65
|
+
if (!base) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const info = parseAzureRemoteUrl(remoteUrl);
|
|
69
|
+
if (!info) {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const encodePath = (value) => value
|
|
73
|
+
.split('/')
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.map((segment) => encodeURIComponent(segment))
|
|
76
|
+
.join('/');
|
|
77
|
+
try {
|
|
78
|
+
const parsed = new URL(base);
|
|
79
|
+
const decode = (path) => path
|
|
80
|
+
.split('/')
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.map((segment) => decodeURIComponent(segment));
|
|
83
|
+
const baseParts = decode(parsed.pathname);
|
|
84
|
+
const remoteParts = info.projectPath.split('/').filter(Boolean);
|
|
85
|
+
let overlap = Math.min(baseParts.length, remoteParts.length);
|
|
86
|
+
while (overlap &&
|
|
87
|
+
!baseParts
|
|
88
|
+
.slice(-overlap)
|
|
89
|
+
.every((part, index) => part.toLowerCase() === remoteParts[index].toLowerCase()))
|
|
90
|
+
overlap--;
|
|
91
|
+
return `${parsed.origin}/${[...baseParts, ...remoteParts.slice(overlap)].map(encodeURIComponent).join('/')}`;
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return `${base}/${encodePath(info.projectPath)}`;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Builds the pullrequests REST URL from the server base URL and origin remote. */
|
|
98
|
+
export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
|
|
99
|
+
const prefix = buildAzureProjectPrefix(baseUrl, remoteUrl);
|
|
100
|
+
if (!prefix) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const info = parseAzureRemoteUrl(remoteUrl);
|
|
104
|
+
if (!info) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Default Azure DevOps HTTP runner: shells out to the Windows built-in
|
|
111
|
+
* curl.exe with NTLM + Negotiate and an empty username, which authenticates
|
|
112
|
+
* with the current Windows session (SSPI single sign-on).
|
|
113
|
+
*/
|
|
114
|
+
function defaultAzureRequest(method, url, body) {
|
|
115
|
+
return new Promise((resolveRequest, rejectRequest) => {
|
|
116
|
+
const args = [
|
|
117
|
+
'-sS',
|
|
118
|
+
'--ntlm',
|
|
119
|
+
'--negotiate',
|
|
120
|
+
'-u',
|
|
121
|
+
':',
|
|
122
|
+
'--max-time',
|
|
123
|
+
'60',
|
|
124
|
+
'-w',
|
|
125
|
+
'\n%{http_code}',
|
|
126
|
+
'-X',
|
|
127
|
+
method,
|
|
128
|
+
];
|
|
129
|
+
if (method === 'POST') {
|
|
130
|
+
args.push('-H', 'Content-Type: application/json', '--data-binary', '@-');
|
|
131
|
+
}
|
|
132
|
+
args.push(url);
|
|
133
|
+
const child = spawn('curl.exe', args, { windowsHide: true });
|
|
134
|
+
let stdout = '';
|
|
135
|
+
let stderr = '';
|
|
136
|
+
child.stdout.on('data', (chunk) => {
|
|
137
|
+
stdout += chunk;
|
|
138
|
+
});
|
|
139
|
+
child.stderr.on('data', (chunk) => {
|
|
140
|
+
stderr += chunk;
|
|
141
|
+
});
|
|
142
|
+
// EPIPE when curl exits before reading the whole body; the close handler
|
|
143
|
+
// reports the real failure.
|
|
144
|
+
child.stdin.on('error', () => { });
|
|
145
|
+
child.on('error', (err) => {
|
|
146
|
+
rejectRequest(new Error(`Failed to run curl.exe (Windows integrated auth requires Windows): ${err.message}`));
|
|
147
|
+
});
|
|
148
|
+
child.on('close', (code) => {
|
|
149
|
+
if (code !== 0) {
|
|
150
|
+
rejectRequest(new Error(stderr.trim() || `curl.exe exited with code ${code}.`));
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// -w appends "\n<http_code>" after the response body.
|
|
154
|
+
const separator = stdout.lastIndexOf('\n');
|
|
155
|
+
const status = Number.parseInt(stdout.slice(separator + 1), 10);
|
|
156
|
+
if (separator < 0 || !Number.isFinite(status)) {
|
|
157
|
+
rejectRequest(new Error('Unexpected curl.exe output (missing HTTP status).'));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
resolveRequest({ status, body: stdout.slice(0, separator) });
|
|
161
|
+
});
|
|
162
|
+
child.stdin.end(body, 'utf8');
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const randomBranchSuffix = () => {
|
|
166
|
+
const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
167
|
+
const bytes = randomBytes(6);
|
|
168
|
+
let suffix = '';
|
|
169
|
+
for (const byte of bytes) {
|
|
170
|
+
suffix += alphabet[byte % alphabet.length];
|
|
171
|
+
}
|
|
172
|
+
return suffix;
|
|
173
|
+
};
|
|
174
|
+
/** Extracts Azure DevOps' human-readable error message from a REST response. */
|
|
175
|
+
const azureErrorMessage = (result) => {
|
|
176
|
+
try {
|
|
177
|
+
return String(JSON.parse(result.body)?.message ?? '');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return result.body;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, autoReload, azureRequestImpl, avatarFetchImpl, }) {
|
|
14
184
|
// Initialize server
|
|
15
185
|
const app = fastify({
|
|
16
186
|
logger: false,
|
|
@@ -47,6 +217,27 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
47
217
|
}
|
|
48
218
|
// Initialize git lib
|
|
49
219
|
const git = simpleGit(repositoryPath);
|
|
220
|
+
const avatars = avatarCache(avatarFetchImpl);
|
|
221
|
+
app.get('/api/avatar', async (req, reply) => {
|
|
222
|
+
const email = String(req.query?.email ?? '').trim();
|
|
223
|
+
if (!email || email.length > 320)
|
|
224
|
+
return reply.code(400).send({ error: 'Valid email required' });
|
|
225
|
+
const image = await avatars(email);
|
|
226
|
+
reply.header('Cache-Control', 'private, max-age=3600');
|
|
227
|
+
return image ? reply.type(image.contentType).send(image.data) : reply.code(204).send();
|
|
228
|
+
});
|
|
229
|
+
let stateRequest;
|
|
230
|
+
app.get('/api/repository-state', async (_req, reply) => {
|
|
231
|
+
try {
|
|
232
|
+
stateRequest ?? (stateRequest = repositoryState(git, repositoryPath).finally(() => {
|
|
233
|
+
stateRequest = undefined;
|
|
234
|
+
}));
|
|
235
|
+
return await stateRequest;
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
return reply.code(400).send({ error: err.message });
|
|
239
|
+
}
|
|
240
|
+
});
|
|
50
241
|
// ==================== Diff parsing ====================
|
|
51
242
|
function parseUnifiedDiff(rawDiff) {
|
|
52
243
|
const files = [];
|
|
@@ -138,11 +329,88 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
138
329
|
return result;
|
|
139
330
|
};
|
|
140
331
|
const repoRoot = async () => (await git.revparse(['--show-toplevel'])).trim();
|
|
332
|
+
// ==================== Azure DevOps settings ====================
|
|
333
|
+
// Settings live in the repository's git directory so they are per-repo and
|
|
334
|
+
// never committed. The VS Code extension passes its own setting via
|
|
335
|
+
// azureDevOpsUrl, which wins over the file.
|
|
336
|
+
let settingsPath = '';
|
|
337
|
+
try {
|
|
338
|
+
const gitDir = (await git.revparse(['--absolute-git-dir'])).trim();
|
|
339
|
+
settingsPath = join(gitDir, 'guito-settings.json');
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
settingsPath = join(repositoryPath, '.git', 'guito-settings.json');
|
|
343
|
+
}
|
|
344
|
+
const readSettings = async () => {
|
|
345
|
+
try {
|
|
346
|
+
return JSON.parse(await readFile(settingsPath, 'utf8'));
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return {};
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
const writeSettings = async (settings) => {
|
|
353
|
+
await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
|
|
354
|
+
};
|
|
355
|
+
const azureRequest = azureRequestImpl ?? defaultAzureRequest;
|
|
356
|
+
const configuredAzureUrl = azureDevOpsUrl?.trim() ?? '';
|
|
357
|
+
const effectiveAzureUrl = async () => {
|
|
358
|
+
if (configuredAzureUrl) {
|
|
359
|
+
return { url: configuredAzureUrl, source: 'vscode' };
|
|
360
|
+
}
|
|
361
|
+
const settings = await readSettings();
|
|
362
|
+
const url = typeof settings.azureDevOpsUrl === 'string' ? settings.azureDevOpsUrl.trim() : '';
|
|
363
|
+
return { url, source: url ? 'file' : '' };
|
|
364
|
+
};
|
|
365
|
+
/** Resolves the Azure DevOps project prefix or throws a user-facing error. */
|
|
366
|
+
const azureProjectContext = async () => {
|
|
367
|
+
const effective = await effectiveAzureUrl();
|
|
368
|
+
if (!effective.url) {
|
|
369
|
+
throw new Error('Azure DevOps URL is not configured. Set it in Settings (gear icon) or the guito.azureDevOpsUrl VS Code setting.');
|
|
370
|
+
}
|
|
371
|
+
let remoteUrl = '';
|
|
372
|
+
try {
|
|
373
|
+
remoteUrl = (await git.remote(['get-url', 'origin'])).trim();
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
throw new Error('No "origin" remote is configured for this repository.');
|
|
377
|
+
}
|
|
378
|
+
const prefix = buildAzureProjectPrefix(effective.url, remoteUrl);
|
|
379
|
+
if (!prefix) {
|
|
380
|
+
throw new Error(`The origin remote does not point to an Azure DevOps project: ${remoteUrl}`);
|
|
381
|
+
}
|
|
382
|
+
return { prefix, remoteUrl };
|
|
383
|
+
};
|
|
384
|
+
/** Merges the VS Code settings with the server-side settings file. */
|
|
385
|
+
const effectiveSettings = async () => {
|
|
386
|
+
const [file, azure] = await Promise.all([readSettings(), effectiveAzureUrl()]);
|
|
387
|
+
const fileAutoReload = typeof file.autoReload === 'boolean' ? file.autoReload : undefined;
|
|
388
|
+
return {
|
|
389
|
+
azureDevOpsUrl: azure.url,
|
|
390
|
+
source: azure.source,
|
|
391
|
+
autoReload: typeof autoReload === 'boolean' ? autoReload : (fileAutoReload ?? true),
|
|
392
|
+
};
|
|
393
|
+
};
|
|
394
|
+
const configuredIdentity = async () => {
|
|
395
|
+
const config = await git.listConfig();
|
|
396
|
+
return {
|
|
397
|
+
name: String(config.all['user.name'] ?? '').trim(),
|
|
398
|
+
email: String(config.all['user.email'] ?? '').trim(),
|
|
399
|
+
};
|
|
400
|
+
};
|
|
401
|
+
const authorIdentity = (name, email, identity) => ({
|
|
402
|
+
author_name: identity.name && identity.email && email.trim().toLowerCase() === identity.email.toLowerCase()
|
|
403
|
+
? identity.name
|
|
404
|
+
: name,
|
|
405
|
+
author_email: email,
|
|
406
|
+
});
|
|
141
407
|
// ==================== Repository ====================
|
|
142
408
|
app.get('/api/repo', async (_req, resp) => {
|
|
143
409
|
try {
|
|
144
410
|
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
145
|
-
return resp
|
|
411
|
+
return resp
|
|
412
|
+
.type('application/json')
|
|
413
|
+
.send({ root, name: basename(root), identity: await configuredIdentity() });
|
|
146
414
|
}
|
|
147
415
|
catch (err) {
|
|
148
416
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -177,12 +445,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
177
445
|
if (Number.isFinite(skip) && skip > 0) {
|
|
178
446
|
options['--skip'] = String(skip);
|
|
179
447
|
}
|
|
180
|
-
const [log, count] = await Promise.all([
|
|
448
|
+
const [log, count, identity] = await Promise.all([
|
|
181
449
|
git.log(options),
|
|
182
450
|
git.raw(['rev-list', '--count', '--all']),
|
|
451
|
+
configuredIdentity(),
|
|
183
452
|
]);
|
|
184
453
|
const commits = log.all.map((commit) => ({
|
|
185
454
|
...commit,
|
|
455
|
+
...authorIdentity(commit.author_name, commit.author_email, identity),
|
|
186
456
|
parents: String(commit.parents ?? '')
|
|
187
457
|
.split(' ')
|
|
188
458
|
.filter(Boolean),
|
|
@@ -639,6 +909,303 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
639
909
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
640
910
|
}
|
|
641
911
|
});
|
|
912
|
+
// ==================== Settings ====================
|
|
913
|
+
app.get('/api/settings', async (_req, resp) => {
|
|
914
|
+
try {
|
|
915
|
+
const settings = await effectiveSettings();
|
|
916
|
+
return resp.type('application/json').send(settings);
|
|
917
|
+
}
|
|
918
|
+
catch (err) {
|
|
919
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
app.post('/api/settings', async (req, resp) => {
|
|
923
|
+
try {
|
|
924
|
+
const url = String(req.body?.azureDevOpsUrl ?? '').trim();
|
|
925
|
+
if (url && !/^https?:\/\//i.test(url)) {
|
|
926
|
+
return resp
|
|
927
|
+
.status(400)
|
|
928
|
+
.type('application/json')
|
|
929
|
+
.send({ error: 'The Azure DevOps URL must start with http:// or https://.' });
|
|
930
|
+
}
|
|
931
|
+
const settings = await readSettings();
|
|
932
|
+
settings.azureDevOpsUrl = url;
|
|
933
|
+
await writeSettings(settings);
|
|
934
|
+
const effective = await effectiveSettings();
|
|
935
|
+
return resp.type('application/json').send(effective);
|
|
936
|
+
}
|
|
937
|
+
catch (err) {
|
|
938
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
939
|
+
}
|
|
940
|
+
});
|
|
941
|
+
// ==================== Azure DevOps Pull Requests ====================
|
|
942
|
+
app.post('/api/azure-devops/pullrequest', async (req, resp) => {
|
|
943
|
+
try {
|
|
944
|
+
const { sourceBranch, targetBranch, title, description, newBranch } = req.body ?? {};
|
|
945
|
+
if (!targetBranch) {
|
|
946
|
+
return resp.status(400).type('application/json').send({ error: 'targetBranch required' });
|
|
947
|
+
}
|
|
948
|
+
const { prefix, remoteUrl } = await azureProjectContext();
|
|
949
|
+
const info = parseAzureRemoteUrl(remoteUrl);
|
|
950
|
+
if (!info) {
|
|
951
|
+
return resp
|
|
952
|
+
.status(400)
|
|
953
|
+
.type('application/json')
|
|
954
|
+
.send({ error: 'Unable to build the Azure DevOps pull request URL.' });
|
|
955
|
+
}
|
|
956
|
+
const prUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
957
|
+
let source = String(sourceBranch ?? '').trim();
|
|
958
|
+
if (newBranch) {
|
|
959
|
+
// Create a remote-only branch from the current HEAD; the local
|
|
960
|
+
// repository and the checked-out branch stay untouched.
|
|
961
|
+
const name = `pr/${randomBranchSuffix()}`;
|
|
962
|
+
await git.push(['origin', `HEAD:refs/heads/${name}`]);
|
|
963
|
+
source = name;
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
if (!source) {
|
|
967
|
+
return resp.status(400).type('application/json').send({ error: 'sourceBranch required' });
|
|
968
|
+
}
|
|
969
|
+
if (source.startsWith('origin/')) {
|
|
970
|
+
// Remote-tracking branch: the PR source is the branch name on the remote.
|
|
971
|
+
source = source.slice('origin/'.length);
|
|
972
|
+
}
|
|
973
|
+
else {
|
|
974
|
+
// Publish the branch first when the remote does not have it yet.
|
|
975
|
+
// The remote-tracking ref is a local check; a redundant push is a
|
|
976
|
+
// harmless no-op ("Everything up-to-date").
|
|
977
|
+
// rev-parse --verify --quiet does not fail reliably through
|
|
978
|
+
// simple-git, so list the tracking refs instead.
|
|
979
|
+
const trackingRefs = await git.raw([
|
|
980
|
+
'for-each-ref',
|
|
981
|
+
'refs/remotes/origin',
|
|
982
|
+
'--format=%(refname)',
|
|
983
|
+
]);
|
|
984
|
+
const published = trackingRefs
|
|
985
|
+
.split('\n')
|
|
986
|
+
.some((line) => line.trim() === `refs/remotes/origin/${source}`);
|
|
987
|
+
if (!published) {
|
|
988
|
+
await git.push(['origin', source]);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
const payload = {
|
|
993
|
+
isDraft: req.body?.isDraft === true,
|
|
994
|
+
sourceRefName: `refs/heads/${source}`,
|
|
995
|
+
targetRefName: `refs/heads/${String(targetBranch)
|
|
996
|
+
.replace(/^refs\/heads\//, '')
|
|
997
|
+
.replace(/^origin\//, '')}`,
|
|
998
|
+
};
|
|
999
|
+
const trimmedTitle = String(title ?? '').trim();
|
|
1000
|
+
const trimmedDescription = String(description ?? '').trim();
|
|
1001
|
+
if (trimmedTitle) {
|
|
1002
|
+
payload.title = trimmedTitle;
|
|
1003
|
+
}
|
|
1004
|
+
if (trimmedDescription) {
|
|
1005
|
+
payload.description = trimmedDescription;
|
|
1006
|
+
}
|
|
1007
|
+
const reviewerList = Array.isArray(req.body?.reviewers) ? req.body.reviewers : [];
|
|
1008
|
+
const payloadReviewers = reviewerList
|
|
1009
|
+
.map((entry) => ({ id: String(entry?.id ?? ''), isRequired: !!entry?.required }))
|
|
1010
|
+
.filter((entry) => entry.id);
|
|
1011
|
+
if (payloadReviewers.length) {
|
|
1012
|
+
payload.reviewers = payloadReviewers;
|
|
1013
|
+
}
|
|
1014
|
+
const workItemList = Array.isArray(req.body?.workItems) ? req.body.workItems : [];
|
|
1015
|
+
const payloadWorkItems = workItemList
|
|
1016
|
+
.map((id) => Number(id))
|
|
1017
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
1018
|
+
if (payloadWorkItems.length) {
|
|
1019
|
+
payload.workItemRefs = payloadWorkItems.map((id) => ({ id: String(id) }));
|
|
1020
|
+
}
|
|
1021
|
+
const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
|
|
1022
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1023
|
+
return resp
|
|
1024
|
+
.status(400)
|
|
1025
|
+
.type('application/json')
|
|
1026
|
+
.send({
|
|
1027
|
+
error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const created = JSON.parse(result.body);
|
|
1031
|
+
// Tags (labels) are a separate resource; add them after the PR exists.
|
|
1032
|
+
const labelNames = (Array.isArray(req.body?.labels) ? req.body.labels : [])
|
|
1033
|
+
.map((name) => String(name ?? '').trim())
|
|
1034
|
+
.filter(Boolean);
|
|
1035
|
+
const warnings = [];
|
|
1036
|
+
if (labelNames.length && created.pullRequestId) {
|
|
1037
|
+
// Built from the prefix: prUrl already carries the api-version query.
|
|
1038
|
+
const labelsUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}` +
|
|
1039
|
+
`/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
|
|
1040
|
+
for (const name of labelNames) {
|
|
1041
|
+
try {
|
|
1042
|
+
const labelResult = await azureRequest('POST', labelsUrl, JSON.stringify({ name }));
|
|
1043
|
+
if (labelResult.status < 200 || labelResult.status >= 300) {
|
|
1044
|
+
warnings.push(`Could not add tag "${name}" (HTTP ${labelResult.status}).`);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
catch (err) {
|
|
1048
|
+
warnings.push(`Could not add tag "${name}": ${err.message}`);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return resp.type('application/json').send({
|
|
1053
|
+
id: created.pullRequestId,
|
|
1054
|
+
url: created._links?.web?.href ?? '',
|
|
1055
|
+
branch: source,
|
|
1056
|
+
...(warnings.length ? { warnings } : {}),
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
catch (err) {
|
|
1060
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1061
|
+
}
|
|
1062
|
+
});
|
|
1063
|
+
// ==================== Azure DevOps PR metadata ====================
|
|
1064
|
+
// Collection-scoped identity IDs can be passed directly as PR reviewers.
|
|
1065
|
+
app.get('/api/azure-devops/reviewers', async (req, resp) => {
|
|
1066
|
+
try {
|
|
1067
|
+
const query = String((req.query ?? {}).query ?? '').trim();
|
|
1068
|
+
if (!query) {
|
|
1069
|
+
return resp.type('application/json').send({ reviewers: [] });
|
|
1070
|
+
}
|
|
1071
|
+
const { prefix } = await azureProjectContext();
|
|
1072
|
+
const collection = prefix.slice(0, prefix.lastIndexOf('/'));
|
|
1073
|
+
const identityBase = collection.replace('https://dev.azure.com/', 'https://vssps.dev.azure.com/');
|
|
1074
|
+
const url = `${identityBase}/_apis/identities?api-version=5.0` +
|
|
1075
|
+
`&searchFilter=General&filterValue=${encodeURIComponent(query)}&queryMembership=None`;
|
|
1076
|
+
const result = await azureRequest('GET', url, '');
|
|
1077
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1078
|
+
return resp
|
|
1079
|
+
.status(400)
|
|
1080
|
+
.type('application/json')
|
|
1081
|
+
.send({
|
|
1082
|
+
error: azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
const data = JSON.parse(result.body);
|
|
1086
|
+
const list = Array.isArray(data) ? data : (data.value ?? data.identities ?? []);
|
|
1087
|
+
const reviewers = list
|
|
1088
|
+
.map((entry) => ({
|
|
1089
|
+
id: String(entry?.id ?? entry?.identity?.id ?? ''),
|
|
1090
|
+
label: String(entry?.customDisplayName ||
|
|
1091
|
+
entry?.providerDisplayName ||
|
|
1092
|
+
entry?.label ||
|
|
1093
|
+
entry?.displayName ||
|
|
1094
|
+
entry?.name ||
|
|
1095
|
+
''),
|
|
1096
|
+
description: entry?.properties?.Mail?.$value ??
|
|
1097
|
+
entry?.properties?.Account?.$value ??
|
|
1098
|
+
entry?.description ??
|
|
1099
|
+
undefined,
|
|
1100
|
+
}))
|
|
1101
|
+
.filter((entry) => entry.id && entry.label);
|
|
1102
|
+
return resp.type('application/json').send({ reviewers });
|
|
1103
|
+
}
|
|
1104
|
+
catch (err) {
|
|
1105
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
// Work item search by id or title: a WIQL query returns ids, a batch read
|
|
1109
|
+
// then fetches title/state for the first matches.
|
|
1110
|
+
app.get('/api/azure-devops/workitems', async (req, resp) => {
|
|
1111
|
+
try {
|
|
1112
|
+
const query = String((req.query ?? {}).query ?? '')
|
|
1113
|
+
.trim()
|
|
1114
|
+
.replace(/^#(?=\d+$)/, '');
|
|
1115
|
+
if (!query) {
|
|
1116
|
+
return resp.type('application/json').send({ workItems: [] });
|
|
1117
|
+
}
|
|
1118
|
+
const { prefix } = await azureProjectContext();
|
|
1119
|
+
const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
|
|
1120
|
+
if (/^\d+$/.test(query)) {
|
|
1121
|
+
clauses.push(`[System.Id] = ${Number(query)}`);
|
|
1122
|
+
}
|
|
1123
|
+
const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
|
|
1124
|
+
`WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(' OR ')}) ` +
|
|
1125
|
+
'ORDER BY [System.ChangedDate] DESC';
|
|
1126
|
+
const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
|
|
1127
|
+
if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
|
|
1128
|
+
return resp
|
|
1129
|
+
.status(400)
|
|
1130
|
+
.type('application/json')
|
|
1131
|
+
.send({
|
|
1132
|
+
error: azureErrorMessage(wiqlResult) || `Azure DevOps returned HTTP ${wiqlResult.status}.`,
|
|
1133
|
+
});
|
|
1134
|
+
}
|
|
1135
|
+
const ids = (JSON.parse(wiqlResult.body).workItems ?? [])
|
|
1136
|
+
.map((entry) => Number(entry?.id))
|
|
1137
|
+
.filter((id) => Number.isInteger(id) && id > 0)
|
|
1138
|
+
.slice(0, 20);
|
|
1139
|
+
if (!ids.length) {
|
|
1140
|
+
return resp.type('application/json').send({ workItems: [] });
|
|
1141
|
+
}
|
|
1142
|
+
const batchResult = await azureRequest('GET', `${prefix}/_apis/wit/workitems?ids=${ids.join(',')}` +
|
|
1143
|
+
`&fields=System.Id,System.Title,System.State&api-version=${AZURE_API_VERSION}`, '');
|
|
1144
|
+
if (batchResult.status < 200 || batchResult.status >= 300) {
|
|
1145
|
+
return resp
|
|
1146
|
+
.status(400)
|
|
1147
|
+
.type('application/json')
|
|
1148
|
+
.send({
|
|
1149
|
+
error: azureErrorMessage(batchResult) || `Azure DevOps returned HTTP ${batchResult.status}.`,
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
const workItems = (JSON.parse(batchResult.body).value ?? []).map((item) => ({
|
|
1153
|
+
id: Number(item?.id),
|
|
1154
|
+
title: String(item?.fields?.['System.Title'] ?? ''),
|
|
1155
|
+
state: String(item?.fields?.['System.State'] ?? ''),
|
|
1156
|
+
}));
|
|
1157
|
+
return resp.type('application/json').send({ workItems });
|
|
1158
|
+
}
|
|
1159
|
+
catch (err) {
|
|
1160
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1161
|
+
}
|
|
1162
|
+
});
|
|
1163
|
+
// Existing pull request labels for the tag autocomplete.
|
|
1164
|
+
app.get('/api/azure-devops/tags', async (_req, resp) => {
|
|
1165
|
+
try {
|
|
1166
|
+
const { prefix, remoteUrl } = await azureProjectContext();
|
|
1167
|
+
const repo = parseAzureRemoteUrl(remoteUrl).repo;
|
|
1168
|
+
// PR labels are distinct from Azure Boards work item tags.
|
|
1169
|
+
const names = new Set();
|
|
1170
|
+
for (let skip = 0;; skip += 100) {
|
|
1171
|
+
const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}/pullrequests` +
|
|
1172
|
+
`?searchCriteria.status=all&$top=100&$skip=${skip}&api-version=${AZURE_API_VERSION}`, '');
|
|
1173
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1174
|
+
throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
|
|
1175
|
+
}
|
|
1176
|
+
const data = JSON.parse(result.body);
|
|
1177
|
+
const requests = Array.isArray(data) ? data : (data.value ?? []);
|
|
1178
|
+
// Some Server releases omit labels from the PR list response.
|
|
1179
|
+
// Read those resources explicitly, with at most five requests in flight.
|
|
1180
|
+
for (let offset = 0; offset < requests.length; offset += 5) {
|
|
1181
|
+
const labels = await Promise.all(requests.slice(offset, offset + 5).map(async (request) => {
|
|
1182
|
+
if (Array.isArray(request.labels))
|
|
1183
|
+
return request.labels;
|
|
1184
|
+
if (!Number.isInteger(request.pullRequestId))
|
|
1185
|
+
return [];
|
|
1186
|
+
const result = await azureRequest('GET', `${prefix}/_apis/git/repositories/${encodeURIComponent(repo)}` +
|
|
1187
|
+
`/pullrequests/${request.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`, '');
|
|
1188
|
+
if (result.status < 200 || result.status >= 300) {
|
|
1189
|
+
throw new Error(azureErrorMessage(result) || `Azure DevOps returned HTTP ${result.status}.`);
|
|
1190
|
+
}
|
|
1191
|
+
const data = JSON.parse(result.body);
|
|
1192
|
+
return Array.isArray(data) ? data : (data.value ?? []);
|
|
1193
|
+
}));
|
|
1194
|
+
for (const label of labels.flat()) {
|
|
1195
|
+
if (typeof label.name === 'string' && label.name.trim())
|
|
1196
|
+
names.add(label.name.trim());
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
if (requests.length < 100)
|
|
1200
|
+
break;
|
|
1201
|
+
}
|
|
1202
|
+
const tags = [...names].sort((a, b) => a.localeCompare(b));
|
|
1203
|
+
return resp.type('application/json').send({ tags });
|
|
1204
|
+
}
|
|
1205
|
+
catch (err) {
|
|
1206
|
+
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1207
|
+
}
|
|
1208
|
+
});
|
|
642
1209
|
// ==================== Commit Detail ====================
|
|
643
1210
|
app.post('/api/commit/detail', async (req, resp) => {
|
|
644
1211
|
try {
|
|
@@ -663,8 +1230,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
663
1230
|
message,
|
|
664
1231
|
refs: '',
|
|
665
1232
|
body: body ?? '',
|
|
666
|
-
|
|
667
|
-
author_email: authorEmail,
|
|
1233
|
+
...authorIdentity(authorName, authorEmail, await configuredIdentity()),
|
|
668
1234
|
parents: String(parents ?? '')
|
|
669
1235
|
.split(' ')
|
|
670
1236
|
.filter(Boolean),
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
// Git resolves the per-worktree HEAD/index and shared refs for us. This also
|
|
5
|
+
// handles atomic ref replacement, packed refs and changes made by other tools.
|
|
6
|
+
export async function repositoryState(git, root) {
|
|
7
|
+
const [refs, head, branch, status, config, mailmap, index] = await Promise.all([
|
|
8
|
+
git.raw(['for-each-ref', '--format=%(refname) %(objectname)']),
|
|
9
|
+
git.revparse(['--verify', 'HEAD']).catch(() => ''),
|
|
10
|
+
git.raw(['symbolic-ref', '--quiet', 'HEAD']).catch(() => ''),
|
|
11
|
+
git.status(),
|
|
12
|
+
git.listConfig(),
|
|
13
|
+
readFile(resolve(root, '.mailmap'), 'utf8').catch(() => ''),
|
|
14
|
+
git.raw(['diff', '--cached', '--raw', '-z', '--no-abbrev']),
|
|
15
|
+
]);
|
|
16
|
+
const files = await Promise.all(status.files.map(async (file) => {
|
|
17
|
+
const info = await stat(resolve(root, file.path)).catch(() => null);
|
|
18
|
+
return [file, info?.mtimeMs, info?.ctimeMs, info?.size];
|
|
19
|
+
}));
|
|
20
|
+
const digest = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
21
|
+
return {
|
|
22
|
+
history: digest([refs, head, branch, config.all, mailmap]),
|
|
23
|
+
working: digest([files, index]),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
<style>:root{--bg:#1e1e1e;--bg-alt:#232323;--bg-hover:#2a2d2e;--border:#3c3c3c;--text:#cccccc;--text-bright:#e8e8e8;--text-dim:#9d9d9d;--accent:#3794ff;--red:#f14c4c;--mono:ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace}*{box-sizing:border-box}html,body{height:100%;margin:0}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;font-size:13px;color:var(--text);background:var(--bg);overflow:hidden}</style><link rel="stylesheet" href="styles-I7K2YZTW.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" href="styles-I7K2YZTW.css"></noscript></head>
|
|
10
10
|
<body>
|
|
11
11
|
<app-root></app-root>
|
|
12
|
-
<script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-
|
|
12
|
+
<script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-JDFRJ57Z.js" type="module"></script></body>
|
|
13
13
|
</html>
|