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
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
import * as z from 'zod/v4';
|
|
2
|
+
import { assertSafeToPersist } from '../runtime/offline-outbox.js';
|
|
3
|
+
import { languageTag, } from '../runtime/questionnaire-store.js';
|
|
4
|
+
import { copies, format } from '../runtime/texts.js';
|
|
5
|
+
import { sha256, stableStringify } from '../utilities/hash.js';
|
|
6
|
+
import { answerData, askFailed, askQuestionnaire } from './questionnaire-tools.js';
|
|
7
|
+
const line = z.string().trim().min(1);
|
|
8
|
+
const choice = z.strictObject({ label: line, description: line });
|
|
9
|
+
const question = { message: line, context: line, example: line };
|
|
10
|
+
const wording = z.strictObject({
|
|
11
|
+
site: z.strictObject({ ...question, other: choice, addressTitle: line }),
|
|
12
|
+
linkSite: z.strictObject(question),
|
|
13
|
+
linkProject: z.strictObject({
|
|
14
|
+
...question,
|
|
15
|
+
option: line,
|
|
16
|
+
other: choice,
|
|
17
|
+
none: choice,
|
|
18
|
+
keyTitle: line,
|
|
19
|
+
}),
|
|
20
|
+
replace: z.strictObject({ ...question, replace: choice, keep: choice }),
|
|
21
|
+
unlink: z.strictObject({ ...question, unlink: choice, keep: choice }),
|
|
22
|
+
disconnect: z.strictObject({ ...question, disconnect: choice, keep: choice }),
|
|
23
|
+
people: z.strictObject({
|
|
24
|
+
...question,
|
|
25
|
+
person: line,
|
|
26
|
+
unnamed: line,
|
|
27
|
+
match: line,
|
|
28
|
+
other: choice,
|
|
29
|
+
none: choice,
|
|
30
|
+
handleTitle: line,
|
|
31
|
+
}),
|
|
32
|
+
again: z.strictObject({ unknown: line, ambiguous: line, context: line, example: line }),
|
|
33
|
+
});
|
|
34
|
+
const optionalRepoRoot = z.string().min(1).optional();
|
|
35
|
+
const requestKey = z
|
|
36
|
+
.string()
|
|
37
|
+
.regex(/^[A-Za-z0-9_-]{1,80}$/)
|
|
38
|
+
.describe('A key you choose for this one request of the user. Reuse it unchanged to retry or to continue after the user answers; use a new one only when the user asks for this action again later.');
|
|
39
|
+
const presentation = z.literal('host_native').optional();
|
|
40
|
+
const site = z.object({ id: z.string(), url: z.string(), name: z.string() });
|
|
41
|
+
const connectResult = z.object({
|
|
42
|
+
outcome: z.enum(['connected', 'site_choice_required']),
|
|
43
|
+
requestId: z.string(),
|
|
44
|
+
connection: z.object({ siteName: z.string() }).nullable(),
|
|
45
|
+
sites: z.array(site),
|
|
46
|
+
actingAs: z.string().nullable(),
|
|
47
|
+
reconnected: z.boolean(),
|
|
48
|
+
accountChanged: z.boolean(),
|
|
49
|
+
});
|
|
50
|
+
const projectState = z.object({
|
|
51
|
+
projectId: z.string(),
|
|
52
|
+
projectName: z.string(),
|
|
53
|
+
organizationId: z.string(),
|
|
54
|
+
configured: z.boolean(),
|
|
55
|
+
link: z
|
|
56
|
+
.object({
|
|
57
|
+
connectionId: z.string(),
|
|
58
|
+
siteName: z.string(),
|
|
59
|
+
jiraProjectId: z.string(),
|
|
60
|
+
jiraProjectKey: z.string(),
|
|
61
|
+
jiraProjectName: z.string(),
|
|
62
|
+
status: z.enum(['active', 'paused']),
|
|
63
|
+
})
|
|
64
|
+
.nullable(),
|
|
65
|
+
connections: z.array(z.object({ id: z.string(), siteName: z.string(), siteUrl: z.string() })),
|
|
66
|
+
});
|
|
67
|
+
const organizationStatus = z.object({
|
|
68
|
+
configured: z.boolean(),
|
|
69
|
+
connections: z.array(z.object({
|
|
70
|
+
id: z.string(),
|
|
71
|
+
siteName: z.string(),
|
|
72
|
+
status: z.enum(['active', 'needs_reconnect', 'disconnected']),
|
|
73
|
+
managedByYou: z.boolean(),
|
|
74
|
+
links: z.array(z.object({ status: z.enum(['active', 'paused']) })),
|
|
75
|
+
people: z.object({ unmapped: z.number(), mapped: z.number(), noCounterpart: z.number() }),
|
|
76
|
+
})),
|
|
77
|
+
});
|
|
78
|
+
const jiraProjects = z.object({
|
|
79
|
+
projects: z.array(z.object({ id: z.string(), key: z.string(), name: z.string() })),
|
|
80
|
+
more: z.boolean(),
|
|
81
|
+
});
|
|
82
|
+
const member = z.object({ userId: z.string(), handle: z.string() });
|
|
83
|
+
const people = z.object({
|
|
84
|
+
siteName: z.string(),
|
|
85
|
+
complete: z.boolean(),
|
|
86
|
+
people: z.array(z.object({
|
|
87
|
+
accountId: z.string(),
|
|
88
|
+
displayName: z.string(),
|
|
89
|
+
decision: z.enum(['unmapped', 'mapped', 'no_counterpart']),
|
|
90
|
+
lockVersion: z.number(),
|
|
91
|
+
candidates: z.array(member),
|
|
92
|
+
})),
|
|
93
|
+
members: z.array(member),
|
|
94
|
+
counts: z.object({ unmapped: z.number(), mapped: z.number(), noCounterpart: z.number() }),
|
|
95
|
+
});
|
|
96
|
+
const notConfigured = 'Jira is not configured on this server. Its owner registers the Engineering Memory app with Atlassian and sets the three Jira settings described in the Jira connection guide; nothing else changes until then.';
|
|
97
|
+
const shownPages = new Map();
|
|
98
|
+
const shownPageLifetimeMs = 60 * 60 * 1000;
|
|
99
|
+
export function registerJiraTools(server, service) {
|
|
100
|
+
server.registerTool('jira.status', {
|
|
101
|
+
description: 'Show the Jira connections of an organization and, for a project, which Jira project it follows. Without arguments it uses the project bound to this repository; with organizationId alone it shows only the organization. The answer names what to do next: connect, reconnect, link a project or match people. Any organization member may read it.',
|
|
102
|
+
inputSchema: z.strictObject({
|
|
103
|
+
repoRoot: optionalRepoRoot,
|
|
104
|
+
organizationId: z.uuid().optional(),
|
|
105
|
+
projectId: z.uuid().optional(),
|
|
106
|
+
}),
|
|
107
|
+
}, async (input) => status(service, input));
|
|
108
|
+
server.registerTool('jira.connect', {
|
|
109
|
+
description: 'Connect a Jira Cloud site to the organization of this project, or reconnect one that needs it. The first call returns a link: give it to the user to open in a browser on this computer, where Atlassian asks them to approve read access; never open it yourself. When they say they approved, call jira.connect again with the same arguments to see the result; if Atlassian granted several sites, it asks which one. Pass restart only to discard an unused link and start over, never when checking the result. An organization owner, or an owner or maintainer of the project, may connect. Tokens stay on the server; the chat never sees them.',
|
|
110
|
+
inputSchema: z.strictObject({
|
|
111
|
+
repoRoot: optionalRepoRoot,
|
|
112
|
+
organizationId: z.uuid().optional(),
|
|
113
|
+
projectId: z.uuid().optional(),
|
|
114
|
+
restart: z.boolean().optional(),
|
|
115
|
+
language: languageTag.optional(),
|
|
116
|
+
presentation,
|
|
117
|
+
}),
|
|
118
|
+
}, async (input, context) => connect(server, service, input, context));
|
|
119
|
+
server.registerTool('jira.disconnect', {
|
|
120
|
+
description: 'Disconnect one Jira site after the user confirms it in a native question. The stored access is deleted and every project linked through it is paused; work items, stages and people matches stay, and connecting the same site again restores them. Only the person who connected it or an organization owner may disconnect. Read the connection id with jira.status.',
|
|
121
|
+
inputSchema: z.strictObject({
|
|
122
|
+
repoRoot: optionalRepoRoot,
|
|
123
|
+
organizationId: z.uuid().optional(),
|
|
124
|
+
connectionId: z.uuid(),
|
|
125
|
+
requestKey,
|
|
126
|
+
language: languageTag.optional(),
|
|
127
|
+
presentation,
|
|
128
|
+
}),
|
|
129
|
+
}, async (input, context) => disconnect(server, service, input, context));
|
|
130
|
+
server.registerTool('jira.link_project', {
|
|
131
|
+
description: 'Link the project bound here, or projectId, to one Jira project, asking natively which site and which Jira project unless jiraProjectKey names it. Linking makes the statuses of the Jira project the stages of this project; stages that exist only in Engineering Memory stay, and nothing moves. Replacing an active link to another Jira project asks for confirmation. Project owners and maintainers may link through a connection they made or one an organization owner made.',
|
|
132
|
+
inputSchema: z.strictObject({
|
|
133
|
+
repoRoot: optionalRepoRoot,
|
|
134
|
+
projectId: z.uuid().optional(),
|
|
135
|
+
connectionId: z.uuid().optional(),
|
|
136
|
+
jiraProjectKey: z
|
|
137
|
+
.string()
|
|
138
|
+
.regex(/^[A-Za-z][A-Za-z0-9_]{0,31}$/)
|
|
139
|
+
.optional(),
|
|
140
|
+
requestKey,
|
|
141
|
+
language: languageTag.optional(),
|
|
142
|
+
presentation,
|
|
143
|
+
}),
|
|
144
|
+
}, async (input, context) => linkProject(server, service, input, context));
|
|
145
|
+
server.registerTool('jira.unlink_project', {
|
|
146
|
+
description: 'Stop following the Jira project of the project bound here, or projectId, after the user confirms it in a native question. The link is paused, not deleted: stages and work stay, and linking the same Jira project again resumes it.',
|
|
147
|
+
inputSchema: z.strictObject({
|
|
148
|
+
repoRoot: optionalRepoRoot,
|
|
149
|
+
projectId: z.uuid().optional(),
|
|
150
|
+
requestKey,
|
|
151
|
+
language: languageTag.optional(),
|
|
152
|
+
presentation,
|
|
153
|
+
}),
|
|
154
|
+
}, async (input, context) => unlinkProject(server, service, input, context));
|
|
155
|
+
server.registerTool('jira.map_people', {
|
|
156
|
+
description: 'Match the people who hold open Jira issues in the linked projects of one connection with members of the organization, in native forms of up to eight people. Each person is offered up to two similar members, "someone else" to type the part of the e-mail before @, and "no one in Engineering Memory". Nothing is matched without the answer. Continue by calling it again with the same arguments until it reports done. Only the person who connected the site or an organization owner may match. Read the connection id with jira.status.',
|
|
157
|
+
inputSchema: z.strictObject({
|
|
158
|
+
repoRoot: optionalRepoRoot,
|
|
159
|
+
organizationId: z.uuid().optional(),
|
|
160
|
+
connectionId: z.uuid(),
|
|
161
|
+
requestKey,
|
|
162
|
+
language: languageTag.optional(),
|
|
163
|
+
presentation,
|
|
164
|
+
}),
|
|
165
|
+
}, async (input, context) => mapPeople(server, service, input, context));
|
|
166
|
+
}
|
|
167
|
+
async function status(service, input) {
|
|
168
|
+
let project = null;
|
|
169
|
+
let projectData = null;
|
|
170
|
+
if (!input.organizationId || input.projectId) {
|
|
171
|
+
const state = await service.jiraProjectState(input);
|
|
172
|
+
if (!state.ok)
|
|
173
|
+
return output(state);
|
|
174
|
+
const parsed = projectState.safeParse(state.data);
|
|
175
|
+
if (!parsed.success)
|
|
176
|
+
return unexpected();
|
|
177
|
+
project = parsed.data;
|
|
178
|
+
projectData = state.data ?? null;
|
|
179
|
+
}
|
|
180
|
+
const organizationId = input.organizationId ?? project.organizationId;
|
|
181
|
+
const organization = await service.jiraOrganizationStatus(organizationId);
|
|
182
|
+
if (!organization.ok)
|
|
183
|
+
return output(organization);
|
|
184
|
+
const parsed = organizationStatus.safeParse(organization.data);
|
|
185
|
+
if (!parsed.success)
|
|
186
|
+
return unexpected();
|
|
187
|
+
return output({
|
|
188
|
+
ok: true,
|
|
189
|
+
data: {
|
|
190
|
+
organization: organization.data ?? null,
|
|
191
|
+
project: projectData,
|
|
192
|
+
nextAction: nextSteps(parsed.data, project),
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function nextSteps(organization, project) {
|
|
197
|
+
if (!organization.configured)
|
|
198
|
+
return notConfigured;
|
|
199
|
+
const steps = [];
|
|
200
|
+
const active = organization.connections.filter((connection) => connection.status === 'active');
|
|
201
|
+
if (!active.length)
|
|
202
|
+
steps.push('No Jira site is connected: jira.connect connects one (an organization owner, or an owner or maintainer of a project).');
|
|
203
|
+
for (const connection of organization.connections)
|
|
204
|
+
if (connection.status === 'needs_reconnect')
|
|
205
|
+
steps.push(`${connection.siteName} needs to be connected again: jira.connect with the same Atlassian site restores it.`);
|
|
206
|
+
if (project && !project.link)
|
|
207
|
+
steps.push('This project follows no Jira project: jira.link_project links it.');
|
|
208
|
+
if (project?.link?.status === 'paused')
|
|
209
|
+
steps.push(`The link to ${project.link.jiraProjectKey} is paused: jira.link_project links it again.`);
|
|
210
|
+
for (const connection of active)
|
|
211
|
+
if (connection.managedByYou &&
|
|
212
|
+
connection.links.some((link) => link.status === 'active') &&
|
|
213
|
+
(connection.people.unmapped > 0 ||
|
|
214
|
+
connection.people.mapped + connection.people.noCounterpart === 0))
|
|
215
|
+
steps.push(`The Jira people of ${connection.siteName} are not all matched with members: jira.map_people matches them.`);
|
|
216
|
+
return steps.join(' ') || 'Jira is connected and nothing waits for the user.';
|
|
217
|
+
}
|
|
218
|
+
async function connect(server, service, input, context) {
|
|
219
|
+
let organizationId = input.organizationId;
|
|
220
|
+
let projectId = null;
|
|
221
|
+
if (!organizationId || input.projectId) {
|
|
222
|
+
const state = await service.jiraProjectState(input);
|
|
223
|
+
if (!state.ok)
|
|
224
|
+
return output(state);
|
|
225
|
+
const project = projectState.safeParse(state.data);
|
|
226
|
+
if (!project.success)
|
|
227
|
+
return unexpected();
|
|
228
|
+
if (!project.data.configured)
|
|
229
|
+
return refusal(notConfigured, 'jira.status');
|
|
230
|
+
if (organizationId && organizationId !== project.data.organizationId)
|
|
231
|
+
return refusal('This project belongs to another organization. Name only the organizationId, or only the projectId.', 'jira.status');
|
|
232
|
+
organizationId = project.data.organizationId;
|
|
233
|
+
projectId = project.data.projectId;
|
|
234
|
+
}
|
|
235
|
+
const started = await service.jiraConnectStart({
|
|
236
|
+
organizationId,
|
|
237
|
+
projectId,
|
|
238
|
+
restart: input.restart ?? false,
|
|
239
|
+
});
|
|
240
|
+
if (!started.ok)
|
|
241
|
+
return output(started);
|
|
242
|
+
const state = started.data;
|
|
243
|
+
if (state.phase === 'waiting')
|
|
244
|
+
return output({
|
|
245
|
+
ok: true,
|
|
246
|
+
data: {
|
|
247
|
+
status: 'waiting',
|
|
248
|
+
connectUrl: state.connectUrl,
|
|
249
|
+
expiresAt: state.expiresAt,
|
|
250
|
+
nextAction: 'Give this link to the user to open in a browser on this computer. Atlassian asks them to choose the Jira site and approve read access for Engineering Memory; the link works once, for 15 minutes. Never open it yourself. When the user says they approved, call jira.connect again with the same arguments, without restart.',
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
if (state.phase === 'finishing')
|
|
254
|
+
return output({
|
|
255
|
+
ok: true,
|
|
256
|
+
data: {
|
|
257
|
+
status: 'finishing',
|
|
258
|
+
nextAction: 'Atlassian answered and the connection is being finished. Call jira.connect again with the same arguments in a few seconds.',
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
if (state.phase !== 'answered') {
|
|
262
|
+
await service.jiraConnectForget(organizationId);
|
|
263
|
+
if (state.phase === 'failed')
|
|
264
|
+
return refusal(state.message, state.recovery ?? 'jira.status', 'jira_connect');
|
|
265
|
+
return output({
|
|
266
|
+
ok: true,
|
|
267
|
+
data: {
|
|
268
|
+
status: state.phase,
|
|
269
|
+
nextAction: 'Nothing was connected. Call jira.connect again only if the user wants to try again.',
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
const result = connectResult.safeParse(state.result);
|
|
274
|
+
if (!result.success) {
|
|
275
|
+
await service.jiraConnectForget(organizationId);
|
|
276
|
+
return unexpected();
|
|
277
|
+
}
|
|
278
|
+
if (result.data.outcome === 'connected') {
|
|
279
|
+
await service.jiraConnectForget(organizationId);
|
|
280
|
+
return connected(state.result);
|
|
281
|
+
}
|
|
282
|
+
const told = await service.language(input.language);
|
|
283
|
+
const { language, copy } = wordingFor(told);
|
|
284
|
+
const sites = [...result.data.sites].sort((a, b) => a.name.localeCompare(b.name));
|
|
285
|
+
const listed = sites.length > 12 ? sites.slice(0, 11) : sites;
|
|
286
|
+
const { repoRoot: _repoRoot, presentation: _presentation, ...retryArguments } = input;
|
|
287
|
+
const answer = await askQuestionnaire(server, service, {
|
|
288
|
+
questionnaireId: 'jira-site-' + result.data.requestId,
|
|
289
|
+
language,
|
|
290
|
+
impact: 'routine',
|
|
291
|
+
message: copy.site.message,
|
|
292
|
+
context: copy.site.context,
|
|
293
|
+
example: format(copy.site.example, language, { site: listed[0].name }),
|
|
294
|
+
options: [
|
|
295
|
+
...listed.map((entry) => ({ id: entry.id, label: entry.name, description: entry.url })),
|
|
296
|
+
...(listed.length < sites.length
|
|
297
|
+
? [
|
|
298
|
+
{
|
|
299
|
+
id: 'other',
|
|
300
|
+
label: copy.site.other.label,
|
|
301
|
+
description: copy.site.other.description,
|
|
302
|
+
},
|
|
303
|
+
]
|
|
304
|
+
: []),
|
|
305
|
+
],
|
|
306
|
+
...(listed.length < sites.length
|
|
307
|
+
? {
|
|
308
|
+
textField: {
|
|
309
|
+
title: copy.site.addressTitle,
|
|
310
|
+
maxLength: 200,
|
|
311
|
+
requiredForChoice: 'other',
|
|
312
|
+
},
|
|
313
|
+
}
|
|
314
|
+
: {}),
|
|
315
|
+
binding: { organizationId, requestId: result.data.requestId },
|
|
316
|
+
repoRoot: input.repoRoot,
|
|
317
|
+
presentation: input.presentation,
|
|
318
|
+
}, context, [], { tool: 'jira.connect', retryArguments });
|
|
319
|
+
const picked = answerData(answer);
|
|
320
|
+
if (!picked?.choice)
|
|
321
|
+
return answer;
|
|
322
|
+
const host = (address) => address
|
|
323
|
+
.trim()
|
|
324
|
+
.toLowerCase()
|
|
325
|
+
.replace(/^https?:\/\//, '')
|
|
326
|
+
.replace(/\/.*$/, '');
|
|
327
|
+
const chosen = picked.choice === 'other'
|
|
328
|
+
? sites.find((entry) => host(entry.url) === host(picked.text ?? ''))
|
|
329
|
+
: sites.find((entry) => entry.id === picked.choice);
|
|
330
|
+
if (!chosen) {
|
|
331
|
+
await service.jiraConnectForget(organizationId);
|
|
332
|
+
return refusal('The typed address is not one of the sites Atlassian granted. Start again with jira.connect and choose the site from the list.', 'jira.connect');
|
|
333
|
+
}
|
|
334
|
+
const finished = await service.jiraChooseSite({
|
|
335
|
+
organizationId,
|
|
336
|
+
requestId: result.data.requestId,
|
|
337
|
+
cloudId: chosen.id,
|
|
338
|
+
});
|
|
339
|
+
await service.jiraConnectForget(organizationId);
|
|
340
|
+
if (!finished.ok)
|
|
341
|
+
return output(finished);
|
|
342
|
+
return connected(finished.data);
|
|
343
|
+
}
|
|
344
|
+
function connected(data) {
|
|
345
|
+
const result = connectResult.parse(data);
|
|
346
|
+
const site = result.connection?.siteName ?? '';
|
|
347
|
+
return output({
|
|
348
|
+
ok: true,
|
|
349
|
+
data: {
|
|
350
|
+
status: 'connected',
|
|
351
|
+
connection: data.connection ?? null,
|
|
352
|
+
actingAs: result.actingAs,
|
|
353
|
+
reconnected: result.reconnected,
|
|
354
|
+
accountChanged: result.accountChanged,
|
|
355
|
+
nextAction: `Tell the user that ${site} is connected` +
|
|
356
|
+
(result.actingAs ? ` and acts as the Atlassian account ${result.actingAs}` : '') +
|
|
357
|
+
'.' +
|
|
358
|
+
(result.accountChanged
|
|
359
|
+
? ' It now acts as a different Atlassian account than before, so it sees what that account sees.'
|
|
360
|
+
: '') +
|
|
361
|
+
' Next, link a project with jira.link_project, then match people with jira.map_people.',
|
|
362
|
+
},
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
async function disconnect(server, service, input, context) {
|
|
366
|
+
const organizationId = await organizationOf(service, input);
|
|
367
|
+
if (typeof organizationId !== 'string')
|
|
368
|
+
return organizationId;
|
|
369
|
+
const current = await service.jiraOrganizationStatus(organizationId);
|
|
370
|
+
if (!current.ok)
|
|
371
|
+
return output(current);
|
|
372
|
+
const parsed = organizationStatus.safeParse(current.data);
|
|
373
|
+
if (!parsed.success)
|
|
374
|
+
return unexpected();
|
|
375
|
+
const connection = parsed.data.connections.find((entry) => entry.id === input.connectionId);
|
|
376
|
+
if (!connection)
|
|
377
|
+
return refusal('This organization has no Jira connection with that id. Read the connections with jira.status.', 'jira.status');
|
|
378
|
+
if (connection.status === 'disconnected')
|
|
379
|
+
return output({ ok: true, data: { status: 'already_disconnected' } });
|
|
380
|
+
const told = await service.language(input.language);
|
|
381
|
+
const { language, copy } = wordingFor(told);
|
|
382
|
+
const values = { site: connection.siteName };
|
|
383
|
+
const answer = await ask(server, service, input, context, 'jira.disconnect', {
|
|
384
|
+
questionnaireId: 'jira-disconnect-' +
|
|
385
|
+
sha256(stableStringify({ requestKey: input.requestKey, organizationId, id: connection.id })),
|
|
386
|
+
language,
|
|
387
|
+
impact: 'critical',
|
|
388
|
+
message: format(copy.disconnect.message, language, values),
|
|
389
|
+
context: format(copy.disconnect.context, language, values),
|
|
390
|
+
example: format(copy.disconnect.example, language, values),
|
|
391
|
+
options: [
|
|
392
|
+
option('keep', copy.disconnect.keep),
|
|
393
|
+
option('disconnect', copy.disconnect.disconnect),
|
|
394
|
+
],
|
|
395
|
+
binding: { organizationId, connectionId: connection.id },
|
|
396
|
+
});
|
|
397
|
+
const picked = answerData(answer)?.choice;
|
|
398
|
+
if (!picked)
|
|
399
|
+
return answer;
|
|
400
|
+
if (picked === 'keep')
|
|
401
|
+
return output({ ok: true, data: { status: 'kept' } });
|
|
402
|
+
const result = await service.jiraDisconnect({ organizationId, connectionId: connection.id });
|
|
403
|
+
if (!result.ok)
|
|
404
|
+
return output(result);
|
|
405
|
+
return output({
|
|
406
|
+
ok: true,
|
|
407
|
+
data: {
|
|
408
|
+
status: 'disconnected',
|
|
409
|
+
connection: result.data ?? null,
|
|
410
|
+
nextAction: 'Tell the user it is disconnected. Atlassian offers no call to withdraw the approval, so to remove Engineering Memory from the Atlassian account too, they open id.atlassian.com, then Connected apps.',
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
async function linkProject(server, service, input, context) {
|
|
415
|
+
const state = await service.jiraProjectState(input);
|
|
416
|
+
if (!state.ok)
|
|
417
|
+
return output(state);
|
|
418
|
+
const parsed = projectState.safeParse(state.data);
|
|
419
|
+
if (!parsed.success)
|
|
420
|
+
return unexpected();
|
|
421
|
+
const project = parsed.data;
|
|
422
|
+
if (!project.configured)
|
|
423
|
+
return refusal(notConfigured, 'jira.status');
|
|
424
|
+
if (!project.connections.length)
|
|
425
|
+
return refusal('No Jira connection in this organization can be linked by this person. Connect a site with jira.connect, or ask the organization owner or whoever connected the site.', 'jira.connect');
|
|
426
|
+
const told = await service.language(input.language);
|
|
427
|
+
const { language, copy } = wordingFor(told);
|
|
428
|
+
const values = { project: project.projectName };
|
|
429
|
+
const base = { requestKey: input.requestKey, projectId: project.projectId };
|
|
430
|
+
let connection = project.connections.find((entry) => entry.id === (input.connectionId ?? project.connections[0].id));
|
|
431
|
+
if (!connection)
|
|
432
|
+
return refusal('That Jira connection cannot be linked by this person. Read the usable ones with jira.status.', 'jira.status');
|
|
433
|
+
if (!input.connectionId && project.connections.length > 1) {
|
|
434
|
+
const listed = project.connections.slice(0, 12);
|
|
435
|
+
const answer = await ask(server, service, input, context, 'jira.link_project', {
|
|
436
|
+
questionnaireId: 'jira-link-site-' +
|
|
437
|
+
sha256(stableStringify({ ...base, connections: listed.map((entry) => entry.id) })),
|
|
438
|
+
language,
|
|
439
|
+
impact: 'routine',
|
|
440
|
+
message: format(copy.linkSite.message, language, values),
|
|
441
|
+
context: copy.linkSite.context,
|
|
442
|
+
example: format(copy.linkSite.example, language, { site: listed[0].siteName }),
|
|
443
|
+
options: listed.map((entry) => ({
|
|
444
|
+
id: entry.id,
|
|
445
|
+
label: entry.siteName,
|
|
446
|
+
description: entry.siteUrl,
|
|
447
|
+
})),
|
|
448
|
+
binding: { projectId: project.projectId },
|
|
449
|
+
});
|
|
450
|
+
const picked = answerData(answer)?.choice;
|
|
451
|
+
if (!picked)
|
|
452
|
+
return answer;
|
|
453
|
+
connection = listed.find((entry) => entry.id === picked);
|
|
454
|
+
}
|
|
455
|
+
let jiraProject;
|
|
456
|
+
let typedKey = input.jiraProjectKey?.toUpperCase();
|
|
457
|
+
if (!typedKey) {
|
|
458
|
+
const listing = await service.jiraProjects({
|
|
459
|
+
projectId: project.projectId,
|
|
460
|
+
connectionId: connection.id,
|
|
461
|
+
});
|
|
462
|
+
if (!listing.ok)
|
|
463
|
+
return output(listing);
|
|
464
|
+
const found = jiraProjects.safeParse(listing.data);
|
|
465
|
+
if (!found.success)
|
|
466
|
+
return unexpected();
|
|
467
|
+
const { projects, more } = found.data;
|
|
468
|
+
if (!projects.length)
|
|
469
|
+
return refusal(`The Atlassian account behind ${connection.siteName} sees no Jira project. Give it access in Jira, or connect with another account using jira.connect.`, 'jira.connect');
|
|
470
|
+
const answer = await ask(server, service, input, context, 'jira.link_project', {
|
|
471
|
+
questionnaireId: 'jira-link-project-' +
|
|
472
|
+
sha256(stableStringify({ ...base, connectionId: connection.id, projects, more })),
|
|
473
|
+
language,
|
|
474
|
+
impact: 'routine',
|
|
475
|
+
message: format(copy.linkProject.message, language, values),
|
|
476
|
+
context: copy.linkProject.context,
|
|
477
|
+
example: format(copy.linkProject.example, language, {
|
|
478
|
+
...values,
|
|
479
|
+
key: projects[0].key,
|
|
480
|
+
}),
|
|
481
|
+
options: [
|
|
482
|
+
...projects.map((entry) => ({
|
|
483
|
+
id: 'jira-' + entry.id,
|
|
484
|
+
label: `${entry.name} (${entry.key})`,
|
|
485
|
+
description: format(copy.linkProject.option, language, { ...values, key: entry.key }),
|
|
486
|
+
})),
|
|
487
|
+
...(more
|
|
488
|
+
? [option('other', copy.linkProject.other)]
|
|
489
|
+
: projects.length === 1
|
|
490
|
+
? [option('none', copy.linkProject.none)]
|
|
491
|
+
: []),
|
|
492
|
+
],
|
|
493
|
+
...(more
|
|
494
|
+
? {
|
|
495
|
+
textField: {
|
|
496
|
+
title: copy.linkProject.keyTitle,
|
|
497
|
+
maxLength: 32,
|
|
498
|
+
requiredForChoice: 'other',
|
|
499
|
+
},
|
|
500
|
+
}
|
|
501
|
+
: {}),
|
|
502
|
+
binding: { projectId: project.projectId, connectionId: connection.id },
|
|
503
|
+
});
|
|
504
|
+
const picked = answerData(answer);
|
|
505
|
+
if (!picked?.choice)
|
|
506
|
+
return answer;
|
|
507
|
+
if (picked.choice === 'none')
|
|
508
|
+
return output({ ok: true, data: { status: 'not_linked' } });
|
|
509
|
+
if (picked.choice === 'other')
|
|
510
|
+
typedKey = picked.text?.trim().toUpperCase();
|
|
511
|
+
else
|
|
512
|
+
jiraProject = projects.find((entry) => 'jira-' + entry.id === picked.choice);
|
|
513
|
+
}
|
|
514
|
+
if (!jiraProject) {
|
|
515
|
+
if (!typedKey || !/^[A-Z][A-Z0-9_]{0,31}$/.test(typedKey))
|
|
516
|
+
return refusal('A Jira project key is letters and digits, such as KAN. Call jira.link_project again with jiraProjectKey and a new requestKey.', 'jira.link_project');
|
|
517
|
+
const lookup = await service.jiraProjects({
|
|
518
|
+
projectId: project.projectId,
|
|
519
|
+
connectionId: connection.id,
|
|
520
|
+
key: typedKey,
|
|
521
|
+
});
|
|
522
|
+
if (!lookup.ok)
|
|
523
|
+
return output(lookup);
|
|
524
|
+
const found = jiraProjects.safeParse(lookup.data);
|
|
525
|
+
if (!found.success)
|
|
526
|
+
return unexpected();
|
|
527
|
+
jiraProject = found.data.projects[0];
|
|
528
|
+
if (!jiraProject)
|
|
529
|
+
return refusal(`${connection.siteName} has no Jira project ${typedKey} that the connected account can see. Check the key and call jira.link_project again with a new requestKey.`, 'jira.link_project');
|
|
530
|
+
}
|
|
531
|
+
const current = project.link;
|
|
532
|
+
const replace = current?.status === 'active' &&
|
|
533
|
+
(current.connectionId !== connection.id || current.jiraProjectId !== jiraProject.id);
|
|
534
|
+
if (replace) {
|
|
535
|
+
const swap = {
|
|
536
|
+
...values,
|
|
537
|
+
current: current.jiraProjectKey,
|
|
538
|
+
next: jiraProject.key,
|
|
539
|
+
};
|
|
540
|
+
const answer = await ask(server, service, input, context, 'jira.link_project', {
|
|
541
|
+
questionnaireId: 'jira-link-replace-' +
|
|
542
|
+
sha256(stableStringify({
|
|
543
|
+
...base,
|
|
544
|
+
from: [current.connectionId, current.jiraProjectId],
|
|
545
|
+
to: [connection.id, jiraProject.id],
|
|
546
|
+
})),
|
|
547
|
+
language,
|
|
548
|
+
impact: 'critical',
|
|
549
|
+
message: format(copy.replace.message, language, swap),
|
|
550
|
+
context: format(copy.replace.context, language, swap),
|
|
551
|
+
example: format(copy.replace.example, language, swap),
|
|
552
|
+
options: [
|
|
553
|
+
option('keep', copy.replace.keep, swap, language),
|
|
554
|
+
option('replace', copy.replace.replace, swap, language),
|
|
555
|
+
],
|
|
556
|
+
binding: { projectId: project.projectId, connectionId: connection.id },
|
|
557
|
+
});
|
|
558
|
+
const picked = answerData(answer)?.choice;
|
|
559
|
+
if (!picked)
|
|
560
|
+
return answer;
|
|
561
|
+
if (picked === 'keep')
|
|
562
|
+
return output({ ok: true, data: { status: 'kept', link: current } });
|
|
563
|
+
}
|
|
564
|
+
const linked = await service.jiraLink({
|
|
565
|
+
projectId: project.projectId,
|
|
566
|
+
connectionId: connection.id,
|
|
567
|
+
jiraProjectId: jiraProject.id,
|
|
568
|
+
replace,
|
|
569
|
+
});
|
|
570
|
+
if (!linked.ok)
|
|
571
|
+
return output(linked);
|
|
572
|
+
return output({
|
|
573
|
+
ok: true,
|
|
574
|
+
data: {
|
|
575
|
+
status: 'linked',
|
|
576
|
+
...linked.data,
|
|
577
|
+
nextAction: 'Tell the user which Jira project is linked and which stages were added, renamed, restored or archived. Then offer, one at a time and only as they want: the roles that move work automatically (work_item.setup_workflow; rolesOnLocalStatuses lists roles still aimed at stages that exist only in Engineering Memory), what a stage means (work_item.describe_status), moving work out of stages that exist only in Engineering Memory (work_item.remap_statuses), the stage new work starts in (work_item.set_initial_status), and matching people (jira.map_people).',
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async function unlinkProject(server, service, input, context) {
|
|
582
|
+
const state = await service.jiraProjectState(input);
|
|
583
|
+
if (!state.ok)
|
|
584
|
+
return output(state);
|
|
585
|
+
const parsed = projectState.safeParse(state.data);
|
|
586
|
+
if (!parsed.success)
|
|
587
|
+
return unexpected();
|
|
588
|
+
const project = parsed.data;
|
|
589
|
+
if (project.link?.status !== 'active')
|
|
590
|
+
return output({ ok: true, data: { status: 'not_linked', link: project.link } });
|
|
591
|
+
const told = await service.language(input.language);
|
|
592
|
+
const { language, copy } = wordingFor(told);
|
|
593
|
+
const values = { project: project.projectName, current: project.link.jiraProjectKey };
|
|
594
|
+
const answer = await ask(server, service, input, context, 'jira.unlink_project', {
|
|
595
|
+
questionnaireId: 'jira-unlink-' +
|
|
596
|
+
sha256(stableStringify({
|
|
597
|
+
requestKey: input.requestKey,
|
|
598
|
+
projectId: project.projectId,
|
|
599
|
+
link: [project.link.connectionId, project.link.jiraProjectId],
|
|
600
|
+
})),
|
|
601
|
+
language,
|
|
602
|
+
impact: 'critical',
|
|
603
|
+
message: format(copy.unlink.message, language, values),
|
|
604
|
+
context: copy.unlink.context,
|
|
605
|
+
example: format(copy.unlink.example, language, values),
|
|
606
|
+
options: [option('keep', copy.unlink.keep), option('unlink', copy.unlink.unlink)],
|
|
607
|
+
binding: { projectId: project.projectId },
|
|
608
|
+
});
|
|
609
|
+
const picked = answerData(answer)?.choice;
|
|
610
|
+
if (!picked)
|
|
611
|
+
return answer;
|
|
612
|
+
if (picked === 'keep')
|
|
613
|
+
return output({ ok: true, data: { status: 'kept', link: project.link } });
|
|
614
|
+
const result = await service.jiraUnlink(project.projectId);
|
|
615
|
+
if (!result.ok)
|
|
616
|
+
return output(result);
|
|
617
|
+
return output({ ok: true, data: { status: 'unlinked', link: result.data ?? null } });
|
|
618
|
+
}
|
|
619
|
+
async function mapPeople(server, service, input, context) {
|
|
620
|
+
const organizationId = await organizationOf(service, input);
|
|
621
|
+
if (typeof organizationId !== 'string')
|
|
622
|
+
return organizationId;
|
|
623
|
+
const told = await service.language(input.language);
|
|
624
|
+
const { language, copy } = wordingFor(told);
|
|
625
|
+
const key = [organizationId, input.connectionId, input.requestKey].join(':');
|
|
626
|
+
const applied = { mapped: 0, noCounterpart: 0 };
|
|
627
|
+
for (let round = 0; round < 20; round++) {
|
|
628
|
+
let page = shownPages.get(key);
|
|
629
|
+
if (!page || Date.now() - page.shownAt > shownPageLifetimeMs) {
|
|
630
|
+
const response = await service.jiraPeople({
|
|
631
|
+
organizationId,
|
|
632
|
+
connectionId: input.connectionId,
|
|
633
|
+
});
|
|
634
|
+
if (!response.ok)
|
|
635
|
+
return output(response);
|
|
636
|
+
const found = people.safeParse(response.data);
|
|
637
|
+
if (!found.success)
|
|
638
|
+
return unexpected();
|
|
639
|
+
const open = found.data.people.filter((person) => person.decision === 'unmapped');
|
|
640
|
+
if (!open.length) {
|
|
641
|
+
shownPages.delete(key);
|
|
642
|
+
return output({
|
|
643
|
+
ok: true,
|
|
644
|
+
data: {
|
|
645
|
+
status: 'done',
|
|
646
|
+
appliedNow: applied,
|
|
647
|
+
counts: found.data.counts,
|
|
648
|
+
complete: found.data.complete,
|
|
649
|
+
nextAction: found.data.complete
|
|
650
|
+
? 'Every person holding open Jira issues in the linked projects is matched or marked as having no member.'
|
|
651
|
+
: 'Jira was read up to its limit for one run, so some people may not have been seen yet. Call jira.map_people again later with a new requestKey.',
|
|
652
|
+
},
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
page = {
|
|
656
|
+
people: open.slice(0, 8),
|
|
657
|
+
members: found.data.members,
|
|
658
|
+
siteName: found.data.siteName,
|
|
659
|
+
remaining: open.length,
|
|
660
|
+
shownAt: Date.now(),
|
|
661
|
+
};
|
|
662
|
+
for (const [shownKey, shown] of shownPages)
|
|
663
|
+
if (Date.now() - shown.shownAt > shownPageLifetimeMs)
|
|
664
|
+
shownPages.delete(shownKey);
|
|
665
|
+
shownPages.set(key, page);
|
|
666
|
+
}
|
|
667
|
+
const form = {
|
|
668
|
+
questionnaireId: 'jira-people-' +
|
|
669
|
+
sha256(stableStringify({
|
|
670
|
+
requestKey: input.requestKey,
|
|
671
|
+
organizationId,
|
|
672
|
+
connectionId: input.connectionId,
|
|
673
|
+
people: page.people,
|
|
674
|
+
})),
|
|
675
|
+
language,
|
|
676
|
+
impact: 'routine',
|
|
677
|
+
message: format(copy.people.message, language, {
|
|
678
|
+
site: page.siteName,
|
|
679
|
+
count: page.remaining,
|
|
680
|
+
shown: page.people.length,
|
|
681
|
+
}),
|
|
682
|
+
context: copy.people.context,
|
|
683
|
+
example: copy.people.example,
|
|
684
|
+
questions: page.people.map((person, index) => ({
|
|
685
|
+
id: `person${index + 1}`,
|
|
686
|
+
message: format(copy.people.person, language, {
|
|
687
|
+
name: shownName(person.displayName, index, copy, language),
|
|
688
|
+
}),
|
|
689
|
+
options: [
|
|
690
|
+
...person.candidates.slice(0, 2).map((candidate) => ({
|
|
691
|
+
id: 'member-' + candidate.userId,
|
|
692
|
+
label: candidate.handle,
|
|
693
|
+
description: format(copy.people.match, language, { handle: candidate.handle }),
|
|
694
|
+
})),
|
|
695
|
+
option('other', copy.people.other),
|
|
696
|
+
option('none', copy.people.none),
|
|
697
|
+
],
|
|
698
|
+
otherAsText: true,
|
|
699
|
+
textField: { title: copy.people.handleTitle, maxLength: 120, requiredForChoice: 'other' },
|
|
700
|
+
})),
|
|
701
|
+
binding: { organizationId, connectionId: input.connectionId },
|
|
702
|
+
};
|
|
703
|
+
const answer = await ask(server, service, input, context, 'jira.map_people', form);
|
|
704
|
+
const answers = formAnswers(answer);
|
|
705
|
+
if (!answers)
|
|
706
|
+
return privacyRefusal(answer) ?? answer;
|
|
707
|
+
const decisions = [];
|
|
708
|
+
for (const [index, person] of page.people.entries()) {
|
|
709
|
+
let picked = answers[`person${index + 1}`];
|
|
710
|
+
let parent = form.questionnaireId;
|
|
711
|
+
for (let attempt = 0; attempt < 5 && picked; attempt++) {
|
|
712
|
+
const decided = decision(picked, page.members);
|
|
713
|
+
if ('userId' in decided || 'noCounterpart' in decided) {
|
|
714
|
+
decisions.push({
|
|
715
|
+
accountId: person.accountId,
|
|
716
|
+
lockVersion: person.lockVersion,
|
|
717
|
+
...decided,
|
|
718
|
+
});
|
|
719
|
+
break;
|
|
720
|
+
}
|
|
721
|
+
const again = {
|
|
722
|
+
questionnaireId: 'jira-person-' +
|
|
723
|
+
sha256(stableStringify({ parent, accountId: person.accountId, typed: decided.typed })),
|
|
724
|
+
language,
|
|
725
|
+
impact: 'routine',
|
|
726
|
+
message: format(decided.ambiguous ? copy.again.ambiguous : copy.again.unknown, language, {
|
|
727
|
+
name: shownName(person.displayName, index, copy, language),
|
|
728
|
+
typed: decided.typed,
|
|
729
|
+
}),
|
|
730
|
+
context: copy.again.context,
|
|
731
|
+
example: copy.again.example,
|
|
732
|
+
options: [
|
|
733
|
+
...decided.matches.map((entry) => ({
|
|
734
|
+
id: 'member-' + entry.userId,
|
|
735
|
+
label: entry.handle,
|
|
736
|
+
description: format(copy.people.match, language, { handle: entry.handle }),
|
|
737
|
+
})),
|
|
738
|
+
option('other', copy.people.other),
|
|
739
|
+
option('none', copy.people.none),
|
|
740
|
+
],
|
|
741
|
+
textField: { title: copy.people.handleTitle, maxLength: 120, requiredForChoice: 'other' },
|
|
742
|
+
binding: { organizationId, connectionId: input.connectionId },
|
|
743
|
+
};
|
|
744
|
+
const followUp = await ask(server, service, input, context, 'jira.map_people', again);
|
|
745
|
+
picked = answerData(followUp);
|
|
746
|
+
if (!picked)
|
|
747
|
+
return privacyRefusal(followUp) ?? followUp;
|
|
748
|
+
parent = again.questionnaireId;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
shownPages.delete(key);
|
|
752
|
+
if (!decisions.length)
|
|
753
|
+
return output({
|
|
754
|
+
ok: true,
|
|
755
|
+
data: {
|
|
756
|
+
status: 'undecided',
|
|
757
|
+
nextAction: 'No member could be identified for these people. Call jira.map_people again with a new requestKey when the user knows who they are.',
|
|
758
|
+
},
|
|
759
|
+
});
|
|
760
|
+
const result = await service.jiraDecidePeople({
|
|
761
|
+
organizationId,
|
|
762
|
+
connectionId: input.connectionId,
|
|
763
|
+
decisions,
|
|
764
|
+
});
|
|
765
|
+
if (!result.ok)
|
|
766
|
+
return output(result);
|
|
767
|
+
for (const entry of decisions)
|
|
768
|
+
if (entry.userId)
|
|
769
|
+
applied.mapped++;
|
|
770
|
+
else
|
|
771
|
+
applied.noCounterpart++;
|
|
772
|
+
}
|
|
773
|
+
return output({
|
|
774
|
+
ok: true,
|
|
775
|
+
data: {
|
|
776
|
+
status: 'continue',
|
|
777
|
+
appliedNow: applied,
|
|
778
|
+
nextAction: 'Call jira.map_people again with the same arguments to continue.',
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
function decision(picked, members) {
|
|
783
|
+
if (picked.choice === 'none')
|
|
784
|
+
return { noCounterpart: true };
|
|
785
|
+
const listed = members.find((entry) => 'member-' + entry.userId === picked.choice);
|
|
786
|
+
if (listed)
|
|
787
|
+
return { userId: listed.userId };
|
|
788
|
+
const typed = (picked.text ?? '').trim().replace(/^@/, '').split('@')[0].trim().toLowerCase();
|
|
789
|
+
const exact = members.filter((entry) => entry.handle.toLowerCase() === typed);
|
|
790
|
+
if (exact.length === 1)
|
|
791
|
+
return { userId: exact[0].userId };
|
|
792
|
+
const local = members.filter((entry) => entry.handle.toLowerCase().split(' (')[0] === typed);
|
|
793
|
+
if (local.length === 1)
|
|
794
|
+
return { userId: local[0].userId };
|
|
795
|
+
if (local.length > 1)
|
|
796
|
+
return { typed, ambiguous: true, matches: local.slice(0, 10) };
|
|
797
|
+
return {
|
|
798
|
+
typed,
|
|
799
|
+
ambiguous: false,
|
|
800
|
+
matches: typed
|
|
801
|
+
? members.filter((entry) => entry.handle.toLowerCase().includes(typed)).slice(0, 10)
|
|
802
|
+
: [],
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
function shownName(name, index, copy, language) {
|
|
806
|
+
const shown = name.split('@')[0].trim();
|
|
807
|
+
let safe = shown.length > 0;
|
|
808
|
+
try {
|
|
809
|
+
assertSafeToPersist(shown, 'message', '', true);
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
safe = false;
|
|
813
|
+
}
|
|
814
|
+
return safe ? shown : format(copy.people.unnamed, language, { number: index + 1 });
|
|
815
|
+
}
|
|
816
|
+
async function organizationOf(service, input) {
|
|
817
|
+
if (input.organizationId)
|
|
818
|
+
return input.organizationId;
|
|
819
|
+
const state = await service.jiraProjectState({ repoRoot: input.repoRoot });
|
|
820
|
+
if (!state.ok)
|
|
821
|
+
return output(state);
|
|
822
|
+
const project = projectState.safeParse(state.data);
|
|
823
|
+
if (!project.success)
|
|
824
|
+
return unexpected();
|
|
825
|
+
return project.data.organizationId;
|
|
826
|
+
}
|
|
827
|
+
async function ask(server, service, input, context, tool, definition) {
|
|
828
|
+
const { repoRoot: _repoRoot, presentation: _presentation, ...retryArguments } = input;
|
|
829
|
+
const owner = { tool, retryArguments };
|
|
830
|
+
return askQuestionnaire(server, service, { ...definition, repoRoot: input.repoRoot, presentation: input.presentation }, context, [], owner);
|
|
831
|
+
}
|
|
832
|
+
function formAnswers(result) {
|
|
833
|
+
const text = result.content?.find((item) => item.type === 'text')?.text;
|
|
834
|
+
if (!text)
|
|
835
|
+
return undefined;
|
|
836
|
+
try {
|
|
837
|
+
const value = JSON.parse(text);
|
|
838
|
+
return value.ok && value.data?.status === 'answered' && value.data.answerAvailable
|
|
839
|
+
? value.data.answers
|
|
840
|
+
: undefined;
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
return undefined;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
function privacyRefusal(result) {
|
|
847
|
+
if (!askFailed(result))
|
|
848
|
+
return undefined;
|
|
849
|
+
const text = result.content[0].text;
|
|
850
|
+
if (!text.includes('cannot be persisted'))
|
|
851
|
+
return undefined;
|
|
852
|
+
return refusal('An answer contained a full e-mail address or other contact data, which forms do not keep. Type only the part of the e-mail before @, then call jira.map_people again with the same arguments to see the same form.', 'jira.map_people', 'jira_people');
|
|
853
|
+
}
|
|
854
|
+
function wordingFor(language) {
|
|
855
|
+
return copies(wording, 'jira', language)[0];
|
|
856
|
+
}
|
|
857
|
+
function option(id, copy, values = {}, language = 'en') {
|
|
858
|
+
return {
|
|
859
|
+
id,
|
|
860
|
+
label: format(copy.label, language, values),
|
|
861
|
+
description: format(copy.description, language, values),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
function unexpected() {
|
|
865
|
+
return refusal('The server answered Jira in a shape this version does not know. Update Engineering Memory and try again.', 'jira.status');
|
|
866
|
+
}
|
|
867
|
+
function refusal(message, recovery, kind = 'jira') {
|
|
868
|
+
return output({ ok: false, error: { kind, message, recovery, retryable: false } });
|
|
869
|
+
}
|
|
870
|
+
function output(result) {
|
|
871
|
+
return {
|
|
872
|
+
content: [{ type: 'text', text: JSON.stringify(result) }],
|
|
873
|
+
isError: !result.ok,
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
//# sourceMappingURL=jira-tools.js.map
|