engineering-memory 1.11.28 → 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.
@@ -24,7 +24,8 @@ 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';
28
29
  export const validationIds = [
29
30
  'format',
30
31
  'static_analysis',
@@ -453,6 +454,7 @@ export class BridgeService {
453
454
  taskKind: input.taskKind,
454
455
  workItemKey: input.workItemKey,
455
456
  workItemId: input.workItemId,
457
+ reviewFixOf: input.reviewFixOf,
456
458
  mode: input.mode,
457
459
  knownRevisions: input.knownRevisions,
458
460
  }, repository.repoRoot);
@@ -513,6 +515,9 @@ export class BridgeService {
513
515
  this.poolAllocations.set(repository.repoRoot, poolAllocation);
514
516
  }
515
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;
516
521
  const response = await this.requestWithNewerFields(endpoints.sessionBootstrap, ['checkoutFingerprints'], {
517
522
  method: 'POST',
518
523
  idempotencyKey: checkpointId,
@@ -523,7 +528,8 @@ export class BridgeService {
523
528
  objective: persistedBootstrap.objective,
524
529
  taskKind: persistedBootstrap.taskKind,
525
530
  workItemKey: persistedBootstrap.workItemKey,
526
- workItemId: persistedBootstrap.workItemId,
531
+ workItemId: persistedBootstrap.workItemId ?? reviewed?.workItemId ?? undefined,
532
+ reviewFixOfTaskId: persistedBootstrap.reviewFixOf,
527
533
  mode: persistedBootstrap.mode ?? 'write',
528
534
  repoFingerprint: repository.repoFingerprint,
529
535
  checkoutFingerprints: await this.dependencies.repositories.checkoutFingerprints(repository.repoRoot),
@@ -2608,6 +2614,14 @@ export class BridgeService {
2608
2614
  if (unusable)
2609
2615
  throw refuse(unusable, 'task.branch');
2610
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');
2611
2625
  const [preferences, currentBranch, folder, suggestedName] = await Promise.all([
2612
2626
  this.dependencies.client.request(endpoints.projectGitPreferences(repository.projectId)),
2613
2627
  this.dependencies.repositories.git.currentBranch(repository.repoRoot),
@@ -2621,6 +2635,9 @@ export class BridgeService {
2621
2635
  currentCommit: repository.git.head,
2622
2636
  folder,
2623
2637
  suggestedName,
2638
+ reviewFix: reviewed
2639
+ ? { branch: reviewed.branch, externalTaskId: reviewed.externalTaskId }
2640
+ : null,
2624
2641
  ...(input.workItemId
2625
2642
  ? await this.workItemBranches(repository.repoRoot, repository.projectId, input.workItemProjectId ?? repository.projectId, input.workItemId, preferences.data)
2626
2643
  : { planBranch: null, continueBranches: [] }),
@@ -4169,7 +4186,7 @@ export class BridgeService {
4169
4186
  const livePointers = authenticated && state === 'bound'
4170
4187
  ? this.dependencies.activeContexts.list(repository.repoFingerprint)
4171
4188
  : Promise.resolve([]);
4172
- const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews,] = await Promise.all([
4189
+ const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews, codeReviews,] = await Promise.all([
4173
4190
  this.clientUpdate(authenticated),
4174
4191
  livePointers,
4175
4192
  authenticated && state === 'bound' && projectId
@@ -4196,6 +4213,9 @@ export class BridgeService {
4196
4213
  authenticated && state === 'bound' && projectId
4197
4214
  ? this.reviewCandidates(projectId)
4198
4215
  : Promise.resolve(null),
4216
+ authenticated && state === 'bound' && projectId
4217
+ ? this.awaitingReviews(projectId)
4218
+ : Promise.resolve(null),
4199
4219
  ]);
4200
4220
  timer.mark('independent_lookups');
4201
4221
  if (projectId && reviews)
@@ -4257,12 +4277,13 @@ export class BridgeService {
4257
4277
  workItemKey: delivery.workItemKey ?? null,
4258
4278
  branch: delivery.branch ?? null,
4259
4279
  targetBranch: delivery.baseBranch ?? null,
4260
- draft: delivery.choice === 'commit_push_draft_pr',
4280
+ draft: opensAsDraft(delivery),
4261
4281
  })),
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.',
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.',
4263
4283
  },
4264
4284
  }
4265
4285
  : {}),
4286
+ ...(codeReviews ? { awaitingReviews: codeReviews } : {}),
4266
4287
  client,
4267
4288
  ...moved,
4268
4289
  ...(incomplete && state === 'bound'
@@ -4382,6 +4403,42 @@ export class BridgeService {
4382
4403
  return null;
4383
4404
  }
4384
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
+ }
4385
4442
  async actionableWorkItems(projectId) {
4386
4443
  try {
4387
4444
  const response = await this.dependencies.client.request(`${endpoints.workItemList(projectId)}?limit=100&actionable=true`);
@@ -4692,6 +4749,7 @@ export class BridgeService {
4692
4749
  expectedVersion: input.expectedVersion,
4693
4750
  choice: input.choice,
4694
4751
  baseBranch: input.baseBranch,
4752
+ expectedReviewPolicy: input.expectedReviewPolicy,
4695
4753
  answerSource: input.answerSource,
4696
4754
  })));
4697
4755
  }
@@ -4778,6 +4836,201 @@ export class BridgeService {
4778
4836
  });
4779
4837
  });
4780
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
+ }
4781
5034
  async reportDeliveryOutcome(report) {
4782
5035
  const path = report.outcome === 'delivered'
4783
5036
  ? endpoints.taskDeliveryDeliver(report.projectId, report.taskId)
@@ -5954,6 +6207,21 @@ function slugify(value) {
5954
6207
  .replace(/[^a-z0-9]+/g, '-')
5955
6208
  .replace(/^-|-$/g, '');
5956
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
+ });
5957
6225
  function deterministicUuid(...parts) {
5958
6226
  const value = sha256(parts.join('\n')).slice(0, 32).split('');
5959
6227
  value[12] = '5';
@@ -13,6 +13,7 @@ export const reviewDeliverySchema = z.object({
13
13
  pushed: z.boolean().nullish(),
14
14
  branch: z.string().nullish(),
15
15
  workItemKey: z.string().nullish(),
16
+ reviewState: z.string().nullish(),
16
17
  reviewRequest: z
17
18
  .object({
18
19
  provider: z.string(),
@@ -26,6 +27,14 @@ export const reviewDeliverySchema = z.object({
26
27
  .nullish(),
27
28
  });
28
29
  const pullRequestChoices = ['commit_push_draft_pr', 'commit_push_pr'];
30
+ export const waitingReview = ['pending', 'in_review', 'findings_open'];
31
+ export const concludedReview = ['passed', 'skipped', 'exempted'];
32
+ const draftPrefix = /^\s*(?:(?:\[draft\]|\(draft\)|draft:|\[wip\]|wip:)\s*)+/i;
33
+ export function opensAsDraft(delivery) {
34
+ const review = delivery.reviewState ?? '';
35
+ return (waitingReview.includes(review) ||
36
+ (delivery.choice === 'commit_push_draft_pr' && !concludedReview.includes(review)));
37
+ }
29
38
  export function awaitsReviewRequest(delivery) {
30
39
  return (!delivery.reviewRequest &&
31
40
  delivery.state === 'delivered' &&
@@ -67,7 +76,7 @@ export class MergeRequests {
67
76
  const target = input.targetBranch ?? (chosen ? delivery.baseBranch : null);
68
77
  if (!target)
69
78
  throw new BridgeRecoveryError('The delivery answer did not choose a pull request, so it names no target branch. If the user wants a merge request, call again with the targetBranch they name.', 'task.open_review_request');
70
- const draft = input.draft ?? delivery.choice === 'commit_push_draft_pr';
79
+ const draft = input.draft ?? opensAsDraft(delivery);
71
80
  const pushUrl = await this.git.branchPushUrl(input.repoRoot, branch);
72
81
  if (!pushUrl)
73
82
  throw new BridgeRecoveryError(`This clone has no remote that ${branch} is pushed to. ${manualWay}`, 'task.review_request');
@@ -125,6 +134,38 @@ export class MergeRequests {
125
134
  throw error;
126
135
  }
127
136
  }
137
+ async markReady(request) {
138
+ const link = await this.tokens.forLink(request.host, request.repository);
139
+ const token = link ? await this.tokens.token(link.address) : null;
140
+ if (!link || !token)
141
+ throw new BridgeRecoveryError(`No GitLab token is saved on this computer for ${request.host}. Ask the user to mark ${request.url} ready in GitLab ("Mark as ready"); Engineering Memory follows it. Or call gitlab.token so they save their own token here, then call review.make_ready again.`, 'gitlab.token');
142
+ try {
143
+ const current = await this.gitlab.mergeRequest(link.address, token, link.project, request.number);
144
+ if (reviewState(current) !== 'draft')
145
+ return { url: current.web_url, state: reviewState(current) };
146
+ if (current.title === undefined)
147
+ throw new GitLabError('unexpected_answer', link.address, null, null);
148
+ const after = await this.gitlab.retitleMergeRequest(link.address, token, link.project, request.number, current.title.replace(draftPrefix, '') || defaultTitle(current.source_branch, null));
149
+ return { url: after.web_url, state: reviewState(after) };
150
+ }
151
+ catch (error) {
152
+ if (!(error instanceof GitLabError))
153
+ throw error;
154
+ const at = error.address;
155
+ const reason = error.failure === 'token_rejected'
156
+ ? `GitLab at ${at} no longer accepts the saved token (expired or revoked); gitlab.token saves a new one.`
157
+ : error.failure === 'not_allowed'
158
+ ? `GitLab at ${at} refused this with the saved token: changing a merge request needs the api scope and Developer or higher.`
159
+ : error.failure === 'unreachable'
160
+ ? `This computer cannot reach ${at}; if GitLab is reachable only from the company network, connect and call review.make_ready again.`
161
+ : error.failure === 'unknown_outcome'
162
+ ? `The answer of GitLab at ${at} was lost after the change was sent, so it may already be ready; calling review.make_ready again is safe and reads where it stands.`
163
+ : `GitLab at ${at} answered ${error.failure.replace(/_/g, ' ')}${error.status ? ` (${error.status})` : ''}.`;
164
+ throw new BridgeRecoveryError(`${reason} ${request.url} may still be a draft: the user can mark it ready in GitLab ("Mark as ready"); Engineering Memory follows it.`, error.failure === 'token_rejected' || error.failure === 'not_allowed'
165
+ ? 'gitlab.token'
166
+ : 'review.make_ready', { address: at, failure: error.failure, status: error.status });
167
+ }
168
+ }
128
169
  schedule(projectId, repoRoot, deliveries) {
129
170
  const now = Date.now();
130
171
  const last = this.passes.get(projectId);
@@ -0,0 +1,31 @@
1
+ import { restrictedValueKind } from './privacy-detector.js';
2
+ const masks = [
3
+ [/[\p{L}\p{N}._%+-]{1,64}@[\p{L}\p{N}.-]+\.\p{L}{2,63}/gu, 'e-mail address', () => '[e-mail]'],
4
+ [
5
+ /(?<![\d.])(?:\d{1,3}\.){3}\d{1,3}(?![\d.])/g,
6
+ 'IP address',
7
+ (match) => (match.split('.').every((part) => Number(part) <= 255) ? '[IP]' : match),
8
+ ],
9
+ [
10
+ /[A-Z]:[\\/](?:Users|Documents and Settings)[\\/][^\\/\s"'<>:|?*]+|\\\\[^\\\s]+\\Users\\[^\\/\s"'<>*]+|(?:\/mnt\/[a-z]|\/cygdrive\/[a-z]|\/System\/Volumes\/Data|\/Volumes\/[^/\s]+|\/export)\/(?:Users|home)\/[^\\/\s"'<>*]+/gi,
11
+ 'user folder',
12
+ () => '~',
13
+ ],
14
+ [
15
+ /(^|[\s"'(<=])\/(?:[a-z]\/)?(?:Users|home)\/[^\\/\s"'<>*]+/gi,
16
+ 'user folder',
17
+ (_, before) => before + '~',
18
+ ],
19
+ ];
20
+ export function maskFinding(description) {
21
+ const masked = new Set();
22
+ let text = description;
23
+ for (const [pattern, kind, replace] of masks) {
24
+ const next = text.replace(pattern, replace);
25
+ if (next !== text)
26
+ masked.add(kind);
27
+ text = next;
28
+ }
29
+ return { text, masked: [...masked], left: restrictedValueKind(text, 'description') };
30
+ }
31
+ //# sourceMappingURL=review-masking.js.map
@@ -51,6 +51,7 @@ const startWording = z.strictObject({
51
51
  newBranch: text,
52
52
  newBranchOrContinue: text,
53
53
  continuePlan: text,
54
+ continueReview: text,
54
55
  example: text,
55
56
  stay: text,
56
57
  base: z.strictObject({
@@ -199,11 +200,16 @@ export function taskStartDefinition(facts) {
199
200
  message: copy.message,
200
201
  context: keepOnly
201
202
  ? copy.keepContext
202
- : binding.base?.kind === 'existing'
203
- ? format(copy.continuePlan, language, { branch: binding.base.branch })
204
- : format(Object.values(binding.bases ?? {}).some((base) => base.kind === 'existing')
205
- ? copy.newBranchOrContinue
206
- : copy.newBranch, language, { name: binding.name }) + (binding.base ? ' · ' + sourceLabel(binding.base, facts.currentBranch) : ''),
203
+ : binding.base?.kind === 'existing' && facts.reviewedTask
204
+ ? format(copy.continueReview, language, {
205
+ branch: binding.base.branch,
206
+ task: facts.reviewedTask,
207
+ })
208
+ : binding.base?.kind === 'existing'
209
+ ? format(copy.continuePlan, language, { branch: binding.base.branch })
210
+ : format(Object.values(binding.bases ?? {}).some((base) => base.kind === 'existing')
211
+ ? copy.newBranchOrContinue
212
+ : copy.newBranch, language, { name: binding.name }) + (binding.base ? ' · ' + sourceLabel(binding.base, facts.currentBranch) : ''),
207
213
  example: keepOnly ? format(copy.stay, language, { source: current }) : copy.example,
208
214
  questions,
209
215
  binding: JSON.parse(JSON.stringify(binding)),
@@ -216,7 +222,7 @@ function legacyTaskStartDefinition(facts) {
216
222
  const folder = facts.folder;
217
223
  const keepOnly = facts.keepCurrent === true || !facts.currentCommit;
218
224
  const here = !keepOnly && !folder.managed && !folder.heldBy && folder.clean;
219
- const keep = keepOnly || (!folder.managed && !folder.heldBy && !folder.holder);
225
+ const keep = !facts.reviewedTask && (keepOnly || (!folder.managed && !folder.heldBy && !folder.holder));
220
226
  const holder = here && folder.holder ? holderIdentity(folder.holder) : null;
221
227
  const currentLabel = sourceLabel({ kind: 'current', ...(facts.currentCommit ? { commit: facts.currentCommit } : {}) }, facts.currentBranch);
222
228
  const binding = {
@@ -300,6 +300,16 @@ When the delivery chose a pull request and the branch is pushed to a GitLab, ope
300
300
 
301
301
  When the user later says a pull request became ready, was merged or was closed, record that state with `task.review_request` too; it works from any chat, also for a delivery `session.entry` lists. Engineering Memory then moves the task's work item as the project's workflow rules say: to the `pr_opened` target once every pull request of the work item is ready, to the `all_prs_merged` target (READY FOR QA by default) once they are all merged, and to the draft target when one is still a draft and the project names one. It waits while another task of the work item is still open, or closed with a delivery that has no pull request yet. Relay the returned `nextAction` in one line. A record is what was reported, unless a member's computer read it from GitLab: where someone has saved a GitLab token, the bridge reads the project's merge requests in the background when a chat opens or work items are listed, and records a merge, a close or a draft turning ready by itself, also for a merge request someone opened by hand. Never report a merge the user has not confirmed. A merge is final, and a delivery keeps the first pull request recorded for it. GitHub pull request and GitLab merge request links are recorded; for another provider, move the work item with `work_item.update`.
302
302
 
303
+ ## Code review
304
+
305
+ A project can review code before a pull request is ready: `project.review_policy` reads its setting — none, optional or required — and, only when the user asks to change it, `change: true` asks them in one native form; only an owner or maintainer can change it. Under optional the delivery question offers a pull request reviewed first, which opens as a draft, and one without review; under required only the reviewed one. A user's own instruction for a ready pull request under required becomes the reviewed one, and the tool says so; relay it. When the tool says the setting changed while the question was open, tell the user and call it again.
306
+
307
+ After a reviewed pull request is pushed and the worktree released, open it as a draft (`task.open_review_request`, or your own tools and `task.review_request` with `draft`), then call `review.start` in a clone of the project. It fetches the pushed branch without checking anything out and returns the review packet. Give the packet to a fresh subagent that did not write the change; only when your host cannot start one, review it yourself under the same rules. The reviewer reads the change only through the read-only Git commands the packet lists: it never checks out, switches branches, edits, commits, pushes or changes a file. It judges the change against the packet's records (more with `review.rules`) and says whether each earlier finding still holds. Each finding names the file by its path in the repository, the line in the reviewed commit when there is one, the key of the record it breaks when one applies, and what is wrong and why in plain words. Record the result with `review.submit`, an empty list when there are no findings. Describe a problem without the value itself: e-mail addresses, IP addresses and user folders are masked, and a description that still looks like it holds a secret or personal data is refused.
308
+
309
+ Show the findings to the user and ask whether to fix them. A fix is a write task: `task.branch` with `reviewFixOf` set to the reviewed task's id, then `session.bootstrap` with the same `reviewFixOf`; it continues the pull request's branch, and its delivery offers commit or commit and push onto that branch — never a second pull request. After its push the open findings are marked fixed; ask whether to review again (`review.start`) or end the review (`review.conclude` with passed). A finding the user disagrees with is rejected with `review.reject_finding`, where the user writes the reason in the form; never write it for them. `review.conclude` also skips a review where the setting is not required, and exempts one under required, which only an owner or maintainer can do and only with the reason the user writes. Every one of these asks the user in a native form first.
310
+
311
+ Once a review has ended, call `review.make_ready`: on GitLab, with the user's own token saved, it removes Draft and records the pull request ready; anywhere else follow its `nextAction` (for example `gh pr ready`) and record the state with `task.review_request`. It also makes ready, when the user asks, a pull request that has no code review. Never mark a draft ready while its review has not ended. `session.entry` lists as `awaitingReviews` the deliveries that wait for code review; mention them in one line and start nothing on your own. When the user asks, `review.queue` and `review.get` read them, and every member except readers can continue any review where it stopped.
312
+
303
313
  ## Code source and memory applicability
304
314
 
305
315
  The bridge sends the checkout's committed Git commit and tree at bootstrap. Task and session