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
|
@@ -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';
|
|
@@ -24,7 +24,9 @@ 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
26
|
import { gitLabRemote } from '../providers/gitlab.js';
|
|
27
|
-
import { awaitsReviewRequest, reviewDeliveries, } from './merge-request-sync.js';
|
|
27
|
+
import { awaitsReviewRequest, concludedReview, opensAsDraft, reviewDeliveries, reviewDeliverySchema, waitingReview, } from './merge-request-sync.js';
|
|
28
|
+
import { maskFinding } from './review-masking.js';
|
|
29
|
+
import { commentReviewSchema, dueComment, markComment } from './review-comment.js';
|
|
28
30
|
export const validationIds = [
|
|
29
31
|
'format',
|
|
30
32
|
'static_analysis',
|
|
@@ -453,6 +455,7 @@ export class BridgeService {
|
|
|
453
455
|
taskKind: input.taskKind,
|
|
454
456
|
workItemKey: input.workItemKey,
|
|
455
457
|
workItemId: input.workItemId,
|
|
458
|
+
reviewFixOf: input.reviewFixOf,
|
|
456
459
|
mode: input.mode,
|
|
457
460
|
knownRevisions: input.knownRevisions,
|
|
458
461
|
}, repository.repoRoot);
|
|
@@ -513,6 +516,9 @@ export class BridgeService {
|
|
|
513
516
|
this.poolAllocations.set(repository.repoRoot, poolAllocation);
|
|
514
517
|
}
|
|
515
518
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
519
|
+
const reviewed = persistedBootstrap.reviewFixOf
|
|
520
|
+
? (await this.review(projectId, persistedBootstrap.reviewFixOf)).delivery
|
|
521
|
+
: null;
|
|
516
522
|
const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
|
|
517
523
|
method: 'POST',
|
|
518
524
|
idempotencyKey: checkpointId,
|
|
@@ -523,7 +529,8 @@ export class BridgeService {
|
|
|
523
529
|
objective: persistedBootstrap.objective,
|
|
524
530
|
taskKind: persistedBootstrap.taskKind,
|
|
525
531
|
workItemKey: persistedBootstrap.workItemKey,
|
|
526
|
-
workItemId: persistedBootstrap.workItemId,
|
|
532
|
+
workItemId: persistedBootstrap.workItemId ?? reviewed?.workItemId ?? undefined,
|
|
533
|
+
reviewFixOfTaskId: persistedBootstrap.reviewFixOf,
|
|
527
534
|
mode: persistedBootstrap.mode ?? 'write',
|
|
528
535
|
repoFingerprint: repository.repoFingerprint,
|
|
529
536
|
checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
|
|
@@ -2608,6 +2615,14 @@ export class BridgeService {
|
|
|
2608
2615
|
if (unusable)
|
|
2609
2616
|
throw refuse(unusable, 'task.branch');
|
|
2610
2617
|
}
|
|
2618
|
+
const reviewed = input.reviewFixOf
|
|
2619
|
+
? (await this.review(repository.projectId, input.reviewFixOf)).delivery
|
|
2620
|
+
: null;
|
|
2621
|
+
if (reviewed &&
|
|
2622
|
+
(!waitingReview.includes(reviewed.reviewState ?? '') ||
|
|
2623
|
+
!reviewed.pushed ||
|
|
2624
|
+
!reviewed.branch))
|
|
2625
|
+
throw refuse(`The review of ${reviewed.externalTaskId} is not waiting for a fix (${reviewed.reviewState ?? 'no review'}${reviewed.pushed ? '' : ', not pushed'}), so there is no pull request branch to fix. Read it with review.get.`, 'review.get');
|
|
2611
2626
|
const [preferences, currentBranch, folder, suggestedName] = await Promise.all([
|
|
2612
2627
|
this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId)),
|
|
2613
2628
|
this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
@@ -2621,6 +2636,9 @@ export class BridgeService {
|
|
|
2621
2636
|
currentCommit: repository.git.head,
|
|
2622
2637
|
folder,
|
|
2623
2638
|
suggestedName,
|
|
2639
|
+
reviewFix: reviewed
|
|
2640
|
+
? { branch: reviewed.branch, externalTaskId: reviewed.externalTaskId }
|
|
2641
|
+
: null,
|
|
2624
2642
|
...(input.workItemId
|
|
2625
2643
|
? await this.workItemBranches(repository.repoRoot, repository.projectId, input.workItemProjectId ?? repository.projectId, input.workItemId, preferences.data)
|
|
2626
2644
|
: { planBranch: null, continueBranches: [] }),
|
|
@@ -4169,7 +4187,7 @@ export class BridgeService {
|
|
|
4169
4187
|
const livePointers = authenticated && state === 'bound'
|
|
4170
4188
|
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
4171
4189
|
: Promise.resolve([]);
|
|
4172
|
-
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews,] = await Promise.all([
|
|
4190
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews, codeReviews,] = await Promise.all([
|
|
4173
4191
|
this.clientUpdate(authenticated),
|
|
4174
4192
|
livePointers,
|
|
4175
4193
|
authenticated && state === 'bound' && projectId
|
|
@@ -4196,6 +4214,9 @@ export class BridgeService {
|
|
|
4196
4214
|
authenticated && state === 'bound' && projectId
|
|
4197
4215
|
? this.reviewCandidates(projectId)
|
|
4198
4216
|
: Promise.resolve(null),
|
|
4217
|
+
authenticated && state === 'bound' && projectId
|
|
4218
|
+
? this.awaitingReviews(projectId)
|
|
4219
|
+
: Promise.resolve(null),
|
|
4199
4220
|
]);
|
|
4200
4221
|
timer.mark('independent_lookups');
|
|
4201
4222
|
if (projectId && reviews)
|
|
@@ -4257,12 +4278,13 @@ export class BridgeService {
|
|
|
4257
4278
|
workItemKey: delivery.workItemKey ?? null,
|
|
4258
4279
|
branch: delivery.branch ?? null,
|
|
4259
4280
|
targetBranch: delivery.baseBranch ?? null,
|
|
4260
|
-
draft: delivery
|
|
4281
|
+
draft: opensAsDraft(delivery),
|
|
4261
4282
|
})),
|
|
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.',
|
|
4283
|
+
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, as a draft where draft is true. A merge request someone opened by hand on a GitLab this computer follows is found by itself.',
|
|
4263
4284
|
},
|
|
4264
4285
|
}
|
|
4265
4286
|
: {}),
|
|
4287
|
+
...(codeReviews ? { awaitingReviews: codeReviews } : {}),
|
|
4266
4288
|
client,
|
|
4267
4289
|
...moved,
|
|
4268
4290
|
...(incomplete && state === 'bound'
|
|
@@ -4382,6 +4404,55 @@ export class BridgeService {
|
|
|
4382
4404
|
return null;
|
|
4383
4405
|
}
|
|
4384
4406
|
}
|
|
4407
|
+
async awaitingReviews(projectId) {
|
|
4408
|
+
try {
|
|
4409
|
+
const response = await this.dependencies.client.request(`${endpoints.reviewQueue(projectId)}?offset=0&limit=5`);
|
|
4410
|
+
const page = z
|
|
4411
|
+
.object({
|
|
4412
|
+
total: z.number().int(),
|
|
4413
|
+
items: z.array(reviewDeliverySchema.extend({
|
|
4414
|
+
reviewReopenReason: z.string().nullish(),
|
|
4415
|
+
providerJobSince: z.string().nullish(),
|
|
4416
|
+
providerFailure: z.string().nullish(),
|
|
4417
|
+
openRound: z.object({ number: z.number().int(), createdAt: z.string() }).nullish(),
|
|
4418
|
+
openFindings: z.number().int(),
|
|
4419
|
+
})),
|
|
4420
|
+
})
|
|
4421
|
+
.parse(response.data);
|
|
4422
|
+
if (!page.total)
|
|
4423
|
+
return null;
|
|
4424
|
+
return asJsonValue({
|
|
4425
|
+
total: page.total,
|
|
4426
|
+
items: page.items.map((item) => ({
|
|
4427
|
+
taskId: item.taskId,
|
|
4428
|
+
externalTaskId: item.externalTaskId ?? null,
|
|
4429
|
+
workItemKey: item.workItemKey ?? null,
|
|
4430
|
+
reviewState: item.reviewState ?? null,
|
|
4431
|
+
reopened: item.reviewReopenReason ?? null,
|
|
4432
|
+
pullRequest: item.reviewRequest
|
|
4433
|
+
? { url: item.reviewRequest.url, state: item.reviewRequest.state }
|
|
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,
|
|
4444
|
+
openRound: item.openRound
|
|
4445
|
+
? { number: item.openRound.number, since: item.openRound.createdAt }
|
|
4446
|
+
: null,
|
|
4447
|
+
openFindings: item.openFindings,
|
|
4448
|
+
})),
|
|
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.',
|
|
4450
|
+
});
|
|
4451
|
+
}
|
|
4452
|
+
catch {
|
|
4453
|
+
return null;
|
|
4454
|
+
}
|
|
4455
|
+
}
|
|
4385
4456
|
async actionableWorkItems(projectId) {
|
|
4386
4457
|
try {
|
|
4387
4458
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100&actionable=true`);
|
|
@@ -4692,6 +4763,7 @@ export class BridgeService {
|
|
|
4692
4763
|
expectedVersion: input.expectedVersion,
|
|
4693
4764
|
choice: input.choice,
|
|
4694
4765
|
baseBranch: input.baseBranch,
|
|
4766
|
+
expectedReviewPolicy: input.expectedReviewPolicy,
|
|
4695
4767
|
answerSource: input.answerSource,
|
|
4696
4768
|
})));
|
|
4697
4769
|
}
|
|
@@ -4699,7 +4771,18 @@ export class BridgeService {
|
|
|
4699
4771
|
return this.execute(() => this.mutateWithOutbox('task.delivery', endpoints.taskDeliveryCancel(input.projectId, input.taskId), { reason: input.reason }));
|
|
4700
4772
|
}
|
|
4701
4773
|
async taskReviewRequest(input) {
|
|
4702
|
-
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
|
+
});
|
|
4703
4786
|
}
|
|
4704
4787
|
async taskOpenReviewRequest(input) {
|
|
4705
4788
|
return this.execute(async () => {
|
|
@@ -4778,6 +4861,282 @@ export class BridgeService {
|
|
|
4778
4861
|
});
|
|
4779
4862
|
});
|
|
4780
4863
|
}
|
|
4864
|
+
async projectReviewPolicy(input) {
|
|
4865
|
+
return this.execute(async () => {
|
|
4866
|
+
const projects = await this.dependencies.client.request(endpoints.projectList);
|
|
4867
|
+
const project = z
|
|
4868
|
+
.array(z.object({
|
|
4869
|
+
id: z.string(),
|
|
4870
|
+
name: z.string(),
|
|
4871
|
+
lockVersion: z.number().int(),
|
|
4872
|
+
role: z.string().optional(),
|
|
4873
|
+
reviewPolicy: reviewPolicySchema.optional(),
|
|
4874
|
+
reviewPolicyChangedAt: z.string().nullish(),
|
|
4875
|
+
}))
|
|
4876
|
+
.parse(projects.data)
|
|
4877
|
+
.find((candidate) => candidate.id === input.projectId);
|
|
4878
|
+
if (!project)
|
|
4879
|
+
throw refuse('This project is not among the projects you can open.', 'project.list');
|
|
4880
|
+
if (!project.reviewPolicy)
|
|
4881
|
+
throw refuse('This Engineering Memory server has no code review setting yet.', 'session.entry');
|
|
4882
|
+
return asJsonValue(project);
|
|
4883
|
+
});
|
|
4884
|
+
}
|
|
4885
|
+
async reviewQueue(input) {
|
|
4886
|
+
return this.execute(async () => {
|
|
4887
|
+
const query = new URLSearchParams({
|
|
4888
|
+
offset: String(input.offset ?? 0),
|
|
4889
|
+
limit: String(input.limit ?? 20),
|
|
4890
|
+
});
|
|
4891
|
+
return asJsonValue((await this.dependencies.client.request(`${endpoints.reviewQueue(input.projectId)}?${query}`)).data);
|
|
4892
|
+
});
|
|
4893
|
+
}
|
|
4894
|
+
async reviewGet(input) {
|
|
4895
|
+
return this.execute(async () => asJsonValue((await this.dependencies.client.request(endpoints.review(input.projectId, input.taskId))).data));
|
|
4896
|
+
}
|
|
4897
|
+
async reviewStart(input) {
|
|
4898
|
+
return this.execute(async () => {
|
|
4899
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
4900
|
+
if (repository.projectId !== input.projectId)
|
|
4901
|
+
throw refuse("Call this in a clone of the reviewed task's project repository: this folder belongs to another project, or to none.", 'session.entry');
|
|
4902
|
+
const { delivery } = await this.review(input.projectId, input.taskId);
|
|
4903
|
+
const task = delivery.externalTaskId;
|
|
4904
|
+
if (!waitingReview.includes(delivery.reviewState ?? ''))
|
|
4905
|
+
throw refuse(delivery.reviewState && concludedReview.includes(delivery.reviewState)
|
|
4906
|
+
? `The review of ${task} already ended (${delivery.reviewState}), so there is nothing to review. review.make_ready makes its PR/MR ready.`
|
|
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');
|
|
4908
|
+
if (delivery.state !== 'delivered' || !delivery.pushed || !delivery.branch)
|
|
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');
|
|
4910
|
+
const target = input.targetBranch ?? delivery.reviewRequest?.targetBranch ?? delivery.baseBranch;
|
|
4911
|
+
if (!target)
|
|
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');
|
|
4913
|
+
const git = this.dependencies.repositories.git;
|
|
4914
|
+
const fetched = (branch) => git.fetchBranchCommit(repository.repoRoot, 'origin', branch).catch(() => null);
|
|
4915
|
+
const head = await fetched(delivery.branch);
|
|
4916
|
+
if (!head)
|
|
4917
|
+
throw refuse(`This review reads ${delivery.branch} from origin, and this clone could not fetch it: either its origin remote is not the project's repository, or this computer cannot reach the Git server. If the server is reachable only from the company network, connect (for example through the VPN) and call review.start again; any member's computer that reaches it can run the review.`, 'review.start');
|
|
4918
|
+
const tip = await fetched(target);
|
|
4919
|
+
if (!tip)
|
|
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');
|
|
4921
|
+
const base = await git.mergeBase(repository.repoRoot, head.sourceCommit, tip.sourceCommit);
|
|
4922
|
+
const startRound = (withTarget) => this.dependencies.client.request(endpoints.reviewRounds(input.projectId, input.taskId), {
|
|
4923
|
+
method: 'POST',
|
|
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);
|
|
4934
|
+
})).data);
|
|
4935
|
+
const round = started.rounds.find((candidate) => candidate.state === 'open');
|
|
4936
|
+
if (!round)
|
|
4937
|
+
throw refuse(`The review of ${task} has no open round. Read it with review.get.`, 'review.get');
|
|
4938
|
+
const files = base
|
|
4939
|
+
? await git.changedFiles(repository.repoRoot, base, head.sourceCommit)
|
|
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;
|
|
4947
|
+
const records = await this.dependencies.client.request(`${endpoints.reviewRoundRules(input.projectId, round.id)}?offset=0&limit=5`);
|
|
4948
|
+
const range = base ? `${base} ${head.sourceCommit}` : head.sourceCommit;
|
|
4949
|
+
return asJsonValue({
|
|
4950
|
+
review: {
|
|
4951
|
+
projectId: input.projectId,
|
|
4952
|
+
taskId: input.taskId,
|
|
4953
|
+
externalTaskId: task,
|
|
4954
|
+
workItemKey: delivery.workItemKey ?? null,
|
|
4955
|
+
reviewPolicy: delivery.reviewPolicy ?? null,
|
|
4956
|
+
branch: delivery.branch,
|
|
4957
|
+
targetBranch: target,
|
|
4958
|
+
pullRequest: delivery.reviewRequest?.url ?? null,
|
|
4959
|
+
},
|
|
4960
|
+
round: {
|
|
4961
|
+
id: round.id,
|
|
4962
|
+
number: round.number,
|
|
4963
|
+
headCommit: head.sourceCommit,
|
|
4964
|
+
baseCommit: base,
|
|
4965
|
+
},
|
|
4966
|
+
changedFiles: files.slice(0, 300),
|
|
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
|
+
: {}),
|
|
4977
|
+
earlierFindings: round.number > 1 ? started.findings : [],
|
|
4978
|
+
records: records.data,
|
|
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.`,
|
|
4998
|
+
});
|
|
4999
|
+
});
|
|
5000
|
+
}
|
|
5001
|
+
async reviewRules(input) {
|
|
5002
|
+
return this.execute(async () => {
|
|
5003
|
+
const query = new URLSearchParams({
|
|
5004
|
+
offset: String(input.offset ?? 0),
|
|
5005
|
+
limit: String(input.limit ?? 5),
|
|
5006
|
+
});
|
|
5007
|
+
return asJsonValue((await this.dependencies.client.request(`${endpoints.reviewRoundRules(input.projectId, input.roundId)}?${query}`)).data);
|
|
5008
|
+
});
|
|
5009
|
+
}
|
|
5010
|
+
async reviewSubmit(input) {
|
|
5011
|
+
return this.execute(async () => {
|
|
5012
|
+
const absolute = input.findings.findIndex(({ path }) => /^(?:[A-Za-z]:|~)?[\\/]/.test(path));
|
|
5013
|
+
if (absolute >= 0)
|
|
5014
|
+
throw refuse(`Finding ${absolute + 1} names its file by an absolute path. Name it by its path in the repository, as git diff shows it (for example src/app.ts), and call review.submit again.`, 'review.submit');
|
|
5015
|
+
const checked = input.findings.map((finding) => ({
|
|
5016
|
+
finding,
|
|
5017
|
+
mask: maskFinding(finding.description),
|
|
5018
|
+
}));
|
|
5019
|
+
const unsafe = checked.findIndex(({ mask }) => mask.left);
|
|
5020
|
+
if (unsafe >= 0)
|
|
5021
|
+
throw refuse(`The description of finding ${unsafe + 1} still looks like it holds private data (${checked[unsafe].mask.left}) after e-mail addresses, IP addresses and user folders were masked. Describe the problem without the value itself, for example "a secret is committed in this file", and call review.submit again.`, 'review.submit');
|
|
5022
|
+
const findings = checked.map(({ finding, mask }) => ({
|
|
5023
|
+
...finding,
|
|
5024
|
+
description: mask.text,
|
|
5025
|
+
}));
|
|
5026
|
+
const idempotencyKey = deterministicUuid('review.submit', input.roundId, stableStringify(findings));
|
|
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);
|
|
5029
|
+
return asJsonValue({
|
|
5030
|
+
review: response.data,
|
|
5031
|
+
masked: [...new Set(checked.flatMap(({ mask }) => mask.masked))],
|
|
5032
|
+
...(comment ? { comment } : {}),
|
|
5033
|
+
});
|
|
5034
|
+
});
|
|
5035
|
+
}
|
|
5036
|
+
async reviewRejectFinding(input) {
|
|
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
|
+
});
|
|
5042
|
+
}
|
|
5043
|
+
async reviewConclude(input) {
|
|
5044
|
+
return this.execute(async () => {
|
|
5045
|
+
const path = input.outcome === 'passed'
|
|
5046
|
+
? endpoints.reviewPass(input.projectId, input.taskId)
|
|
5047
|
+
: input.outcome === 'skipped'
|
|
5048
|
+
? endpoints.reviewSkip(input.projectId, input.taskId)
|
|
5049
|
+
: endpoints.reviewExemption(input.projectId, input.taskId);
|
|
5050
|
+
const response = await this.dependencies.client.request(path, {
|
|
5051
|
+
method: 'POST',
|
|
5052
|
+
...(input.outcome === 'exempted' ? { body: { reason: input.reason ?? '' } } : {}),
|
|
5053
|
+
});
|
|
5054
|
+
const comment = await this.reviewComment(input.projectId, response.data);
|
|
5055
|
+
return asJsonValue({ ...objectOrEmpty(response.data), ...(comment ? { comment } : {}) });
|
|
5056
|
+
});
|
|
5057
|
+
}
|
|
5058
|
+
async reviewMakeReady(input) {
|
|
5059
|
+
return this.execute(async () => {
|
|
5060
|
+
const data = (await this.dependencies.client.request(endpoints.review(input.projectId, input.taskId))).data;
|
|
5061
|
+
const { delivery } = reviewSchema.parse(data);
|
|
5062
|
+
const task = delivery.externalTaskId;
|
|
5063
|
+
const request = delivery.reviewRequest;
|
|
5064
|
+
if (waitingReview.includes(delivery.reviewState ?? ''))
|
|
5065
|
+
throw refuse(`The review of ${task} has not ended (${delivery.reviewState}), so its PR/MR stays a draft until it does.`, 'review.get');
|
|
5066
|
+
if (!request)
|
|
5067
|
+
return asJsonValue({
|
|
5068
|
+
ready: false,
|
|
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.`,
|
|
5070
|
+
});
|
|
5071
|
+
const comment = await this.reviewComment(input.projectId, data);
|
|
5072
|
+
const commented = comment ? { comment } : {};
|
|
5073
|
+
if (request.state !== 'draft')
|
|
5074
|
+
return asJsonValue({
|
|
5075
|
+
ready: request.state === 'ready',
|
|
5076
|
+
pullRequest: { url: request.url, state: request.state },
|
|
5077
|
+
...commented,
|
|
5078
|
+
nextAction: `${request.url} is ${request.state} already; nothing was changed.`,
|
|
5079
|
+
});
|
|
5080
|
+
if (request.provider !== 'gitlab' || !this.dependencies.gitlab)
|
|
5081
|
+
return asJsonValue({
|
|
5082
|
+
ready: false,
|
|
5083
|
+
pullRequest: { url: request.url, state: request.state },
|
|
5084
|
+
...commented,
|
|
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.`,
|
|
5086
|
+
});
|
|
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
|
+
});
|
|
5100
|
+
if (readied.state !== 'ready')
|
|
5101
|
+
return asJsonValue({
|
|
5102
|
+
ready: false,
|
|
5103
|
+
pullRequest: readied,
|
|
5104
|
+
...commented,
|
|
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.`,
|
|
5106
|
+
});
|
|
5107
|
+
const report = await this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: readied.url, state: 'ready', source: 'machine' });
|
|
5108
|
+
return asJsonValue({ ready: true, pullRequest: readied, ...commented, report });
|
|
5109
|
+
});
|
|
5110
|
+
}
|
|
5111
|
+
async review(projectId, taskId) {
|
|
5112
|
+
return reviewSchema.parse((await this.dependencies.client.request(endpoints.review(projectId, taskId))).data);
|
|
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
|
+
}
|
|
4781
5140
|
async reportDeliveryOutcome(report) {
|
|
4782
5141
|
const path = report.outcome === 'delivered'
|
|
4783
5142
|
? endpoints.taskDeliveryDeliver(report.projectId, report.taskId)
|
|
@@ -5954,6 +6313,21 @@ function slugify(value) {
|
|
|
5954
6313
|
.replace(/[^a-z0-9]+/g, '-')
|
|
5955
6314
|
.replace(/^-|-$/g, '');
|
|
5956
6315
|
}
|
|
6316
|
+
const reviewPolicySchema = z.enum(['none', 'optional', 'required']);
|
|
6317
|
+
const reviewSchema = z.object({
|
|
6318
|
+
delivery: reviewDeliverySchema.extend({
|
|
6319
|
+
externalTaskId: z.string(),
|
|
6320
|
+
workItemId: z.string().nullish(),
|
|
6321
|
+
reviewPolicy: reviewPolicySchema.optional(),
|
|
6322
|
+
}),
|
|
6323
|
+
rounds: z.array(z.object({
|
|
6324
|
+
id: z.string(),
|
|
6325
|
+
number: z.number().int(),
|
|
6326
|
+
state: z.string(),
|
|
6327
|
+
headCommit: z.string(),
|
|
6328
|
+
})),
|
|
6329
|
+
findings: z.array(z.unknown()),
|
|
6330
|
+
});
|
|
5957
6331
|
function deterministicUuid(...parts) {
|
|
5958
6332
|
const value = sha256(parts.join('\n')).slice(0, 32).split('');
|
|
5959
6333
|
value[12] = '5';
|
|
@@ -5973,11 +6347,6 @@ function classifyError(error) {
|
|
|
5973
6347
|
}
|
|
5974
6348
|
return 'bridge_delivery_error';
|
|
5975
6349
|
}
|
|
5976
|
-
function backendRejectsField(error, fields) {
|
|
5977
|
-
return (error instanceof ApiResponseError &&
|
|
5978
|
-
error.httpStatus === 400 &&
|
|
5979
|
-
fields.some((field) => error.message.includes(`property ${field} should not exist`)));
|
|
5980
|
-
}
|
|
5981
6350
|
function isDefinitiveAuthenticationFailure(error) {
|
|
5982
6351
|
return (error instanceof ApiResponseError &&
|
|
5983
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')
|