engineering-memory 1.11.29 → 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 +3 -0
- package/runtime/dist/src/localization/catalogue.generated.js +18 -0
- package/runtime/dist/src/mcp/delivery-tools.js +24 -5
- package/runtime/dist/src/mcp/review-tools.js +6 -3
- package/runtime/dist/src/providers/gitlab.js +8 -0
- package/runtime/dist/src/runtime/api-client.js +5 -0
- package/runtime/dist/src/runtime/bridge-service.js +132 -31
- 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 +129 -13
- package/runtime/dist/src/runtime/review-comment.js +135 -0
- package/skill/references/lifecycle.md +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "engineering-memory",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.30",
|
|
4
4
|
"description": "Installs the Engineering Memory skill and its local MCP bridge. Sign in after installing; your organization and project are resolved from your account.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
package/runtime/build.json
CHANGED
|
@@ -80,6 +80,9 @@ export const endpoints = {
|
|
|
80
80
|
reviewPass: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/pass`,
|
|
81
81
|
reviewSkip: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/skip`,
|
|
82
82
|
reviewExemption: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/exemption`,
|
|
83
|
+
reviewRoundComment: (projectId, roundId) => `/projects/${projectId}/review-rounds/${roundId}/comment`,
|
|
84
|
+
reviewComment: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/comment`,
|
|
85
|
+
reviewProviderFailure: (projectId, taskId) => `/projects/${projectId}/tasks/${taskId}/review/provider-failure`,
|
|
83
86
|
projectSetup: '/projects/setup',
|
|
84
87
|
projectList: '/projects',
|
|
85
88
|
projectResolve: '/projects/resolve',
|
|
@@ -270,6 +270,15 @@ export const bundledTexts = {
|
|
|
270
270
|
'reviewBatch.untitled': 'untitled {kind}',
|
|
271
271
|
'reviewBatch.unversioned': 'Written without a source inspection: {titles}. Tasks that read memory by source version will not see them until an inspection retains them.',
|
|
272
272
|
'reviewBatch.why': 'Why',
|
|
273
|
+
'reviewComment.exempted': 'Engineering Memory code review of {task}: exempted. Reason: {reason}',
|
|
274
|
+
'reviewComment.findingsOf': 'Findings of round {round}:',
|
|
275
|
+
'reviewComment.fixed': 'fixed in {commit}',
|
|
276
|
+
'reviewComment.open': 'still open',
|
|
277
|
+
'reviewComment.passed': 'Engineering Memory code review of {task}: passed after {rounds, plural, one {# round} other {# rounds}}',
|
|
278
|
+
'reviewComment.rejected': 'rejected: {reason}',
|
|
279
|
+
'reviewComment.round': 'Engineering Memory code review of {task}, round {round}: {count, plural, one {# finding} other {# findings}}',
|
|
280
|
+
'reviewComment.rule': 'rule {rule}',
|
|
281
|
+
'reviewComment.skipped': 'Engineering Memory code review of {task}: skipped',
|
|
273
282
|
'reviewConclude.confirm.description': 'The review ends as described above.',
|
|
274
283
|
'reviewConclude.confirm.label': 'Yes, end it',
|
|
275
284
|
'reviewConclude.defer.description': 'The review stays open; nothing is recorded.',
|
|
@@ -736,6 +745,15 @@ export const bundledTexts = {
|
|
|
736
745
|
'reviewBatch.untitled': 'başlıksız {kind}',
|
|
737
746
|
'reviewBatch.unversioned': 'Kaynak incelemesi yapılmadan yazılanlar: {titles}. Hafızayı kaynak sürümüne göre okuyan görevler, bir inceleme bunları saklayana kadar görmez.',
|
|
738
747
|
'reviewBatch.why': 'Nedeni',
|
|
748
|
+
'reviewComment.exempted': '{task} için Engineering Memory kod incelemesi: muaf tutuldu. Gerekçe: {reason}',
|
|
749
|
+
'reviewComment.findingsOf': 'Tur {round} bulguları:',
|
|
750
|
+
'reviewComment.fixed': '{commit} ile düzeltildi',
|
|
751
|
+
'reviewComment.open': 'hâlâ açık',
|
|
752
|
+
'reviewComment.passed': '{task} için Engineering Memory kod incelemesi: {rounds, plural, one {# turda} other {# turda}} geçti',
|
|
753
|
+
'reviewComment.rejected': 'reddedildi: {reason}',
|
|
754
|
+
'reviewComment.round': '{task} için Engineering Memory kod incelemesi, tur {round}: {count, plural, one {# bulgu} other {# bulgu}}',
|
|
755
|
+
'reviewComment.rule': 'kural {rule}',
|
|
756
|
+
'reviewComment.skipped': '{task} için Engineering Memory kod incelemesi: atlandı',
|
|
739
757
|
'reviewConclude.confirm.description': 'İnceleme yukarıda anlatıldığı gibi sonuçlanır.',
|
|
740
758
|
'reviewConclude.confirm.label': 'Evet, bitir',
|
|
741
759
|
'reviewConclude.defer.description': 'İnceleme açık kalır; hiçbir şey kaydedilmez.',
|
|
@@ -647,28 +647,47 @@ function registerTaskDelivery(server, service) {
|
|
|
647
647
|
}
|
|
648
648
|
function registerTaskReviewRequest(server, service) {
|
|
649
649
|
server.registerTool('task.review_request', {
|
|
650
|
-
description: "Record the pull request (GitHub) or merge request (GitLab) a closed task's changes went into, and its state: draft, ready, merged or closed. Call it with the link copied from the provider as soon as the pull request is opened, and again when the user says it became ready, was merged or was closed. Engineering Memory then moves the task's work item as the project's workflow rules say, for example to PR when every pull request of the work item is ready and to READY FOR QA when they are all merged. It works from any chat, also for a delivery session.entry lists. What is recorded is what was reported: Engineering Memory does not check it with the provider, and performs no Git or provider action.",
|
|
650
|
+
description: "Record the pull request (GitHub) or merge request (GitLab) a closed task's changes went into, and its state: draft, ready, merged or closed. Call it with the link copied from the provider as soon as the pull request is opened, and again when the user says it became ready, was merged or was closed. Pass headCommit and targetBranch when you can read them from the provider (on GitHub, gh pr view with --json headRefOid,baseRefName): a code review that had passed waits again when the pull request gets a new commit or a new target branch. Engineering Memory then moves the task's work item as the project's workflow rules say, for example to PR when every pull request of the work item is ready and to READY FOR QA when they are all merged. It works from any chat, also for a delivery session.entry lists. What is recorded is what was reported: Engineering Memory does not check it with the provider, and performs no Git or provider action.",
|
|
651
651
|
inputSchema: z.strictObject({
|
|
652
652
|
projectId: z.string().uuid(),
|
|
653
653
|
taskId: z.string().uuid(),
|
|
654
654
|
url: z.string().trim().min(1).max(500),
|
|
655
655
|
state: z.enum(['draft', 'ready', 'merged', 'closed']),
|
|
656
|
+
headCommit: z
|
|
657
|
+
.string()
|
|
658
|
+
.regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/)
|
|
659
|
+
.optional(),
|
|
660
|
+
targetBranch: z.string().trim().min(1).max(255).optional(),
|
|
656
661
|
}),
|
|
657
662
|
}, async (input) => {
|
|
658
663
|
const result = await service.taskReviewRequest(input);
|
|
659
664
|
if (!result.ok)
|
|
660
665
|
return output(result);
|
|
661
666
|
const recorded = result.data;
|
|
667
|
+
const reopened = recorded.data?.reviewReopened;
|
|
668
|
+
const job = recorded.data?.providerJob;
|
|
662
669
|
return output({
|
|
663
670
|
ok: true,
|
|
664
671
|
data: {
|
|
665
672
|
...result.data,
|
|
666
673
|
nextAction: recorded.queued
|
|
667
674
|
? 'Engineering Memory is unreachable, so this report is queued. It is recorded, and the work item moves as the rules say, when Engineering Memory is reachable again.'
|
|
668
|
-
:
|
|
669
|
-
|
|
670
|
-
?
|
|
671
|
-
: '
|
|
675
|
+
: [
|
|
676
|
+
reopened
|
|
677
|
+
? `The code review of this delivery had passed, but the pull request has ${reopened === 'new_target' ? 'a new target branch' : 'a new commit'} now, so the review waits again. Tell the user in one line; review.start runs the next round.`
|
|
678
|
+
: '',
|
|
679
|
+
job === 'make_draft'
|
|
680
|
+
? 'This project requires code review, so turn the pull request back into a draft with your own tools (on GitHub, gh pr ready --undo) and record it with state draft.'
|
|
681
|
+
: job === 'make_ready'
|
|
682
|
+
? 'Its code review has already ended: review.make_ready makes the pull request ready.'
|
|
683
|
+
: '',
|
|
684
|
+
recorded.data?.reviewEvent?.nextAction ??
|
|
685
|
+
(recorded.data?.reviewEvent
|
|
686
|
+
? 'The pull request is recorded; the work item stays where it is.'
|
|
687
|
+
: 'The pull request is recorded. The work item does not move yet: other work on it is still open or undelivered, or the task has no active work item.'),
|
|
688
|
+
]
|
|
689
|
+
.filter(Boolean)
|
|
690
|
+
.join(' '),
|
|
672
691
|
},
|
|
673
692
|
});
|
|
674
693
|
});
|
|
@@ -39,6 +39,7 @@ const reviewRead = z.object({
|
|
|
39
39
|
delivery: z.object({
|
|
40
40
|
externalTaskId: z.string(),
|
|
41
41
|
reviewState: z.string().nullish(),
|
|
42
|
+
reviewReopenReason: z.string().nullish(),
|
|
42
43
|
reviewPolicy: z.string().optional(),
|
|
43
44
|
}),
|
|
44
45
|
rounds: z.array(z.object({ id: z.string(), state: z.string() })),
|
|
@@ -132,7 +133,7 @@ export function registerReviewTools(server, service) {
|
|
|
132
133
|
inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
|
|
133
134
|
}, async (input) => output(await service.reviewGet(input)));
|
|
134
135
|
server.registerTool('review.start', {
|
|
135
|
-
description: "Start the next code review round of a delivered task, or return the round already open at the same commit, from a clone of the task's project on this computer. It fetches the pushed branch and its target from origin without checking anything out and returns the review packet: the commits to compare, the changed files, the records that governed the change, earlier findings and the read-only Git commands the reviewer may use.
|
|
136
|
+
description: "Start the next code review round of a delivered task, or return the round already open at the same commit, from a clone of the task's project on this computer. It fetches the pushed branch and its target from origin without checking anything out and returns the review packet: the commits to compare, the changed files, the records that governed the change, earlier findings and the read-only Git commands the reviewer may use; from the second round on, also what changed since the previous round. The target branch is the one recorded for its PR/MR, else the one the delivery answer named; pass targetBranch only when neither is right.",
|
|
136
137
|
inputSchema: z.strictObject({
|
|
137
138
|
projectId: z.string().uuid(),
|
|
138
139
|
taskId: z.string().uuid(),
|
|
@@ -150,7 +151,7 @@ export function registerReviewTools(server, service) {
|
|
|
150
151
|
}),
|
|
151
152
|
}, async (input) => output(await service.reviewRules(input)));
|
|
152
153
|
server.registerTool('review.submit', {
|
|
153
|
-
description: 'Record the result of a review round: its findings, or an empty list when there are none. E-mail addresses, IP addresses and user folders in descriptions are masked first; a description that still holds a secret or personal data is refused so it can be rephrased. Sending the same result again changes nothing.',
|
|
154
|
+
description: 'Record the result of a review round: its findings, or an empty list when there are none. E-mail addresses, IP addresses and user folders in descriptions are masked first; a description that still holds a secret or personal data is refused so it can be rephrased. Sending the same result again changes nothing. The findings, and later the conclusion, are written on the PR/MR as a comment: on GitLab with the token the user saved on this computer, elsewhere comment.body is returned for you to post.',
|
|
154
155
|
inputSchema: z.strictObject({
|
|
155
156
|
projectId: z.string().uuid(),
|
|
156
157
|
roundId: z.string().uuid(),
|
|
@@ -285,6 +286,8 @@ export function registerReviewTools(server, service) {
|
|
|
285
286
|
});
|
|
286
287
|
if (!waitingReview.includes(state ?? ''))
|
|
287
288
|
return refusal(`The review of ${task} is ${state ?? 'not tracked'}, so there is nothing to end. review.get shows where it stands.`, 'review.get');
|
|
289
|
+
if (input.outcome === 'passed' && review.delivery.reviewReopenReason)
|
|
290
|
+
return refusal(`The review of ${task} had passed, then its PR/MR got ${review.delivery.reviewReopenReason === 'new_target' ? 'a new target branch' : 'a new commit'}, so it passes again only after a round reviews it. Start one with review.start, or ask the user whether to skip or exempt it.`, 'review.start');
|
|
288
291
|
if (input.outcome === 'passed' &&
|
|
289
292
|
(state !== 'pending' ||
|
|
290
293
|
!review.rounds.some((round) => round.state === 'submitted') ||
|
|
@@ -353,7 +356,7 @@ export function registerReviewTools(server, service) {
|
|
|
353
356
|
});
|
|
354
357
|
});
|
|
355
358
|
server.registerTool('review.make_ready', {
|
|
356
|
-
description: "Make the PR/MR of a delivered task ready for the team once its code review ended, or, when the user asks, one that has no code review; while a review runs it refuses. On GitLab with the user's own token saved on this computer, it removes Draft from the merge request and records it; anywhere else it says exactly what to do, since the draft is marked ready with the provider's own tools or by the user.",
|
|
359
|
+
description: "Make the PR/MR of a delivered task ready for the team once its code review ended, or, when the user asks, one that has no code review; while a review runs it refuses. On GitLab with the user's own token saved on this computer, it writes the review conclusion as a comment when it is not written yet, removes Draft from the merge request and records it; anywhere else it says exactly what to do, since the draft is marked ready with the provider's own tools or by the user.",
|
|
357
360
|
inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
|
|
358
361
|
}, async (input) => output(await service.reviewMakeReady(input)));
|
|
359
362
|
}
|
|
@@ -152,6 +152,7 @@ export const mergeRequestSchema = z.object({
|
|
|
152
152
|
project_id: z.number().optional(),
|
|
153
153
|
source_project_id: z.number().optional(),
|
|
154
154
|
});
|
|
155
|
+
const noteSchema = z.object({ id: z.number(), body: z.string() });
|
|
155
156
|
export function reviewState(request) {
|
|
156
157
|
if (request.state === 'merged')
|
|
157
158
|
return 'merged';
|
|
@@ -227,6 +228,13 @@ export class GitLabClient {
|
|
|
227
228
|
...(request.description ? { description: request.description } : {}),
|
|
228
229
|
}));
|
|
229
230
|
}
|
|
231
|
+
async notes(address, token, project, number) {
|
|
232
|
+
const query = new URLSearchParams({ sort: 'desc', order_by: 'created_at', per_page: '100' });
|
|
233
|
+
return this.shape(address, z.array(noteSchema), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/merge_requests/${number}/notes?${query}`));
|
|
234
|
+
}
|
|
235
|
+
async createNote(address, token, project, number, body) {
|
|
236
|
+
this.shape(address, noteSchema, await this.call(address, token, 'POST', `/projects/${encodeURIComponent(project)}/merge_requests/${number}/notes`, { body }));
|
|
237
|
+
}
|
|
230
238
|
async retitleMergeRequest(address, token, project, number, title) {
|
|
231
239
|
return this.shape(address, mergeRequestSchema, await this.call(address, token, 'PUT', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`, { title }));
|
|
232
240
|
}
|
|
@@ -95,6 +95,11 @@ export class ApiResponseError extends Error {
|
|
|
95
95
|
this.recovery = normalizedRecovery;
|
|
96
96
|
}
|
|
97
97
|
}
|
|
98
|
+
export function backendRejectsField(error, fields) {
|
|
99
|
+
return (error instanceof ApiResponseError &&
|
|
100
|
+
error.httpStatus === 400 &&
|
|
101
|
+
fields.some((field) => error.message.includes(`property ${field} should not exist`)));
|
|
102
|
+
}
|
|
98
103
|
export class ApiClient {
|
|
99
104
|
options;
|
|
100
105
|
fetchImplementation;
|
|
@@ -15,7 +15,7 @@ import { minimatch } from 'minimatch';
|
|
|
15
15
|
import { endpoints } from '../config.js';
|
|
16
16
|
import { startPhaseTimer } from './phase-timer.js';
|
|
17
17
|
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
18
|
-
import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
|
|
18
|
+
import { ApiResponseError, BackendUnavailableError, backendRejectsField, } from './api-client.js';
|
|
19
19
|
import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
|
|
20
20
|
import { restrictedValueKind } from './privacy-detector.js';
|
|
21
21
|
import { principalFingerprint } from './principal-state.js';
|
|
@@ -26,6 +26,7 @@ import { BridgeRecoveryError } from './recovery-error.js';
|
|
|
26
26
|
import { gitLabRemote } from '../providers/gitlab.js';
|
|
27
27
|
import { awaitsReviewRequest, concludedReview, opensAsDraft, reviewDeliveries, reviewDeliverySchema, waitingReview, } from './merge-request-sync.js';
|
|
28
28
|
import { maskFinding } from './review-masking.js';
|
|
29
|
+
import { commentReviewSchema, dueComment, markComment } from './review-comment.js';
|
|
29
30
|
export const validationIds = [
|
|
30
31
|
'format',
|
|
31
32
|
'static_analysis',
|
|
@@ -4410,6 +4411,9 @@ export class BridgeService {
|
|
|
4410
4411
|
.object({
|
|
4411
4412
|
total: z.number().int(),
|
|
4412
4413
|
items: z.array(reviewDeliverySchema.extend({
|
|
4414
|
+
reviewReopenReason: z.string().nullish(),
|
|
4415
|
+
providerJobSince: z.string().nullish(),
|
|
4416
|
+
providerFailure: z.string().nullish(),
|
|
4413
4417
|
openRound: z.object({ number: z.number().int(), createdAt: z.string() }).nullish(),
|
|
4414
4418
|
openFindings: z.number().int(),
|
|
4415
4419
|
})),
|
|
@@ -4424,15 +4428,25 @@ export class BridgeService {
|
|
|
4424
4428
|
externalTaskId: item.externalTaskId ?? null,
|
|
4425
4429
|
workItemKey: item.workItemKey ?? null,
|
|
4426
4430
|
reviewState: item.reviewState ?? null,
|
|
4431
|
+
reopened: item.reviewReopenReason ?? null,
|
|
4427
4432
|
pullRequest: item.reviewRequest
|
|
4428
4433
|
? { url: item.reviewRequest.url, state: item.reviewRequest.state }
|
|
4429
4434
|
: null,
|
|
4435
|
+
readyBeforeReview: waitingReview.includes(item.reviewState ?? '') && item.reviewRequest?.state === 'ready',
|
|
4436
|
+
pullRequestChange: item.providerJob
|
|
4437
|
+
? {
|
|
4438
|
+
job: item.providerJob,
|
|
4439
|
+
since: item.providerJobSince ?? null,
|
|
4440
|
+
failure: item.providerFailure ?? null,
|
|
4441
|
+
failedAt: item.providerFailedAt ?? null,
|
|
4442
|
+
}
|
|
4443
|
+
: null,
|
|
4430
4444
|
openRound: item.openRound
|
|
4431
4445
|
? { number: item.openRound.number, since: item.openRound.createdAt }
|
|
4432
4446
|
: null,
|
|
4433
4447
|
openFindings: item.openFindings,
|
|
4434
4448
|
})),
|
|
4435
|
-
nextAction: 'These deliveries wait for code review. Mention them to the user in one line and start nothing on your own. When the user asks, review.get reads one and review.start runs its next round from a clone of this project; a review that ended while its PR/MR is still a draft waits for review.make_ready.',
|
|
4449
|
+
nextAction: 'These deliveries wait for code review. Mention them to the user in one line and start nothing on your own. When the user asks, review.get reads one and review.start runs its next round from a clone of this project; a review that ended while its PR/MR is still a draft waits for review.make_ready. reopened (new_commit or new_target) means the review had passed and then a new commit or a new target branch arrived, so it waits again; readyBeforeReview means its PR/MR was made ready while the review had not ended; pullRequestChange is a make-ready or make-draft that a member computer with a GitLab token does in the background, with the last failure when one was recorded.',
|
|
4436
4450
|
});
|
|
4437
4451
|
}
|
|
4438
4452
|
catch {
|
|
@@ -4757,7 +4771,18 @@ export class BridgeService {
|
|
|
4757
4771
|
return this.execute(() => this.mutateWithOutbox('task.delivery', endpoints.taskDeliveryCancel(input.projectId, input.taskId), { reason: input.reason }));
|
|
4758
4772
|
}
|
|
4759
4773
|
async taskReviewRequest(input) {
|
|
4760
|
-
return this.execute(() =>
|
|
4774
|
+
return this.execute(() => {
|
|
4775
|
+
const report = (reading) => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), cleanJson({
|
|
4776
|
+
url: input.url,
|
|
4777
|
+
state: input.state,
|
|
4778
|
+
...(reading ? { headCommit: input.headCommit, targetBranch: input.targetBranch } : {}),
|
|
4779
|
+
}));
|
|
4780
|
+
return report(true).catch((error) => {
|
|
4781
|
+
if (!backendRejectsField(error, ['headCommit', 'targetBranch']))
|
|
4782
|
+
throw error;
|
|
4783
|
+
return report(false);
|
|
4784
|
+
});
|
|
4785
|
+
});
|
|
4761
4786
|
}
|
|
4762
4787
|
async taskOpenReviewRequest(input) {
|
|
4763
4788
|
return this.execute(async () => {
|
|
@@ -4882,7 +4907,7 @@ export class BridgeService {
|
|
|
4882
4907
|
: `No code review was asked for ${task}. A review starts when its delivery chooses a reviewed PR/MR, or, where review is required, when its PR/MR is recorded.`, 'review.get');
|
|
4883
4908
|
if (delivery.state !== 'delivered' || !delivery.pushed || !delivery.branch)
|
|
4884
4909
|
throw refuse(`${task} is not pushed yet, and a review reads the pushed branch. Push it and record the delivery (worktree.release with deliveryOutcome delivered), then call review.start again.`, 'review.get');
|
|
4885
|
-
const target = input.targetBranch ?? delivery.baseBranch;
|
|
4910
|
+
const target = input.targetBranch ?? delivery.reviewRequest?.targetBranch ?? delivery.baseBranch;
|
|
4886
4911
|
if (!target)
|
|
4887
4912
|
throw refuse(`The delivery of ${task} names no target branch. Call review.start again with targetBranch: the branch its PR/MR goes into.`, 'review.start');
|
|
4888
4913
|
const git = this.dependencies.repositories.git;
|
|
@@ -4894,9 +4919,18 @@ export class BridgeService {
|
|
|
4894
4919
|
if (!tip)
|
|
4895
4920
|
throw refuse(`The target branch ${target} could not be fetched from origin. If the PR/MR goes into another branch, call review.start again with targetBranch.`, 'review.start');
|
|
4896
4921
|
const base = await git.mergeBase(repository.repoRoot, head.sourceCommit, tip.sourceCommit);
|
|
4897
|
-
const
|
|
4922
|
+
const startRound = (withTarget) => this.dependencies.client.request(endpoints.reviewRounds(input.projectId, input.taskId), {
|
|
4898
4923
|
method: 'POST',
|
|
4899
|
-
body: cleanJson({
|
|
4924
|
+
body: cleanJson({
|
|
4925
|
+
commit: head.sourceCommit,
|
|
4926
|
+
baseCommit: base ?? undefined,
|
|
4927
|
+
targetBranch: withTarget ? target : undefined,
|
|
4928
|
+
}),
|
|
4929
|
+
});
|
|
4930
|
+
const started = reviewSchema.parse((await startRound(true).catch((error) => {
|
|
4931
|
+
if (!backendRejectsField(error, ['targetBranch']))
|
|
4932
|
+
throw error;
|
|
4933
|
+
return startRound(false);
|
|
4900
4934
|
})).data);
|
|
4901
4935
|
const round = started.rounds.find((candidate) => candidate.state === 'open');
|
|
4902
4936
|
if (!round)
|
|
@@ -4904,6 +4938,12 @@ export class BridgeService {
|
|
|
4904
4938
|
const files = base
|
|
4905
4939
|
? await git.changedFiles(repository.repoRoot, base, head.sourceCommit)
|
|
4906
4940
|
: [];
|
|
4941
|
+
const previous = started.rounds.find((candidate) => candidate.state === 'submitted' && candidate.number < round.number);
|
|
4942
|
+
const since = previous &&
|
|
4943
|
+
previous.headCommit !== head.sourceCommit &&
|
|
4944
|
+
(await git.isAncestor(repository.repoRoot, previous.headCommit, head.sourceCommit))
|
|
4945
|
+
? await git.changedFiles(repository.repoRoot, previous.headCommit, head.sourceCommit)
|
|
4946
|
+
: null;
|
|
4907
4947
|
const records = await this.dependencies.client.request(`${endpoints.reviewRoundRules(input.projectId, round.id)}?offset=0&limit=5`);
|
|
4908
4948
|
const range = base ? `${base} ${head.sourceCommit}` : head.sourceCommit;
|
|
4909
4949
|
return asJsonValue({
|
|
@@ -4925,22 +4965,36 @@ export class BridgeService {
|
|
|
4925
4965
|
},
|
|
4926
4966
|
changedFiles: files.slice(0, 300),
|
|
4927
4967
|
changedFileCount: files.length,
|
|
4968
|
+
...(previous && since
|
|
4969
|
+
? {
|
|
4970
|
+
sinceLastRound: {
|
|
4971
|
+
commit: previous.headCommit,
|
|
4972
|
+
changedFiles: since.slice(0, 300),
|
|
4973
|
+
changedFileCount: since.length,
|
|
4974
|
+
},
|
|
4975
|
+
}
|
|
4976
|
+
: {}),
|
|
4928
4977
|
earlierFindings: round.number > 1 ? started.findings : [],
|
|
4929
4978
|
records: records.data,
|
|
4930
|
-
commands:
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4979
|
+
commands: [
|
|
4980
|
+
...(base
|
|
4981
|
+
? [
|
|
4982
|
+
`git diff --stat ${range}`,
|
|
4983
|
+
`git diff ${range} -- <path>`,
|
|
4984
|
+
`git show ${head.sourceCommit}:<path>`,
|
|
4985
|
+
`git grep -n <pattern> ${head.sourceCommit} -- <path>`,
|
|
4986
|
+
`git log --oneline ${base}..${head.sourceCommit}`,
|
|
4987
|
+
]
|
|
4988
|
+
: [
|
|
4989
|
+
`git show --stat ${head.sourceCommit}`,
|
|
4990
|
+
`git show ${head.sourceCommit}:<path>`,
|
|
4991
|
+
`git grep -n <pattern> ${head.sourceCommit} -- <path>`,
|
|
4992
|
+
]),
|
|
4993
|
+
...(previous && since
|
|
4994
|
+
? [`git diff ${previous.headCommit} ${head.sourceCommit} -- <path>`]
|
|
4995
|
+
: []),
|
|
4996
|
+
],
|
|
4997
|
+
nextAction: `Give this packet to a fresh subagent that did not write the change; if your host cannot start one, review it yourself under the same rules. The reviewer reads the change from baseCommit to headCommit only through the listed read-only Git commands: it never checks out, switches branches, edits, commits or pushes, and changes no file. It judges the change against the records (more with review.rules for this round from offset 5 while more are listed) and says whether each earlier finding still holds${previous && since ? ', starting from sinceLastRound: what changed since the previous round' : ''}. Each finding names the file path, the line in headCommit when there is one, the ruleKey of the record it breaks when one applies, and a plain description of what is wrong and why. Then call review.submit with this projectId, roundId and the findings, an empty list when there are none.`,
|
|
4944
4998
|
});
|
|
4945
4999
|
});
|
|
4946
5000
|
}
|
|
@@ -4971,14 +5025,20 @@ export class BridgeService {
|
|
|
4971
5025
|
}));
|
|
4972
5026
|
const idempotencyKey = deterministicUuid('review.submit', input.roundId, stableStringify(findings));
|
|
4973
5027
|
const response = await this.dependencies.client.request(endpoints.reviewRoundResult(input.projectId, input.roundId), { method: 'POST', body: cleanJson({ idempotencyKey, findings }) });
|
|
5028
|
+
const comment = await this.reviewComment(input.projectId, response.data);
|
|
4974
5029
|
return asJsonValue({
|
|
4975
5030
|
review: response.data,
|
|
4976
5031
|
masked: [...new Set(checked.flatMap(({ mask }) => mask.masked))],
|
|
5032
|
+
...(comment ? { comment } : {}),
|
|
4977
5033
|
});
|
|
4978
5034
|
});
|
|
4979
5035
|
}
|
|
4980
5036
|
async reviewRejectFinding(input) {
|
|
4981
|
-
return this.execute(async () =>
|
|
5037
|
+
return this.execute(async () => {
|
|
5038
|
+
const response = await this.dependencies.client.request(endpoints.reviewFindingRejection(input.projectId, input.findingId), { method: 'POST', body: { reason: input.reason } });
|
|
5039
|
+
const comment = await this.reviewComment(input.projectId, response.data);
|
|
5040
|
+
return asJsonValue({ ...objectOrEmpty(response.data), ...(comment ? { comment } : {}) });
|
|
5041
|
+
});
|
|
4982
5042
|
}
|
|
4983
5043
|
async reviewConclude(input) {
|
|
4984
5044
|
return this.execute(async () => {
|
|
@@ -4987,15 +5047,18 @@ export class BridgeService {
|
|
|
4987
5047
|
: input.outcome === 'skipped'
|
|
4988
5048
|
? endpoints.reviewSkip(input.projectId, input.taskId)
|
|
4989
5049
|
: endpoints.reviewExemption(input.projectId, input.taskId);
|
|
4990
|
-
|
|
5050
|
+
const response = await this.dependencies.client.request(path, {
|
|
4991
5051
|
method: 'POST',
|
|
4992
5052
|
...(input.outcome === 'exempted' ? { body: { reason: input.reason ?? '' } } : {}),
|
|
4993
|
-
})
|
|
5053
|
+
});
|
|
5054
|
+
const comment = await this.reviewComment(input.projectId, response.data);
|
|
5055
|
+
return asJsonValue({ ...objectOrEmpty(response.data), ...(comment ? { comment } : {}) });
|
|
4994
5056
|
});
|
|
4995
5057
|
}
|
|
4996
5058
|
async reviewMakeReady(input) {
|
|
4997
5059
|
return this.execute(async () => {
|
|
4998
|
-
const
|
|
5060
|
+
const data = (await this.dependencies.client.request(endpoints.review(input.projectId, input.taskId))).data;
|
|
5061
|
+
const { delivery } = reviewSchema.parse(data);
|
|
4999
5062
|
const task = delivery.externalTaskId;
|
|
5000
5063
|
const request = delivery.reviewRequest;
|
|
5001
5064
|
if (waitingReview.includes(delivery.reviewState ?? ''))
|
|
@@ -5005,32 +5068,75 @@ export class BridgeService {
|
|
|
5005
5068
|
ready: false,
|
|
5006
5069
|
nextAction: `No PR/MR is recorded for ${task}. Open it now as ready: on GitLab task.open_review_request with draft false; elsewhere with your own tools, then record it with task.review_request.`,
|
|
5007
5070
|
});
|
|
5071
|
+
const comment = await this.reviewComment(input.projectId, data);
|
|
5072
|
+
const commented = comment ? { comment } : {};
|
|
5008
5073
|
if (request.state !== 'draft')
|
|
5009
5074
|
return asJsonValue({
|
|
5010
5075
|
ready: request.state === 'ready',
|
|
5011
5076
|
pullRequest: { url: request.url, state: request.state },
|
|
5077
|
+
...commented,
|
|
5012
5078
|
nextAction: `${request.url} is ${request.state} already; nothing was changed.`,
|
|
5013
5079
|
});
|
|
5014
5080
|
if (request.provider !== 'gitlab' || !this.dependencies.gitlab)
|
|
5015
5081
|
return asJsonValue({
|
|
5016
5082
|
ready: false,
|
|
5017
5083
|
pullRequest: { url: request.url, state: request.state },
|
|
5084
|
+
...commented,
|
|
5018
5085
|
nextAction: `Mark ${request.url} ready for review with your own tools (on GitHub, gh pr ready or the "Ready for review" button), or ask the user to, then record it with task.review_request state ready.`,
|
|
5019
5086
|
});
|
|
5020
|
-
const readied = await this.dependencies.gitlab.requests
|
|
5087
|
+
const readied = await this.dependencies.gitlab.requests
|
|
5088
|
+
.markReady(request)
|
|
5089
|
+
.catch(async (error) => {
|
|
5090
|
+
const failure = error instanceof BridgeRecoveryError ? objectValue(error.details)?.failure : undefined;
|
|
5091
|
+
if (delivery.providerJob === 'make_ready' && typeof failure === 'string')
|
|
5092
|
+
await this.dependencies.client
|
|
5093
|
+
.request(endpoints.reviewProviderFailure(input.projectId, input.taskId), {
|
|
5094
|
+
method: 'POST',
|
|
5095
|
+
body: { job: 'make_ready', failure },
|
|
5096
|
+
})
|
|
5097
|
+
.catch(() => undefined);
|
|
5098
|
+
throw error;
|
|
5099
|
+
});
|
|
5021
5100
|
if (readied.state !== 'ready')
|
|
5022
5101
|
return asJsonValue({
|
|
5023
5102
|
ready: false,
|
|
5024
5103
|
pullRequest: readied,
|
|
5104
|
+
...commented,
|
|
5025
5105
|
nextAction: `GitLab still shows ${readied.url} as ${readied.state}. Ask the user to mark it ready in GitLab ("Mark as ready"); Engineering Memory follows it.`,
|
|
5026
5106
|
});
|
|
5027
5107
|
const report = await this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: readied.url, state: 'ready', source: 'machine' });
|
|
5028
|
-
return asJsonValue({ ready: true, pullRequest: readied, report });
|
|
5108
|
+
return asJsonValue({ ready: true, pullRequest: readied, ...commented, report });
|
|
5029
5109
|
});
|
|
5030
5110
|
}
|
|
5031
5111
|
async review(projectId, taskId) {
|
|
5032
5112
|
return reviewSchema.parse((await this.dependencies.client.request(endpoints.review(projectId, taskId))).data);
|
|
5033
5113
|
}
|
|
5114
|
+
async reviewComment(projectId, data) {
|
|
5115
|
+
const review = commentReviewSchema.safeParse(data);
|
|
5116
|
+
const request = review.success ? review.data.delivery.reviewRequest : null;
|
|
5117
|
+
const comment = review.success && request ? dueComment(review.data, (await this.language()) ?? 'en') : null;
|
|
5118
|
+
if (!review.success || !request || !comment)
|
|
5119
|
+
return null;
|
|
5120
|
+
const taskId = review.data.delivery.taskId;
|
|
5121
|
+
if (request.provider === 'gitlab' && this.dependencies.gitlab) {
|
|
5122
|
+
const written = await this.dependencies.gitlab.requests.comment(projectId, taskId, request, comment);
|
|
5123
|
+
if (written.posted)
|
|
5124
|
+
return { posted: true };
|
|
5125
|
+
return {
|
|
5126
|
+
posted: false,
|
|
5127
|
+
...(written.failure ? { failure: written.failure } : {}),
|
|
5128
|
+
nextAction: written.failure
|
|
5129
|
+
? `GitLab refused the review comment on ${request.url} (${written.failure.replace(/_/g, ' ')}); a member computer with a GitLab token writes it later, and nothing waits for it.`
|
|
5130
|
+
: `No GitLab token is saved on this computer, so the review comment was not written on ${request.url}; a member computer with a GitLab token writes it later, and gitlab.token lets this computer write it too.`,
|
|
5131
|
+
};
|
|
5132
|
+
}
|
|
5133
|
+
await markComment(this.dependencies.client, projectId, taskId, comment);
|
|
5134
|
+
return {
|
|
5135
|
+
posted: false,
|
|
5136
|
+
body: comment.body,
|
|
5137
|
+
nextAction: `Post body unchanged as a comment on ${request.url} with your own tools (on GitHub, gh pr comment with the body in a file), so the team sees the review there. It is recorded as handed over and is not handed over again.`,
|
|
5138
|
+
};
|
|
5139
|
+
}
|
|
5034
5140
|
async reportDeliveryOutcome(report) {
|
|
5035
5141
|
const path = report.outcome === 'delivered'
|
|
5036
5142
|
? endpoints.taskDeliveryDeliver(report.projectId, report.taskId)
|
|
@@ -6241,11 +6347,6 @@ function classifyError(error) {
|
|
|
6241
6347
|
}
|
|
6242
6348
|
return 'bridge_delivery_error';
|
|
6243
6349
|
}
|
|
6244
|
-
function backendRejectsField(error, fields) {
|
|
6245
|
-
return (error instanceof ApiResponseError &&
|
|
6246
|
-
error.httpStatus === 400 &&
|
|
6247
|
-
fields.some((field) => error.message.includes(`property ${field} should not exist`)));
|
|
6248
|
-
}
|
|
6249
6350
|
function isDefinitiveAuthenticationFailure(error) {
|
|
6250
6351
|
return (error instanceof ApiResponseError &&
|
|
6251
6352
|
(error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
|
|
@@ -113,7 +113,7 @@ export function createBridgeService(options = {}) {
|
|
|
113
113
|
gitlab: {
|
|
114
114
|
tokens: gitlabTokens,
|
|
115
115
|
page: new GitLabTokenPage(gitlabTokens, gitlab, signedInLanguage),
|
|
116
|
-
requests: new MergeRequests(client, gitlabTokens, gitlab, git),
|
|
116
|
+
requests: new MergeRequests(client, gitlabTokens, gitlab, git, signedInLanguage),
|
|
117
117
|
},
|
|
118
118
|
}));
|
|
119
119
|
}
|
|
@@ -29,6 +29,9 @@ const kindByPrefix = [
|
|
|
29
29
|
['sync-start-', 'project_onboarding'],
|
|
30
30
|
['workflow-apply-', 'project_onboarding'],
|
|
31
31
|
['status-description-', 'project_onboarding'],
|
|
32
|
+
['review-policy-', 'review_decision'],
|
|
33
|
+
['review-reject-', 'review_decision'],
|
|
34
|
+
['review-conclude-', 'review_decision'],
|
|
32
35
|
];
|
|
33
36
|
export function liveQuestionKind(record) {
|
|
34
37
|
if (record.owner?.tool === 'decision.mode')
|
|
@@ -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(),
|
|
@@ -14,6 +16,10 @@ export const reviewDeliverySchema = z.object({
|
|
|
14
16
|
branch: z.string().nullish(),
|
|
15
17
|
workItemKey: z.string().nullish(),
|
|
16
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(),
|
|
17
23
|
reviewRequest: z
|
|
18
24
|
.object({
|
|
19
25
|
provider: z.string(),
|
|
@@ -23,6 +29,7 @@ export const reviewDeliverySchema = z.object({
|
|
|
23
29
|
url: z.string(),
|
|
24
30
|
state: z.string(),
|
|
25
31
|
source: z.string().optional(),
|
|
32
|
+
targetBranch: z.string().nullish(),
|
|
26
33
|
})
|
|
27
34
|
.nullish(),
|
|
28
35
|
});
|
|
@@ -54,13 +61,16 @@ export class MergeRequests {
|
|
|
54
61
|
tokens;
|
|
55
62
|
gitlab;
|
|
56
63
|
git;
|
|
64
|
+
language;
|
|
57
65
|
passes = new Map();
|
|
58
66
|
running = new Set();
|
|
59
|
-
|
|
67
|
+
readingRefused = false;
|
|
68
|
+
constructor(client, tokens, gitlab, git, language) {
|
|
60
69
|
this.client = client;
|
|
61
70
|
this.tokens = tokens;
|
|
62
71
|
this.gitlab = gitlab;
|
|
63
72
|
this.git = git;
|
|
73
|
+
this.language = language;
|
|
64
74
|
}
|
|
65
75
|
async open(input) {
|
|
66
76
|
const delivery = reviewDeliverySchema.parse((await this.client.request(endpoints.taskDelivery(input.projectId, input.taskId)))
|
|
@@ -166,6 +176,21 @@ export class MergeRequests {
|
|
|
166
176
|
: 'review.make_ready', { address: at, failure: error.failure, status: error.status });
|
|
167
177
|
}
|
|
168
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
|
+
}
|
|
169
194
|
schedule(projectId, repoRoot, deliveries) {
|
|
170
195
|
const now = Date.now();
|
|
171
196
|
const last = this.passes.get(projectId);
|
|
@@ -192,8 +217,17 @@ export class MergeRequests {
|
|
|
192
217
|
const worker = async () => {
|
|
193
218
|
for (let delivery = queue.shift(); delivery && pass.calls > 0; delivery = queue.shift()) {
|
|
194
219
|
const found = await this.follow(delivery, repoRoot, pass).catch(() => null);
|
|
195
|
-
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;
|
|
196
227
|
reported++;
|
|
228
|
+
job = answer.providerJob;
|
|
229
|
+
}
|
|
230
|
+
await this.provide(projectId, delivery, found, job, pass).catch(() => undefined);
|
|
197
231
|
}
|
|
198
232
|
};
|
|
199
233
|
await Promise.all([worker(), worker(), worker(), worker()]);
|
|
@@ -209,10 +243,9 @@ export class MergeRequests {
|
|
|
209
243
|
if (!link || !token)
|
|
210
244
|
return null;
|
|
211
245
|
const request = await this.call(pass, link.address, () => this.gitlab.mergeRequest(link.address, token, link.project, known.number));
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
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;
|
|
216
249
|
}
|
|
217
250
|
if (!repoRoot || !delivery.branch)
|
|
218
251
|
return null;
|
|
@@ -242,7 +275,78 @@ export class MergeRequests {
|
|
|
242
275
|
.sort((left, right) => rank(left) - rank(right) ||
|
|
243
276
|
Number(right.sha === delivery.deliveredCommit) -
|
|
244
277
|
Number(left.sha === delivery.deliveredCommit))[0];
|
|
245
|
-
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);
|
|
246
350
|
}
|
|
247
351
|
async call(pass, address, request) {
|
|
248
352
|
if (pass.failed.has(address) || pass.calls <= 0)
|
|
@@ -259,16 +363,28 @@ export class MergeRequests {
|
|
|
259
363
|
}
|
|
260
364
|
}
|
|
261
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
|
+
});
|
|
262
376
|
try {
|
|
263
|
-
await
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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();
|
|
267
382
|
});
|
|
268
|
-
|
|
383
|
+
const answer = z.object({ providerJob: z.string().nullish() }).safeParse(response.data);
|
|
384
|
+
return { providerJob: answer.success ? (answer.data.providerJob ?? null) : null };
|
|
269
385
|
}
|
|
270
386
|
catch {
|
|
271
|
-
return
|
|
387
|
+
return null;
|
|
272
388
|
}
|
|
273
389
|
}
|
|
274
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
|
|
@@ -304,12 +304,14 @@ When the user later says a pull request became ready, was merged or was closed,
|
|
|
304
304
|
|
|
305
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
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. 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.
|
|
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
308
|
|
|
309
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
310
|
|
|
311
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
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
|
+
|
|
313
315
|
## Code source and memory applicability
|
|
314
316
|
|
|
315
317
|
The bridge sends the checkout's committed Git commit and tree at bootstrap. Task and session
|