engineering-memory 1.11.27 → 1.11.29
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 +9 -0
- package/runtime/dist/src/git/git-inspector.js +40 -8
- package/runtime/dist/src/localization/catalogue.generated.js +124 -0
- package/runtime/dist/src/mcp/delivery-tools.js +241 -50
- package/runtime/dist/src/mcp/review-tools.js +369 -0
- package/runtime/dist/src/mcp/tool-annotations.js +11 -0
- package/runtime/dist/src/mcp/tool-definitions.js +18 -0
- package/runtime/dist/src/mcp/worktree-tools.js +12 -1
- package/runtime/dist/src/providers/gitlab-token-page.js +226 -0
- package/runtime/dist/src/providers/gitlab.js +421 -0
- package/runtime/dist/src/runtime/api-client.js +7 -0
- package/runtime/dist/src/runtime/bridge-service.js +366 -3
- package/runtime/dist/src/runtime/create-bridge-service.js +16 -2
- package/runtime/dist/src/runtime/merge-request-sync.js +322 -0
- package/runtime/dist/src/runtime/principal-state.js +4 -1
- 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 -2
|
@@ -23,6 +23,9 @@ import { sameHolder, suggestedBranchNames, taskStartChoice } from './task-start.
|
|
|
23
23
|
import { catalogueLanguages, copies, fetchLanguage, format } from './texts.js';
|
|
24
24
|
import * as z from 'zod/v4';
|
|
25
25
|
import { BridgeRecoveryError } from './recovery-error.js';
|
|
26
|
+
import { gitLabRemote } from '../providers/gitlab.js';
|
|
27
|
+
import { awaitsReviewRequest, concludedReview, opensAsDraft, reviewDeliveries, reviewDeliverySchema, waitingReview, } from './merge-request-sync.js';
|
|
28
|
+
import { maskFinding } from './review-masking.js';
|
|
26
29
|
export const validationIds = [
|
|
27
30
|
'format',
|
|
28
31
|
'static_analysis',
|
|
@@ -451,6 +454,7 @@ export class BridgeService {
|
|
|
451
454
|
taskKind: input.taskKind,
|
|
452
455
|
workItemKey: input.workItemKey,
|
|
453
456
|
workItemId: input.workItemId,
|
|
457
|
+
reviewFixOf: input.reviewFixOf,
|
|
454
458
|
mode: input.mode,
|
|
455
459
|
knownRevisions: input.knownRevisions,
|
|
456
460
|
}, repository.repoRoot);
|
|
@@ -511,6 +515,9 @@ export class BridgeService {
|
|
|
511
515
|
this.poolAllocations.set(repository.repoRoot, poolAllocation);
|
|
512
516
|
}
|
|
513
517
|
const checkpointId = deterministicUuid('session.bootstrap', projectId, repository.repoFingerprint, input.externalTaskId);
|
|
518
|
+
const reviewed = persistedBootstrap.reviewFixOf
|
|
519
|
+
? (await this.review(projectId, persistedBootstrap.reviewFixOf)).delivery
|
|
520
|
+
: null;
|
|
514
521
|
const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
|
|
515
522
|
method: 'POST',
|
|
516
523
|
idempotencyKey: checkpointId,
|
|
@@ -521,7 +528,8 @@ export class BridgeService {
|
|
|
521
528
|
objective: persistedBootstrap.objective,
|
|
522
529
|
taskKind: persistedBootstrap.taskKind,
|
|
523
530
|
workItemKey: persistedBootstrap.workItemKey,
|
|
524
|
-
workItemId: persistedBootstrap.workItemId,
|
|
531
|
+
workItemId: persistedBootstrap.workItemId ?? reviewed?.workItemId ?? undefined,
|
|
532
|
+
reviewFixOfTaskId: persistedBootstrap.reviewFixOf,
|
|
525
533
|
mode: persistedBootstrap.mode ?? 'write',
|
|
526
534
|
repoFingerprint: repository.repoFingerprint,
|
|
527
535
|
checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
|
|
@@ -2606,6 +2614,14 @@ export class BridgeService {
|
|
|
2606
2614
|
if (unusable)
|
|
2607
2615
|
throw refuse(unusable, 'task.branch');
|
|
2608
2616
|
}
|
|
2617
|
+
const reviewed = input.reviewFixOf
|
|
2618
|
+
? (await this.review(repository.projectId, input.reviewFixOf)).delivery
|
|
2619
|
+
: null;
|
|
2620
|
+
if (reviewed &&
|
|
2621
|
+
(!waitingReview.includes(reviewed.reviewState ?? '') ||
|
|
2622
|
+
!reviewed.pushed ||
|
|
2623
|
+
!reviewed.branch))
|
|
2624
|
+
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');
|
|
2609
2625
|
const [preferences, currentBranch, folder, suggestedName] = await Promise.all([
|
|
2610
2626
|
this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId)),
|
|
2611
2627
|
this.dependencies.repositories.git.currentBranch(repository.repoRoot),
|
|
@@ -2619,6 +2635,9 @@ export class BridgeService {
|
|
|
2619
2635
|
currentCommit: repository.git.head,
|
|
2620
2636
|
folder,
|
|
2621
2637
|
suggestedName,
|
|
2638
|
+
reviewFix: reviewed
|
|
2639
|
+
? { branch: reviewed.branch, externalTaskId: reviewed.externalTaskId }
|
|
2640
|
+
: null,
|
|
2622
2641
|
...(input.workItemId
|
|
2623
2642
|
? await this.workItemBranches(repository.repoRoot, repository.projectId, input.workItemProjectId ?? repository.projectId, input.workItemId, preferences.data)
|
|
2624
2643
|
: { planBranch: null, continueBranches: [] }),
|
|
@@ -3774,6 +3793,10 @@ export class BridgeService {
|
|
|
3774
3793
|
query.set('limit', String(input.limit));
|
|
3775
3794
|
const suffix = query.size > 0 ? `?${query.toString()}` : '';
|
|
3776
3795
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
|
|
3796
|
+
this.followMergeRequests(input.projectId, async () => {
|
|
3797
|
+
const here = await this.dependencies.repositories.resolveIdentity(process.cwd());
|
|
3798
|
+
return here.projectId === input.projectId ? here.repoRoot : null;
|
|
3799
|
+
});
|
|
3777
3800
|
return asJsonValue({
|
|
3778
3801
|
selectionRequired: true,
|
|
3779
3802
|
questionnaire: 'Select the project work item to run in this chat',
|
|
@@ -4163,7 +4186,7 @@ export class BridgeService {
|
|
|
4163
4186
|
const livePointers = authenticated && state === 'bound'
|
|
4164
4187
|
? this.dependencies.activeContexts.list(repository.repoFingerprint)
|
|
4165
4188
|
: Promise.resolve([]);
|
|
4166
|
-
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries,] = await Promise.all([
|
|
4189
|
+
const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews, codeReviews,] = await Promise.all([
|
|
4167
4190
|
this.clientUpdate(authenticated),
|
|
4168
4191
|
livePointers,
|
|
4169
4192
|
authenticated && state === 'bound' && projectId
|
|
@@ -4187,8 +4210,17 @@ export class BridgeService {
|
|
|
4187
4210
|
authenticated && state === 'bound' && projectId
|
|
4188
4211
|
? this.openDeliveries(projectId)
|
|
4189
4212
|
: Promise.resolve(null),
|
|
4213
|
+
authenticated && state === 'bound' && projectId
|
|
4214
|
+
? this.reviewCandidates(projectId)
|
|
4215
|
+
: Promise.resolve(null),
|
|
4216
|
+
authenticated && state === 'bound' && projectId
|
|
4217
|
+
? this.awaitingReviews(projectId)
|
|
4218
|
+
: Promise.resolve(null),
|
|
4190
4219
|
]);
|
|
4191
4220
|
timer.mark('independent_lookups');
|
|
4221
|
+
if (projectId && reviews)
|
|
4222
|
+
this.followMergeRequests(projectId, repository.repoRoot, reviews);
|
|
4223
|
+
const awaiting = reviews?.filter(awaitsReviewRequest) ?? [];
|
|
4192
4224
|
const liveTasks = liveTasksRaw.map(describePointer);
|
|
4193
4225
|
const shipped = objectValue(objectValue(client)?.shippedKnowledge);
|
|
4194
4226
|
const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
|
|
@@ -4235,6 +4267,23 @@ export class BridgeService {
|
|
|
4235
4267
|
? { deferredTaskStarts: pendingQuestionnaires.deferred }
|
|
4236
4268
|
: {}),
|
|
4237
4269
|
...(deliveries ? { openDeliveries: deliveries } : {}),
|
|
4270
|
+
...(awaiting.length
|
|
4271
|
+
? {
|
|
4272
|
+
awaitingReviewRequests: {
|
|
4273
|
+
total: awaiting.length,
|
|
4274
|
+
items: awaiting.slice(0, 5).map((delivery) => ({
|
|
4275
|
+
taskId: delivery.taskId,
|
|
4276
|
+
externalTaskId: delivery.externalTaskId ?? null,
|
|
4277
|
+
workItemKey: delivery.workItemKey ?? null,
|
|
4278
|
+
branch: delivery.branch ?? null,
|
|
4279
|
+
targetBranch: delivery.baseBranch ?? null,
|
|
4280
|
+
draft: opensAsDraft(delivery),
|
|
4281
|
+
})),
|
|
4282
|
+
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.',
|
|
4283
|
+
},
|
|
4284
|
+
}
|
|
4285
|
+
: {}),
|
|
4286
|
+
...(codeReviews ? { awaitingReviews: codeReviews } : {}),
|
|
4238
4287
|
client,
|
|
4239
4288
|
...moved,
|
|
4240
4289
|
...(incomplete && state === 'bound'
|
|
@@ -4354,6 +4403,42 @@ export class BridgeService {
|
|
|
4354
4403
|
return null;
|
|
4355
4404
|
}
|
|
4356
4405
|
}
|
|
4406
|
+
async awaitingReviews(projectId) {
|
|
4407
|
+
try {
|
|
4408
|
+
const response = await this.dependencies.client.request(`${endpoints.reviewQueue(projectId)}?offset=0&limit=5`);
|
|
4409
|
+
const page = z
|
|
4410
|
+
.object({
|
|
4411
|
+
total: z.number().int(),
|
|
4412
|
+
items: z.array(reviewDeliverySchema.extend({
|
|
4413
|
+
openRound: z.object({ number: z.number().int(), createdAt: z.string() }).nullish(),
|
|
4414
|
+
openFindings: z.number().int(),
|
|
4415
|
+
})),
|
|
4416
|
+
})
|
|
4417
|
+
.parse(response.data);
|
|
4418
|
+
if (!page.total)
|
|
4419
|
+
return null;
|
|
4420
|
+
return asJsonValue({
|
|
4421
|
+
total: page.total,
|
|
4422
|
+
items: page.items.map((item) => ({
|
|
4423
|
+
taskId: item.taskId,
|
|
4424
|
+
externalTaskId: item.externalTaskId ?? null,
|
|
4425
|
+
workItemKey: item.workItemKey ?? null,
|
|
4426
|
+
reviewState: item.reviewState ?? null,
|
|
4427
|
+
pullRequest: item.reviewRequest
|
|
4428
|
+
? { url: item.reviewRequest.url, state: item.reviewRequest.state }
|
|
4429
|
+
: null,
|
|
4430
|
+
openRound: item.openRound
|
|
4431
|
+
? { number: item.openRound.number, since: item.openRound.createdAt }
|
|
4432
|
+
: null,
|
|
4433
|
+
openFindings: item.openFindings,
|
|
4434
|
+
})),
|
|
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.',
|
|
4436
|
+
});
|
|
4437
|
+
}
|
|
4438
|
+
catch {
|
|
4439
|
+
return null;
|
|
4440
|
+
}
|
|
4441
|
+
}
|
|
4357
4442
|
async actionableWorkItems(projectId) {
|
|
4358
4443
|
try {
|
|
4359
4444
|
const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100&actionable=true`);
|
|
@@ -4664,6 +4749,7 @@ export class BridgeService {
|
|
|
4664
4749
|
expectedVersion: input.expectedVersion,
|
|
4665
4750
|
choice: input.choice,
|
|
4666
4751
|
baseBranch: input.baseBranch,
|
|
4752
|
+
expectedReviewPolicy: input.expectedReviewPolicy,
|
|
4667
4753
|
answerSource: input.answerSource,
|
|
4668
4754
|
})));
|
|
4669
4755
|
}
|
|
@@ -4673,6 +4759,73 @@ export class BridgeService {
|
|
|
4673
4759
|
async taskReviewRequest(input) {
|
|
4674
4760
|
return this.execute(() => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: input.url, state: input.state }));
|
|
4675
4761
|
}
|
|
4762
|
+
async taskOpenReviewRequest(input) {
|
|
4763
|
+
return this.execute(async () => {
|
|
4764
|
+
const gitlab = this.gitlab();
|
|
4765
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
4766
|
+
if (repository.projectId !== input.projectId)
|
|
4767
|
+
throw refuse("Call this in a clone of the task's project repository: this folder belongs to another project, or to none.", 'session.entry');
|
|
4768
|
+
const opened = await gitlab.requests.open({ ...input, repoRoot: repository.repoRoot });
|
|
4769
|
+
if ('recorded' in opened)
|
|
4770
|
+
return asJsonValue({ alreadyRecorded: opened.recorded });
|
|
4771
|
+
try {
|
|
4772
|
+
const report = await this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: opened.url, state: opened.state, source: 'machine' });
|
|
4773
|
+
return asJsonValue({ mergeRequest: opened, report });
|
|
4774
|
+
}
|
|
4775
|
+
catch (error) {
|
|
4776
|
+
return asJsonValue({
|
|
4777
|
+
mergeRequest: opened,
|
|
4778
|
+
reportRefused: error instanceof Error ? error.message : String(error),
|
|
4779
|
+
});
|
|
4780
|
+
}
|
|
4781
|
+
});
|
|
4782
|
+
}
|
|
4783
|
+
async gitlabToken(input = {}) {
|
|
4784
|
+
return this.execute(async () => {
|
|
4785
|
+
const gitlab = this.gitlab();
|
|
4786
|
+
if (!(await this.dependencies.credentials.get('access-token')))
|
|
4787
|
+
throw refuse('Sign in to Engineering Memory first: a GitLab token is kept for the signed-in account.', 'auth.signin_browser');
|
|
4788
|
+
await this.language(input.language);
|
|
4789
|
+
const git = this.dependencies.repositories.git;
|
|
4790
|
+
const pushUrl = await git
|
|
4791
|
+
.findRoot(input.repoRoot ?? process.cwd())
|
|
4792
|
+
.then(async (root) => (await git.pushDestination(root)).pushUrl)
|
|
4793
|
+
.catch(() => null);
|
|
4794
|
+
const remote = pushUrl ? gitLabRemote(pushUrl) : null;
|
|
4795
|
+
if (remote && 'refusal' in remote && remote.refusal === 'github')
|
|
4796
|
+
throw refuse('This repository is on GitHub. Engineering Memory opens merge requests only on GitLab so far; a GitHub pull request is opened with the tools you have and recorded with task.review_request.', 'task.review_request');
|
|
4797
|
+
const target = remote && !('refusal' in remote)
|
|
4798
|
+
? {
|
|
4799
|
+
host: remote.host,
|
|
4800
|
+
address: (await gitlab.tokens.address(remote.host)) ?? remote.address,
|
|
4801
|
+
}
|
|
4802
|
+
: { host: null, address: null };
|
|
4803
|
+
const link = await gitlab.page.open(target);
|
|
4804
|
+
return asJsonValue({
|
|
4805
|
+
...link,
|
|
4806
|
+
address: target.address,
|
|
4807
|
+
tokenSaved: target.address ? Boolean(await gitlab.tokens.token(target.address)) : false,
|
|
4808
|
+
nextAction: "Give this link to the user to open in a browser on this computer, and tell them to enter their GitLab address and personal access token there: the api scope lets Engineering Memory open merge requests, read_api only lets it follow them. Never open the link, fill in the page or ask for the token yourself, and if the user pastes a token into the chat, do not use it: ask them to revoke it and use the page. The page checks the token with GitLab, keeps it in this computer's credential store and closes after one save or removal, or after 10 minutes. When the user says it is saved, retry what needed it.",
|
|
4809
|
+
});
|
|
4810
|
+
});
|
|
4811
|
+
}
|
|
4812
|
+
gitlab() {
|
|
4813
|
+
if (!this.dependencies.gitlab)
|
|
4814
|
+
throw refuse('This bridge was started without GitLab support. Open the merge request in GitLab and record it with task.review_request.', 'task.review_request');
|
|
4815
|
+
return this.dependencies.gitlab;
|
|
4816
|
+
}
|
|
4817
|
+
followMergeRequests(projectId, repoRoot, deliveries) {
|
|
4818
|
+
this.dependencies.gitlab?.requests.schedule(projectId, repoRoot, deliveries);
|
|
4819
|
+
}
|
|
4820
|
+
async reviewCandidates(projectId) {
|
|
4821
|
+
try {
|
|
4822
|
+
const response = await this.dependencies.client.request(`${endpoints.projectDeliveries(projectId)}?state=review&limit=100`);
|
|
4823
|
+
return reviewDeliveries(response.data);
|
|
4824
|
+
}
|
|
4825
|
+
catch {
|
|
4826
|
+
return null;
|
|
4827
|
+
}
|
|
4828
|
+
}
|
|
4676
4829
|
async taskDeliveryRecord(input) {
|
|
4677
4830
|
return this.execute(async () => {
|
|
4678
4831
|
const response = await this.dependencies.client.request(endpoints.taskDelivery(input.projectId, input.taskId));
|
|
@@ -4683,6 +4836,201 @@ export class BridgeService {
|
|
|
4683
4836
|
});
|
|
4684
4837
|
});
|
|
4685
4838
|
}
|
|
4839
|
+
async projectReviewPolicy(input) {
|
|
4840
|
+
return this.execute(async () => {
|
|
4841
|
+
const projects = await this.dependencies.client.request(endpoints.projectList);
|
|
4842
|
+
const project = z
|
|
4843
|
+
.array(z.object({
|
|
4844
|
+
id: z.string(),
|
|
4845
|
+
name: z.string(),
|
|
4846
|
+
lockVersion: z.number().int(),
|
|
4847
|
+
role: z.string().optional(),
|
|
4848
|
+
reviewPolicy: reviewPolicySchema.optional(),
|
|
4849
|
+
reviewPolicyChangedAt: z.string().nullish(),
|
|
4850
|
+
}))
|
|
4851
|
+
.parse(projects.data)
|
|
4852
|
+
.find((candidate) => candidate.id === input.projectId);
|
|
4853
|
+
if (!project)
|
|
4854
|
+
throw refuse('This project is not among the projects you can open.', 'project.list');
|
|
4855
|
+
if (!project.reviewPolicy)
|
|
4856
|
+
throw refuse('This Engineering Memory server has no code review setting yet.', 'session.entry');
|
|
4857
|
+
return asJsonValue(project);
|
|
4858
|
+
});
|
|
4859
|
+
}
|
|
4860
|
+
async reviewQueue(input) {
|
|
4861
|
+
return this.execute(async () => {
|
|
4862
|
+
const query = new URLSearchParams({
|
|
4863
|
+
offset: String(input.offset ?? 0),
|
|
4864
|
+
limit: String(input.limit ?? 20),
|
|
4865
|
+
});
|
|
4866
|
+
return asJsonValue((await this.dependencies.client.request(`${endpoints.reviewQueue(input.projectId)}?${query}`)).data);
|
|
4867
|
+
});
|
|
4868
|
+
}
|
|
4869
|
+
async reviewGet(input) {
|
|
4870
|
+
return this.execute(async () => asJsonValue((await this.dependencies.client.request(endpoints.review(input.projectId, input.taskId))).data));
|
|
4871
|
+
}
|
|
4872
|
+
async reviewStart(input) {
|
|
4873
|
+
return this.execute(async () => {
|
|
4874
|
+
const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
|
|
4875
|
+
if (repository.projectId !== input.projectId)
|
|
4876
|
+
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');
|
|
4877
|
+
const { delivery } = await this.review(input.projectId, input.taskId);
|
|
4878
|
+
const task = delivery.externalTaskId;
|
|
4879
|
+
if (!waitingReview.includes(delivery.reviewState ?? ''))
|
|
4880
|
+
throw refuse(delivery.reviewState && concludedReview.includes(delivery.reviewState)
|
|
4881
|
+
? `The review of ${task} already ended (${delivery.reviewState}), so there is nothing to review. review.make_ready makes its PR/MR ready.`
|
|
4882
|
+
: `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
|
+
if (delivery.state !== 'delivered' || !delivery.pushed || !delivery.branch)
|
|
4884
|
+
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;
|
|
4886
|
+
if (!target)
|
|
4887
|
+
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
|
+
const git = this.dependencies.repositories.git;
|
|
4889
|
+
const fetched = (branch) => git.fetchBranchCommit(repository.repoRoot, 'origin', branch).catch(() => null);
|
|
4890
|
+
const head = await fetched(delivery.branch);
|
|
4891
|
+
if (!head)
|
|
4892
|
+
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');
|
|
4893
|
+
const tip = await fetched(target);
|
|
4894
|
+
if (!tip)
|
|
4895
|
+
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
|
+
const base = await git.mergeBase(repository.repoRoot, head.sourceCommit, tip.sourceCommit);
|
|
4897
|
+
const started = reviewSchema.parse((await this.dependencies.client.request(endpoints.reviewRounds(input.projectId, input.taskId), {
|
|
4898
|
+
method: 'POST',
|
|
4899
|
+
body: cleanJson({ commit: head.sourceCommit, baseCommit: base ?? undefined }),
|
|
4900
|
+
})).data);
|
|
4901
|
+
const round = started.rounds.find((candidate) => candidate.state === 'open');
|
|
4902
|
+
if (!round)
|
|
4903
|
+
throw refuse(`The review of ${task} has no open round. Read it with review.get.`, 'review.get');
|
|
4904
|
+
const files = base
|
|
4905
|
+
? await git.changedFiles(repository.repoRoot, base, head.sourceCommit)
|
|
4906
|
+
: [];
|
|
4907
|
+
const records = await this.dependencies.client.request(`${endpoints.reviewRoundRules(input.projectId, round.id)}?offset=0&limit=5`);
|
|
4908
|
+
const range = base ? `${base} ${head.sourceCommit}` : head.sourceCommit;
|
|
4909
|
+
return asJsonValue({
|
|
4910
|
+
review: {
|
|
4911
|
+
projectId: input.projectId,
|
|
4912
|
+
taskId: input.taskId,
|
|
4913
|
+
externalTaskId: task,
|
|
4914
|
+
workItemKey: delivery.workItemKey ?? null,
|
|
4915
|
+
reviewPolicy: delivery.reviewPolicy ?? null,
|
|
4916
|
+
branch: delivery.branch,
|
|
4917
|
+
targetBranch: target,
|
|
4918
|
+
pullRequest: delivery.reviewRequest?.url ?? null,
|
|
4919
|
+
},
|
|
4920
|
+
round: {
|
|
4921
|
+
id: round.id,
|
|
4922
|
+
number: round.number,
|
|
4923
|
+
headCommit: head.sourceCommit,
|
|
4924
|
+
baseCommit: base,
|
|
4925
|
+
},
|
|
4926
|
+
changedFiles: files.slice(0, 300),
|
|
4927
|
+
changedFileCount: files.length,
|
|
4928
|
+
earlierFindings: round.number > 1 ? started.findings : [],
|
|
4929
|
+
records: records.data,
|
|
4930
|
+
commands: base
|
|
4931
|
+
? [
|
|
4932
|
+
`git diff --stat ${range}`,
|
|
4933
|
+
`git diff ${range} -- <path>`,
|
|
4934
|
+
`git show ${head.sourceCommit}:<path>`,
|
|
4935
|
+
`git grep -n <pattern> ${head.sourceCommit} -- <path>`,
|
|
4936
|
+
`git log --oneline ${base}..${head.sourceCommit}`,
|
|
4937
|
+
]
|
|
4938
|
+
: [
|
|
4939
|
+
`git show --stat ${head.sourceCommit}`,
|
|
4940
|
+
`git show ${head.sourceCommit}:<path>`,
|
|
4941
|
+
`git grep -n <pattern> ${head.sourceCommit} -- <path>`,
|
|
4942
|
+
],
|
|
4943
|
+
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. 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
|
+
});
|
|
4945
|
+
});
|
|
4946
|
+
}
|
|
4947
|
+
async reviewRules(input) {
|
|
4948
|
+
return this.execute(async () => {
|
|
4949
|
+
const query = new URLSearchParams({
|
|
4950
|
+
offset: String(input.offset ?? 0),
|
|
4951
|
+
limit: String(input.limit ?? 5),
|
|
4952
|
+
});
|
|
4953
|
+
return asJsonValue((await this.dependencies.client.request(`${endpoints.reviewRoundRules(input.projectId, input.roundId)}?${query}`)).data);
|
|
4954
|
+
});
|
|
4955
|
+
}
|
|
4956
|
+
async reviewSubmit(input) {
|
|
4957
|
+
return this.execute(async () => {
|
|
4958
|
+
const absolute = input.findings.findIndex(({ path }) => /^(?:[A-Za-z]:|~)?[\\/]/.test(path));
|
|
4959
|
+
if (absolute >= 0)
|
|
4960
|
+
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');
|
|
4961
|
+
const checked = input.findings.map((finding) => ({
|
|
4962
|
+
finding,
|
|
4963
|
+
mask: maskFinding(finding.description),
|
|
4964
|
+
}));
|
|
4965
|
+
const unsafe = checked.findIndex(({ mask }) => mask.left);
|
|
4966
|
+
if (unsafe >= 0)
|
|
4967
|
+
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');
|
|
4968
|
+
const findings = checked.map(({ finding, mask }) => ({
|
|
4969
|
+
...finding,
|
|
4970
|
+
description: mask.text,
|
|
4971
|
+
}));
|
|
4972
|
+
const idempotencyKey = deterministicUuid('review.submit', input.roundId, stableStringify(findings));
|
|
4973
|
+
const response = await this.dependencies.client.request(endpoints.reviewRoundResult(input.projectId, input.roundId), { method: 'POST', body: cleanJson({ idempotencyKey, findings }) });
|
|
4974
|
+
return asJsonValue({
|
|
4975
|
+
review: response.data,
|
|
4976
|
+
masked: [...new Set(checked.flatMap(({ mask }) => mask.masked))],
|
|
4977
|
+
});
|
|
4978
|
+
});
|
|
4979
|
+
}
|
|
4980
|
+
async reviewRejectFinding(input) {
|
|
4981
|
+
return this.execute(async () => asJsonValue((await this.dependencies.client.request(endpoints.reviewFindingRejection(input.projectId, input.findingId), { method: 'POST', body: { reason: input.reason } })).data));
|
|
4982
|
+
}
|
|
4983
|
+
async reviewConclude(input) {
|
|
4984
|
+
return this.execute(async () => {
|
|
4985
|
+
const path = input.outcome === 'passed'
|
|
4986
|
+
? endpoints.reviewPass(input.projectId, input.taskId)
|
|
4987
|
+
: input.outcome === 'skipped'
|
|
4988
|
+
? endpoints.reviewSkip(input.projectId, input.taskId)
|
|
4989
|
+
: endpoints.reviewExemption(input.projectId, input.taskId);
|
|
4990
|
+
return asJsonValue((await this.dependencies.client.request(path, {
|
|
4991
|
+
method: 'POST',
|
|
4992
|
+
...(input.outcome === 'exempted' ? { body: { reason: input.reason ?? '' } } : {}),
|
|
4993
|
+
})).data);
|
|
4994
|
+
});
|
|
4995
|
+
}
|
|
4996
|
+
async reviewMakeReady(input) {
|
|
4997
|
+
return this.execute(async () => {
|
|
4998
|
+
const { delivery } = await this.review(input.projectId, input.taskId);
|
|
4999
|
+
const task = delivery.externalTaskId;
|
|
5000
|
+
const request = delivery.reviewRequest;
|
|
5001
|
+
if (waitingReview.includes(delivery.reviewState ?? ''))
|
|
5002
|
+
throw refuse(`The review of ${task} has not ended (${delivery.reviewState}), so its PR/MR stays a draft until it does.`, 'review.get');
|
|
5003
|
+
if (!request)
|
|
5004
|
+
return asJsonValue({
|
|
5005
|
+
ready: false,
|
|
5006
|
+
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
|
+
});
|
|
5008
|
+
if (request.state !== 'draft')
|
|
5009
|
+
return asJsonValue({
|
|
5010
|
+
ready: request.state === 'ready',
|
|
5011
|
+
pullRequest: { url: request.url, state: request.state },
|
|
5012
|
+
nextAction: `${request.url} is ${request.state} already; nothing was changed.`,
|
|
5013
|
+
});
|
|
5014
|
+
if (request.provider !== 'gitlab' || !this.dependencies.gitlab)
|
|
5015
|
+
return asJsonValue({
|
|
5016
|
+
ready: false,
|
|
5017
|
+
pullRequest: { url: request.url, state: request.state },
|
|
5018
|
+
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
|
+
});
|
|
5020
|
+
const readied = await this.dependencies.gitlab.requests.markReady(request);
|
|
5021
|
+
if (readied.state !== 'ready')
|
|
5022
|
+
return asJsonValue({
|
|
5023
|
+
ready: false,
|
|
5024
|
+
pullRequest: readied,
|
|
5025
|
+
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
|
+
});
|
|
5027
|
+
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 });
|
|
5029
|
+
});
|
|
5030
|
+
}
|
|
5031
|
+
async review(projectId, taskId) {
|
|
5032
|
+
return reviewSchema.parse((await this.dependencies.client.request(endpoints.review(projectId, taskId))).data);
|
|
5033
|
+
}
|
|
4686
5034
|
async reportDeliveryOutcome(report) {
|
|
4687
5035
|
const path = report.outcome === 'delivered'
|
|
4688
5036
|
? endpoints.taskDeliveryDeliver(report.projectId, report.taskId)
|
|
@@ -5699,7 +6047,7 @@ function deliveryQuestion(touchedContract, destination, publishApplicable, commi
|
|
|
5699
6047
|
alsoAsk: 'This task changed an endpoint contract. Ask whether to document the change for the linked project, and if so write what the other side must do differently with memory.propose_revision as an integration_note.',
|
|
5700
6048
|
}
|
|
5701
6049
|
: {}),
|
|
5702
|
-
afterPullRequest: 'Do not end the turn once
|
|
6050
|
+
afterPullRequest: 'Do not end the turn once the pull request is due. On a GitLab remote, task.open_review_request opens it from this computer and records it in one call; when it says no GitLab token is saved, gitlab.token gives the user a page on this computer to save their own. Anywhere else, open it with the tools you have and record it with task.review_request: its link, and draft or ready as it was opened. Then check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything. When the user later says it became ready, was merged or was closed, record that with task.review_request too; a GitLab merge request this computer can read is also followed by itself.',
|
|
5703
6051
|
afterCommit,
|
|
5704
6052
|
});
|
|
5705
6053
|
}
|
|
@@ -5859,6 +6207,21 @@ function slugify(value) {
|
|
|
5859
6207
|
.replace(/[^a-z0-9]+/g, '-')
|
|
5860
6208
|
.replace(/^-|-$/g, '');
|
|
5861
6209
|
}
|
|
6210
|
+
const reviewPolicySchema = z.enum(['none', 'optional', 'required']);
|
|
6211
|
+
const reviewSchema = z.object({
|
|
6212
|
+
delivery: reviewDeliverySchema.extend({
|
|
6213
|
+
externalTaskId: z.string(),
|
|
6214
|
+
workItemId: z.string().nullish(),
|
|
6215
|
+
reviewPolicy: reviewPolicySchema.optional(),
|
|
6216
|
+
}),
|
|
6217
|
+
rounds: z.array(z.object({
|
|
6218
|
+
id: z.string(),
|
|
6219
|
+
number: z.number().int(),
|
|
6220
|
+
state: z.string(),
|
|
6221
|
+
headCommit: z.string(),
|
|
6222
|
+
})),
|
|
6223
|
+
findings: z.array(z.unknown()),
|
|
6224
|
+
});
|
|
5862
6225
|
function deterministicUuid(...parts) {
|
|
5863
6226
|
const value = sha256(parts.join('\n')).slice(0, 32).split('');
|
|
5864
6227
|
value[12] = '5';
|
|
@@ -18,7 +18,10 @@ import { RepositoryDecisionStore } from './repository-decision-store.js';
|
|
|
18
18
|
import { ShadowNoticeStore } from './shadow-notice-store.js';
|
|
19
19
|
import { LanguageStore } from './language-store.js';
|
|
20
20
|
import { UpdateChoiceStore } from './update-choice-store.js';
|
|
21
|
-
import { PrincipalStateGuard } from './principal-state.js';
|
|
21
|
+
import { PrincipalStateGuard, principalFingerprint } from './principal-state.js';
|
|
22
|
+
import { MergeRequests } from './merge-request-sync.js';
|
|
23
|
+
import { GitLabClient, GitLabTokens } from '../providers/gitlab.js';
|
|
24
|
+
import { GitLabTokenPage } from '../providers/gitlab-token-page.js';
|
|
22
25
|
import { OnboardingStore } from './onboarding-store.js';
|
|
23
26
|
import { QuestionnaireStore } from './questionnaire-store.js';
|
|
24
27
|
import { sha256 } from '../utilities/hash.js';
|
|
@@ -78,8 +81,14 @@ export function createBridgeService(options = {}) {
|
|
|
78
81
|
knownReservations,
|
|
79
82
|
});
|
|
80
83
|
const gate = new VerificationGate(stateRoot, git, outbox, undefined, worktreePool);
|
|
81
|
-
const
|
|
84
|
+
const gitlabTokens = new GitLabTokens(stateRoot, credentials);
|
|
85
|
+
const principalState = new PrincipalStateGuard(stateRoot, credentials, cache, outbox, activeContexts, gate, gitlabTokens);
|
|
82
86
|
const languages = new LanguageStore(stateRoot);
|
|
87
|
+
const gitlab = new GitLabClient(config.requestTimeoutMs);
|
|
88
|
+
const signedInLanguage = async () => {
|
|
89
|
+
const accessToken = await credentials.get('access-token');
|
|
90
|
+
return languages.read(accessToken ? principalFingerprint(accessToken) : sha256('anonymous'));
|
|
91
|
+
};
|
|
83
92
|
return (service = new BridgeService({
|
|
84
93
|
live,
|
|
85
94
|
releaseNotes: new ReleaseNotes(stateRoot, client, credentials),
|
|
@@ -101,6 +110,11 @@ export function createBridgeService(options = {}) {
|
|
|
101
110
|
onboarding: new OnboardingStore(stateRoot),
|
|
102
111
|
clientVersion: options.clientVersion === undefined ? config.clientVersion : options.clientVersion,
|
|
103
112
|
principalState,
|
|
113
|
+
gitlab: {
|
|
114
|
+
tokens: gitlabTokens,
|
|
115
|
+
page: new GitLabTokenPage(gitlabTokens, gitlab, signedInLanguage),
|
|
116
|
+
requests: new MergeRequests(client, gitlabTokens, gitlab, git),
|
|
117
|
+
},
|
|
104
118
|
}));
|
|
105
119
|
}
|
|
106
120
|
//# sourceMappingURL=create-bridge-service.js.map
|