engineering-memory 1.11.30 → 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 +11 -0
- package/runtime/dist/src/localization/catalogue.generated.js +120 -0
- package/runtime/dist/src/mcp/jira-tools.js +876 -0
- 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/jira-connect.js +182 -0
- package/runtime/dist/src/runtime/api-client.js +1 -0
- package/runtime/dist/src/runtime/bridge-service.js +84 -0
- package/runtime/dist/src/runtime/create-bridge-service.js +2 -0
- package/skill/references/lifecycle.md +21 -0
|
@@ -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({}),
|
|
@@ -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
|
|
@@ -4361,6 +4361,89 @@ export class BridgeService {
|
|
|
4361
4361
|
return asJsonValue(response.data);
|
|
4362
4362
|
});
|
|
4363
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
|
+
}
|
|
4364
4447
|
async boundProject(repoRoot) {
|
|
4365
4448
|
const repository = await this.dependencies.repositories.resolveIdentity(repoRoot ?? process.cwd());
|
|
4366
4449
|
if (!repository.projectId)
|
|
@@ -4604,6 +4687,7 @@ export class BridgeService {
|
|
|
4604
4687
|
await this.dependencies.credentials.clear();
|
|
4605
4688
|
}
|
|
4606
4689
|
await this.dependencies.browserAuth.dispose();
|
|
4690
|
+
await this.dependencies.jiraConnect?.dispose();
|
|
4607
4691
|
return asJsonValue({ loggedOut: true, remoteRevoked, remoteStatus });
|
|
4608
4692
|
}, true);
|
|
4609
4693
|
}
|
|
@@ -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';
|
|
@@ -115,6 +116,7 @@ export function createBridgeService(options = {}) {
|
|
|
115
116
|
page: new GitLabTokenPage(gitlabTokens, gitlab, signedInLanguage),
|
|
116
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
|
|
@@ -456,3 +456,24 @@ roles stay as they are; change an aimed role separately with work_item.setup_wor
|
|
|
456
456
|
bound to the counts it showed: when they change, the question is asked again, and a change at the
|
|
457
457
|
moment of writing moves nothing. It is critical under the task mode. An empty source needs no
|
|
458
458
|
question. Keep the same requestKey to resume and honor deferral as in the flows above.
|
|
459
|
+
|
|
460
|
+
## Jira connection
|
|
461
|
+
|
|
462
|
+
When the user asks to connect Jira, to link the project to a Jira project or to match people, start
|
|
463
|
+
with `jira.status`: it reads the bound project's link and the organization's connections and names
|
|
464
|
+
the next step. `jira.connect` returns a one-time link: give it to the user as it is and never open it
|
|
465
|
+
yourself; they open it on this computer and approve access in Atlassian. Call `jira.connect` again
|
|
466
|
+
without `restart` to learn how it ended, and while it still waits, wait for the user; `restart: true`
|
|
467
|
+
only discards a link the user no longer wants. When Jira is not configured on the server, say that
|
|
468
|
+
the person who runs the server must add its settings, and do not retry. A connection that needs
|
|
469
|
+
reconnecting is renewed with `jira.connect`.
|
|
470
|
+
|
|
471
|
+
`jira.link_project`, `jira.unlink_project`, `jira.disconnect` and `jira.map_people` take a
|
|
472
|
+
`requestKey`: reuse it to retry or continue the same request, and use a new one when the user asks
|
|
473
|
+
again later. Every choice here (the site, the Jira project, replacing or pausing a link,
|
|
474
|
+
disconnecting, who each person is) is asked of the user natively in every task mode; never answer it
|
|
475
|
+
yourself. Pass a Jira project key the user names as `jiraProjectKey`. Match a person only from the
|
|
476
|
+
user's answer and never guess a handle; a typed e-mail address is refused, so ask for the part before
|
|
477
|
+
`@`. The access only reads Jira and Jira issues are not brought in yet, so do not promise writing to
|
|
478
|
+
Jira or importing issues. After a disconnect the user can also remove the app on Atlassian's side, at
|
|
479
|
+
id.atlassian.com → Connected apps, as the tool says.
|