guito 0.6.0 → 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
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
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.1] - 2026-09-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Rebase the current branch onto any branch from the pull menu. The new "Rebase (from)..." entry opens a branch picker with origin/main as the default choice, and the option list scrolls when a repository has many branches.
|
|
10
|
+
- Force push from the push menu. The red "Push (force)" entry asks for confirmation before overwriting the remote branch with the local history.
|
|
11
|
+
- Create pull requests on an on-prem Azure DevOps Server from the push menu. After setting the server URL with the new settings button (or the guito.azureDevOpsUrl VS Code setting), a "Create Pull Request..." entry opens a dialog with title, description, source and target branches, and shows a link to the created pull request when it is created.
|
|
12
|
+
- Fill pull requests with optional metadata: required and optional reviewers, linked work items searched by id or title, and tags, all with autocomplete suggestions from the Azure DevOps API.
|
|
13
|
+
- Create the pull request from a new remote branch with a random name without leaving the current branch, and publish the source branch to the remote automatically when it has not been pushed yet.
|
|
14
|
+
- Authenticate to Azure DevOps with Windows integrated authentication, so no credentials are stored or entered for on-prem servers.
|
|
15
|
+
|
|
5
16
|
## [0.6.0] - 2026-09-07
|
|
6
17
|
|
|
7
18
|
### Added
|
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';
|
|
@@ -10,7 +11,166 @@ import { simpleGit } from 'simple-git';
|
|
|
10
11
|
import { promisify } from 'node:util';
|
|
11
12
|
import { workingTree } from './working-tree.js';
|
|
12
13
|
const execFileAsync = promisify(execFile);
|
|
13
|
-
|
|
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, }) {
|
|
14
174
|
// Initialize server
|
|
15
175
|
const app = fastify({
|
|
16
176
|
logger: false,
|
|
@@ -138,6 +298,58 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
138
298
|
return result;
|
|
139
299
|
};
|
|
140
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
|
+
};
|
|
141
353
|
// ==================== Repository ====================
|
|
142
354
|
app.get('/api/repo', async (_req, resp) => {
|
|
143
355
|
try {
|
|
@@ -639,6 +851,270 @@ export async function startGuitoServer({ repositoryPath, uiRoot, host, port = 80
|
|
|
639
851
|
return resp.status(400).type('application/json').send({ error: err.message });
|
|
640
852
|
}
|
|
641
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
|
+
});
|
|
642
1118
|
// ==================== Commit Detail ====================
|
|
643
1119
|
app.post('/api/commit/detail', async (req, resp) => {
|
|
644
1120
|
try {
|
|
@@ -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-SN64JYAQ.js" type="module"></script></body>
|
|
13
13
|
</html>
|