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.
- package/package.json +1 -1
- package/runtime/build.json +1 -1
- package/runtime/dist/src/config.js +14 -0
- package/runtime/dist/src/localization/catalogue.generated.js +138 -0
- package/runtime/dist/src/mcp/delivery-tools.js +24 -5
- package/runtime/dist/src/mcp/jira-tools.js +876 -0
- package/runtime/dist/src/mcp/review-tools.js +6 -3
- package/runtime/dist/src/mcp/tool-annotations.js +6 -0
- package/runtime/dist/src/mcp/tool-definitions.js +8 -0
- package/runtime/dist/src/providers/gitlab.js +8 -0
- package/runtime/dist/src/providers/jira-connect.js +182 -0
- package/runtime/dist/src/runtime/api-client.js +6 -0
- package/runtime/dist/src/runtime/bridge-service.js +216 -31
- package/runtime/dist/src/runtime/create-bridge-service.js +3 -1
- package/runtime/dist/src/runtime/live-signals.js +3 -0
- package/runtime/dist/src/runtime/merge-request-sync.js +129 -13
- package/runtime/dist/src/runtime/review-comment.js +135 -0
- package/skill/references/lifecycle.md +24 -1
|
@@ -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(() =>
|
|
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
|
|
5006
|
+
const startRound = (withTarget) => this.dependencies.client.request(endpoints.reviewRounds(input.projectId, input.taskId), {
|
|
4898
5007
|
method: 'POST',
|
|
4899
|
-
body: cleanJson({
|
|
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:
|
|
4931
|
-
|
|
4932
|
-
|
|
4933
|
-
|
|
4934
|
-
|
|
4935
|
-
|
|
4936
|
-
|
|
4937
|
-
|
|
4938
|
-
|
|
4939
|
-
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
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 () =>
|
|
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
|
-
|
|
5134
|
+
const response = await this.dependencies.client.request(path, {
|
|
4991
5135
|
method: 'POST',
|
|
4992
5136
|
...(input.outcome === 'exempted' ? { body: { reason: input.reason ?? '' } } : {}),
|
|
4993
|
-
})
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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,
|
|
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
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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
|
|
387
|
+
return null;
|
|
272
388
|
}
|
|
273
389
|
}
|
|
274
390
|
}
|