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.
@@ -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. Pass targetBranch only when the delivery names no target branch.",
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;