engineering-memory 1.11.28 → 1.11.30
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/config.js +12 -0
- package/runtime/dist/src/git/git-inspector.js +13 -0
- package/runtime/dist/src/localization/catalogue.generated.js +98 -0
- package/runtime/dist/src/mcp/delivery-tools.js +209 -55
- package/runtime/dist/src/mcp/review-tools.js +372 -0
- package/runtime/dist/src/mcp/tool-annotations.js +9 -0
- package/runtime/dist/src/mcp/tool-definitions.js +16 -0
- package/runtime/dist/src/mcp/worktree-tools.js +12 -1
- package/runtime/dist/src/providers/gitlab.js +12 -0
- package/runtime/dist/src/runtime/api-client.js +12 -0
- package/runtime/dist/src/runtime/bridge-service.js +381 -12
- package/runtime/dist/src/runtime/create-bridge-service.js +1 -1
- package/runtime/dist/src/runtime/live-signals.js +3 -0
- package/runtime/dist/src/runtime/merge-request-sync.js +171 -14
- package/runtime/dist/src/runtime/review-comment.js +135 -0
- package/runtime/dist/src/runtime/review-masking.js +31 -0
- package/runtime/dist/src/runtime/task-start.js +12 -6
- package/skill/references/lifecycle.md +12 -0
|
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import * as z from 'zod/v4';
|
|
3
3
|
import { endpoints } from '../config.js';
|
|
4
4
|
import { GitLabError, gitLabRemote, projectPath, reviewState, } from '../providers/gitlab.js';
|
|
5
|
+
import { backendRejectsField } from './api-client.js';
|
|
5
6
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
7
|
+
import { commentReviewSchema, dueComment, markComment } from './review-comment.js';
|
|
6
8
|
export const reviewDeliverySchema = z.object({
|
|
7
9
|
taskId: z.string(),
|
|
8
10
|
externalTaskId: z.string().nullish(),
|
|
@@ -13,6 +15,11 @@ export const reviewDeliverySchema = z.object({
|
|
|
13
15
|
pushed: z.boolean().nullish(),
|
|
14
16
|
branch: z.string().nullish(),
|
|
15
17
|
workItemKey: z.string().nullish(),
|
|
18
|
+
reviewState: z.string().nullish(),
|
|
19
|
+
reviewCommit: z.string().nullish(),
|
|
20
|
+
reviewCommentPostedAt: z.string().nullish(),
|
|
21
|
+
providerJob: z.string().nullish(),
|
|
22
|
+
providerFailedAt: z.string().nullish(),
|
|
16
23
|
reviewRequest: z
|
|
17
24
|
.object({
|
|
18
25
|
provider: z.string(),
|
|
@@ -22,10 +29,19 @@ export const reviewDeliverySchema = z.object({
|
|
|
22
29
|
url: z.string(),
|
|
23
30
|
state: z.string(),
|
|
24
31
|
source: z.string().optional(),
|
|
32
|
+
targetBranch: z.string().nullish(),
|
|
25
33
|
})
|
|
26
34
|
.nullish(),
|
|
27
35
|
});
|
|
28
36
|
const pullRequestChoices = ['commit_push_draft_pr', 'commit_push_pr'];
|
|
37
|
+
export const waitingReview = ['pending', 'in_review', 'findings_open'];
|
|
38
|
+
export const concludedReview = ['passed', 'skipped', 'exempted'];
|
|
39
|
+
const draftPrefix = /^\s*(?:(?:\[draft\]|\(draft\)|draft:|\[wip\]|wip:)\s*)+/i;
|
|
40
|
+
export function opensAsDraft(delivery) {
|
|
41
|
+
const review = delivery.reviewState ?? '';
|
|
42
|
+
return (waitingReview.includes(review) ||
|
|
43
|
+
(delivery.choice === 'commit_push_draft_pr' && !concludedReview.includes(review)));
|
|
44
|
+
}
|
|
29
45
|
export function awaitsReviewRequest(delivery) {
|
|
30
46
|
return (!delivery.reviewRequest &&
|
|
31
47
|
delivery.state === 'delivered' &&
|
|
@@ -45,13 +61,16 @@ export class MergeRequests {
|
|
|
45
61
|
tokens;
|
|
46
62
|
gitlab;
|
|
47
63
|
git;
|
|
64
|
+
language;
|
|
48
65
|
passes = new Map();
|
|
49
66
|
running = new Set();
|
|
50
|
-
|
|
67
|
+
readingRefused = false;
|
|
68
|
+
constructor(client, tokens, gitlab, git, language) {
|
|
51
69
|
this.client = client;
|
|
52
70
|
this.tokens = tokens;
|
|
53
71
|
this.gitlab = gitlab;
|
|
54
72
|
this.git = git;
|
|
73
|
+
this.language = language;
|
|
55
74
|
}
|
|
56
75
|
async open(input) {
|
|
57
76
|
const delivery = reviewDeliverySchema.parse((await this.client.request(endpoints.taskDelivery(input.projectId, input.taskId)))
|
|
@@ -67,7 +86,7 @@ export class MergeRequests {
|
|
|
67
86
|
const target = input.targetBranch ?? (chosen ? delivery.baseBranch : null);
|
|
68
87
|
if (!target)
|
|
69
88
|
throw new BridgeRecoveryError('The delivery answer did not choose a pull request, so it names no target branch. If the user wants a merge request, call again with the targetBranch they name.', 'task.open_review_request');
|
|
70
|
-
const draft = input.draft ?? delivery
|
|
89
|
+
const draft = input.draft ?? opensAsDraft(delivery);
|
|
71
90
|
const pushUrl = await this.git.branchPushUrl(input.repoRoot, branch);
|
|
72
91
|
if (!pushUrl)
|
|
73
92
|
throw new BridgeRecoveryError(`This clone has no remote that ${branch} is pushed to. ${manualWay}`, 'task.review_request');
|
|
@@ -125,6 +144,53 @@ export class MergeRequests {
|
|
|
125
144
|
throw error;
|
|
126
145
|
}
|
|
127
146
|
}
|
|
147
|
+
async markReady(request) {
|
|
148
|
+
const link = await this.tokens.forLink(request.host, request.repository);
|
|
149
|
+
const token = link ? await this.tokens.token(link.address) : null;
|
|
150
|
+
if (!link || !token)
|
|
151
|
+
throw new BridgeRecoveryError(`No GitLab token is saved on this computer for ${request.host}. Ask the user to mark ${request.url} ready in GitLab ("Mark as ready"); Engineering Memory follows it. Or call gitlab.token so they save their own token here, then call review.make_ready again.`, 'gitlab.token');
|
|
152
|
+
try {
|
|
153
|
+
const current = await this.gitlab.mergeRequest(link.address, token, link.project, request.number);
|
|
154
|
+
if (reviewState(current) !== 'draft')
|
|
155
|
+
return { url: current.web_url, state: reviewState(current) };
|
|
156
|
+
if (current.title === undefined)
|
|
157
|
+
throw new GitLabError('unexpected_answer', link.address, null, null);
|
|
158
|
+
const after = await this.gitlab.retitleMergeRequest(link.address, token, link.project, request.number, current.title.replace(draftPrefix, '') || defaultTitle(current.source_branch, null));
|
|
159
|
+
return { url: after.web_url, state: reviewState(after) };
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (!(error instanceof GitLabError))
|
|
163
|
+
throw error;
|
|
164
|
+
const at = error.address;
|
|
165
|
+
const reason = error.failure === 'token_rejected'
|
|
166
|
+
? `GitLab at ${at} no longer accepts the saved token (expired or revoked); gitlab.token saves a new one.`
|
|
167
|
+
: error.failure === 'not_allowed'
|
|
168
|
+
? `GitLab at ${at} refused this with the saved token: changing a merge request needs the api scope and Developer or higher.`
|
|
169
|
+
: error.failure === 'unreachable'
|
|
170
|
+
? `This computer cannot reach ${at}; if GitLab is reachable only from the company network, connect and call review.make_ready again.`
|
|
171
|
+
: error.failure === 'unknown_outcome'
|
|
172
|
+
? `The answer of GitLab at ${at} was lost after the change was sent, so it may already be ready; calling review.make_ready again is safe and reads where it stands.`
|
|
173
|
+
: `GitLab at ${at} answered ${error.failure.replace(/_/g, ' ')}${error.status ? ` (${error.status})` : ''}.`;
|
|
174
|
+
throw new BridgeRecoveryError(`${reason} ${request.url} may still be a draft: the user can mark it ready in GitLab ("Mark as ready"); Engineering Memory follows it.`, error.failure === 'token_rejected' || error.failure === 'not_allowed'
|
|
175
|
+
? 'gitlab.token'
|
|
176
|
+
: 'review.make_ready', { address: at, failure: error.failure, status: error.status });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async comment(projectId, taskId, request, comment) {
|
|
180
|
+
const link = await this.tokens.forLink(request.host, request.repository);
|
|
181
|
+
const token = link ? await this.tokens.token(link.address) : null;
|
|
182
|
+
if (!link || !token)
|
|
183
|
+
return { posted: false };
|
|
184
|
+
try {
|
|
185
|
+
await this.post(projectId, taskId, { address: link.address, token, project: link.project, number: request.number }, comment);
|
|
186
|
+
return { posted: true };
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
if (!(error instanceof GitLabError))
|
|
190
|
+
throw error;
|
|
191
|
+
return { posted: false, failure: error.failure };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
128
194
|
schedule(projectId, repoRoot, deliveries) {
|
|
129
195
|
const now = Date.now();
|
|
130
196
|
const last = this.passes.get(projectId);
|
|
@@ -151,8 +217,17 @@ export class MergeRequests {
|
|
|
151
217
|
const worker = async () => {
|
|
152
218
|
for (let delivery = queue.shift(); delivery && pass.calls > 0; delivery = queue.shift()) {
|
|
153
219
|
const found = await this.follow(delivery, repoRoot, pass).catch(() => null);
|
|
154
|
-
if (found
|
|
220
|
+
if (!found)
|
|
221
|
+
continue;
|
|
222
|
+
let job = delivery.providerJob ?? null;
|
|
223
|
+
if (this.changed(delivery, found)) {
|
|
224
|
+
const answer = await this.report(projectId, delivery.taskId, found);
|
|
225
|
+
if (!answer)
|
|
226
|
+
continue;
|
|
155
227
|
reported++;
|
|
228
|
+
job = answer.providerJob;
|
|
229
|
+
}
|
|
230
|
+
await this.provide(projectId, delivery, found, job, pass).catch(() => undefined);
|
|
156
231
|
}
|
|
157
232
|
};
|
|
158
233
|
await Promise.all([worker(), worker(), worker(), worker()]);
|
|
@@ -168,10 +243,9 @@ export class MergeRequests {
|
|
|
168
243
|
if (!link || !token)
|
|
169
244
|
return null;
|
|
170
245
|
const request = await this.call(pass, link.address, () => this.gitlab.mergeRequest(link.address, token, link.project, known.number));
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
return state !== known.state || known.source !== 'machine' ? { url: known.url, state } : null;
|
|
246
|
+
return request
|
|
247
|
+
? { url: known.url, request, address: link.address, token, project: link.project }
|
|
248
|
+
: null;
|
|
175
249
|
}
|
|
176
250
|
if (!repoRoot || !delivery.branch)
|
|
177
251
|
return null;
|
|
@@ -201,7 +275,78 @@ export class MergeRequests {
|
|
|
201
275
|
.sort((left, right) => rank(left) - rank(right) ||
|
|
202
276
|
Number(right.sha === delivery.deliveredCommit) -
|
|
203
277
|
Number(left.sha === delivery.deliveredCommit))[0];
|
|
204
|
-
return chosen ? { url: chosen.web_url,
|
|
278
|
+
return chosen ? { url: chosen.web_url, request: chosen, address, token, project: path } : null;
|
|
279
|
+
}
|
|
280
|
+
changed(delivery, found) {
|
|
281
|
+
const known = delivery.reviewRequest;
|
|
282
|
+
const head = found.request.sha;
|
|
283
|
+
return (!known ||
|
|
284
|
+
reviewState(found.request) !== known.state ||
|
|
285
|
+
known.source !== 'machine' ||
|
|
286
|
+
(known.targetBranch !== undefined && found.request.target_branch !== known.targetBranch) ||
|
|
287
|
+
(!this.readingRefused &&
|
|
288
|
+
delivery.reviewState === 'passed' &&
|
|
289
|
+
!!head &&
|
|
290
|
+
!!delivery.reviewCommit &&
|
|
291
|
+
head !== delivery.reviewCommit));
|
|
292
|
+
}
|
|
293
|
+
async provide(projectId, delivery, found, job, pass) {
|
|
294
|
+
const state = reviewState(found.request);
|
|
295
|
+
const failedAt = job === (delivery.providerJob ?? null) ? delivery.providerFailedAt : null;
|
|
296
|
+
if ((state !== 'draft' && state !== 'ready') ||
|
|
297
|
+
(failedAt && Date.now() - Date.parse(failedAt) < 30 * 60 * 1000) ||
|
|
298
|
+
pass.failed.has(found.address) ||
|
|
299
|
+
pass.calls < 3)
|
|
300
|
+
return;
|
|
301
|
+
if (delivery.reviewState === 'findings_open' ||
|
|
302
|
+
(concludedReview.includes(delivery.reviewState ?? '') &&
|
|
303
|
+
delivery.reviewCommentPostedAt === null)) {
|
|
304
|
+
const review = commentReviewSchema.safeParse((await this.client
|
|
305
|
+
.request(endpoints.review(projectId, delivery.taskId))
|
|
306
|
+
.catch(() => null))?.data);
|
|
307
|
+
const comment = review.success
|
|
308
|
+
? dueComment(review.data, (await this.language()) ?? 'en')
|
|
309
|
+
: null;
|
|
310
|
+
if (comment)
|
|
311
|
+
await this.post(projectId, delivery.taskId, { ...found, number: found.request.iid }, comment, pass).catch(() => undefined);
|
|
312
|
+
}
|
|
313
|
+
const due = (job === 'make_ready' && state === 'draft') || (job === 'make_draft' && state === 'ready')
|
|
314
|
+
? job
|
|
315
|
+
: null;
|
|
316
|
+
if (!due)
|
|
317
|
+
return;
|
|
318
|
+
try {
|
|
319
|
+
pass.calls--;
|
|
320
|
+
const title = found.request.title;
|
|
321
|
+
if (title === undefined)
|
|
322
|
+
throw new GitLabError('unexpected_answer', found.address, null, null);
|
|
323
|
+
const after = await this.gitlab.retitleMergeRequest(found.address, found.token, found.project, found.request.iid, due === 'make_ready'
|
|
324
|
+
? title.replace(draftPrefix, '') || defaultTitle(found.request.source_branch, null)
|
|
325
|
+
: `Draft: ${title}`.slice(0, 255));
|
|
326
|
+
await this.report(projectId, delivery.taskId, { ...found, request: after });
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (!(error instanceof GitLabError))
|
|
330
|
+
return;
|
|
331
|
+
pass.failed.add(found.address);
|
|
332
|
+
await this.client
|
|
333
|
+
.request(endpoints.reviewProviderFailure(projectId, delivery.taskId), {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
body: { job: due, failure: error.failure },
|
|
336
|
+
})
|
|
337
|
+
.catch(() => undefined);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
async post(projectId, taskId, target, comment, pass) {
|
|
341
|
+
if (pass)
|
|
342
|
+
pass.calls--;
|
|
343
|
+
const notes = await this.gitlab.notes(target.address, target.token, target.project, target.number);
|
|
344
|
+
if (!notes.some((note) => note.body.includes(comment.marker))) {
|
|
345
|
+
if (pass)
|
|
346
|
+
pass.calls--;
|
|
347
|
+
await this.gitlab.createNote(target.address, target.token, target.project, target.number, comment.body);
|
|
348
|
+
}
|
|
349
|
+
await markComment(this.client, projectId, taskId, comment);
|
|
205
350
|
}
|
|
206
351
|
async call(pass, address, request) {
|
|
207
352
|
if (pass.failed.has(address) || pass.calls <= 0)
|
|
@@ -218,16 +363,28 @@ export class MergeRequests {
|
|
|
218
363
|
}
|
|
219
364
|
}
|
|
220
365
|
async report(projectId, taskId, found) {
|
|
366
|
+
const body = { url: found.url, state: reviewState(found.request), source: 'machine' };
|
|
367
|
+
const reading = {
|
|
368
|
+
...(found.request.sha ? { headCommit: found.request.sha } : {}),
|
|
369
|
+
targetBranch: found.request.target_branch,
|
|
370
|
+
};
|
|
371
|
+
const send = () => this.client.request(endpoints.taskReviewRequest(projectId, taskId), {
|
|
372
|
+
method: 'POST',
|
|
373
|
+
body: this.readingRefused ? body : { ...body, ...reading },
|
|
374
|
+
idempotencyKey: randomUUID(),
|
|
375
|
+
});
|
|
221
376
|
try {
|
|
222
|
-
await
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
377
|
+
const response = await send().catch(async (error) => {
|
|
378
|
+
if (this.readingRefused || !backendRejectsField(error, ['headCommit', 'targetBranch']))
|
|
379
|
+
throw error;
|
|
380
|
+
this.readingRefused = true;
|
|
381
|
+
return send();
|
|
226
382
|
});
|
|
227
|
-
|
|
383
|
+
const answer = z.object({ providerJob: z.string().nullish() }).safeParse(response.data);
|
|
384
|
+
return { providerJob: answer.success ? (answer.data.providerJob ?? null) : null };
|
|
228
385
|
}
|
|
229
386
|
catch {
|
|
230
|
-
return
|
|
387
|
+
return null;
|
|
231
388
|
}
|
|
232
389
|
}
|
|
233
390
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { endpoints } from '../config.js';
|
|
3
|
+
import { concludedReview } from './merge-request-sync.js';
|
|
4
|
+
import { copies, format } from './texts.js';
|
|
5
|
+
const text = z.string().min(1);
|
|
6
|
+
const commentWording = z.strictObject({
|
|
7
|
+
round: text,
|
|
8
|
+
passed: text,
|
|
9
|
+
skipped: text,
|
|
10
|
+
exempted: text,
|
|
11
|
+
findingsOf: text,
|
|
12
|
+
rule: text,
|
|
13
|
+
open: text,
|
|
14
|
+
fixed: text,
|
|
15
|
+
rejected: text,
|
|
16
|
+
});
|
|
17
|
+
export const commentReviewSchema = z.object({
|
|
18
|
+
delivery: z.object({
|
|
19
|
+
id: z.string(),
|
|
20
|
+
taskId: z.string(),
|
|
21
|
+
externalTaskId: z.string(),
|
|
22
|
+
workItemKey: z.string().nullish(),
|
|
23
|
+
reviewState: z.string().nullish(),
|
|
24
|
+
reviewChangedAt: z.string().nullish(),
|
|
25
|
+
reviewExemptionReason: z.string().nullish(),
|
|
26
|
+
reviewCommentPostedAt: z.string().nullish(),
|
|
27
|
+
reviewRequest: z
|
|
28
|
+
.object({
|
|
29
|
+
provider: z.string(),
|
|
30
|
+
host: z.string(),
|
|
31
|
+
repository: z.string(),
|
|
32
|
+
number: z.number().int().positive(),
|
|
33
|
+
url: z.string(),
|
|
34
|
+
state: z.string(),
|
|
35
|
+
})
|
|
36
|
+
.nullish(),
|
|
37
|
+
}),
|
|
38
|
+
rounds: z.array(z.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
number: z.number().int(),
|
|
41
|
+
state: z.string(),
|
|
42
|
+
commentPostedAt: z.string().nullish(),
|
|
43
|
+
})),
|
|
44
|
+
totalRounds: z.number().int().optional(),
|
|
45
|
+
findings: z.array(z.object({
|
|
46
|
+
position: z.number().int(),
|
|
47
|
+
path: z.string(),
|
|
48
|
+
line: z.number().int().nullish(),
|
|
49
|
+
ruleKey: z.string().nullish(),
|
|
50
|
+
description: z.string(),
|
|
51
|
+
resolution: z.string(),
|
|
52
|
+
rejectionReason: z.string().nullish(),
|
|
53
|
+
fixedCommit: z.string().nullish(),
|
|
54
|
+
})),
|
|
55
|
+
});
|
|
56
|
+
export function dueComment(review, language) {
|
|
57
|
+
const { delivery, findings } = review;
|
|
58
|
+
const { copy, language: shown } = copies(commentWording, 'reviewComment', language)[0];
|
|
59
|
+
const task = delivery.workItemKey ?? delivery.externalTaskId;
|
|
60
|
+
const where = (finding) => '`' + (finding.path + (finding.line ? `:${finding.line}` : '')).replace(/`/g, '') + '`';
|
|
61
|
+
const latest = review.rounds.find((round) => round.state === 'submitted');
|
|
62
|
+
if (delivery.reviewState === 'findings_open' &&
|
|
63
|
+
latest?.commentPostedAt === null &&
|
|
64
|
+
findings.length) {
|
|
65
|
+
const marker = `<!-- engineering-memory review-round ${latest.id} -->`;
|
|
66
|
+
const lines = findings.map((finding) => `${finding.position}. ${where(finding)}` +
|
|
67
|
+
(finding.ruleKey
|
|
68
|
+
? ` (${format(copy.rule, shown, { rule: oneLine(finding.ruleKey) })})`
|
|
69
|
+
: '') +
|
|
70
|
+
`: ${oneLine(finding.description)}`);
|
|
71
|
+
return {
|
|
72
|
+
kind: 'round',
|
|
73
|
+
roundId: latest.id,
|
|
74
|
+
marker,
|
|
75
|
+
body: [
|
|
76
|
+
marker,
|
|
77
|
+
`**${format(copy.round, shown, { task, round: latest.number, count: findings.length })}**`,
|
|
78
|
+
'',
|
|
79
|
+
...lines,
|
|
80
|
+
].join('\n'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const outcome = delivery.reviewState;
|
|
84
|
+
if (!outcome ||
|
|
85
|
+
!concludedReview.includes(outcome) ||
|
|
86
|
+
!delivery.reviewChangedAt ||
|
|
87
|
+
delivery.reviewCommentPostedAt !== null ||
|
|
88
|
+
!['draft', 'ready'].includes(delivery.reviewRequest?.state ?? ''))
|
|
89
|
+
return null;
|
|
90
|
+
const marker = `<!-- engineering-memory review-conclusion ${delivery.id} ${delivery.reviewChangedAt} -->`;
|
|
91
|
+
const title = outcome === 'passed'
|
|
92
|
+
? format(copy.passed, shown, { task, rounds: review.totalRounds ?? review.rounds.length })
|
|
93
|
+
: outcome === 'skipped'
|
|
94
|
+
? format(copy.skipped, shown, { task })
|
|
95
|
+
: format(copy.exempted, shown, {
|
|
96
|
+
task,
|
|
97
|
+
reason: oneLine(delivery.reviewExemptionReason ?? ''),
|
|
98
|
+
});
|
|
99
|
+
const status = (finding) => finding.resolution === 'fixed'
|
|
100
|
+
? format(copy.fixed, shown, { commit: (finding.fixedCommit ?? '').slice(0, 12) })
|
|
101
|
+
: finding.resolution === 'rejected'
|
|
102
|
+
? format(copy.rejected, shown, { reason: oneLine(finding.rejectionReason ?? '') })
|
|
103
|
+
: copy.open;
|
|
104
|
+
return {
|
|
105
|
+
kind: 'conclusion',
|
|
106
|
+
concludedAt: delivery.reviewChangedAt,
|
|
107
|
+
marker,
|
|
108
|
+
body: [
|
|
109
|
+
marker,
|
|
110
|
+
`**${title}**`,
|
|
111
|
+
...(findings.length && latest
|
|
112
|
+
? [
|
|
113
|
+
'',
|
|
114
|
+
format(copy.findingsOf, shown, { round: latest.number }),
|
|
115
|
+
...findings.map((finding) => `${finding.position}. ${where(finding)}: ${status(finding)}`),
|
|
116
|
+
]
|
|
117
|
+
: []),
|
|
118
|
+
].join('\n'),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export async function markComment(client, projectId, taskId, comment) {
|
|
122
|
+
await client
|
|
123
|
+
.request(comment.kind === 'round'
|
|
124
|
+
? endpoints.reviewRoundComment(projectId, comment.roundId)
|
|
125
|
+
: endpoints.reviewComment(projectId, taskId), {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
...(comment.kind === 'conclusion' ? { body: { concludedAt: comment.concludedAt } } : {}),
|
|
128
|
+
})
|
|
129
|
+
.catch(() => undefined);
|
|
130
|
+
}
|
|
131
|
+
function oneLine(value) {
|
|
132
|
+
const line = value.replace(/\s+/g, ' ').trim();
|
|
133
|
+
return (line.length > 1500 ? `${line.slice(0, 1499)}…` : line).replace(/@/g, '@');
|
|
134
|
+
}
|
|
135
|
+
//# sourceMappingURL=review-comment.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { restrictedValueKind } from './privacy-detector.js';
|
|
2
|
+
const masks = [
|
|
3
|
+
[/[\p{L}\p{N}._%+-]{1,64}@[\p{L}\p{N}.-]+\.\p{L}{2,63}/gu, 'e-mail address', () => '[e-mail]'],
|
|
4
|
+
[
|
|
5
|
+
/(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])/g,
|
|
6
|
+
'IP address',
|
|
7
|
+
(match) => (match.split('.').every((part) => Number(part) <= 255) ? '[IP]' : match),
|
|
8
|
+
],
|
|
9
|
+
[
|
|
10
|
+
/[A-Z]:[\\/](?:Users|Documents and Settings)[\\/][^\\/\s"'<>:|?*]+|\\\\[^\\\s]+\\Users\\[^\\/\s"'<>*]+|(?:\/mnt\/[a-z]|\/cygdrive\/[a-z]|\/System\/Volumes\/Data|\/Volumes\/[^/\s]+|\/export)\/(?:Users|home)\/[^\\/\s"'<>*]+/gi,
|
|
11
|
+
'user folder',
|
|
12
|
+
() => '~',
|
|
13
|
+
],
|
|
14
|
+
[
|
|
15
|
+
/(^|[\s"'(<=])\/(?:[a-z]\/)?(?:Users|home)\/[^\\/\s"'<>*]+/gi,
|
|
16
|
+
'user folder',
|
|
17
|
+
(_, before) => before + '~',
|
|
18
|
+
],
|
|
19
|
+
];
|
|
20
|
+
export function maskFinding(description) {
|
|
21
|
+
const masked = new Set();
|
|
22
|
+
let text = description;
|
|
23
|
+
for (const [pattern, kind, replace] of masks) {
|
|
24
|
+
const next = text.replace(pattern, replace);
|
|
25
|
+
if (next !== text)
|
|
26
|
+
masked.add(kind);
|
|
27
|
+
text = next;
|
|
28
|
+
}
|
|
29
|
+
return { text, masked: [...masked], left: restrictedValueKind(text, 'description') };
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=review-masking.js.map
|
|
@@ -51,6 +51,7 @@ const startWording = z.strictObject({
|
|
|
51
51
|
newBranch: text,
|
|
52
52
|
newBranchOrContinue: text,
|
|
53
53
|
continuePlan: text,
|
|
54
|
+
continueReview: text,
|
|
54
55
|
example: text,
|
|
55
56
|
stay: text,
|
|
56
57
|
base: z.strictObject({
|
|
@@ -199,11 +200,16 @@ export function taskStartDefinition(facts) {
|
|
|
199
200
|
message: copy.message,
|
|
200
201
|
context: keepOnly
|
|
201
202
|
? copy.keepContext
|
|
202
|
-
: binding.base?.kind === 'existing'
|
|
203
|
-
? format(copy.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
203
|
+
: binding.base?.kind === 'existing' && facts.reviewedTask
|
|
204
|
+
? format(copy.continueReview, language, {
|
|
205
|
+
branch: binding.base.branch,
|
|
206
|
+
task: facts.reviewedTask,
|
|
207
|
+
})
|
|
208
|
+
: binding.base?.kind === 'existing'
|
|
209
|
+
? format(copy.continuePlan, language, { branch: binding.base.branch })
|
|
210
|
+
: format(Object.values(binding.bases ?? {}).some((base) => base.kind === 'existing')
|
|
211
|
+
? copy.newBranchOrContinue
|
|
212
|
+
: copy.newBranch, language, { name: binding.name }) + (binding.base ? ' · ' + sourceLabel(binding.base, facts.currentBranch) : ''),
|
|
207
213
|
example: keepOnly ? format(copy.stay, language, { source: current }) : copy.example,
|
|
208
214
|
questions,
|
|
209
215
|
binding: JSON.parse(JSON.stringify(binding)),
|
|
@@ -216,7 +222,7 @@ function legacyTaskStartDefinition(facts) {
|
|
|
216
222
|
const folder = facts.folder;
|
|
217
223
|
const keepOnly = facts.keepCurrent === true || !facts.currentCommit;
|
|
218
224
|
const here = !keepOnly && !folder.managed && !folder.heldBy && folder.clean;
|
|
219
|
-
const keep = keepOnly || (!folder.managed && !folder.heldBy && !folder.holder);
|
|
225
|
+
const keep = !facts.reviewedTask && (keepOnly || (!folder.managed && !folder.heldBy && !folder.holder));
|
|
220
226
|
const holder = here && folder.holder ? holderIdentity(folder.holder) : null;
|
|
221
227
|
const currentLabel = sourceLabel({ kind: 'current', ...(facts.currentCommit ? { commit: facts.currentCommit } : {}) }, facts.currentBranch);
|
|
222
228
|
const binding = {
|
|
@@ -300,6 +300,18 @@ When the delivery chose a pull request and the branch is pushed to a GitLab, ope
|
|
|
300
300
|
|
|
301
301
|
When the user later says a pull request became ready, was merged or was closed, record that state with `task.review_request` too; it works from any chat, also for a delivery `session.entry` lists. Engineering Memory then moves the task's work item as the project's workflow rules say: to the `pr_opened` target once every pull request of the work item is ready, to the `all_prs_merged` target (READY FOR QA by default) once they are all merged, and to the draft target when one is still a draft and the project names one. It waits while another task of the work item is still open, or closed with a delivery that has no pull request yet. Relay the returned `nextAction` in one line. A record is what was reported, unless a member's computer read it from GitLab: where someone has saved a GitLab token, the bridge reads the project's merge requests in the background when a chat opens or work items are listed, and records a merge, a close or a draft turning ready by itself, also for a merge request someone opened by hand. Never report a merge the user has not confirmed. A merge is final, and a delivery keeps the first pull request recorded for it. GitHub pull request and GitLab merge request links are recorded; for another provider, move the work item with `work_item.update`.
|
|
302
302
|
|
|
303
|
+
## Code review
|
|
304
|
+
|
|
305
|
+
A project can review code before a pull request is ready: `project.review_policy` reads its setting — none, optional or required — and, only when the user asks to change it, `change: true` asks them in one native form; only an owner or maintainer can change it. Under optional the delivery question offers a pull request reviewed first, which opens as a draft, and one without review; under required only the reviewed one. A user's own instruction for a ready pull request under required becomes the reviewed one, and the tool says so; relay it. When the tool says the setting changed while the question was open, tell the user and call it again.
|
|
306
|
+
|
|
307
|
+
After a reviewed pull request is pushed and the worktree released, open it as a draft (`task.open_review_request`, or your own tools and `task.review_request` with `draft`), then call `review.start` in a clone of the project. It fetches the pushed branch without checking anything out and returns the review packet. Give the packet to a fresh subagent that did not write the change; only when your host cannot start one, review it yourself under the same rules. The reviewer reads the change only through the read-only Git commands the packet lists: it never checks out, switches branches, edits, commits, pushes or changes a file. It judges the change against the packet's records (more with `review.rules`) and says whether each earlier finding still holds; from the second round on it starts from `sinceLastRound`, what changed since the previous round. Each finding names the file by its path in the repository, the line in the reviewed commit when there is one, the key of the record it breaks when one applies, and what is wrong and why in plain words. Record the result with `review.submit`, an empty list when there are no findings. Describe a problem without the value itself: e-mail addresses, IP addresses and user folders are masked, and a description that still looks like it holds a secret or personal data is refused.
|
|
308
|
+
|
|
309
|
+
Show the findings to the user and ask whether to fix them. A fix is a write task: `task.branch` with `reviewFixOf` set to the reviewed task's id, then `session.bootstrap` with the same `reviewFixOf`; it continues the pull request's branch, and its delivery offers commit or commit and push onto that branch — never a second pull request. After its push the open findings are marked fixed; ask whether to review again (`review.start`) or end the review (`review.conclude` with passed). A finding the user disagrees with is rejected with `review.reject_finding`, where the user writes the reason in the form; never write it for them. `review.conclude` also skips a review where the setting is not required, and exempts one under required, which only an owner or maintainer can do and only with the reason the user writes. Every one of these asks the user in a native form first.
|
|
310
|
+
|
|
311
|
+
Once a review has ended, call `review.make_ready`: on GitLab, with the user's own token saved, it removes Draft and records the pull request ready; anywhere else follow its `nextAction` (for example `gh pr ready`) and record the state with `task.review_request`. It also makes ready, when the user asks, a pull request that has no code review. Never mark a draft ready while its review has not ended. `session.entry` lists as `awaitingReviews` the deliveries that wait for code review; mention them in one line and start nothing on your own. When the user asks, `review.queue` and `review.get` read them, and every member except readers can continue any review where it stopped.
|
|
312
|
+
|
|
313
|
+
The review is written on the pull request as comments: the findings of each round and the conclusion. On GitLab the bridge writes them with the user's saved token; when a result of `review.submit`, `review.reject_finding`, `review.conclude` or `review.make_ready` carries `comment.body`, post that body unchanged as a comment with your own tools (on GitHub, `gh pr comment` with the body in a file) — it is recorded as handed over and not handed over again. When you record a pull request with `task.review_request`, pass `headCommit` and `targetBranch` when you can read them (on GitHub, `gh pr view --json headRefOid,baseRefName`): a review that had passed waits again when its pull request gets a new commit or a new target branch, and the answer says so; tell the user in one line, and do not pass it again without a round — `review.start` runs the next one, and skipping or exempting still asks the user. An `awaitingReviews` entry shows `reopened`, `readyBeforeReview` and `pullRequestChange`: a make-ready or make-draft that a member computer with a GitLab token does in the background, with its last failure. Mention a failure in one line; on GitHub, when the answer asks for a draft again, make it a draft with your own tools (`gh pr ready --undo`) and record it with `task.review_request`.
|
|
314
|
+
|
|
303
315
|
## Code source and memory applicability
|
|
304
316
|
|
|
305
317
|
The bridge sends the checkout's committed Git commit and tree at bootstrap. Task and session
|