engineering-memory 1.11.29 → 1.11.31

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.
@@ -15,7 +15,7 @@ import { minimatch } from 'minimatch';
15
15
  import { endpoints } from '../config.js';
16
16
  import { startPhaseTimer } from './phase-timer.js';
17
17
  import { sha256, stableStringify } from '../utilities/hash.js';
18
- import { ApiResponseError, BackendUnavailableError, } from './api-client.js';
18
+ import { ApiResponseError, BackendUnavailableError, backendRejectsField, } from './api-client.js';
19
19
  import { assertSafeToPersist, normalizeRepositoryPaths } from './offline-outbox.js';
20
20
  import { restrictedValueKind } from './privacy-detector.js';
21
21
  import { principalFingerprint } from './principal-state.js';
@@ -26,6 +26,7 @@ import { BridgeRecoveryError } from './recovery-error.js';
26
26
  import { gitLabRemote } from '../providers/gitlab.js';
27
27
  import { awaitsReviewRequest, concludedReview, opensAsDraft, reviewDeliveries, reviewDeliverySchema, waitingReview, } from './merge-request-sync.js';
28
28
  import { maskFinding } from './review-masking.js';
29
+ import { commentReviewSchema, dueComment, markComment } from './review-comment.js';
29
30
  export const validationIds = [
30
31
  'format',
31
32
  'static_analysis',
@@ -4360,6 +4361,89 @@ export class BridgeService {
4360
4361
  return asJsonValue(response.data);
4361
4362
  });
4362
4363
  }
4364
+ async jiraProjectState(input) {
4365
+ return await this.execute(async () => {
4366
+ const projectId = input.projectId ?? (await this.jiraBoundProject(input.repoRoot));
4367
+ const response = await this.dependencies.client.request(endpoints.projectJira(projectId));
4368
+ return asJsonValue(response.data);
4369
+ });
4370
+ }
4371
+ async jiraOrganizationStatus(organizationId) {
4372
+ return await this.execute(async () => {
4373
+ const response = await this.dependencies.client.request(endpoints.jiraStatus(organizationId));
4374
+ return asJsonValue(response.data);
4375
+ });
4376
+ }
4377
+ async jiraConnectStart(input) {
4378
+ return await this.execute(async () => {
4379
+ const connector = this.dependencies.jiraConnect;
4380
+ if (!connector)
4381
+ throw refuse('This bridge was started without Jira support. Update Engineering Memory and run jira.connect again.', 'jira.status');
4382
+ const current = connector.state(input.organizationId);
4383
+ if (current && !input.restart && current.phase !== 'expired')
4384
+ return asJsonValue(current);
4385
+ return asJsonValue(await connector.start(input.organizationId, input.projectId));
4386
+ });
4387
+ }
4388
+ async jiraConnectForget(organizationId) {
4389
+ await this.dependencies.jiraConnect?.forget(organizationId);
4390
+ }
4391
+ async jiraChooseSite(input) {
4392
+ return await this.execute(async () => {
4393
+ const response = await this.dependencies.client.request(endpoints.jiraConnectSite(input.organizationId), { method: 'POST', body: { requestId: input.requestId, cloudId: input.cloudId } });
4394
+ return asJsonValue(response.data);
4395
+ });
4396
+ }
4397
+ async jiraDisconnect(input) {
4398
+ return await this.execute(async () => {
4399
+ const response = await this.dependencies.client.request(endpoints.jiraDisconnect(input.organizationId, input.connectionId), { method: 'POST' });
4400
+ return asJsonValue(response.data);
4401
+ });
4402
+ }
4403
+ async jiraProjects(input) {
4404
+ return await this.execute(async () => {
4405
+ const query = input.key ? `?${new URLSearchParams({ key: input.key })}` : '';
4406
+ const response = await this.dependencies.client.request(endpoints.projectJiraProjects(input.projectId, input.connectionId) + query);
4407
+ return asJsonValue(response.data);
4408
+ });
4409
+ }
4410
+ async jiraLink(input) {
4411
+ return await this.execute(async () => {
4412
+ const response = await this.dependencies.client.request(endpoints.projectJiraLink(input.projectId), {
4413
+ method: 'PUT',
4414
+ body: {
4415
+ connectionId: input.connectionId,
4416
+ jiraProjectId: input.jiraProjectId,
4417
+ ...(input.replace ? { replace: true } : {}),
4418
+ },
4419
+ });
4420
+ return asJsonValue(response.data);
4421
+ });
4422
+ }
4423
+ async jiraUnlink(projectId) {
4424
+ return await this.execute(async () => {
4425
+ const response = await this.dependencies.client.request(endpoints.projectJiraUnlink(projectId), { method: 'POST' });
4426
+ return asJsonValue(response.data);
4427
+ });
4428
+ }
4429
+ async jiraPeople(input) {
4430
+ return await this.execute(async () => {
4431
+ const response = await this.dependencies.client.request(endpoints.jiraPeople(input.organizationId, input.connectionId));
4432
+ return asJsonValue(response.data);
4433
+ });
4434
+ }
4435
+ async jiraDecidePeople(input) {
4436
+ return await this.execute(async () => {
4437
+ const response = await this.dependencies.client.request(endpoints.jiraPeopleDecisions(input.organizationId, input.connectionId), { method: 'POST', body: { decisions: input.decisions } });
4438
+ return asJsonValue(response.data);
4439
+ });
4440
+ }
4441
+ async jiraBoundProject(repoRoot) {
4442
+ const repository = await this.dependencies.repositories.resolveIdentity(repoRoot ?? process.cwd());
4443
+ if (!repository.projectId)
4444
+ throw refuse('This repository has no selected project. Name the projectId, or the organizationId for the organization-wide Jira tools.', 'session.entry');
4445
+ return repository.projectId;
4446
+ }
4363
4447
  async boundProject(repoRoot) {
4364
4448
  const repository = await this.dependencies.repositories.resolveIdentity(repoRoot ?? process.cwd());
4365
4449
  if (!repository.projectId)
@@ -4410,6 +4494,9 @@ export class BridgeService {
4410
4494
  .object({
4411
4495
  total: z.number().int(),
4412
4496
  items: z.array(reviewDeliverySchema.extend({
4497
+ reviewReopenReason: z.string().nullish(),
4498
+ providerJobSince: z.string().nullish(),
4499
+ providerFailure: z.string().nullish(),
4413
4500
  openRound: z.object({ number: z.number().int(), createdAt: z.string() }).nullish(),
4414
4501
  openFindings: z.number().int(),
4415
4502
  })),
@@ -4424,15 +4511,25 @@ export class BridgeService {
4424
4511
  externalTaskId: item.externalTaskId ?? null,
4425
4512
  workItemKey: item.workItemKey ?? null,
4426
4513
  reviewState: item.reviewState ?? null,
4514
+ reopened: item.reviewReopenReason ?? null,
4427
4515
  pullRequest: item.reviewRequest
4428
4516
  ? { url: item.reviewRequest.url, state: item.reviewRequest.state }
4429
4517
  : null,
4518
+ readyBeforeReview: waitingReview.includes(item.reviewState ?? '') && item.reviewRequest?.state === 'ready',
4519
+ pullRequestChange: item.providerJob
4520
+ ? {
4521
+ job: item.providerJob,
4522
+ since: item.providerJobSince ?? null,
4523
+ failure: item.providerFailure ?? null,
4524
+ failedAt: item.providerFailedAt ?? null,
4525
+ }
4526
+ : null,
4430
4527
  openRound: item.openRound
4431
4528
  ? { number: item.openRound.number, since: item.openRound.createdAt }
4432
4529
  : null,
4433
4530
  openFindings: item.openFindings,
4434
4531
  })),
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.',
4532
+ nextAction: 'These deliveries wait for code review. Mention them to the user in one line and start nothing on your own. When the user asks, review.get reads one and review.start runs its next round from a clone of this project; a review that ended while its PR/MR is still a draft waits for review.make_ready. reopened (new_commit or new_target) means the review had passed and then a new commit or a new target branch arrived, so it waits again; readyBeforeReview means its PR/MR was made ready while the review had not ended; pullRequestChange is a make-ready or make-draft that a member computer with a GitLab token does in the background, with the last failure when one was recorded.',
4436
4533
  });
4437
4534
  }
4438
4535
  catch {
@@ -4590,6 +4687,7 @@ export class BridgeService {
4590
4687
  await this.dependencies.credentials.clear();
4591
4688
  }
4592
4689
  await this.dependencies.browserAuth.dispose();
4690
+ await this.dependencies.jiraConnect?.dispose();
4593
4691
  return asJsonValue({ loggedOut: true, remoteRevoked, remoteStatus });
4594
4692
  }, true);
4595
4693
  }
@@ -4757,7 +4855,18 @@ export class BridgeService {
4757
4855
  return this.execute(() => this.mutateWithOutbox('task.delivery', endpoints.taskDeliveryCancel(input.projectId, input.taskId), { reason: input.reason }));
4758
4856
  }
4759
4857
  async taskReviewRequest(input) {
4760
- return this.execute(() => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), { url: input.url, state: input.state }));
4858
+ return this.execute(() => {
4859
+ const report = (reading) => this.mutateWithOutbox('task.review_request', endpoints.taskReviewRequest(input.projectId, input.taskId), cleanJson({
4860
+ url: input.url,
4861
+ state: input.state,
4862
+ ...(reading ? { headCommit: input.headCommit, targetBranch: input.targetBranch } : {}),
4863
+ }));
4864
+ return report(true).catch((error) => {
4865
+ if (!backendRejectsField(error, ['headCommit', 'targetBranch']))
4866
+ throw error;
4867
+ return report(false);
4868
+ });
4869
+ });
4761
4870
  }
4762
4871
  async taskOpenReviewRequest(input) {
4763
4872
  return this.execute(async () => {
@@ -4882,7 +4991,7 @@ export class BridgeService {
4882
4991
  : `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
4992
  if (delivery.state !== 'delivered' || !delivery.pushed || !delivery.branch)
4884
4993
  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;
4994
+ const target = input.targetBranch ?? delivery.reviewRequest?.targetBranch ?? delivery.baseBranch;
4886
4995
  if (!target)
4887
4996
  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
4997
  const git = this.dependencies.repositories.git;
@@ -4894,9 +5003,18 @@ export class BridgeService {
4894
5003
  if (!tip)
4895
5004
  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
5005
  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), {
5006
+ const startRound = (withTarget) => this.dependencies.client.request(endpoints.reviewRounds(input.projectId, input.taskId), {
4898
5007
  method: 'POST',
4899
- body: cleanJson({ commit: head.sourceCommit, baseCommit: base ?? undefined }),
5008
+ body: cleanJson({
5009
+ commit: head.sourceCommit,
5010
+ baseCommit: base ?? undefined,
5011
+ targetBranch: withTarget ? target : undefined,
5012
+ }),
5013
+ });
5014
+ const started = reviewSchema.parse((await startRound(true).catch((error) => {
5015
+ if (!backendRejectsField(error, ['targetBranch']))
5016
+ throw error;
5017
+ return startRound(false);
4900
5018
  })).data);
4901
5019
  const round = started.rounds.find((candidate) => candidate.state === 'open');
4902
5020
  if (!round)
@@ -4904,6 +5022,12 @@ export class BridgeService {
4904
5022
  const files = base
4905
5023
  ? await git.changedFiles(repository.repoRoot, base, head.sourceCommit)
4906
5024
  : [];
5025
+ const previous = started.rounds.find((candidate) => candidate.state === 'submitted' && candidate.number < round.number);
5026
+ const since = previous &&
5027
+ previous.headCommit !== head.sourceCommit &&
5028
+ (await git.isAncestor(repository.repoRoot, previous.headCommit, head.sourceCommit))
5029
+ ? await git.changedFiles(repository.repoRoot, previous.headCommit, head.sourceCommit)
5030
+ : null;
4907
5031
  const records = await this.dependencies.client.request(`${endpoints.reviewRoundRules(input.projectId, round.id)}?offset=0&limit=5`);
4908
5032
  const range = base ? `${base} ${head.sourceCommit}` : head.sourceCommit;
4909
5033
  return asJsonValue({
@@ -4925,22 +5049,36 @@ export class BridgeService {
4925
5049
  },
4926
5050
  changedFiles: files.slice(0, 300),
4927
5051
  changedFileCount: files.length,
5052
+ ...(previous && since
5053
+ ? {
5054
+ sinceLastRound: {
5055
+ commit: previous.headCommit,
5056
+ changedFiles: since.slice(0, 300),
5057
+ changedFileCount: since.length,
5058
+ },
5059
+ }
5060
+ : {}),
4928
5061
  earlierFindings: round.number > 1 ? started.findings : [],
4929
5062
  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.`,
5063
+ commands: [
5064
+ ...(base
5065
+ ? [
5066
+ `git diff --stat ${range}`,
5067
+ `git diff ${range} -- <path>`,
5068
+ `git show ${head.sourceCommit}:<path>`,
5069
+ `git grep -n <pattern> ${head.sourceCommit} -- <path>`,
5070
+ `git log --oneline ${base}..${head.sourceCommit}`,
5071
+ ]
5072
+ : [
5073
+ `git show --stat ${head.sourceCommit}`,
5074
+ `git show ${head.sourceCommit}:<path>`,
5075
+ `git grep -n <pattern> ${head.sourceCommit} -- <path>`,
5076
+ ]),
5077
+ ...(previous && since
5078
+ ? [`git diff ${previous.headCommit} ${head.sourceCommit} -- <path>`]
5079
+ : []),
5080
+ ],
5081
+ nextAction: `Give this packet to a fresh subagent that did not write the change; if your host cannot start one, review it yourself under the same rules. The reviewer reads the change from baseCommit to headCommit only through the listed read-only Git commands: it never checks out, switches branches, edits, commits or pushes, and changes no file. It judges the change against the records (more with review.rules for this round from offset 5 while more are listed) and says whether each earlier finding still holds${previous && since ? ', starting from sinceLastRound: what changed since the previous round' : ''}. Each finding names the file path, the line in headCommit when there is one, the ruleKey of the record it breaks when one applies, and a plain description of what is wrong and why. Then call review.submit with this projectId, roundId and the findings, an empty list when there are none.`,
4944
5082
  });
4945
5083
  });
4946
5084
  }
@@ -4971,14 +5109,20 @@ export class BridgeService {
4971
5109
  }));
4972
5110
  const idempotencyKey = deterministicUuid('review.submit', input.roundId, stableStringify(findings));
4973
5111
  const response = await this.dependencies.client.request(endpoints.reviewRoundResult(input.projectId, input.roundId), { method: 'POST', body: cleanJson({ idempotencyKey, findings }) });
5112
+ const comment = await this.reviewComment(input.projectId, response.data);
4974
5113
  return asJsonValue({
4975
5114
  review: response.data,
4976
5115
  masked: [...new Set(checked.flatMap(({ mask }) => mask.masked))],
5116
+ ...(comment ? { comment } : {}),
4977
5117
  });
4978
5118
  });
4979
5119
  }
4980
5120
  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));
5121
+ return this.execute(async () => {
5122
+ const response = await this.dependencies.client.request(endpoints.reviewFindingRejection(input.projectId, input.findingId), { method: 'POST', body: { reason: input.reason } });
5123
+ const comment = await this.reviewComment(input.projectId, response.data);
5124
+ return asJsonValue({ ...objectOrEmpty(response.data), ...(comment ? { comment } : {}) });
5125
+ });
4982
5126
  }
4983
5127
  async reviewConclude(input) {
4984
5128
  return this.execute(async () => {
@@ -4987,15 +5131,18 @@ export class BridgeService {
4987
5131
  : input.outcome === 'skipped'
4988
5132
  ? endpoints.reviewSkip(input.projectId, input.taskId)
4989
5133
  : endpoints.reviewExemption(input.projectId, input.taskId);
4990
- return asJsonValue((await this.dependencies.client.request(path, {
5134
+ const response = await this.dependencies.client.request(path, {
4991
5135
  method: 'POST',
4992
5136
  ...(input.outcome === 'exempted' ? { body: { reason: input.reason ?? '' } } : {}),
4993
- })).data);
5137
+ });
5138
+ const comment = await this.reviewComment(input.projectId, response.data);
5139
+ return asJsonValue({ ...objectOrEmpty(response.data), ...(comment ? { comment } : {}) });
4994
5140
  });
4995
5141
  }
4996
5142
  async reviewMakeReady(input) {
4997
5143
  return this.execute(async () => {
4998
- const { delivery } = await this.review(input.projectId, input.taskId);
5144
+ const data = (await this.dependencies.client.request(endpoints.review(input.projectId, input.taskId))).data;
5145
+ const { delivery } = reviewSchema.parse(data);
4999
5146
  const task = delivery.externalTaskId;
5000
5147
  const request = delivery.reviewRequest;
5001
5148
  if (waitingReview.includes(delivery.reviewState ?? ''))
@@ -5005,32 +5152,75 @@ export class BridgeService {
5005
5152
  ready: false,
5006
5153
  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
5154
  });
5155
+ const comment = await this.reviewComment(input.projectId, data);
5156
+ const commented = comment ? { comment } : {};
5008
5157
  if (request.state !== 'draft')
5009
5158
  return asJsonValue({
5010
5159
  ready: request.state === 'ready',
5011
5160
  pullRequest: { url: request.url, state: request.state },
5161
+ ...commented,
5012
5162
  nextAction: `${request.url} is ${request.state} already; nothing was changed.`,
5013
5163
  });
5014
5164
  if (request.provider !== 'gitlab' || !this.dependencies.gitlab)
5015
5165
  return asJsonValue({
5016
5166
  ready: false,
5017
5167
  pullRequest: { url: request.url, state: request.state },
5168
+ ...commented,
5018
5169
  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
5170
  });
5020
- const readied = await this.dependencies.gitlab.requests.markReady(request);
5171
+ const readied = await this.dependencies.gitlab.requests
5172
+ .markReady(request)
5173
+ .catch(async (error) => {
5174
+ const failure = error instanceof BridgeRecoveryError ? objectValue(error.details)?.failure : undefined;
5175
+ if (delivery.providerJob === 'make_ready' && typeof failure === 'string')
5176
+ await this.dependencies.client
5177
+ .request(endpoints.reviewProviderFailure(input.projectId, input.taskId), {
5178
+ method: 'POST',
5179
+ body: { job: 'make_ready', failure },
5180
+ })
5181
+ .catch(() => undefined);
5182
+ throw error;
5183
+ });
5021
5184
  if (readied.state !== 'ready')
5022
5185
  return asJsonValue({
5023
5186
  ready: false,
5024
5187
  pullRequest: readied,
5188
+ ...commented,
5025
5189
  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
5190
  });
5027
5191
  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 });
5192
+ return asJsonValue({ ready: true, pullRequest: readied, ...commented, report });
5029
5193
  });
5030
5194
  }
5031
5195
  async review(projectId, taskId) {
5032
5196
  return reviewSchema.parse((await this.dependencies.client.request(endpoints.review(projectId, taskId))).data);
5033
5197
  }
5198
+ async reviewComment(projectId, data) {
5199
+ const review = commentReviewSchema.safeParse(data);
5200
+ const request = review.success ? review.data.delivery.reviewRequest : null;
5201
+ const comment = review.success && request ? dueComment(review.data, (await this.language()) ?? 'en') : null;
5202
+ if (!review.success || !request || !comment)
5203
+ return null;
5204
+ const taskId = review.data.delivery.taskId;
5205
+ if (request.provider === 'gitlab' && this.dependencies.gitlab) {
5206
+ const written = await this.dependencies.gitlab.requests.comment(projectId, taskId, request, comment);
5207
+ if (written.posted)
5208
+ return { posted: true };
5209
+ return {
5210
+ posted: false,
5211
+ ...(written.failure ? { failure: written.failure } : {}),
5212
+ nextAction: written.failure
5213
+ ? `GitLab refused the review comment on ${request.url} (${written.failure.replace(/_/g, ' ')}); a member computer with a GitLab token writes it later, and nothing waits for it.`
5214
+ : `No GitLab token is saved on this computer, so the review comment was not written on ${request.url}; a member computer with a GitLab token writes it later, and gitlab.token lets this computer write it too.`,
5215
+ };
5216
+ }
5217
+ await markComment(this.dependencies.client, projectId, taskId, comment);
5218
+ return {
5219
+ posted: false,
5220
+ body: comment.body,
5221
+ nextAction: `Post body unchanged as a comment on ${request.url} with your own tools (on GitHub, gh pr comment with the body in a file), so the team sees the review there. It is recorded as handed over and is not handed over again.`,
5222
+ };
5223
+ }
5034
5224
  async reportDeliveryOutcome(report) {
5035
5225
  const path = report.outcome === 'delivered'
5036
5226
  ? endpoints.taskDeliveryDeliver(report.projectId, report.taskId)
@@ -6241,11 +6431,6 @@ function classifyError(error) {
6241
6431
  }
6242
6432
  return 'bridge_delivery_error';
6243
6433
  }
6244
- function backendRejectsField(error, fields) {
6245
- return (error instanceof ApiResponseError &&
6246
- error.httpStatus === 400 &&
6247
- fields.some((field) => error.message.includes(`property ${field} should not exist`)));
6248
- }
6249
6434
  function isDefinitiveAuthenticationFailure(error) {
6250
6435
  return (error instanceof ApiResponseError &&
6251
6436
  (error.httpStatus === 400 || error.httpStatus === 401 || error.httpStatus === 403));
@@ -22,6 +22,7 @@ import { PrincipalStateGuard, principalFingerprint } from './principal-state.js'
22
22
  import { MergeRequests } from './merge-request-sync.js';
23
23
  import { GitLabClient, GitLabTokens } from '../providers/gitlab.js';
24
24
  import { GitLabTokenPage } from '../providers/gitlab-token-page.js';
25
+ import { JiraConnectCoordinator } from '../providers/jira-connect.js';
25
26
  import { OnboardingStore } from './onboarding-store.js';
26
27
  import { QuestionnaireStore } from './questionnaire-store.js';
27
28
  import { sha256 } from '../utilities/hash.js';
@@ -113,8 +114,9 @@ export function createBridgeService(options = {}) {
113
114
  gitlab: {
114
115
  tokens: gitlabTokens,
115
116
  page: new GitLabTokenPage(gitlabTokens, gitlab, signedInLanguage),
116
- requests: new MergeRequests(client, gitlabTokens, gitlab, git),
117
+ requests: new MergeRequests(client, gitlabTokens, gitlab, git, signedInLanguage),
117
118
  },
119
+ jiraConnect: new JiraConnectCoordinator(client, signedInLanguage),
118
120
  }));
119
121
  }
120
122
  //# sourceMappingURL=create-bridge-service.js.map
@@ -29,6 +29,9 @@ const kindByPrefix = [
29
29
  ['sync-start-', 'project_onboarding'],
30
30
  ['workflow-apply-', 'project_onboarding'],
31
31
  ['status-description-', 'project_onboarding'],
32
+ ['review-policy-', 'review_decision'],
33
+ ['review-reject-', 'review_decision'],
34
+ ['review-conclude-', 'review_decision'],
32
35
  ];
33
36
  export function liveQuestionKind(record) {
34
37
  if (record.owner?.tool === 'decision.mode')
@@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto';
2
2
  import * as z from 'zod/v4';
3
3
  import { endpoints } from '../config.js';
4
4
  import { GitLabError, gitLabRemote, projectPath, reviewState, } from '../providers/gitlab.js';
5
+ import { backendRejectsField } from './api-client.js';
5
6
  import { BridgeRecoveryError } from './recovery-error.js';
7
+ import { commentReviewSchema, dueComment, markComment } from './review-comment.js';
6
8
  export const reviewDeliverySchema = z.object({
7
9
  taskId: z.string(),
8
10
  externalTaskId: z.string().nullish(),
@@ -14,6 +16,10 @@ export const reviewDeliverySchema = z.object({
14
16
  branch: z.string().nullish(),
15
17
  workItemKey: z.string().nullish(),
16
18
  reviewState: z.string().nullish(),
19
+ reviewCommit: z.string().nullish(),
20
+ reviewCommentPostedAt: z.string().nullish(),
21
+ providerJob: z.string().nullish(),
22
+ providerFailedAt: z.string().nullish(),
17
23
  reviewRequest: z
18
24
  .object({
19
25
  provider: z.string(),
@@ -23,6 +29,7 @@ export const reviewDeliverySchema = z.object({
23
29
  url: z.string(),
24
30
  state: z.string(),
25
31
  source: z.string().optional(),
32
+ targetBranch: z.string().nullish(),
26
33
  })
27
34
  .nullish(),
28
35
  });
@@ -54,13 +61,16 @@ export class MergeRequests {
54
61
  tokens;
55
62
  gitlab;
56
63
  git;
64
+ language;
57
65
  passes = new Map();
58
66
  running = new Set();
59
- constructor(client, tokens, gitlab, git) {
67
+ readingRefused = false;
68
+ constructor(client, tokens, gitlab, git, language) {
60
69
  this.client = client;
61
70
  this.tokens = tokens;
62
71
  this.gitlab = gitlab;
63
72
  this.git = git;
73
+ this.language = language;
64
74
  }
65
75
  async open(input) {
66
76
  const delivery = reviewDeliverySchema.parse((await this.client.request(endpoints.taskDelivery(input.projectId, input.taskId)))
@@ -166,6 +176,21 @@ export class MergeRequests {
166
176
  : 'review.make_ready', { address: at, failure: error.failure, status: error.status });
167
177
  }
168
178
  }
179
+ async comment(projectId, taskId, request, comment) {
180
+ const link = await this.tokens.forLink(request.host, request.repository);
181
+ const token = link ? await this.tokens.token(link.address) : null;
182
+ if (!link || !token)
183
+ return { posted: false };
184
+ try {
185
+ await this.post(projectId, taskId, { address: link.address, token, project: link.project, number: request.number }, comment);
186
+ return { posted: true };
187
+ }
188
+ catch (error) {
189
+ if (!(error instanceof GitLabError))
190
+ throw error;
191
+ return { posted: false, failure: error.failure };
192
+ }
193
+ }
169
194
  schedule(projectId, repoRoot, deliveries) {
170
195
  const now = Date.now();
171
196
  const last = this.passes.get(projectId);
@@ -192,8 +217,17 @@ export class MergeRequests {
192
217
  const worker = async () => {
193
218
  for (let delivery = queue.shift(); delivery && pass.calls > 0; delivery = queue.shift()) {
194
219
  const found = await this.follow(delivery, repoRoot, pass).catch(() => null);
195
- if (found && (await this.report(projectId, delivery.taskId, found)))
220
+ if (!found)
221
+ continue;
222
+ let job = delivery.providerJob ?? null;
223
+ if (this.changed(delivery, found)) {
224
+ const answer = await this.report(projectId, delivery.taskId, found);
225
+ if (!answer)
226
+ continue;
196
227
  reported++;
228
+ job = answer.providerJob;
229
+ }
230
+ await this.provide(projectId, delivery, found, job, pass).catch(() => undefined);
197
231
  }
198
232
  };
199
233
  await Promise.all([worker(), worker(), worker(), worker()]);
@@ -209,10 +243,9 @@ export class MergeRequests {
209
243
  if (!link || !token)
210
244
  return null;
211
245
  const request = await this.call(pass, link.address, () => this.gitlab.mergeRequest(link.address, token, link.project, known.number));
212
- if (!request)
213
- return null;
214
- const state = reviewState(request);
215
- return state !== known.state || known.source !== 'machine' ? { url: known.url, state } : null;
246
+ return request
247
+ ? { url: known.url, request, address: link.address, token, project: link.project }
248
+ : null;
216
249
  }
217
250
  if (!repoRoot || !delivery.branch)
218
251
  return null;
@@ -242,7 +275,78 @@ export class MergeRequests {
242
275
  .sort((left, right) => rank(left) - rank(right) ||
243
276
  Number(right.sha === delivery.deliveredCommit) -
244
277
  Number(left.sha === delivery.deliveredCommit))[0];
245
- return chosen ? { url: chosen.web_url, state: reviewState(chosen) } : null;
278
+ return chosen ? { url: chosen.web_url, request: chosen, address, token, project: path } : null;
279
+ }
280
+ changed(delivery, found) {
281
+ const known = delivery.reviewRequest;
282
+ const head = found.request.sha;
283
+ return (!known ||
284
+ reviewState(found.request) !== known.state ||
285
+ known.source !== 'machine' ||
286
+ (known.targetBranch !== undefined && found.request.target_branch !== known.targetBranch) ||
287
+ (!this.readingRefused &&
288
+ delivery.reviewState === 'passed' &&
289
+ !!head &&
290
+ !!delivery.reviewCommit &&
291
+ head !== delivery.reviewCommit));
292
+ }
293
+ async provide(projectId, delivery, found, job, pass) {
294
+ const state = reviewState(found.request);
295
+ const failedAt = job === (delivery.providerJob ?? null) ? delivery.providerFailedAt : null;
296
+ if ((state !== 'draft' && state !== 'ready') ||
297
+ (failedAt && Date.now() - Date.parse(failedAt) < 30 * 60 * 1000) ||
298
+ pass.failed.has(found.address) ||
299
+ pass.calls < 3)
300
+ return;
301
+ if (delivery.reviewState === 'findings_open' ||
302
+ (concludedReview.includes(delivery.reviewState ?? '') &&
303
+ delivery.reviewCommentPostedAt === null)) {
304
+ const review = commentReviewSchema.safeParse((await this.client
305
+ .request(endpoints.review(projectId, delivery.taskId))
306
+ .catch(() => null))?.data);
307
+ const comment = review.success
308
+ ? dueComment(review.data, (await this.language()) ?? 'en')
309
+ : null;
310
+ if (comment)
311
+ await this.post(projectId, delivery.taskId, { ...found, number: found.request.iid }, comment, pass).catch(() => undefined);
312
+ }
313
+ const due = (job === 'make_ready' && state === 'draft') || (job === 'make_draft' && state === 'ready')
314
+ ? job
315
+ : null;
316
+ if (!due)
317
+ return;
318
+ try {
319
+ pass.calls--;
320
+ const title = found.request.title;
321
+ if (title === undefined)
322
+ throw new GitLabError('unexpected_answer', found.address, null, null);
323
+ const after = await this.gitlab.retitleMergeRequest(found.address, found.token, found.project, found.request.iid, due === 'make_ready'
324
+ ? title.replace(draftPrefix, '') || defaultTitle(found.request.source_branch, null)
325
+ : `Draft: ${title}`.slice(0, 255));
326
+ await this.report(projectId, delivery.taskId, { ...found, request: after });
327
+ }
328
+ catch (error) {
329
+ if (!(error instanceof GitLabError))
330
+ return;
331
+ pass.failed.add(found.address);
332
+ await this.client
333
+ .request(endpoints.reviewProviderFailure(projectId, delivery.taskId), {
334
+ method: 'POST',
335
+ body: { job: due, failure: error.failure },
336
+ })
337
+ .catch(() => undefined);
338
+ }
339
+ }
340
+ async post(projectId, taskId, target, comment, pass) {
341
+ if (pass)
342
+ pass.calls--;
343
+ const notes = await this.gitlab.notes(target.address, target.token, target.project, target.number);
344
+ if (!notes.some((note) => note.body.includes(comment.marker))) {
345
+ if (pass)
346
+ pass.calls--;
347
+ await this.gitlab.createNote(target.address, target.token, target.project, target.number, comment.body);
348
+ }
349
+ await markComment(this.client, projectId, taskId, comment);
246
350
  }
247
351
  async call(pass, address, request) {
248
352
  if (pass.failed.has(address) || pass.calls <= 0)
@@ -259,16 +363,28 @@ export class MergeRequests {
259
363
  }
260
364
  }
261
365
  async report(projectId, taskId, found) {
366
+ const body = { url: found.url, state: reviewState(found.request), source: 'machine' };
367
+ const reading = {
368
+ ...(found.request.sha ? { headCommit: found.request.sha } : {}),
369
+ targetBranch: found.request.target_branch,
370
+ };
371
+ const send = () => this.client.request(endpoints.taskReviewRequest(projectId, taskId), {
372
+ method: 'POST',
373
+ body: this.readingRefused ? body : { ...body, ...reading },
374
+ idempotencyKey: randomUUID(),
375
+ });
262
376
  try {
263
- await this.client.request(endpoints.taskReviewRequest(projectId, taskId), {
264
- method: 'POST',
265
- body: { url: found.url, state: found.state, source: 'machine' },
266
- idempotencyKey: randomUUID(),
377
+ const response = await send().catch(async (error) => {
378
+ if (this.readingRefused || !backendRejectsField(error, ['headCommit', 'targetBranch']))
379
+ throw error;
380
+ this.readingRefused = true;
381
+ return send();
267
382
  });
268
- return true;
383
+ const answer = z.object({ providerJob: z.string().nullish() }).safeParse(response.data);
384
+ return { providerJob: answer.success ? (answer.data.providerJob ?? null) : null };
269
385
  }
270
386
  catch {
271
- return false;
387
+ return null;
272
388
  }
273
389
  }
274
390
  }