engineering-memory 1.11.26 → 1.11.28

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.
@@ -23,6 +23,8 @@ 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, reviewDeliveries, } from './merge-request-sync.js';
26
28
  export const validationIds = [
27
29
  'format',
28
30
  'static_analysis',
@@ -987,6 +989,7 @@ export class BridgeService {
987
989
  await this.live.taskStarted(backendTask.id);
988
990
  }
989
991
  const backendFresh = responseSource !== 'stale_cache';
992
+ const deliveryState = objectValue(backend.taskDelivery)?.state;
990
993
  return asJsonValue({
991
994
  backend: resumeBackendView(backend, backendFresh || !localJournal.projection),
992
995
  requirements: backend.requirements ?? null,
@@ -999,7 +1002,12 @@ export class BridgeService {
999
1002
  staleReason: backend.staleReason ?? null,
1000
1003
  nextAction: contextRefreshAction(backend.staleReason, resumedBaseline),
1001
1004
  }
1002
- : {}),
1005
+ : backendTask.status === 'closed' &&
1006
+ (deliveryState === 'unanswered' || deliveryState === 'answered')
1007
+ ? {
1008
+ nextAction: 'This task is closed and its changes are not delivered yet. Its delivery continues as before: task.close in this folder asks the delivery question. If the user asks for a further change instead, call context.prepare_change with this sessionId and the paths the change touches: that reopens the task, and the next task.close asks the delivery question again for the new content.',
1009
+ }
1010
+ : {}),
1003
1011
  repository: publicRepository(repository),
1004
1012
  worktree: (await this.ownedAllocation(projectId, repository.repoRoot)) ?? null,
1005
1013
  localJournal: backendFresh ? journalSummary(localJournal) : localJournal,
@@ -1165,6 +1173,9 @@ export class BridgeService {
1165
1173
  const response = await this.dependencies.client.request(endpoints.contextPrepareChange, {
1166
1174
  method: 'POST',
1167
1175
  body,
1176
+ ...(this.dependencies.clientVersion
1177
+ ? { headers: { 'x-client-version': this.dependencies.clientVersion } }
1178
+ : {}),
1168
1179
  ...(input.transitionToWrite
1169
1180
  ? {}
1170
1181
  : {
@@ -1174,6 +1185,8 @@ export class BridgeService {
1174
1185
  });
1175
1186
  const responseSource = this.dependencies.client.getResponseSource(response);
1176
1187
  if (responseSource === 'stale_cache') {
1188
+ if (pointer.closedAt)
1189
+ throw refuse('This task is closed, and reopening it for a further change needs Engineering Memory, which cannot be reached now. Try again once it is reachable.', 'context.prepare_change');
1177
1190
  validateOfflineLease(response.data, input.sessionId, changedPaths, baselineDiffHash);
1178
1191
  await this.dependencies.activeContexts.setChangeBaseline(repository.repoFingerprint, input.sessionId, repository.git, changedPaths);
1179
1192
  return asJsonValue({
@@ -1192,7 +1205,8 @@ export class BridgeService {
1192
1205
  resumeConflicts: pointer.resumeConflicts.filter((value) => value !== 'backend_resume_is_stale'),
1193
1206
  });
1194
1207
  }
1195
- const preparedTask = objectValue(objectValue(response.data)?.task);
1208
+ const preparedData = objectValue(response.data);
1209
+ const preparedTask = objectValue(preparedData?.task);
1196
1210
  await this.dependencies.activeContexts.setChangeBaseline(repository.repoFingerprint, input.sessionId, repository.git, changedPaths, preparedTask &&
1197
1211
  typeof preparedTask.id === 'string' &&
1198
1212
  typeof preparedTask.lockVersion === 'number'
@@ -1201,8 +1215,16 @@ export class BridgeService {
1201
1215
  taskVersion: numericTaskVersion(preparedTask.lockVersion),
1202
1216
  lastSequence: numericSequence(preparedTask.lastSequence),
1203
1217
  }
1204
- : undefined);
1205
- const preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
1218
+ : undefined, selfReviewRecords(preparedData?.selfReviewRequired));
1219
+ let preparedPointer = await this.dependencies.activeContexts.loadForSession(repository.repoFingerprint, input.sessionId);
1220
+ if (preparedPointer?.closedAt && preparedTask?.status === 'open') {
1221
+ const allocation = await this.ownedAllocation(preparedPointer.projectId, repository.repoRoot);
1222
+ if (allocation && this.dependencies.worktreePool)
1223
+ await this.dependencies.worktreePool.reopen(preparedPointer.projectId, repository.repoRoot, allocation.generation);
1224
+ const { closedAt: _closedAt, ...reopened } = preparedPointer;
1225
+ await this.dependencies.activeContexts.save(reopened);
1226
+ preparedPointer = reopened;
1227
+ }
1206
1228
  if (preparedPointer) {
1207
1229
  if (decision) {
1208
1230
  await this.dependencies.repositories.git
@@ -1221,7 +1243,6 @@ export class BridgeService {
1221
1243
  allocation.taskId = preparedPointer.taskId;
1222
1244
  }
1223
1245
  }
1224
- const preparedData = objectValue(response.data);
1225
1246
  await this.seedResumeSnapshot(preparedPointer, {
1226
1247
  ...objectOrEmpty(response.data),
1227
1248
  ...(preparedData?.lease ? { activeLease: preparedData.lease } : {}),
@@ -1910,10 +1931,18 @@ export class BridgeService {
1910
1931
  }
1911
1932
  async taskSelfReview(input) {
1912
1933
  const reviewed = await this.execute(async () => {
1934
+ const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1935
+ const pointer = await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId);
1936
+ const named = new Set(input.files.flatMap(({ rules }) => rules.map((rule) => rule.resourceId)));
1937
+ const leasePaths = pointer?.changeBaseline?.leasePaths ?? [];
1938
+ const unnamed = (pointer?.changeBaseline?.selfReviewRequired ?? []).filter(({ resourceId }) => !named.has(resourceId));
1939
+ if (unnamed.length > 0)
1940
+ throw refuse(`This self review leaves out records the change lease requires, so task.verify would refuse it: ${unnamed
1941
+ .map(({ resourceKey, title, paths }) => `${resourceKey} "${title}" (${paths.length === leasePaths.length ? 'every leased path' : paths.join(', ') || 'no single path'})`)
1942
+ .join('; ')}. Read the changed files against each of them and name it on a changed file it covers, or on any changed file when it covers no single path; then record task.self_review again. Nothing was recorded.`, 'task.self_review');
1913
1943
  const files = await this.approvedDeviations(input);
1914
1944
  assertSafeToPersist(cleanJson({ files }));
1915
- const checkout = await this.dependencies.repositories.resolve(input.repoRoot ?? process.cwd());
1916
- const repository = await this.taskRepository(checkout, await this.dependencies.activeContexts.loadForTask(checkout.repoFingerprint, input.taskId));
1945
+ const repository = await this.taskRepository(checkout, pointer);
1917
1946
  if (this.deferDeliveries) {
1918
1947
  return this.enqueueTaskReceipt('task.self_review', endpoints.taskSelfReview, input.taskId, repository.repoRoot, cleanJson({
1919
1948
  diffHash: repository.git.diffHash,
@@ -3747,6 +3776,10 @@ export class BridgeService {
3747
3776
  query.set('limit', String(input.limit));
3748
3777
  const suffix = query.size > 0 ? `?${query.toString()}` : '';
3749
3778
  const response = await this.dependencies.client.request(`${endpoints.workItemList(input.projectId)}${suffix}`);
3779
+ this.followMergeRequests(input.projectId, async () => {
3780
+ const here = await this.dependencies.repositories.resolveIdentity(process.cwd());
3781
+ return here.projectId === input.projectId ? here.repoRoot : null;
3782
+ });
3750
3783
  return asJsonValue({
3751
3784
  selectionRequired: true,
3752
3785
  questionnaire: 'Select the project work item to run in this chat',
@@ -4136,7 +4169,7 @@ export class BridgeService {
4136
4169
  const livePointers = authenticated && state === 'bound'
4137
4170
  ? this.dependencies.activeContexts.list(repository.repoFingerprint)
4138
4171
  : Promise.resolve([]);
4139
- const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries,] = await Promise.all([
4172
+ const [client, liveTasksRaw, workItems, gitPreferences, worktrees, pendingQuestionnaires, moved, deliveries, reviews,] = await Promise.all([
4140
4173
  this.clientUpdate(authenticated),
4141
4174
  livePointers,
4142
4175
  authenticated && state === 'bound' && projectId
@@ -4160,8 +4193,14 @@ export class BridgeService {
4160
4193
  authenticated && state === 'bound' && projectId
4161
4194
  ? this.openDeliveries(projectId)
4162
4195
  : Promise.resolve(null),
4196
+ authenticated && state === 'bound' && projectId
4197
+ ? this.reviewCandidates(projectId)
4198
+ : Promise.resolve(null),
4163
4199
  ]);
4164
4200
  timer.mark('independent_lookups');
4201
+ if (projectId && reviews)
4202
+ this.followMergeRequests(projectId, repository.repoRoot, reviews);
4203
+ const awaiting = reviews?.filter(awaitsReviewRequest) ?? [];
4165
4204
  const liveTasks = liveTasksRaw.map(describePointer);
4166
4205
  const shipped = objectValue(objectValue(client)?.shippedKnowledge);
4167
4206
  const incomplete = shipped && (shipped.failure !== null || Number(shipped.live) < Number(shipped.expected));
@@ -4208,6 +4247,22 @@ export class BridgeService {
4208
4247
  ? { deferredTaskStarts: pendingQuestionnaires.deferred }
4209
4248
  : {}),
4210
4249
  ...(deliveries ? { openDeliveries: deliveries } : {}),
4250
+ ...(awaiting.length
4251
+ ? {
4252
+ awaitingReviewRequests: {
4253
+ total: awaiting.length,
4254
+ items: awaiting.slice(0, 5).map((delivery) => ({
4255
+ taskId: delivery.taskId,
4256
+ externalTaskId: delivery.externalTaskId ?? null,
4257
+ workItemKey: delivery.workItemKey ?? null,
4258
+ branch: delivery.branch ?? null,
4259
+ targetBranch: delivery.baseBranch ?? null,
4260
+ draft: delivery.choice === 'commit_push_draft_pr',
4261
+ })),
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.',
4263
+ },
4264
+ }
4265
+ : {}),
4211
4266
  client,
4212
4267
  ...moved,
4213
4268
  ...(incomplete && state === 'bound'
@@ -4519,9 +4574,9 @@ export class BridgeService {
4519
4574
  const taskEntries = (await this.dependencies.outbox.list()).filter((entry) => taskIdFromDeliverySafe(entry) === input.taskId);
4520
4575
  const pointer = await this.resolveCheckpointPointer(input, repoRoot);
4521
4576
  if (pointer.closedAt && correction)
4522
- throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more corrections. Record the user's correction as a proposal for the rule it concerns with memory.propose_revision, or in the task that next changes this code.`, 'memory.propose_revision');
4577
+ throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more corrections. When the correction asks for a further code change and the task's changes are not delivered yet, call context.prepare_change in its session first: that reopens the task, and the correction is then recorded on it. Otherwise record it as a proposal for the rule it concerns with memory.propose_revision, or in the task that next changes this code.`, 'memory.propose_revision');
4523
4578
  if (pointer.closedAt)
4524
- throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints. Further work belongs to a new task.`, 'session.bootstrap');
4579
+ throw refuse(`Task ${pointer.taskSlug} is closed, so it takes no more checkpoints. While its changes are not delivered, a further change continues it: call context.prepare_change in its session with the paths the change touches, which reopens it. Work after its delivery belongs to a new task.`, 'context.prepare_change');
4525
4580
  const blockedEntry = taskEntries.find((entry) => entry.lastError && entry.lastError !== 'backend_unavailable');
4526
4581
  if (blockedEntry && blockedEntry.idempotencyKey !== idempotencyKey) {
4527
4582
  throw refuse('A blocked task delivery must be explicitly resolved before checkpointing', 'task.resolve_pending_delivery');
@@ -4643,6 +4698,76 @@ export class BridgeService {
4643
4698
  async taskDeliveryCancel(input) {
4644
4699
  return this.execute(() => this.mutateWithOutbox('task.delivery', endpoints.taskDeliveryCancel(input.projectId, input.taskId), { reason: input.reason }));
4645
4700
  }
4701
+ async taskReviewRequest(input) {
4702
+ return this.execute(() => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: input.url, state: input.state }));
4703
+ }
4704
+ async taskOpenReviewRequest(input) {
4705
+ return this.execute(async () => {
4706
+ const gitlab = this.gitlab();
4707
+ const repository = await this.dependencies.repositories.resolveIdentity(input.repoRoot ?? process.cwd());
4708
+ if (repository.projectId !== input.projectId)
4709
+ throw refuse("Call this in a clone of the task's project repository: this folder belongs to another project, or to none.", 'session.entry');
4710
+ const opened = await gitlab.requests.open({ ...input, repoRoot: repository.repoRoot });
4711
+ if ('recorded' in opened)
4712
+ return asJsonValue({ alreadyRecorded: opened.recorded });
4713
+ try {
4714
+ const report = await this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: opened.url, state: opened.state, source: 'machine' });
4715
+ return asJsonValue({ mergeRequest: opened, report });
4716
+ }
4717
+ catch (error) {
4718
+ return asJsonValue({
4719
+ mergeRequest: opened,
4720
+ reportRefused: error instanceof Error ? error.message : String(error),
4721
+ });
4722
+ }
4723
+ });
4724
+ }
4725
+ async gitlabToken(input = {}) {
4726
+ return this.execute(async () => {
4727
+ const gitlab = this.gitlab();
4728
+ if (!(await this.dependencies.credentials.get('access-token')))
4729
+ throw refuse('Sign in to Engineering Memory first: a GitLab token is kept for the signed-in account.', 'auth.signin_browser');
4730
+ await this.language(input.language);
4731
+ const git = this.dependencies.repositories.git;
4732
+ const pushUrl = await git
4733
+ .findRoot(input.repoRoot ?? process.cwd())
4734
+ .then(async (root) => (await git.pushDestination(root)).pushUrl)
4735
+ .catch(() => null);
4736
+ const remote = pushUrl ? gitLabRemote(pushUrl) : null;
4737
+ if (remote && 'refusal' in remote && remote.refusal === 'github')
4738
+ 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');
4739
+ const target = remote && !('refusal' in remote)
4740
+ ? {
4741
+ host: remote.host,
4742
+ address: (await gitlab.tokens.address(remote.host)) ?? remote.address,
4743
+ }
4744
+ : { host: null, address: null };
4745
+ const link = await gitlab.page.open(target);
4746
+ return asJsonValue({
4747
+ ...link,
4748
+ address: target.address,
4749
+ tokenSaved: target.address ? Boolean(await gitlab.tokens.token(target.address)) : false,
4750
+ 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.",
4751
+ });
4752
+ });
4753
+ }
4754
+ gitlab() {
4755
+ if (!this.dependencies.gitlab)
4756
+ 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');
4757
+ return this.dependencies.gitlab;
4758
+ }
4759
+ followMergeRequests(projectId, repoRoot, deliveries) {
4760
+ this.dependencies.gitlab?.requests.schedule(projectId, repoRoot, deliveries);
4761
+ }
4762
+ async reviewCandidates(projectId) {
4763
+ try {
4764
+ const response = await this.dependencies.client.request(`${endpoints.projectDeliveries(projectId)}?state=review&limit=100`);
4765
+ return reviewDeliveries(response.data);
4766
+ }
4767
+ catch {
4768
+ return null;
4769
+ }
4770
+ }
4646
4771
  async taskDeliveryRecord(input) {
4647
4772
  return this.execute(async () => {
4648
4773
  const response = await this.dependencies.client.request(endpoints.taskDelivery(input.projectId, input.taskId));
@@ -5669,7 +5794,7 @@ function deliveryQuestion(touchedContract, destination, publishApplicable, commi
5669
5794
  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.',
5670
5795
  }
5671
5796
  : {}),
5672
- afterPullRequest: 'Do not end the turn once a pull request exists. Check whether it merges cleanly, report the conflicting files if it does not, and ask whether to resolve them before touching anything.',
5797
+ 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.',
5673
5798
  afterCommit,
5674
5799
  });
5675
5800
  }
@@ -5732,6 +5857,27 @@ function checkpointView(checkpoint) {
5732
5857
  documents: documentViews(checkpoint.documents),
5733
5858
  };
5734
5859
  }
5860
+ function selfReviewRecords(value) {
5861
+ if (!Array.isArray(value))
5862
+ return undefined;
5863
+ return value.flatMap((entry) => {
5864
+ const record = objectValue(entry);
5865
+ return record &&
5866
+ typeof record.resourceId === 'string' &&
5867
+ typeof record.resourceKey === 'string' &&
5868
+ typeof record.title === 'string' &&
5869
+ Array.isArray(record.paths)
5870
+ ? [
5871
+ {
5872
+ resourceId: record.resourceId,
5873
+ resourceKey: record.resourceKey,
5874
+ title: record.title,
5875
+ paths: record.paths.filter((path) => typeof path === 'string'),
5876
+ },
5877
+ ]
5878
+ : [];
5879
+ });
5880
+ }
5735
5881
  function resumeBackendView(backend, includeDocuments) {
5736
5882
  const rest = Object.fromEntries(Object.entries(backend).filter(([key]) => key !== 'requirements'));
5737
5883
  const task = objectValue(backend.task);
@@ -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 principalState = new PrincipalStateGuard(stateRoot, credentials, cache, outbox, activeContexts, gate);
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
@@ -0,0 +1,281 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import * as z from 'zod/v4';
3
+ import { endpoints } from '../config.js';
4
+ import { GitLabError, gitLabRemote, projectPath, reviewState, } from '../providers/gitlab.js';
5
+ import { BridgeRecoveryError } from './recovery-error.js';
6
+ export const reviewDeliverySchema = z.object({
7
+ taskId: z.string(),
8
+ externalTaskId: z.string().nullish(),
9
+ state: z.string(),
10
+ choice: z.string().nullish(),
11
+ baseBranch: z.string().nullish(),
12
+ deliveredCommit: z.string().nullish(),
13
+ pushed: z.boolean().nullish(),
14
+ branch: z.string().nullish(),
15
+ workItemKey: z.string().nullish(),
16
+ reviewRequest: z
17
+ .object({
18
+ provider: z.string(),
19
+ host: z.string(),
20
+ repository: z.string(),
21
+ number: z.number().int().positive(),
22
+ url: z.string(),
23
+ state: z.string(),
24
+ source: z.string().optional(),
25
+ })
26
+ .nullish(),
27
+ });
28
+ const pullRequestChoices = ['commit_push_draft_pr', 'commit_push_pr'];
29
+ export function awaitsReviewRequest(delivery) {
30
+ return (!delivery.reviewRequest &&
31
+ delivery.state === 'delivered' &&
32
+ delivery.pushed === true &&
33
+ pullRequestChoices.includes(delivery.choice ?? ''));
34
+ }
35
+ export function reviewDeliveries(data) {
36
+ const items = z.object({ items: z.array(z.unknown()) }).safeParse(data);
37
+ return (items.success ? items.data.items : []).flatMap((item) => {
38
+ const delivery = reviewDeliverySchema.safeParse(item);
39
+ return delivery.success ? [delivery.data] : [];
40
+ });
41
+ }
42
+ const manualWay = 'Opening it in GitLab yourself always works: record it afterwards with task.review_request and its link.';
43
+ export class MergeRequests {
44
+ client;
45
+ tokens;
46
+ gitlab;
47
+ git;
48
+ passes = new Map();
49
+ running = new Set();
50
+ constructor(client, tokens, gitlab, git) {
51
+ this.client = client;
52
+ this.tokens = tokens;
53
+ this.gitlab = gitlab;
54
+ this.git = git;
55
+ }
56
+ async open(input) {
57
+ const delivery = reviewDeliverySchema.parse((await this.client.request(endpoints.taskDelivery(input.projectId, input.taskId)))
58
+ .data);
59
+ if (delivery.reviewRequest)
60
+ return { recorded: { url: delivery.reviewRequest.url, state: delivery.reviewRequest.state } };
61
+ if (delivery.state === 'cancelled')
62
+ throw new BridgeRecoveryError('This delivery was concluded without delivering, so it gets no merge request.', 'task.delivery');
63
+ const branch = delivery.branch;
64
+ if (!branch)
65
+ throw new BridgeRecoveryError('This task has no branch, so there is nothing to open a merge request from.', 'task.review_request');
66
+ const chosen = pullRequestChoices.includes(delivery.choice ?? '');
67
+ const target = input.targetBranch ?? (chosen ? delivery.baseBranch : null);
68
+ if (!target)
69
+ 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';
71
+ const pushUrl = await this.git.branchPushUrl(input.repoRoot, branch);
72
+ if (!pushUrl)
73
+ throw new BridgeRecoveryError(`This clone has no remote that ${branch} is pushed to. ${manualWay}`, 'task.review_request');
74
+ const remote = gitLabRemote(pushUrl);
75
+ if ('refusal' in remote)
76
+ throw new BridgeRecoveryError(remoteRefusal(remote.refusal, pushUrl), 'task.review_request');
77
+ const address = (await this.tokens.address(remote.host)) ?? remote.address;
78
+ const token = await this.tokens.token(address);
79
+ if (!token)
80
+ throw new BridgeRecoveryError(`No GitLab token is saved on this computer for ${address}. Call gitlab.token and give the user the link it returns; they save their own token there, and then this call works. ${manualWay}`, 'gitlab.token', { address });
81
+ const path = projectPath(address, remote.path);
82
+ try {
83
+ const project = await this.gitlab.project(address, token, path);
84
+ if (!project)
85
+ throw new BridgeRecoveryError(`GitLab at ${address} shows no project ${path} to this token. If GitLab runs under a path (like https://host/gitlab), save that address with gitlab.token; otherwise the token's user needs access to the project. ${manualWay}`, 'gitlab.token', { address, project: path });
86
+ const remoteHead = await this.gitlab.branch(address, token, path, branch);
87
+ if (!remoteHead)
88
+ throw new BridgeRecoveryError(`${branch} is not on GitLab yet. Push it, then call again.`, 'task.open_review_request');
89
+ const local = await this.git.branchCommit(input.repoRoot, branch).catch(() => null);
90
+ const delivered = local?.commit ?? delivery.deliveredCommit ?? null;
91
+ if (delivered &&
92
+ delivered !== remoteHead.commit &&
93
+ !(await this.gitlab.contains(address, token, path, branch, delivered)))
94
+ throw new BridgeRecoveryError(`${branch} on GitLab does not have ${delivered.slice(0, 12)} yet, so a merge request would lack it. Push the branch, then call again.`, 'task.open_review_request');
95
+ const source = { id: project.id, path };
96
+ const existing = (await this.gitlab.mergeRequests(address, token, source, branch, 'opened'))[0];
97
+ if (existing)
98
+ return opened(existing, true);
99
+ if (!(await this.gitlab.branch(address, token, path, target)))
100
+ throw new BridgeRecoveryError(`The target branch ${target} does not exist on GitLab. Ask the user which branch the merge request goes into and call again with targetBranch.`, 'task.open_review_request');
101
+ const title = ((draft ? 'Draft: ' : '') +
102
+ (input.title ??
103
+ defaultTitle(local?.subject || remoteHead.title || branch, delivery.workItemKey))).slice(0, 255);
104
+ try {
105
+ return opened(await this.gitlab.createMergeRequest(address, token, path, {
106
+ source: branch,
107
+ target,
108
+ title,
109
+ ...(input.description ? { description: input.description } : {}),
110
+ }), false);
111
+ }
112
+ catch (error) {
113
+ if (!(error instanceof GitLabError) ||
114
+ !['conflict', 'unknown_outcome', 'server_error'].includes(error.failure))
115
+ throw error;
116
+ const found = (await this.gitlab.mergeRequests(address, token, source, branch, 'opened'))[0];
117
+ if (found)
118
+ return opened(found, true);
119
+ throw error;
120
+ }
121
+ }
122
+ catch (error) {
123
+ if (error instanceof GitLabError)
124
+ throw gitLabRefusal(error);
125
+ throw error;
126
+ }
127
+ }
128
+ schedule(projectId, repoRoot, deliveries) {
129
+ const now = Date.now();
130
+ const last = this.passes.get(projectId);
131
+ if (last !== undefined && now - last < 5 * 60 * 1000)
132
+ return;
133
+ this.passes.set(projectId, now);
134
+ const pass = this.read(projectId, repoRoot, deliveries).then(() => undefined, () => undefined);
135
+ this.running.add(pass);
136
+ void pass.finally(() => this.running.delete(pass));
137
+ }
138
+ async settle() {
139
+ while (this.running.size)
140
+ await Promise.all([...this.running]);
141
+ }
142
+ async read(projectId, root, given) {
143
+ if (!(await this.tokens.any()))
144
+ return { reported: 0 };
145
+ const repoRoot = typeof root === 'function' ? await root().catch(() => null) : root;
146
+ const deliveries = given ??
147
+ reviewDeliveries((await this.client.request(`${endpoints.projectDeliveries(projectId)}?state=review&limit=100`)).data);
148
+ const pass = { failed: new Set(), projects: new Map(), calls: 100 };
149
+ const queue = [...deliveries];
150
+ let reported = 0;
151
+ const worker = async () => {
152
+ for (let delivery = queue.shift(); delivery && pass.calls > 0; delivery = queue.shift()) {
153
+ const found = await this.follow(delivery, repoRoot, pass).catch(() => null);
154
+ if (found && (await this.report(projectId, delivery.taskId, found)))
155
+ reported++;
156
+ }
157
+ };
158
+ await Promise.all([worker(), worker(), worker(), worker()]);
159
+ return { reported };
160
+ }
161
+ async follow(delivery, repoRoot, pass) {
162
+ const known = delivery.reviewRequest;
163
+ if (known) {
164
+ if (known.provider !== 'gitlab' || !['draft', 'ready'].includes(known.state))
165
+ return null;
166
+ const link = await this.tokens.forLink(known.host, known.repository);
167
+ const token = link && !pass.failed.has(link.address) ? await this.tokens.token(link.address) : null;
168
+ if (!link || !token)
169
+ return null;
170
+ const request = await this.call(pass, link.address, () => this.gitlab.mergeRequest(link.address, token, link.project, known.number));
171
+ if (!request)
172
+ return null;
173
+ const state = reviewState(request);
174
+ return state !== known.state || known.source !== 'machine' ? { url: known.url, state } : null;
175
+ }
176
+ if (!repoRoot || !delivery.branch)
177
+ return null;
178
+ const pushUrl = await this.git.branchPushUrl(repoRoot, delivery.branch).catch(() => null);
179
+ const remote = pushUrl ? gitLabRemote(pushUrl) : null;
180
+ if (!remote || 'refusal' in remote)
181
+ return null;
182
+ const address = (await this.tokens.address(remote.host)) ?? remote.address;
183
+ const token = pass.failed.has(address) ? null : await this.tokens.token(address);
184
+ if (!token)
185
+ return null;
186
+ const path = projectPath(address, remote.path);
187
+ const key = `${address}\n${path}`;
188
+ if (!pass.projects.has(key))
189
+ pass.projects.set(key, await this.call(pass, address, () => this.gitlab.project(address, token, path)));
190
+ const project = pass.projects.get(key);
191
+ if (!project)
192
+ return null;
193
+ const requests = await this.call(pass, address, () => this.gitlab.mergeRequests(address, token, { id: project.id, path }, delivery.branch));
194
+ const rank = (request) => request.state === 'opened' || request.state === 'locked'
195
+ ? 0
196
+ : request.state === 'merged'
197
+ ? 1
198
+ : 2;
199
+ const chosen = (requests ?? [])
200
+ .filter((request) => rank(request) < 2)
201
+ .sort((left, right) => rank(left) - rank(right) ||
202
+ Number(right.sha === delivery.deliveredCommit) -
203
+ Number(left.sha === delivery.deliveredCommit))[0];
204
+ return chosen ? { url: chosen.web_url, state: reviewState(chosen) } : null;
205
+ }
206
+ async call(pass, address, request) {
207
+ if (pass.failed.has(address) || pass.calls <= 0)
208
+ return null;
209
+ pass.calls--;
210
+ try {
211
+ return await request();
212
+ }
213
+ catch (error) {
214
+ if (!(error instanceof GitLabError))
215
+ throw error;
216
+ pass.failed.add(address);
217
+ return null;
218
+ }
219
+ }
220
+ async report(projectId, taskId, found) {
221
+ try {
222
+ await this.client.request(endpoints.taskReviewRequest(projectId, taskId), {
223
+ method: 'POST',
224
+ body: { url: found.url, state: found.state, source: 'machine' },
225
+ idempotencyKey: randomUUID(),
226
+ });
227
+ return true;
228
+ }
229
+ catch {
230
+ return false;
231
+ }
232
+ }
233
+ }
234
+ function opened(request, reused) {
235
+ return {
236
+ url: request.web_url,
237
+ state: reviewState(request),
238
+ sourceBranch: request.source_branch,
239
+ targetBranch: request.target_branch,
240
+ reused,
241
+ };
242
+ }
243
+ export function defaultTitle(subject, workItemKey) {
244
+ const title = subject.trim().slice(0, 240) || 'Merge request';
245
+ return workItemKey && !title.toLowerCase().includes(workItemKey.toLowerCase())
246
+ ? `${workItemKey}: ${title}`
247
+ : title;
248
+ }
249
+ function remoteRefusal(refusal, pushUrl) {
250
+ switch (refusal) {
251
+ case 'github':
252
+ return `The branch is pushed to GitHub (${pushUrl}). Engineering Memory opens merge requests only on GitLab so far: open the pull request with the tools you have and record it with task.review_request.`;
253
+ case 'ip':
254
+ return `The remote is reached by an IP address (${pushUrl}), and Engineering Memory records no link with an IP address in it. Open the merge request in GitLab and move the work item with work_item.update.`;
255
+ case 'local':
256
+ return `The remote is a folder on disk (${pushUrl}), not a GitLab server. ${manualWay}`;
257
+ default:
258
+ return `The remote address ${pushUrl} is not one Engineering Memory can read as a GitLab project. ${manualWay}`;
259
+ }
260
+ }
261
+ function gitLabRefusal(error) {
262
+ const at = error.address;
263
+ const details = { address: at, failure: error.failure, status: error.status };
264
+ switch (error.failure) {
265
+ case 'token_rejected':
266
+ return new BridgeRecoveryError(`GitLab at ${at} no longer accepts the saved token (expired or revoked). Call gitlab.token so the user can save a new one. ${manualWay}`, 'gitlab.token', details);
267
+ case 'not_allowed':
268
+ return new BridgeRecoveryError(`GitLab at ${at} refused this with the saved token: it needs the api scope and a role that may open merge requests in the project (Developer or higher). Nothing was opened. ${manualWay}`, 'gitlab.token', details);
269
+ case 'unreachable':
270
+ return new BridgeRecoveryError(`This computer cannot reach ${at}. If GitLab is only reachable from the company network, the user connects (for example through the VPN) and you call again. Nothing was opened.`, 'task.open_review_request', details);
271
+ case 'untrusted_certificate':
272
+ return new BridgeRecoveryError(`This computer does not trust the certificate of ${at}. If the company uses its own certificate, the user sets NODE_EXTRA_CA_CERTS to its file and restarts the coding agent. Nothing was opened.`, 'task.open_review_request', details);
273
+ case 'unknown_outcome':
274
+ return new BridgeRecoveryError(`The answer from ${at} was lost after the merge request was sent, and it is not visible yet. Call again: an open merge request of the branch is reused, never opened twice.`, 'task.open_review_request', details);
275
+ case 'redirected':
276
+ return new BridgeRecoveryError(`${at} answered with a redirect, so the saved address is probably not GitLab's own. Call gitlab.token so the user can correct the address.`, 'gitlab.token', details);
277
+ default:
278
+ return new BridgeRecoveryError(`GitLab at ${at} answered ${error.failure.replace(/_/g, ' ')}${error.status ? ` (${error.status})` : ''}${error.detail ? `: ${error.detail}` : ''}. Nothing more was done. ${manualWay}`, 'task.open_review_request', details);
279
+ }
280
+ }
281
+ //# sourceMappingURL=merge-request-sync.js.map
@@ -9,15 +9,17 @@ export class PrincipalStateGuard {
9
9
  outbox;
10
10
  activeContexts;
11
11
  gate;
12
+ gitlabTokens;
12
13
  ownerPath;
13
14
  queue = Promise.resolve();
14
- constructor(stateRoot, credentials, cache, outbox, activeContexts, gate) {
15
+ constructor(stateRoot, credentials, cache, outbox, activeContexts, gate, gitlabTokens) {
15
16
  this.stateRoot = stateRoot;
16
17
  this.credentials = credentials;
17
18
  this.cache = cache;
18
19
  this.outbox = outbox;
19
20
  this.activeContexts = activeContexts;
20
21
  this.gate = gate;
22
+ this.gitlabTokens = gitlabTokens;
21
23
  this.ownerPath = join(stateRoot, 'principal-owner.json');
22
24
  }
23
25
  async ensure() {
@@ -55,6 +57,7 @@ export class PrincipalStateGuard {
55
57
  await this.cache.clear();
56
58
  await this.activeContexts.clear();
57
59
  await this.gate.clear();
60
+ await this.gitlabTokens?.clear();
58
61
  await removeFile(this.ownerPath, this.stateRoot);
59
62
  }
60
63
  async exclusive(action) {
@@ -588,6 +588,15 @@ export class WorktreePool {
588
588
  await this.save(registry);
589
589
  });
590
590
  }
591
+ async reopen(projectId, repoRoot, generation) {
592
+ return this.transaction(async (registry) => {
593
+ const entry = this.owned(registry, projectId, repoRoot, generation);
594
+ entry.phase = 'working';
595
+ entry.pendingDelivery = false;
596
+ entry.lastActivityAt = this.now();
597
+ await this.save(registry);
598
+ });
599
+ }
591
600
  async release(projectId, repoRoot, generation, deliveryOutcome) {
592
601
  return this.transaction(async (registry) => {
593
602
  const entry = await this.deliveryOwned(registry, projectId, repoRoot, generation);