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.
@@ -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
- export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 8080, apiToken, }) {
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.type('application/json').send({ root, name: basename(root) });
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
- author_name: authorName,
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-GMXCXTX7.js" type="module"></script></body>
12
+ <script src="polyfills-5CFQRCPP.js" type="module"></script><script src="main-JDFRJ57Z.js" type="module"></script></body>
13
13
  </html>