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.
@@ -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
@@ -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',
@@ -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.