engineering-memory 1.11.27 → 1.11.28
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/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/git/git-inspector.js +27 -8
- package/runtime/dist/src/localization/catalogue.generated.js +44 -0
- package/runtime/dist/src/mcp/delivery-tools.js +56 -0
- package/runtime/dist/src/mcp/tool-annotations.js +2 -0
- package/runtime/dist/src/mcp/tool-definitions.js +2 -0
- package/runtime/dist/src/providers/gitlab-token-page.js +226 -0
- package/runtime/dist/src/providers/gitlab.js +417 -0
- package/runtime/dist/src/runtime/bridge-service.js +97 -2
- package/runtime/dist/src/runtime/create-bridge-service.js +16 -2
- package/runtime/dist/src/runtime/merge-request-sync.js +281 -0
- package/runtime/dist/src/runtime/principal-state.js +4 -1
- package/skill/references/lifecycle.md +2 -2
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
import { principalFingerprint } from '../runtime/principal-state.js';
|
|
5
|
+
import { readJson, removeFile, writeJson } from '../utilities/files.js';
|
|
6
|
+
import { sha256 } from '../utilities/hash.js';
|
|
7
|
+
export function gitLabRemote(remote) {
|
|
8
|
+
const trimmed = remote.trim();
|
|
9
|
+
const scpLike = /^(?:[^@/:]+@)?([^/:]+):(.+)$/.exec(trimmed);
|
|
10
|
+
let scheme = 'ssh';
|
|
11
|
+
let hostname;
|
|
12
|
+
let port = '';
|
|
13
|
+
let path;
|
|
14
|
+
if (scpLike &&
|
|
15
|
+
!/^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(trimmed) &&
|
|
16
|
+
!/^[A-Za-z]:[\\/]/.test(trimmed)) {
|
|
17
|
+
hostname = scpLike[1].toLowerCase();
|
|
18
|
+
path = scpLike[2];
|
|
19
|
+
}
|
|
20
|
+
else if (URL.canParse(trimmed)) {
|
|
21
|
+
const url = new URL(trimmed);
|
|
22
|
+
scheme = url.protocol.slice(0, -1).toLowerCase();
|
|
23
|
+
hostname = url.hostname.toLowerCase();
|
|
24
|
+
port = url.port;
|
|
25
|
+
path = url.pathname;
|
|
26
|
+
}
|
|
27
|
+
else
|
|
28
|
+
return { refusal: 'local' };
|
|
29
|
+
if (!hostname || scheme === 'file')
|
|
30
|
+
return { refusal: 'local' };
|
|
31
|
+
if (isIP(hostname.replace(/^\[|\]$/g, '')))
|
|
32
|
+
return { refusal: 'ip' };
|
|
33
|
+
if (hostname === 'github.com' || hostname.endsWith('.github.com'))
|
|
34
|
+
return { refusal: 'github' };
|
|
35
|
+
const segments = path
|
|
36
|
+
.replace(/^\/+|\/+$/g, '')
|
|
37
|
+
.replace(/\.git$/i, '')
|
|
38
|
+
.split('/');
|
|
39
|
+
if (segments.length < 2 || !segments.every(pathSegment))
|
|
40
|
+
return { refusal: 'unsupported' };
|
|
41
|
+
if (scheme === 'https' || scheme === 'http') {
|
|
42
|
+
const host = port ? `${hostname}:${port}` : hostname;
|
|
43
|
+
return {
|
|
44
|
+
host,
|
|
45
|
+
address: scheme === 'http' && !loopback(hostname) ? `https://${hostname}` : `${scheme}://${host}`,
|
|
46
|
+
path: segments.join('/'),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (['ssh', 'git+ssh', 'ssh+git', 'git'].includes(scheme))
|
|
50
|
+
return { host: hostname, address: `https://${hostname}`, path: segments.join('/') };
|
|
51
|
+
return { refusal: 'unsupported' };
|
|
52
|
+
}
|
|
53
|
+
export function gitLabAddress(value) {
|
|
54
|
+
const trimmed = value.trim();
|
|
55
|
+
if (trimmed.length > 300 || !URL.canParse(trimmed))
|
|
56
|
+
return null;
|
|
57
|
+
const url = new URL(trimmed);
|
|
58
|
+
const hostname = url.hostname.toLowerCase();
|
|
59
|
+
const path = url.pathname.replace(/\/+$/, '');
|
|
60
|
+
if ((url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback(hostname))) ||
|
|
61
|
+
url.username ||
|
|
62
|
+
url.password ||
|
|
63
|
+
url.search ||
|
|
64
|
+
url.hash ||
|
|
65
|
+
isIP(hostname.replace(/^\[|\]$/g, '')) ||
|
|
66
|
+
hostname === 'github.com' ||
|
|
67
|
+
hostname.endsWith('.github.com') ||
|
|
68
|
+
!path.split('/').slice(1).every(pathSegment))
|
|
69
|
+
return null;
|
|
70
|
+
return `${url.protocol}//${url.host.toLowerCase()}${path}`;
|
|
71
|
+
}
|
|
72
|
+
export function projectPath(address, path) {
|
|
73
|
+
const prefix = new URL(address).pathname.replace(/^\/+|\/+$/g, '');
|
|
74
|
+
return prefix && path.startsWith(prefix + '/') ? path.slice(prefix.length + 1) : path;
|
|
75
|
+
}
|
|
76
|
+
function pathSegment(segment) {
|
|
77
|
+
return /^[\w.-]+$/.test(segment) && segment !== '.' && segment !== '..';
|
|
78
|
+
}
|
|
79
|
+
function loopback(hostname) {
|
|
80
|
+
return hostname === 'localhost' || hostname.endsWith('.localhost');
|
|
81
|
+
}
|
|
82
|
+
export class GitLabError extends Error {
|
|
83
|
+
failure;
|
|
84
|
+
address;
|
|
85
|
+
status;
|
|
86
|
+
detail;
|
|
87
|
+
constructor(failure, address, status, detail) {
|
|
88
|
+
super(`GitLab at ${address}: ${failure}${status ? ` (${status})` : ''}${detail ? `: ${detail}` : ''}`);
|
|
89
|
+
this.failure = failure;
|
|
90
|
+
this.address = address;
|
|
91
|
+
this.status = status;
|
|
92
|
+
this.detail = detail;
|
|
93
|
+
this.name = 'GitLabError';
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const certificateCodes = new Set([
|
|
97
|
+
'UNABLE_TO_VERIFY_LEAF_SIGNATURE',
|
|
98
|
+
'UNABLE_TO_GET_ISSUER_CERT',
|
|
99
|
+
'UNABLE_TO_GET_ISSUER_CERT_LOCALLY',
|
|
100
|
+
'SELF_SIGNED_CERT_IN_CHAIN',
|
|
101
|
+
'DEPTH_ZERO_SELF_SIGNED_CERT',
|
|
102
|
+
'CERT_HAS_EXPIRED',
|
|
103
|
+
'CERT_NOT_YET_VALID',
|
|
104
|
+
'CERT_UNTRUSTED',
|
|
105
|
+
'CERT_SIGNATURE_FAILURE',
|
|
106
|
+
'ERR_TLS_CERT_ALTNAME_INVALID',
|
|
107
|
+
]);
|
|
108
|
+
const connectCodes = new Set([
|
|
109
|
+
'ENOTFOUND',
|
|
110
|
+
'EAI_AGAIN',
|
|
111
|
+
'ECONNREFUSED',
|
|
112
|
+
'EHOSTUNREACH',
|
|
113
|
+
'ENETUNREACH',
|
|
114
|
+
'EADDRNOTAVAIL',
|
|
115
|
+
'UND_ERR_CONNECT_TIMEOUT',
|
|
116
|
+
]);
|
|
117
|
+
function networkCode(error) {
|
|
118
|
+
let current = error;
|
|
119
|
+
for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth++) {
|
|
120
|
+
const code = current.code;
|
|
121
|
+
if (typeof code === 'string')
|
|
122
|
+
return code;
|
|
123
|
+
const tried = current.errors;
|
|
124
|
+
current = current.cause ?? (Array.isArray(tried) ? tried[0] : null);
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
function gitLabMessage(body) {
|
|
129
|
+
if (!body)
|
|
130
|
+
return null;
|
|
131
|
+
let message = body;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(body);
|
|
134
|
+
message = parsed.message ?? parsed.error ?? null;
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const text = typeof message === 'string' ? message : message ? JSON.stringify(message) : '';
|
|
140
|
+
return text.replace(/[\u0000-\u001f\u007f]+/g, ' ').slice(0, 200) || null;
|
|
141
|
+
}
|
|
142
|
+
export const mergeRequestSchema = z.object({
|
|
143
|
+
iid: z.number().int().positive(),
|
|
144
|
+
web_url: z.string().min(1).max(500),
|
|
145
|
+
state: z.string(),
|
|
146
|
+
draft: z.boolean().optional(),
|
|
147
|
+
work_in_progress: z.boolean().optional(),
|
|
148
|
+
sha: z.string().nullish(),
|
|
149
|
+
source_branch: z.string(),
|
|
150
|
+
target_branch: z.string(),
|
|
151
|
+
project_id: z.number().optional(),
|
|
152
|
+
source_project_id: z.number().optional(),
|
|
153
|
+
});
|
|
154
|
+
export function reviewState(request) {
|
|
155
|
+
if (request.state === 'merged')
|
|
156
|
+
return 'merged';
|
|
157
|
+
if (request.state === 'closed')
|
|
158
|
+
return 'closed';
|
|
159
|
+
return request.draft || request.work_in_progress ? 'draft' : 'ready';
|
|
160
|
+
}
|
|
161
|
+
export class GitLabClient {
|
|
162
|
+
timeoutMs;
|
|
163
|
+
constructor(timeoutMs) {
|
|
164
|
+
this.timeoutMs = timeoutMs;
|
|
165
|
+
}
|
|
166
|
+
async user(address, token) {
|
|
167
|
+
await this.call(address, token, 'GET', '/user');
|
|
168
|
+
}
|
|
169
|
+
async scopes(address, token) {
|
|
170
|
+
try {
|
|
171
|
+
const self = z
|
|
172
|
+
.object({ scopes: z.array(z.string()) })
|
|
173
|
+
.safeParse(await this.call(address, token, 'GET', '/personal_access_tokens/self'));
|
|
174
|
+
return self.success ? self.data.scopes : null;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof GitLabError &&
|
|
178
|
+
['not_found', 'not_allowed', 'invalid_request'].includes(error.failure))
|
|
179
|
+
return null;
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async project(address, token, project) {
|
|
184
|
+
return await this.found(async () => this.shape(address, z.object({ id: z.number() }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}`)));
|
|
185
|
+
}
|
|
186
|
+
async branch(address, token, project, branch) {
|
|
187
|
+
const found = await this.found(async () => this.shape(address, z.object({ commit: z.object({ id: z.string(), title: z.string().nullish() }) }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/repository/branches/${encodeURIComponent(branch)}`)));
|
|
188
|
+
return found ? { commit: found.commit.id, title: found.commit.title ?? '' } : null;
|
|
189
|
+
}
|
|
190
|
+
async contains(address, token, project, branch, commit) {
|
|
191
|
+
const query = new URLSearchParams([
|
|
192
|
+
['refs[]', commit],
|
|
193
|
+
['refs[]', branch],
|
|
194
|
+
]);
|
|
195
|
+
try {
|
|
196
|
+
const base = this.shape(address, z.object({ id: z.string() }), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/repository/merge_base?${query}`));
|
|
197
|
+
return base.id === commit;
|
|
198
|
+
}
|
|
199
|
+
catch (error) {
|
|
200
|
+
if (error instanceof GitLabError &&
|
|
201
|
+
(error.failure === 'not_found' || error.failure === 'invalid_request'))
|
|
202
|
+
return false;
|
|
203
|
+
throw error;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async mergeRequests(address, token, project, branch, state) {
|
|
207
|
+
const query = new URLSearchParams({
|
|
208
|
+
source_branch: branch,
|
|
209
|
+
order_by: 'created_at',
|
|
210
|
+
sort: 'desc',
|
|
211
|
+
per_page: '20',
|
|
212
|
+
...(state ? { state } : {}),
|
|
213
|
+
});
|
|
214
|
+
const listed = this.shape(address, z.array(mergeRequestSchema), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project.path)}/merge_requests?${query}`));
|
|
215
|
+
return listed.filter((request) => request.source_branch === branch &&
|
|
216
|
+
(request.source_project_id === undefined || request.source_project_id === project.id));
|
|
217
|
+
}
|
|
218
|
+
async mergeRequest(address, token, project, number) {
|
|
219
|
+
return this.shape(address, mergeRequestSchema, await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`));
|
|
220
|
+
}
|
|
221
|
+
async createMergeRequest(address, token, project, request) {
|
|
222
|
+
return this.shape(address, mergeRequestSchema, await this.call(address, token, 'POST', `/projects/${encodeURIComponent(project)}/merge_requests`, {
|
|
223
|
+
source_branch: request.source,
|
|
224
|
+
target_branch: request.target,
|
|
225
|
+
title: request.title,
|
|
226
|
+
...(request.description ? { description: request.description } : {}),
|
|
227
|
+
}));
|
|
228
|
+
}
|
|
229
|
+
async found(read) {
|
|
230
|
+
try {
|
|
231
|
+
return await read();
|
|
232
|
+
}
|
|
233
|
+
catch (error) {
|
|
234
|
+
if (error instanceof GitLabError && error.failure === 'not_found')
|
|
235
|
+
return null;
|
|
236
|
+
throw error;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
shape(address, schema, value) {
|
|
240
|
+
const parsed = schema.safeParse(value);
|
|
241
|
+
if (!parsed.success)
|
|
242
|
+
throw new GitLabError('unexpected_answer', address, null, null);
|
|
243
|
+
return parsed.data;
|
|
244
|
+
}
|
|
245
|
+
async call(address, token, method, path, body) {
|
|
246
|
+
let status;
|
|
247
|
+
let text;
|
|
248
|
+
try {
|
|
249
|
+
const response = await fetch(`${address}/api/v4${path}`, {
|
|
250
|
+
method,
|
|
251
|
+
redirect: 'manual',
|
|
252
|
+
headers: {
|
|
253
|
+
'PRIVATE-TOKEN': token,
|
|
254
|
+
Accept: 'application/json',
|
|
255
|
+
...(body ? { 'Content-Type': 'application/json' } : {}),
|
|
256
|
+
},
|
|
257
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
258
|
+
signal: AbortSignal.timeout(this.timeoutMs),
|
|
259
|
+
});
|
|
260
|
+
status = response.status;
|
|
261
|
+
text = await response.text();
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
const code = networkCode(error);
|
|
265
|
+
if (code && certificateCodes.has(code))
|
|
266
|
+
throw new GitLabError('untrusted_certificate', address, null, code);
|
|
267
|
+
if ((code && connectCodes.has(code)) || method === 'GET')
|
|
268
|
+
throw new GitLabError('unreachable', address, null, code);
|
|
269
|
+
throw new GitLabError('unknown_outcome', address, null, code);
|
|
270
|
+
}
|
|
271
|
+
if (status >= 200 && status < 300) {
|
|
272
|
+
try {
|
|
273
|
+
return JSON.parse(text);
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
throw new GitLabError('unexpected_answer', address, status, null);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const failure = status >= 300 && status < 400
|
|
280
|
+
? 'redirected'
|
|
281
|
+
: status === 401
|
|
282
|
+
? 'token_rejected'
|
|
283
|
+
: status === 403
|
|
284
|
+
? 'not_allowed'
|
|
285
|
+
: status === 404
|
|
286
|
+
? 'not_found'
|
|
287
|
+
: status === 409
|
|
288
|
+
? 'conflict'
|
|
289
|
+
: status === 429
|
|
290
|
+
? 'rate_limited'
|
|
291
|
+
: status >= 500
|
|
292
|
+
? 'server_error'
|
|
293
|
+
: 'invalid_request';
|
|
294
|
+
throw new GitLabError(failure, address, status, gitLabMessage(text));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
const savedAddresses = z.object({
|
|
298
|
+
schemaVersion: z.literal(1),
|
|
299
|
+
principalHash: z.string().min(1),
|
|
300
|
+
addresses: z.array(z.object({ host: z.string().min(1), address: z.string().min(1) })),
|
|
301
|
+
});
|
|
302
|
+
export class GitLabTokens {
|
|
303
|
+
stateRoot;
|
|
304
|
+
credentials;
|
|
305
|
+
path;
|
|
306
|
+
queue = Promise.resolve();
|
|
307
|
+
constructor(stateRoot, credentials) {
|
|
308
|
+
this.stateRoot = stateRoot;
|
|
309
|
+
this.credentials = credentials;
|
|
310
|
+
this.path = join(stateRoot, 'gitlab-addresses.json');
|
|
311
|
+
}
|
|
312
|
+
async address(host) {
|
|
313
|
+
return (await this.current())?.addresses.find((entry) => entry.host === host)?.address ?? null;
|
|
314
|
+
}
|
|
315
|
+
async forLink(host, repository) {
|
|
316
|
+
const addresses = [...new Set((await this.current())?.addresses.map((entry) => entry.address))]
|
|
317
|
+
.filter((address) => {
|
|
318
|
+
const url = new URL(address);
|
|
319
|
+
const prefix = url.pathname.replace(/^\/+/, '');
|
|
320
|
+
return url.host === host && (!prefix || repository.startsWith(prefix + '/'));
|
|
321
|
+
})
|
|
322
|
+
.sort((left, right) => right.length - left.length);
|
|
323
|
+
return addresses[0]
|
|
324
|
+
? { address: addresses[0], project: projectPath(addresses[0], repository) }
|
|
325
|
+
: null;
|
|
326
|
+
}
|
|
327
|
+
async token(address) {
|
|
328
|
+
const principal = await this.principal();
|
|
329
|
+
return principal ? await this.credentials.get(tokenName(principal, address)) : null;
|
|
330
|
+
}
|
|
331
|
+
async any() {
|
|
332
|
+
return Boolean((await this.current())?.addresses.length);
|
|
333
|
+
}
|
|
334
|
+
async save(host, address, token) {
|
|
335
|
+
await this.exclusive(async () => {
|
|
336
|
+
const principal = await this.principal();
|
|
337
|
+
if (!principal)
|
|
338
|
+
throw new Error('Sign in to Engineering Memory before saving a GitLab token.');
|
|
339
|
+
let saved = await this.read();
|
|
340
|
+
if (saved && saved.principalHash !== principal) {
|
|
341
|
+
await this.deleteTokens(saved);
|
|
342
|
+
saved = null;
|
|
343
|
+
}
|
|
344
|
+
const previous = saved?.addresses.find((entry) => entry.host === host)?.address;
|
|
345
|
+
await this.credentials.set(tokenName(principal, address), token);
|
|
346
|
+
const addresses = [
|
|
347
|
+
...(saved?.addresses ?? []).filter((entry) => entry.host !== host),
|
|
348
|
+
{ host, address },
|
|
349
|
+
];
|
|
350
|
+
await writeJson(this.path, { schemaVersion: 1, principalHash: principal, addresses }, this.stateRoot);
|
|
351
|
+
if (previous && !addresses.some((entry) => entry.address === previous))
|
|
352
|
+
await this.credentials.delete(tokenName(principal, previous));
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
async remove(address) {
|
|
356
|
+
await this.exclusive(async () => {
|
|
357
|
+
const principal = await this.principal();
|
|
358
|
+
if (!principal)
|
|
359
|
+
return;
|
|
360
|
+
await this.credentials.delete(tokenName(principal, address));
|
|
361
|
+
const saved = await this.read();
|
|
362
|
+
if (saved?.principalHash !== principal)
|
|
363
|
+
return;
|
|
364
|
+
await writeJson(this.path, { ...saved, addresses: saved.addresses.filter((entry) => entry.address !== address) }, this.stateRoot);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async clear() {
|
|
368
|
+
await this.exclusive(async () => {
|
|
369
|
+
const saved = await this.read();
|
|
370
|
+
if (saved)
|
|
371
|
+
await this.deleteTokens(saved);
|
|
372
|
+
await removeFile(this.path, this.stateRoot);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
async deleteTokens(saved) {
|
|
376
|
+
for (const address of new Set(saved.addresses.map((entry) => entry.address)))
|
|
377
|
+
await this.credentials.delete(tokenName(saved.principalHash, address));
|
|
378
|
+
}
|
|
379
|
+
async current() {
|
|
380
|
+
const principal = await this.principal();
|
|
381
|
+
const saved = principal ? await this.read() : null;
|
|
382
|
+
return saved?.principalHash === principal ? saved : null;
|
|
383
|
+
}
|
|
384
|
+
async read() {
|
|
385
|
+
const parsed = savedAddresses.safeParse(await readJson(this.path, this.stateRoot));
|
|
386
|
+
return parsed.success ? parsed.data : null;
|
|
387
|
+
}
|
|
388
|
+
async principal() {
|
|
389
|
+
const accessToken = await this.credentials.get('access-token');
|
|
390
|
+
if (!accessToken)
|
|
391
|
+
return null;
|
|
392
|
+
try {
|
|
393
|
+
return principalFingerprint(accessToken);
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
async exclusive(action) {
|
|
400
|
+
const previous = this.queue;
|
|
401
|
+
let release = () => undefined;
|
|
402
|
+
this.queue = new Promise((resolvePromise) => {
|
|
403
|
+
release = resolvePromise;
|
|
404
|
+
});
|
|
405
|
+
await previous;
|
|
406
|
+
try {
|
|
407
|
+
await action();
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
release();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function tokenName(principal, address) {
|
|
415
|
+
return `gitlab-token-${sha256(`${principal}\n${address}`).slice(0, 32)}`;
|
|
416
|
+
}
|
|
417
|
+
//# sourceMappingURL=gitlab.js.map
|
|
@@ -23,6 +23,8 @@ import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.
|
|
|
23
23
|
import { catalogueLanguages, copies, fetchLanguage, format } from './texts.js';
|
|
24
24
|
import * as z from 'zod/v4';
|
|
25
25
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
26
|
+
import { gitLabRemote } from '../providers/gitlab.js';
|
|
27
|
+
import { awaitsReviewRequest, reviewDeliveries, } from './merge-request-sync.js';
|
|
26
28
|
export const validationIds = [
|
|
27
29
|
'format',
|
|
28
30
|
'static_analysis',
|
|
@@ -3774,6 +3776,10 @@ export class BridgeService {
|
|
|
3774
3776
|
query.set('limit', String(input.limit));
|
|
3775
3777
|
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
3776
3778
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
|
|
3779
|
+
this.followMergeRequests(input.projectId, async () => {
|
|
3780
|
+
const here = await this.dependencies.repositories.resolveIdentity(process.cwd());
|
|
3781
|
+
return here.projectId === input.projectId ? here.repoRoot : null;
|
|
3782
|
+
});
|
|
3777
3783
|
return asJsonValue({
|
|
3778
3784
|
selectionRequired: true,
|
|
3779
3785
|
questionnaire: 'Select the project work item to run in this chat',
|
|
@@ -4163,7 +4169,7 @@ export class BridgeService {
|
|
|
4163
4169
|
const livePointers = authenticated && state === 'bound'
|
|
4164
4170
|
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
4165
4171
|
: Promise.resolve([]);
|
|
4166
|
-
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries,] = await Promise.all([
|
|
4172
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews,] = await Promise.all([
|
|
4167
4173
|
this.clientUpdate(authenticated),
|
|
4168
4174
|
livePointers,
|
|
4169
4175
|
authenticated && state === 'bound' && projectId
|
|
@@ -4187,8 +4193,14 @@ export class BridgeService {
|
|
|
4187
4193
|
authenticated && state === 'bound' && projectId
|
|
4188
4194
|
? this.openDeliveries(projectId)
|
|
4189
4195
|
: Promise.resolve(null),
|
|
4196
|
+
authenticated && state === 'bound' && projectId
|
|
4197
|
+
? this.reviewCandidates(projectId)
|
|
4198
|
+
: Promise.resolve(null),
|
|
4190
4199
|
]);
|
|
4191
4200
|
timer.mark('independent_lookups');
|
|
4201
|
+
if (projectId && reviews)
|
|
4202
|
+
this.followMergeRequests(projectId, repository.repoRoot, reviews);
|
|
4203
|
+
const awaiting = reviews?.filter(awaitsReviewRequest) ?? [];
|
|
4192
4204
|
const liveTasks = liveTasksRaw.map(describePointer);
|
|
4193
4205
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
4194
4206
|
const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
|
|
@@ -4235,6 +4247,22 @@ export class BridgeService {
|
|
|
4235
4247
|
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
4236
4248
|
: {}),
|
|
4237
4249
|
...(deliveries ? { openDeliveries: deliveries } : {}),
|
|
4250
|
+
...(awaiting.length
|
|
4251
|
+
? {
|
|
4252
|
+
awaitingReviewRequests: {
|
|
4253
|
+
total: awaiting.length,
|
|
4254
|
+
items: awaiting.slice(0, 5).map((delivery) => ({
|
|
4255
|
+
taskId: delivery.taskId,
|
|
4256
|
+
externalTaskId: delivery.externalTaskId ?? null,
|
|
4257
|
+
workItemKey: delivery.workItemKey ?? null,
|
|
4258
|
+
branch: delivery.branch ?? null,
|
|
4259
|
+
targetBranch: delivery.baseBranch ?? null,
|
|
4260
|
+
draft: delivery.choice === 'commit_push_draft_pr',
|
|
4261
|
+
})),
|
|
4262
|
+
nextAction: 'These delivered tasks chose a pull request, but none is recorded for them yet. Mention them to the user in one line. On a GitLab remote, task.open_review_request opens one from this computer when the user asks; a pull request opened anywhere else is recorded with task.review_request. A merge request someone opened by hand on a GitLab this computer follows is found by itself.',
|
|
4263
|
+
},
|
|
4264
|
+
}
|
|
4265
|
+
: {}),
|
|
4238
4266
|
client,
|
|
4239
4267
|
...moved,
|
|
4240
4268
|
...(incomplete && state === 'bound'
|
|
@@ -4673,6 +4701,73 @@ export class BridgeService {
|
|
|
4673
4701
|
async taskReviewRequest(input) {
|
|
4674
4702
|
return this.execute(() => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: input.url, state: input.state }));
|
|
4675
4703
|
}
|
|
4704
|
+
async taskOpenReviewRequest(input) {
|
|
4705
|
+
return this.execute(async () => {
|
|
4706
|
+
const gitlab = this.gitlab();
|
|
4707
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
4708
|
+
if (repository.projectId !== input.projectId)
|
|
4709
|
+
throw refuse("Call this in a clone of the task's project repository: this folder belongs to another project, or to none.", 'session.entry');
|
|
4710
|
+
const opened = await gitlab.requests.open({ ...input, repoRoot: repository.repoRoot });
|
|
4711
|
+
if ('recorded' in opened)
|
|
4712
|
+
return asJsonValue({ alreadyRecorded: opened.recorded });
|
|
4713
|
+
try {
|
|
4714
|
+
const report = await this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: opened.url, state: opened.state, source: 'machine' });
|
|
4715
|
+
return asJsonValue({ mergeRequest: opened, report });
|
|
4716
|
+
}
|
|
4717
|
+
catch (error) {
|
|
4718
|
+
return asJsonValue({
|
|
4719
|
+
mergeRequest: opened,
|
|
4720
|
+
reportRefused: error instanceof Error ? error.message : String(error),
|
|
4721
|
+
});
|
|
4722
|
+
}
|
|
4723
|
+
});
|
|
4724
|
+
}
|
|
4725
|
+
async gitlabToken(input = {}) {
|
|
4726
|
+
return this.execute(async () => {
|
|
4727
|
+
const gitlab = this.gitlab();
|
|
4728
|
+
if (!(await this.dependencies.credentials.get('access-token')))
|
|
4729
|
+
throw refuse('Sign in to Engineering Memory first: a GitLab token is kept for the signed-in account.', 'auth.signin_browser');
|
|
4730
|
+
await this.language(input.language);
|
|
4731
|
+
const git = this.dependencies.repositories.git;
|
|
4732
|
+
const pushUrl = await git
|
|
4733
|
+
.findRoot(input.repoRoot ?? process.cwd())
|
|
4734
|
+
.then(async (root) => (await git.pushDestination(root)).pushUrl)
|
|
4735
|
+
.catch(() => null);
|
|
4736
|
+
const remote = pushUrl ? gitLabRemote(pushUrl) : null;
|
|
4737
|
+
if (remote && 'refusal' in remote && remote.refusal === 'github')
|
|
4738
|
+
throw refuse('This repository is on GitHub. Engineering Memory opens merge requests only on GitLab so far; a GitHub pull request is opened with the tools you have and recorded with task.review_request.', 'task.review_request');
|
|
4739
|
+
const target = remote && !('refusal' in remote)
|
|
4740
|
+
? {
|
|
4741
|
+
host: remote.host,
|
|
4742
|
+
address: (await gitlab.tokens.address(remote.host)) ?? remote.address,
|
|
4743
|
+
}
|
|
4744
|
+
: { host: null, address: null };
|
|
4745
|
+
const link = await gitlab.page.open(target);
|
|
4746
|
+
return asJsonValue({
|
|
4747
|
+
...link,
|
|
4748
|
+
address: target.address,
|
|
4749
|
+
tokenSaved: target.address ? Boolean(await gitlab.tokens.token(target.address)) : false,
|
|
4750
|
+
nextAction: "Give this link to the user to open in a browser on this computer, and tell them to enter their GitLab address and personal access token there: the api scope lets Engineering Memory open merge requests, read_api only lets it follow them. Never open the link, fill in the page or ask for the token yourself, and if the user pastes a token into the chat, do not use it: ask them to revoke it and use the page. The page checks the token with GitLab, keeps it in this computer's credential store and closes after one save or removal, or after 10 minutes. When the user says it is saved, retry what needed it.",
|
|
4751
|
+
});
|
|
4752
|
+
});
|
|
4753
|
+
}
|
|
4754
|
+
gitlab() {
|
|
4755
|
+
if (!this.dependencies.gitlab)
|
|
4756
|
+
throw refuse('This bridge was started without GitLab support. Open the merge request in GitLab and record it with task.review_request.', 'task.review_request');
|
|
4757
|
+
return this.dependencies.gitlab;
|
|
4758
|
+
}
|
|
4759
|
+
followMergeRequests(projectId, repoRoot, deliveries) {
|
|
4760
|
+
this.dependencies.gitlab?.requests.schedule(projectId, repoRoot, deliveries);
|
|
4761
|
+
}
|
|
4762
|
+
async reviewCandidates(projectId) {
|
|
4763
|
+
try {
|
|
4764
|
+
const response = await this.dependencies.client.request(`${endpoints.projectDeliveries(projectId)}?state=review&limit=100`);
|
|
4765
|
+
return reviewDeliveries(response.data);
|
|
4766
|
+
}
|
|
4767
|
+
catch {
|
|
4768
|
+
return null;
|
|
4769
|
+
}
|
|
4770
|
+
}
|
|
4676
4771
|
async taskDeliveryRecord(input) {
|
|
4677
4772
|
return this.execute(async () => {
|
|
4678
4773
|
const response = await this.dependencies.client.request(endpoints.taskDelivery(input.projectId, input.taskId));
|
|
@@ -5699,7 +5794,7 @@ function deliveryQuestion(touchedContract, destination, publishApplicable, commi
|
|
|
5699
5794
|
alsoAsk: 'This task changed an endpoint contract. Ask whether to document the change for the linked project, and if so write what the other side must do differently with memory.propose_revision as an integration_note.',
|
|
5700
5795
|
}
|
|
5701
5796
|
: {}),
|
|
5702
|
-
afterPullRequest: 'Do not end the turn once
|
|
5797
|
+
afterPullRequest: 'Do not end the turn once the pull request is due. On a GitLab remote, task.open_review_request opens it from this computer and records it in one call; when it says no GitLab token is saved, gitlab.token gives the user a page on this computer to save their own. Anywhere else, open it with the tools you have and record it with task.review_request: its link, and draft or ready as it was opened. Then check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything. When the user later says it became ready, was merged or was closed, record that with task.review_request too; a GitLab merge request this computer can read is also followed by itself.',
|
|
5703
5798
|
afterCommit,
|
|
5704
5799
|
});
|
|
5705
5800
|
}
|
|
@@ -18,7 +18,10 @@ import { RepositoryDecisionStore } from './repository-decision-store.js';
|
|
|
18
18
|
import { ShadowNoticeStore } from './shadow-notice-store.js';
|
|
19
19
|
import { LanguageStore } from './language-store.js';
|
|
20
20
|
import { UpdateChoiceStore } from './update-choice-store.js';
|
|
21
|
-
import { PrincipalStateGuard } from './principal-state.js';
|
|
21
|
+
import { PrincipalStateGuard, principalFingerprint } from './principal-state.js';
|
|
22
|
+
import { MergeRequests } from './merge-request-sync.js';
|
|
23
|
+
import { GitLabClient, GitLabTokens } from '../providers/gitlab.js';
|
|
24
|
+
import { GitLabTokenPage } from '../providers/gitlab-token-page.js';
|
|
22
25
|
import { OnboardingStore } from './onboarding-store.js';
|
|
23
26
|
import { QuestionnaireStore } from './questionnaire-store.js';
|
|
24
27
|
import { sha256 } from '../utilities/hash.js';
|
|
@@ -78,8 +81,14 @@ export function createBridgeService(options = {}) {
|
|
|
78
81
|
knownReservations,
|
|
79
82
|
});
|
|
80
83
|
const gate = new VerificationGate(stateRoot, git, outbox, undefined, worktreePool);
|
|
81
|
-
const
|
|
84
|
+
const gitlabTokens = new GitLabTokens(stateRoot, credentials);
|
|
85
|
+
const principalState = new PrincipalStateGuard(stateRoot, credentials, cache, outbox, activeContexts, gate, gitlabTokens);
|
|
82
86
|
const languages = new LanguageStore(stateRoot);
|
|
87
|
+
const gitlab = new GitLabClient(config.requestTimeoutMs);
|
|
88
|
+
const signedInLanguage = async () => {
|
|
89
|
+
const accessToken = await credentials.get('access-token');
|
|
90
|
+
return languages.read(accessToken ? principalFingerprint(accessToken) : sha256('anonymous'));
|
|
91
|
+
};
|
|
83
92
|
return (service = new BridgeService({
|
|
84
93
|
live,
|
|
85
94
|
releaseNotes: new ReleaseNotes(stateRoot, client, credentials),
|
|
@@ -101,6 +110,11 @@ export function createBridgeService(options = {}) {
|
|
|
101
110
|
onboarding: new OnboardingStore(stateRoot),
|
|
102
111
|
clientVersion: options.clientVersion === undefined ? config.clientVersion : options.clientVersion,
|
|
103
112
|
principalState,
|
|
113
|
+
gitlab: {
|
|
114
|
+
tokens: gitlabTokens,
|
|
115
|
+
page: new GitLabTokenPage(gitlabTokens, gitlab, signedInLanguage),
|
|
116
|
+
requests: new MergeRequests(client, gitlabTokens, gitlab, git),
|
|
117
|
+
},
|
|
104
118
|
}));
|
|
105
119
|
}
|
|
106
120
|
//# sourceMappingURL=create-bridge-service.js.map
|