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
|
@@ -39,6 +39,7 @@ const reviewRead = z.object({
|
|
|
39
39
|
delivery: z.object({
|
|
40
40
|
externalTaskId: z.string(),
|
|
41
41
|
reviewState: z.string().nullish(),
|
|
42
|
+
reviewReopenReason: z.string().nullish(),
|
|
42
43
|
reviewPolicy: z.string().optional(),
|
|
43
44
|
}),
|
|
44
45
|
rounds: z.array(z.object({ id: z.string(), state: z.string() })),
|
|
@@ -132,7 +133,7 @@ export function registerReviewTools(server, service) {
|
|
|
132
133
|
inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
|
|
133
134
|
}, async (input) => output(await service.reviewGet(input)));
|
|
134
135
|
server.registerTool('review.start', {
|
|
135
|
-
description: "Start the next code review round of a delivered task, or return the round already open at the same commit, from a clone of the task's project on this computer. It fetches the pushed branch and its target from origin without checking anything out and returns the review packet: the commits to compare, the changed files, the records that governed the change, earlier findings and the read-only Git commands the reviewer may use.
|
|
136
|
+
description: "Start the next code review round of a delivered task, or return the round already open at the same commit, from a clone of the task's project on this computer. It fetches the pushed branch and its target from origin without checking anything out and returns the review packet: the commits to compare, the changed files, the records that governed the change, earlier findings and the read-only Git commands the reviewer may use; from the second round on, also what changed since the previous round. The target branch is the one recorded for its PR/MR, else the one the delivery answer named; pass targetBranch only when neither is right.",
|
|
136
137
|
inputSchema: z.strictObject({
|
|
137
138
|
projectId: z.string().uuid(),
|
|
138
139
|
taskId: z.string().uuid(),
|
|
@@ -150,7 +151,7 @@ export function registerReviewTools(server, service) {
|
|
|
150
151
|
}),
|
|
151
152
|
}, async (input) => output(await service.reviewRules(input)));
|
|
152
153
|
server.registerTool('review.submit', {
|
|
153
|
-
description: 'Record the result of a review round: its findings, or an empty list when there are none. E-mail addresses, IP addresses and user folders in descriptions are masked first; a description that still holds a secret or personal data is refused so it can be rephrased. Sending the same result again changes nothing.',
|
|
154
|
+
description: 'Record the result of a review round: its findings, or an empty list when there are none. E-mail addresses, IP addresses and user folders in descriptions are masked first; a description that still holds a secret or personal data is refused so it can be rephrased. Sending the same result again changes nothing. The findings, and later the conclusion, are written on the PR/MR as a comment: on GitLab with the token the user saved on this computer, elsewhere comment.body is returned for you to post.',
|
|
154
155
|
inputSchema: z.strictObject({
|
|
155
156
|
projectId: z.string().uuid(),
|
|
156
157
|
roundId: z.string().uuid(),
|
|
@@ -285,6 +286,8 @@ export function registerReviewTools(server, service) {
|
|
|
285
286
|
});
|
|
286
287
|
if (!waitingReview.includes(state ?? ''))
|
|
287
288
|
return refusal(`The review of ${task} is ${state ?? 'not tracked'}, so there is nothing to end. review.get shows where it stands.`, 'review.get');
|
|
289
|
+
if (input.outcome === 'passed' && review.delivery.reviewReopenReason)
|
|
290
|
+
return refusal(`The review of ${task} had passed, then its PR/MR got ${review.delivery.reviewReopenReason === 'new_target' ? 'a new target branch' : 'a new commit'}, so it passes again only after a round reviews it. Start one with review.start, or ask the user whether to skip or exempt it.`, 'review.start');
|
|
288
291
|
if (input.outcome === 'passed' &&
|
|
289
292
|
(state !== 'pending' ||
|
|
290
293
|
!review.rounds.some((round) => round.state === 'submitted') ||
|
|
@@ -353,7 +356,7 @@ export function registerReviewTools(server, service) {
|
|
|
353
356
|
});
|
|
354
357
|
});
|
|
355
358
|
server.registerTool('review.make_ready', {
|
|
356
|
-
description: "Make the PR/MR of a delivered task ready for the team once its code review ended, or, when the user asks, one that has no code review; while a review runs it refuses. On GitLab with the user's own token saved on this computer, it removes Draft from the merge request and records it; anywhere else it says exactly what to do, since the draft is marked ready with the provider's own tools or by the user.",
|
|
359
|
+
description: "Make the PR/MR of a delivered task ready for the team once its code review ended, or, when the user asks, one that has no code review; while a review runs it refuses. On GitLab with the user's own token saved on this computer, it writes the review conclusion as a comment when it is not written yet, removes Draft from the merge request and records it; anywhere else it says exactly what to do, since the draft is marked ready with the provider's own tools or by the user.",
|
|
357
360
|
inputSchema: z.strictObject({ projectId: z.string().uuid(), taskId: z.string().uuid() }),
|
|
358
361
|
}, async (input) => output(await service.reviewMakeReady(input)));
|
|
359
362
|
}
|
|
@@ -140,6 +140,12 @@ export const toolAnnotations = {
|
|
|
140
140
|
'live_status.list': read,
|
|
141
141
|
'live_status.notice_presented': repeatableWrite,
|
|
142
142
|
'live_status.set_sharing': write,
|
|
143
|
+
'jira.status': read,
|
|
144
|
+
'jira.connect': outward, // the user approves access on Atlassian through the link it returns
|
|
145
|
+
'jira.disconnect': destructive, // the stored access is deleted; only a new approval restores it
|
|
146
|
+
'jira.link_project': write,
|
|
147
|
+
'jira.unlink_project': write,
|
|
148
|
+
'jira.map_people': write,
|
|
143
149
|
'auth.status': read,
|
|
144
150
|
'auth.signin_browser': outward,
|
|
145
151
|
'auth.logout': destructive,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { registerDeliveryTools } from './delivery-tools.js';
|
|
2
2
|
import { registerReviewTools } from './review-tools.js';
|
|
3
3
|
import { registerLiveStatusTools } from './live-status-tools.js';
|
|
4
|
+
import { registerJiraTools } from './jira-tools.js';
|
|
4
5
|
import { registerStatusMeaningTools } from './status-meaning-tools.js';
|
|
5
6
|
import { registerStatusRemapTools } from './status-remap-tools.js';
|
|
6
7
|
import { registerWorkflowTools } from './workflow-tools.js';
|
|
@@ -180,6 +181,12 @@ export const engineeringMemoryToolNames = [
|
|
|
180
181
|
'live_status.list',
|
|
181
182
|
'live_status.notice_presented',
|
|
182
183
|
'live_status.set_sharing',
|
|
184
|
+
'jira.status',
|
|
185
|
+
'jira.connect',
|
|
186
|
+
'jira.disconnect',
|
|
187
|
+
'jira.link_project',
|
|
188
|
+
'jira.unlink_project',
|
|
189
|
+
'jira.map_people',
|
|
183
190
|
'auth.status',
|
|
184
191
|
'auth.signin_browser',
|
|
185
192
|
'auth.logout',
|
|
@@ -1252,6 +1259,7 @@ export function registerEngineeringMemoryTools(server, service) {
|
|
|
1252
1259
|
inputSchema: z.object({ projectId: z.string().min(1) }),
|
|
1253
1260
|
}, async (input) => toolResult(await service.projectLinks(input)));
|
|
1254
1261
|
registerLiveStatusTools(server, service);
|
|
1262
|
+
registerJiraTools(server, service);
|
|
1255
1263
|
server.registerTool('auth.status', {
|
|
1256
1264
|
description: 'Check whether OS credential storage contains an active local Engineering Memory session.',
|
|
1257
1265
|
inputSchema: z.object({}),
|
|
@@ -152,6 +152,7 @@ export const mergeRequestSchema = z.object({
|
|
|
152
152
|
project_id: z.number().optional(),
|
|
153
153
|
source_project_id: z.number().optional(),
|
|
154
154
|
});
|
|
155
|
+
const noteSchema = z.object({ id: z.number(), body: z.string() });
|
|
155
156
|
export function reviewState(request) {
|
|
156
157
|
if (request.state === 'merged')
|
|
157
158
|
return 'merged';
|
|
@@ -227,6 +228,13 @@ export class GitLabClient {
|
|
|
227
228
|
...(request.description ? { description: request.description } : {}),
|
|
228
229
|
}));
|
|
229
230
|
}
|
|
231
|
+
async notes(address, token, project, number) {
|
|
232
|
+
const query = new URLSearchParams({ sort: 'desc', order_by: 'created_at', per_page: '100' });
|
|
233
|
+
return this.shape(address, z.array(noteSchema), await this.call(address, token, 'GET', `/projects/${encodeURIComponent(project)}/merge_requests/${number}/notes?${query}`));
|
|
234
|
+
}
|
|
235
|
+
async createNote(address, token, project, number, body) {
|
|
236
|
+
this.shape(address, noteSchema, await this.call(address, token, 'POST', `/projects/${encodeURIComponent(project)}/merge_requests/${number}/notes`, { body }));
|
|
237
|
+
}
|
|
230
238
|
async retitleMergeRequest(address, token, project, number, title) {
|
|
231
239
|
return this.shape(address, mergeRequestSchema, await this.call(address, token, 'PUT', `/projects/${encodeURIComponent(project)}/merge_requests/${number}`, { title }));
|
|
232
240
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
import { endpoints } from '../config.js';
|
|
5
|
+
import { ApiResponseError } from '../runtime/api-client.js';
|
|
6
|
+
import { copies, escapeHtml, format, textDirection } from '../runtime/texts.js';
|
|
7
|
+
const text = z.string().min(1);
|
|
8
|
+
const pageWording = z.strictObject({
|
|
9
|
+
title: text,
|
|
10
|
+
connected: text,
|
|
11
|
+
chooseSite: text,
|
|
12
|
+
cancelled: text,
|
|
13
|
+
failed: text,
|
|
14
|
+
invalid: text,
|
|
15
|
+
});
|
|
16
|
+
const started = z.object({
|
|
17
|
+
requestId: z.uuid(),
|
|
18
|
+
connectUrl: z.url(),
|
|
19
|
+
expiresAt: z.iso.datetime({ offset: true }),
|
|
20
|
+
});
|
|
21
|
+
export class JiraConnectCoordinator {
|
|
22
|
+
client;
|
|
23
|
+
language;
|
|
24
|
+
flows = new Map();
|
|
25
|
+
constructor(client, language = async () => undefined) {
|
|
26
|
+
this.client = client;
|
|
27
|
+
this.language = language;
|
|
28
|
+
}
|
|
29
|
+
state(organizationId) {
|
|
30
|
+
return this.flows.get(organizationId)?.state ?? null;
|
|
31
|
+
}
|
|
32
|
+
async start(organizationId, projectId) {
|
|
33
|
+
await this.forget(organizationId);
|
|
34
|
+
const codeVerifier = randomBytes(32).toString('base64url');
|
|
35
|
+
const server = createServer((request, response) => {
|
|
36
|
+
void this.callback(organizationId, request, response);
|
|
37
|
+
});
|
|
38
|
+
await new Promise((resolvePromise, reject) => {
|
|
39
|
+
server.once('error', reject);
|
|
40
|
+
server.listen(0, '127.0.0.1', resolvePromise);
|
|
41
|
+
});
|
|
42
|
+
server.unref();
|
|
43
|
+
const port = server.address().port;
|
|
44
|
+
try {
|
|
45
|
+
const envelope = await this.client.request(endpoints.jiraConnect(organizationId), {
|
|
46
|
+
method: 'POST',
|
|
47
|
+
body: {
|
|
48
|
+
codeChallenge: createHash('sha256').update(codeVerifier).digest('base64url'),
|
|
49
|
+
callbackUrl: `http://127.0.0.1:${port}/callback`,
|
|
50
|
+
...(projectId ? { projectId } : {}),
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
const request = started.parse(envelope.data);
|
|
54
|
+
const state = {
|
|
55
|
+
phase: 'waiting',
|
|
56
|
+
connectUrl: request.connectUrl,
|
|
57
|
+
expiresAt: request.expiresAt,
|
|
58
|
+
};
|
|
59
|
+
const timer = setTimeout(() => void this.expire(organizationId, request.requestId), Math.max(0, Date.parse(request.expiresAt) - Date.now()));
|
|
60
|
+
timer.unref();
|
|
61
|
+
this.flows.set(organizationId, {
|
|
62
|
+
requestId: request.requestId,
|
|
63
|
+
codeVerifier,
|
|
64
|
+
port,
|
|
65
|
+
server,
|
|
66
|
+
timer,
|
|
67
|
+
state,
|
|
68
|
+
});
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
await close(server);
|
|
73
|
+
throw error;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async forget(organizationId) {
|
|
77
|
+
const flow = this.flows.get(organizationId);
|
|
78
|
+
if (!flow)
|
|
79
|
+
return;
|
|
80
|
+
this.flows.delete(organizationId);
|
|
81
|
+
clearTimeout(flow.timer);
|
|
82
|
+
await close(flow.server);
|
|
83
|
+
}
|
|
84
|
+
async dispose() {
|
|
85
|
+
for (const organizationId of [...this.flows.keys()])
|
|
86
|
+
await this.forget(organizationId);
|
|
87
|
+
}
|
|
88
|
+
async expire(organizationId, requestId) {
|
|
89
|
+
const flow = this.flows.get(organizationId);
|
|
90
|
+
if (flow?.requestId !== requestId || flow.state.phase === 'finishing')
|
|
91
|
+
return;
|
|
92
|
+
if (flow.state.phase === 'waiting')
|
|
93
|
+
flow.state = { phase: 'expired' };
|
|
94
|
+
await close(flow.server);
|
|
95
|
+
}
|
|
96
|
+
async callback(organizationId, request, response) {
|
|
97
|
+
const flow = this.flows.get(organizationId);
|
|
98
|
+
try {
|
|
99
|
+
const url = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
100
|
+
if (!flow ||
|
|
101
|
+
request.method !== 'GET' ||
|
|
102
|
+
request.headers.host !== `127.0.0.1:${flow.port}` ||
|
|
103
|
+
url.pathname !== '/callback' ||
|
|
104
|
+
url.searchParams.get('requestId') !== flow.requestId ||
|
|
105
|
+
flow.state.phase !== 'waiting' ||
|
|
106
|
+
Date.parse(flow.state.expiresAt) <= Date.now())
|
|
107
|
+
return this.send(response, 400, await this.page('invalid'));
|
|
108
|
+
const code = url.searchParams.get('code');
|
|
109
|
+
if (!code) {
|
|
110
|
+
const cancelled = url.searchParams.get('error') === 'cancelled';
|
|
111
|
+
flow.state = cancelled
|
|
112
|
+
? { phase: 'cancelled' }
|
|
113
|
+
: {
|
|
114
|
+
phase: 'failed',
|
|
115
|
+
message: 'Atlassian did not finish the authorization. Start jira.connect again.',
|
|
116
|
+
recovery: 'jira.connect',
|
|
117
|
+
};
|
|
118
|
+
return this.finish(flow, response, await this.page(cancelled ? 'cancelled' : 'failed'));
|
|
119
|
+
}
|
|
120
|
+
flow.state = { phase: 'finishing' };
|
|
121
|
+
try {
|
|
122
|
+
const envelope = await this.client.request(endpoints.jiraConnectComplete(organizationId), {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
body: { requestId: flow.requestId, code, codeVerifier: flow.codeVerifier },
|
|
125
|
+
});
|
|
126
|
+
const result = envelope.data ?? {};
|
|
127
|
+
flow.state = { phase: 'answered', result };
|
|
128
|
+
const connection = result.connection;
|
|
129
|
+
return this.finish(flow, response, result.outcome === 'connected'
|
|
130
|
+
? await this.page('connected', String(connection?.siteName ?? ''))
|
|
131
|
+
: await this.page('chooseSite'));
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
flow.state = {
|
|
135
|
+
phase: 'failed',
|
|
136
|
+
message: error instanceof Error ? error.message : 'The Jira connection failed.',
|
|
137
|
+
recovery: error instanceof ApiResponseError ? error.recovery : null,
|
|
138
|
+
};
|
|
139
|
+
return this.finish(flow, response, await this.page('failed'), 502);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return this.send(response, 500, await this.page('failed'));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
finish(flow, response, html, status = 200) {
|
|
147
|
+
clearTimeout(flow.timer);
|
|
148
|
+
response.once('finish', () => void close(flow.server));
|
|
149
|
+
this.send(response, status, html);
|
|
150
|
+
}
|
|
151
|
+
send(response, status, html) {
|
|
152
|
+
response.writeHead(status, {
|
|
153
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
154
|
+
'Cache-Control': 'no-store',
|
|
155
|
+
'Referrer-Policy': 'no-referrer',
|
|
156
|
+
'X-Content-Type-Options': 'nosniff',
|
|
157
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
|
|
158
|
+
});
|
|
159
|
+
response.end(html);
|
|
160
|
+
}
|
|
161
|
+
async page(notice, site = '') {
|
|
162
|
+
const told = await this.language().catch(() => undefined);
|
|
163
|
+
const { language, copy } = copies(pageWording, 'jiraConnect', told)[0];
|
|
164
|
+
const message = format(copy[notice], language, { site });
|
|
165
|
+
return (`<!doctype html><html lang="${escapeHtml(language)}" dir="${textDirection(language)}"><head>` +
|
|
166
|
+
'<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">' +
|
|
167
|
+
`<title>${escapeHtml(copy.title)}</title><style>` +
|
|
168
|
+
'body{font:16px/1.5 system-ui,sans-serif;margin:0;padding:24px 16px;background:#f6f7f9;color:#1d2330}' +
|
|
169
|
+
'main{max-width:560px;margin:0 auto}h1{font-size:22px;line-height:1.3}' +
|
|
170
|
+
'@media (prefers-color-scheme:dark){body{background:#14171c;color:#e6e9ef}}' +
|
|
171
|
+
`</style></head><body><main><h1>${escapeHtml(copy.title)}</h1><p>${escapeHtml(message)}</p></main></body></html>`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async function close(server) {
|
|
175
|
+
if (!server.listening)
|
|
176
|
+
return;
|
|
177
|
+
await new Promise((resolvePromise) => {
|
|
178
|
+
server.close(() => resolvePromise());
|
|
179
|
+
server.closeAllConnections();
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
//# sourceMappingURL=jira-connect.js.map
|
|
@@ -9,6 +9,7 @@ export const backendRecoveryOperationNames = [
|
|
|
9
9
|
'auth.web_signin',
|
|
10
10
|
'audit.list',
|
|
11
11
|
'live_status.list',
|
|
12
|
+
'jira.status',
|
|
12
13
|
'organization.member_list',
|
|
13
14
|
'organization.member_upsert',
|
|
14
15
|
'project.setup',
|
|
@@ -95,6 +96,11 @@ export class ApiResponseError extends Error {
|
|
|
95
96
|
this.recovery = normalizedRecovery;
|
|
96
97
|
}
|
|
97
98
|
}
|
|
99
|
+
export function backendRejectsField(error, fields) {
|
|
100
|
+
return (error instanceof ApiResponseError &&
|
|
101
|
+
error.httpStatus === 400 &&
|
|
102
|
+
fields.some((field) => error.message.includes(`property ${field} should not exist`)));
|
|
103
|
+
}
|
|
98
104
|
export class ApiClient {
|
|
99
105
|
options;
|
|
100
106
|
fetchImplementation;
|