guito 0.6.1 → 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 +26 -0
- package/bin/avatars.js +35 -0
- package/bin/guito-server.js +139 -49
- 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-SN64JYAQ.js +0 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Guito are documented in this file. The project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html), and this changelog is structured around [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
4
4
|
|
|
5
|
+
## [0.6.2] - 2026-09-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Show author avatars in the commit table and the commit detail pane. Avatars are looked up by the author email on Gravatar, cached for a day, and fall back to initials when no image is available.
|
|
10
|
+
- Refresh Guito automatically when the repository changes. A lightweight probe runs every few seconds and whenever the window regains focus, detects commits, branches, tags, configuration, mailmap, and working tree changes made outside Guito (for example from a terminal or another tool), and then refreshes the history and the Uncommitted changes panel. History pages that were already loaded are kept, and the selected branch and commit stay valid after the refresh.
|
|
11
|
+
- Resize the Description column of the commit table by dragging, like the other columns. The chosen width is persisted across sessions together with the other column widths.
|
|
12
|
+
- Create draft pull requests with a "Create as draft" option in the Create Pull Request dialog.
|
|
13
|
+
- Move the commit search to the far right of the Guito title bar. The search box with its clear button, match counter, and previous/next navigation now sits next to the title instead of in the toolbar.
|
|
14
|
+
- Add the guito.autoReload setting (enabled by default) that controls whether Guito refreshes automatically as described above; it can also be set per repository in the server-side settings file.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Fill the window width with the commit table on load and when the window is resized: the Description column widens to absorb spare room, while resizing a column changes only that column and may leave the table narrower until the window is resized again. The saved widths act as minimums on narrow windows.
|
|
19
|
+
- The Uncommitted changes row shows your configured Git user name instead of "You", and commits display the author name from your Git configuration when the commit email matches it, while other authors and mailmap-rewritten names are left untouched.
|
|
20
|
+
- Tag suggestions in the Create Pull Request dialog now list labels already used by the repository's pull requests instead of Azure Boards work item tags.
|
|
21
|
+
- Reviewer, work item, and tag lookups in the Create Pull Request dialog show their own loading state and error message, cancel outdated requests while typing, and show a "No matches" hint instead of an empty list.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Build pull request and pull request label URLs through the Azure DevOps repositories API so they resolve on Azure DevOps Server.
|
|
26
|
+
- Link work items to pull requests using the refs format Azure DevOps expects, and scope work item search to the current team project, accepting `#id` queries and returning at most 20 results.
|
|
27
|
+
- Strip `origin/` and `refs/heads/` prefixes from the pull request target branch before creating the pull request.
|
|
28
|
+
- Build Azure DevOps base URLs without duplicating collection path segments when the server URL path overlaps the remote repository path.
|
|
29
|
+
- Search reviewers through the collection identities API so the returned identities can be used directly as pull request reviewers.
|
|
30
|
+
|
|
5
31
|
## [0.6.1] - 2026-09-08
|
|
6
32
|
|
|
7
33
|
### Added
|
package/bin/avatars.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
export function avatarCache(fetchImage = fetch) {
|
|
3
|
+
const entries = new Map();
|
|
4
|
+
return (email) => {
|
|
5
|
+
const hash = createHash('sha256').update(email.trim().toLowerCase()).digest('hex');
|
|
6
|
+
const cached = entries.get(hash);
|
|
7
|
+
if (cached && cached.expires > Date.now())
|
|
8
|
+
return cached.image;
|
|
9
|
+
if (entries.size >= 500)
|
|
10
|
+
entries.delete(entries.keys().next().value);
|
|
11
|
+
const entry = { expires: Date.now() + 86400000, image: Promise.resolve(null) };
|
|
12
|
+
entry.image = (async () => {
|
|
13
|
+
try {
|
|
14
|
+
const response = await fetchImage(`https://www.gravatar.com/avatar/${hash}?s=64&d=404`, {
|
|
15
|
+
signal: AbortSignal.timeout(5000),
|
|
16
|
+
});
|
|
17
|
+
if (response.status === 404)
|
|
18
|
+
return null;
|
|
19
|
+
if (!response.ok || !/^image\/(png|jpeg|gif|webp)/i.test(response.headers.get('content-type') ?? '')) {
|
|
20
|
+
throw new Error('Avatar unavailable');
|
|
21
|
+
}
|
|
22
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
23
|
+
if (buffer.length > 256000)
|
|
24
|
+
throw new Error('Avatar too large');
|
|
25
|
+
return { data: buffer, contentType: response.headers.get('content-type').split(';')[0] };
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
entry.expires = Date.now() + 60000;
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
})();
|
|
32
|
+
entries.set(hash, entry);
|
|
33
|
+
return entry.image;
|
|
34
|
+
};
|
|
35
|
+
}
|
package/bin/guito-server.js
CHANGED
|
@@ -10,8 +10,10 @@ import fastifyCompress from '@fastify/compress';
|
|
|
10
10
|
import { simpleGit } from 'simple-git';
|
|
11
11
|
import { promisify } from 'node:util';
|
|
12
12
|
import { workingTree } from './working-tree.js';
|
|
13
|
+
import { avatarCache } from './avatars.js';
|
|
14
|
+
import { repositoryState } from './repository-state.js';
|
|
13
15
|
const execFileAsync = promisify(execFile);
|
|
14
|
-
const AZURE_API_VERSION = '
|
|
16
|
+
const AZURE_API_VERSION = '5.0';
|
|
15
17
|
/**
|
|
16
18
|
* Extracts {projectPath, repo} from an Azure DevOps git remote URL.
|
|
17
19
|
* Supports https (with optional credentials) and ssh (scp-style and ssh://)
|
|
@@ -74,11 +76,19 @@ export function buildAzureProjectPrefix(baseUrl, remoteUrl) {
|
|
|
74
76
|
.join('/');
|
|
75
77
|
try {
|
|
76
78
|
const parsed = new URL(base);
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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('/')}`;
|
|
82
92
|
}
|
|
83
93
|
catch {
|
|
84
94
|
return `${base}/${encodePath(info.projectPath)}`;
|
|
@@ -94,7 +104,7 @@ export function buildAzurePullRequestUrl(baseUrl, remoteUrl) {
|
|
|
94
104
|
if (!info) {
|
|
95
105
|
return null;
|
|
96
106
|
}
|
|
97
|
-
return `${prefix}/
|
|
107
|
+
return `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
98
108
|
}
|
|
99
109
|
/**
|
|
100
110
|
* Default Azure DevOps HTTP runner: shells out to the Windows built-in
|
|
@@ -170,7 +180,7 @@ const azureErrorMessage = (result) => {
|
|
|
170
180
|
return result.body;
|
|
171
181
|
}
|
|
172
182
|
};
|
|
173
|
-
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, azureRequestImpl, }) {
|
|
183
|
+
export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, azureDevOpsUrl, autoReload, azureRequestImpl, avatarFetchImpl, }) {
|
|
174
184
|
// Initialize server
|
|
175
185
|
const app = fastify({
|
|
176
186
|
logger: false,
|
|
@@ -207,6 +217,27 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
207
217
|
}
|
|
208
218
|
// Initialize git lib
|
|
209
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
|
+
});
|
|
210
241
|
// ==================== Diff parsing ====================
|
|
211
242
|
function parseUnifiedDiff(rawDiff) {
|
|
212
243
|
const files = [];
|
|
@@ -350,11 +381,36 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
350
381
|
}
|
|
351
382
|
return { prefix, remoteUrl };
|
|
352
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
|
+
});
|
|
353
407
|
// ==================== Repository ====================
|
|
354
408
|
app.get('/api/repo', async (_req, resp) => {
|
|
355
409
|
try {
|
|
356
410
|
const root = (await git.revparse(['--show-toplevel'])).trim();
|
|
357
|
-
return resp
|
|
411
|
+
return resp
|
|
412
|
+
.type('application/json')
|
|
413
|
+
.send({ root, name: basename(root), identity: await configuredIdentity() });
|
|
358
414
|
}
|
|
359
415
|
catch (err) {
|
|
360
416
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -389,12 +445,14 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
389
445
|
if (Number.isFinite(skip) && skip > 0) {
|
|
390
446
|
options['--skip'] = String(skip);
|
|
391
447
|
}
|
|
392
|
-
const [log, count] = await Promise.all([
|
|
448
|
+
const [log, count, identity] = await Promise.all([
|
|
393
449
|
git.log(options),
|
|
394
450
|
git.raw(['rev-list', '--count', '--all']),
|
|
451
|
+
configuredIdentity(),
|
|
395
452
|
]);
|
|
396
453
|
const commits = log.all.map((commit) => ({
|
|
397
454
|
...commit,
|
|
455
|
+
...authorIdentity(commit.author_name, commit.author_email, identity),
|
|
398
456
|
parents: String(commit.parents ?? '')
|
|
399
457
|
.split(' ')
|
|
400
458
|
.filter(Boolean),
|
|
@@ -854,10 +912,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
854
912
|
// ==================== Settings ====================
|
|
855
913
|
app.get('/api/settings', async (_req, resp) => {
|
|
856
914
|
try {
|
|
857
|
-
const
|
|
858
|
-
return resp
|
|
859
|
-
.type('application/json')
|
|
860
|
-
.send({ azureDevOpsUrl: effective.url, source: effective.source });
|
|
915
|
+
const settings = await effectiveSettings();
|
|
916
|
+
return resp.type('application/json').send(settings);
|
|
861
917
|
}
|
|
862
918
|
catch (err) {
|
|
863
919
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -875,10 +931,8 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
875
931
|
const settings = await readSettings();
|
|
876
932
|
settings.azureDevOpsUrl = url;
|
|
877
933
|
await writeSettings(settings);
|
|
878
|
-
const effective = await
|
|
879
|
-
return resp
|
|
880
|
-
.type('application/json')
|
|
881
|
-
.send({ azureDevOpsUrl: effective.url, source: effective.source });
|
|
934
|
+
const effective = await effectiveSettings();
|
|
935
|
+
return resp.type('application/json').send(effective);
|
|
882
936
|
}
|
|
883
937
|
catch (err) {
|
|
884
938
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
@@ -899,7 +953,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
899
953
|
.type('application/json')
|
|
900
954
|
.send({ error: 'Unable to build the Azure DevOps pull request URL.' });
|
|
901
955
|
}
|
|
902
|
-
const prUrl = `${prefix}/
|
|
956
|
+
const prUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}/pullrequests?api-version=${AZURE_API_VERSION}`;
|
|
903
957
|
let source = String(sourceBranch ?? '').trim();
|
|
904
958
|
if (newBranch) {
|
|
905
959
|
// Create a remote-only branch from the current HEAD; the local
|
|
@@ -936,8 +990,11 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
936
990
|
}
|
|
937
991
|
}
|
|
938
992
|
const payload = {
|
|
993
|
+
isDraft: req.body?.isDraft === true,
|
|
939
994
|
sourceRefName: `refs/heads/${source}`,
|
|
940
|
-
targetRefName: `refs/heads/${targetBranch
|
|
995
|
+
targetRefName: `refs/heads/${String(targetBranch)
|
|
996
|
+
.replace(/^refs\/heads\//, '')
|
|
997
|
+
.replace(/^origin\//, '')}`,
|
|
941
998
|
};
|
|
942
999
|
const trimmedTitle = String(title ?? '').trim();
|
|
943
1000
|
const trimmedDescription = String(description ?? '').trim();
|
|
@@ -959,7 +1016,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
959
1016
|
.map((id) => Number(id))
|
|
960
1017
|
.filter((id) => Number.isInteger(id) && id > 0);
|
|
961
1018
|
if (payloadWorkItems.length) {
|
|
962
|
-
payload.
|
|
1019
|
+
payload.workItemRefs = payloadWorkItems.map((id) => ({ id: String(id) }));
|
|
963
1020
|
}
|
|
964
1021
|
const result = await azureRequest('POST', prUrl, JSON.stringify(payload));
|
|
965
1022
|
if (result.status < 200 || result.status >= 300) {
|
|
@@ -978,7 +1035,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
978
1035
|
const warnings = [];
|
|
979
1036
|
if (labelNames.length && created.pullRequestId) {
|
|
980
1037
|
// Built from the prefix: prUrl already carries the api-version query.
|
|
981
|
-
const labelsUrl = `${prefix}/
|
|
1038
|
+
const labelsUrl = `${prefix}/_apis/git/repositories/${encodeURIComponent(info.repo)}` +
|
|
982
1039
|
`/pullrequests/${created.pullRequestId}/labels?api-version=${AZURE_API_VERSION}`;
|
|
983
1040
|
for (const name of labelNames) {
|
|
984
1041
|
try {
|
|
@@ -1004,8 +1061,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1004
1061
|
}
|
|
1005
1062
|
});
|
|
1006
1063
|
// ==================== Azure DevOps PR metadata ====================
|
|
1007
|
-
//
|
|
1008
|
-
// web UI itself uses; undocumented but stable across TFS 2018+).
|
|
1064
|
+
// Collection-scoped identity IDs can be passed directly as PR reviewers.
|
|
1009
1065
|
app.get('/api/azure-devops/reviewers', async (req, resp) => {
|
|
1010
1066
|
try {
|
|
1011
1067
|
const query = String((req.query ?? {}).query ?? '').trim();
|
|
@@ -1013,9 +1069,10 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1013
1069
|
return resp.type('application/json').send({ reviewers: [] });
|
|
1014
1070
|
}
|
|
1015
1071
|
const { prefix } = await azureProjectContext();
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
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`;
|
|
1019
1076
|
const result = await azureRequest('GET', url, '');
|
|
1020
1077
|
if (result.status < 200 || result.status >= 300) {
|
|
1021
1078
|
return resp
|
|
@@ -1026,12 +1083,20 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1026
1083
|
});
|
|
1027
1084
|
}
|
|
1028
1085
|
const data = JSON.parse(result.body);
|
|
1029
|
-
const list = Array.isArray(data) ? data : (data.
|
|
1086
|
+
const list = Array.isArray(data) ? data : (data.value ?? data.identities ?? []);
|
|
1030
1087
|
const reviewers = list
|
|
1031
1088
|
.map((entry) => ({
|
|
1032
1089
|
id: String(entry?.id ?? entry?.identity?.id ?? ''),
|
|
1033
|
-
label: String(entry?.
|
|
1034
|
-
|
|
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,
|
|
1035
1100
|
}))
|
|
1036
1101
|
.filter((entry) => entry.id && entry.label);
|
|
1037
1102
|
return resp.type('application/json').send({ reviewers });
|
|
@@ -1044,19 +1109,21 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1044
1109
|
// then fetches title/state for the first matches.
|
|
1045
1110
|
app.get('/api/azure-devops/workitems', async (req, resp) => {
|
|
1046
1111
|
try {
|
|
1047
|
-
const query = String((req.query ?? {}).query ?? '')
|
|
1112
|
+
const query = String((req.query ?? {}).query ?? '')
|
|
1113
|
+
.trim()
|
|
1114
|
+
.replace(/^#(?=\d+$)/, '');
|
|
1048
1115
|
if (!query) {
|
|
1049
1116
|
return resp.type('application/json').send({ workItems: [] });
|
|
1050
1117
|
}
|
|
1051
1118
|
const { prefix } = await azureProjectContext();
|
|
1052
1119
|
const clauses = [`[System.Title] CONTAINS '${query.replace(/'/g, "''")}'`];
|
|
1053
1120
|
if (/^\d+$/.test(query)) {
|
|
1054
|
-
clauses.push(`[System.Id] =
|
|
1121
|
+
clauses.push(`[System.Id] = ${Number(query)}`);
|
|
1055
1122
|
}
|
|
1056
1123
|
const wiql = 'SELECT [System.Id], [System.Title], [System.State] FROM WorkItems ' +
|
|
1057
|
-
`WHERE ${clauses.map((clause) => `(${clause})`).join(' OR ')} ` +
|
|
1124
|
+
`WHERE [System.TeamProject] = @project AND (${clauses.map((clause) => `(${clause})`).join(' OR ')}) ` +
|
|
1058
1125
|
'ORDER BY [System.ChangedDate] DESC';
|
|
1059
|
-
const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql
|
|
1126
|
+
const wiqlResult = await azureRequest('POST', `${prefix}/_apis/wit/wiql?$top=20&api-version=${AZURE_API_VERSION}`, JSON.stringify({ query: wiql }));
|
|
1060
1127
|
if (wiqlResult.status < 200 || wiqlResult.status >= 300) {
|
|
1061
1128
|
return resp
|
|
1062
1129
|
.status(400)
|
|
@@ -1093,22 +1160,46 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1093
1160
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
1094
1161
|
}
|
|
1095
1162
|
});
|
|
1096
|
-
//
|
|
1163
|
+
// Existing pull request labels for the tag autocomplete.
|
|
1097
1164
|
app.get('/api/azure-devops/tags', async (_req, resp) => {
|
|
1098
1165
|
try {
|
|
1099
|
-
const { prefix } = await azureProjectContext();
|
|
1100
|
-
const
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
.
|
|
1106
|
-
|
|
1107
|
-
|
|
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;
|
|
1108
1201
|
}
|
|
1109
|
-
const tags =
|
|
1110
|
-
.map((tag) => String(tag?.name ?? '').trim())
|
|
1111
|
-
.filter(Boolean);
|
|
1202
|
+
const tags = [...names].sort((a, b) => a.localeCompare(b));
|
|
1112
1203
|
return resp.type('application/json').send({ tags });
|
|
1113
1204
|
}
|
|
1114
1205
|
catch (err) {
|
|
@@ -1139,8 +1230,7 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
1139
1230
|
message,
|
|
1140
1231
|
refs: '',
|
|
1141
1232
|
body: body ?? '',
|
|
1142
|
-
|
|
1143
|
-
author_email: authorEmail,
|
|
1233
|
+
...authorIdentity(authorName, authorEmail, await configuredIdentity()),
|
|
1144
1234
|
parents: String(parents ?? '')
|
|
1145
1235
|
.split(' ')
|
|
1146
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>
|