@taiga-ai/mcp-server 0.25.0-rc.474 → 0.26.0-dev.479

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.
Files changed (55) hide show
  1. package/README.md +111 -0
  2. package/dist/index.js +292 -115
  3. package/package.json +15 -2
  4. package/scripts/build.mjs +0 -37
  5. package/scripts/prepare-publish.cjs +0 -49
  6. package/src/client.test.ts +0 -424
  7. package/src/client.ts +0 -977
  8. package/src/config.test.ts +0 -24
  9. package/src/config.ts +0 -28
  10. package/src/index.ts +0 -45
  11. package/src/modern.integration.test.ts +0 -153
  12. package/src/server.integration.test.ts +0 -9214
  13. package/src/server.ts +0 -18
  14. package/src/tools/annotations.ts +0 -38
  15. package/src/tools/autonomy.ts +0 -86
  16. package/src/tools/backlogs.ts +0 -802
  17. package/src/tools/builds.ts +0 -668
  18. package/src/tools/catalogue.test.ts +0 -170
  19. package/src/tools/catalogue.ts +0 -223
  20. package/src/tools/context.ts +0 -513
  21. package/src/tools/documents.ts +0 -375
  22. package/src/tools/errors.ts +0 -172
  23. package/src/tools/hints.ts +0 -99
  24. package/src/tools/inbox.ts +0 -546
  25. package/src/tools/index.ts +0 -239
  26. package/src/tools/inputs.ts +0 -47
  27. package/src/tools/instructions.ts +0 -41
  28. package/src/tools/jobs.ts +0 -90
  29. package/src/tools/knowledge.ts +0 -249
  30. package/src/tools/maintenance.ts +0 -547
  31. package/src/tools/markdown.test.ts +0 -249
  32. package/src/tools/markdown.ts +0 -151
  33. package/src/tools/observe.ts +0 -311
  34. package/src/tools/product-documents.ts +0 -184
  35. package/src/tools/products.ts +0 -529
  36. package/src/tools/prompts.ts +0 -265
  37. package/src/tools/queue.ts +0 -250
  38. package/src/tools/readiness.ts +0 -177
  39. package/src/tools/remote.test.ts +0 -44
  40. package/src/tools/remote.ts +0 -107
  41. package/src/tools/repositories.ts +0 -360
  42. package/src/tools/request-prompt.test.ts +0 -32
  43. package/src/tools/request-prompt.ts +0 -28
  44. package/src/tools/resources.ts +0 -217
  45. package/src/tools/sections.test.ts +0 -143
  46. package/src/tools/sections.ts +0 -287
  47. package/src/tools/setup.ts +0 -455
  48. package/src/tools/summarize.test.ts +0 -20
  49. package/src/tools/summarize.ts +0 -96
  50. package/src/tools/wait.ts +0 -490
  51. package/src/version.test.ts +0 -10
  52. package/src/version.ts +0 -14
  53. package/taiga-ai-mcp-server-0.25.0-rc.474.tgz +0 -0
  54. package/tsconfig.json +0 -21
  55. package/vitest.config.ts +0 -9
@@ -1,455 +0,0 @@
1
- import type { McpServer } from '@modelcontextprotocol/server';
2
- import { DEPLOYMENT_NOTES_MAX_LENGTH, REALITY_DERIVED_DOCUMENT_TYPES } from '@taiga/types';
3
- import { z } from 'zod';
4
- import type { Product } from '@taiga/types';
5
- import { TaigaApiError, type TaigaClient, type WithoutTenant } from '../client.js';
6
- import { OPERATE, OPERATE_SETTING, READ_ONLY, WRITE } from './annotations.js';
7
- import { isImportedProduct } from './catalogue.js';
8
- import { runTool, toolError, toolResult } from './errors.js';
9
- import { setupReadiness } from './readiness.js';
10
- import { withStop } from './hints.js';
11
- import { idInput, productId } from './inputs.js';
12
- import { pickFields } from './summarize.js';
13
-
14
- /**
15
- * Setting a product up (docs.tai.ga, Start: Your first product, Learn: Import
16
- * an existing codebase, Organizations, factories and products): factories to
17
- * put products in, a product started from scratch or from an imported
18
- * codebase, what Taiga builds for it, the import, and finishing or reopening
19
- * Discovery. Who may reach a factory (its members) and deleting a factory or
20
- * a product stay in the web.
21
- */
22
-
23
- const PRODUCT_FIELDS = [
24
- 'id',
25
- 'name',
26
- 'slug',
27
- 'status',
28
- 'factoryId',
29
- 'ownsInfrastructure',
30
- 'ownsDeployment',
31
- 'deploymentNotes',
32
- 'emitDesignStandards',
33
- 'settings',
34
- ] as const;
35
-
36
- const FACTORY_FIELDS = ['id', 'name', 'slug', 'isDefault'] as const;
37
-
38
- const factoryId = idInput('factory', 'taiga_list_factories (query finds one by name) gives it');
39
-
40
- const buildScope = {
41
- ownsInfrastructure: z
42
- .boolean()
43
- .nullable()
44
- .optional()
45
- .describe('Whether Taiga writes the infrastructure code; null follows the organization'),
46
- ownsDeployment: z
47
- .boolean()
48
- .nullable()
49
- .optional()
50
- .describe('Whether Taiga writes the CI/CD pipelines; null follows the organization'),
51
- };
52
-
53
- const deploymentNotes = z
54
- .string()
55
- .trim()
56
- .min(1)
57
- .max(DEPLOYMENT_NOTES_MAX_LENGTH)
58
- .describe('When a half is off: a sentence on how it is done today, which Taiga designs and plans against');
59
-
60
- /** How a missing required document comes to exist, said with a documents_incomplete refusal. */
61
- const MISSING_DOCUMENTS_HOW =
62
- 'An imported product gets them from its import (taiga_start_import); a product started from scratch gets the ' +
63
- 'specification from the Discovery conversation in the web and the rest from taiga_generate_product_documents. ' +
64
- 'A draft is published with taiga_publish_product_document.';
65
-
66
- type ProductRow = WithoutTenant<Product>;
67
-
68
- /**
69
- * Refuse a name already taken (PRO-2800): the API appends -2 to the slug and
70
- * creates a second one, so a retried call or two agents at once left
71
- * duplicates nobody asked for. The API checks it in the create's transaction
72
- * (PRO-2808), so names past any page and two creates at once are caught too.
73
- * allowDuplicateName says the person means it.
74
- */
75
- const allowDuplicateName = z
76
- .boolean()
77
- .optional()
78
- .describe('Create it even when one with the same name exists (default false: refused, naming the existing one)');
79
-
80
- /**
81
- * The API's refusal of a taken name, as the tool's: it names the one that
82
- * exists when the caller may read it, and leaves the choice to the person.
83
- * Another session may have made it, so "use it" would hand the agent someone
84
- * else's work (PRO-2800, review 6). Anything else is not this refusal.
85
- */
86
- function duplicateName(kind: 'factory' | 'product', error: unknown, where: string) {
87
- if (!(error instanceof TaigaApiError) || error.status !== 409 || error.details?.['reason'] !== 'duplicate_name') {
88
- return null;
89
- }
90
- const said = (key: string) => (typeof error.details?.[key] === 'string' ? (error.details[key] as string) : undefined);
91
- const [existingId, existingSlug, name, createdAt] = [
92
- said('existingId'),
93
- said('existingSlug'),
94
- said('existingName'),
95
- said('existingCreatedAt'),
96
- ];
97
- const which = name ? `A ${kind} named "${name}"` : `A ${kind} with this name`;
98
- const ids = existingId ? ` (${existingId}${createdAt ? `, created ${createdAt}` : ''})` : '';
99
- // Without the holder's id the caller cannot read it, so "that one" is not a choice they have.
100
- const choices = existingId
101
- ? "It may not be this person's: ask whether they meant that one, want another name, or want a second"
102
- : 'The caller cannot read it: ask whether they want another name or a second';
103
- return toolError('DUPLICATE_NAME', `${which} already exists ${where}${ids}. ${choices} (allowDuplicateName: true).`, {
104
- ...(existingId ? { existingId } : {}),
105
- ...(existingSlug ? { existingSlug } : {}),
106
- });
107
- }
108
-
109
- /** A product row the model can act on: how it started, and while in setup what it still needs. */
110
- async function productRow(client: TaigaClient, product: ProductRow) {
111
- const { settings: _settings, ...rest } = pickFields([product], PRODUCT_FIELDS)[0]!;
112
- const readiness = await setupReadiness(client, product);
113
- return {
114
- ...rest,
115
- projectType: isImportedProduct(product) ? 'brownfield' : 'greenfield',
116
- ...(readiness ? { readiness } : {}),
117
- };
118
- }
119
-
120
- /**
121
- * A half turned off with nothing said about how it is done today. Allowed (the
122
- * web asks for the sentence but does not insist), so a warning rather than a
123
- * refusal: plans then design against a gap.
124
- */
125
- function missingNotesWarning(product: ProductRow): string[] {
126
- const off = product.ownsInfrastructure === false || product.ownsDeployment === false;
127
- const notes = typeof product.deploymentNotes === 'string' ? product.deploymentNotes.trim() : '';
128
- return off && !notes
129
- ? [
130
- 'A half Taiga does not build has no deploymentNotes: plans cannot design against how it is done today. ' +
131
- 'Ask the person for a sentence and set it.',
132
- ]
133
- : [];
134
- }
135
-
136
- /**
137
- * The sentence is for a half Taiga does not build; the web asks for it only
138
- * then. When both halves are decided on here, a sentence has nothing to say.
139
- */
140
- function notesWithNothingToDescribe(params: {
141
- ownsInfrastructure?: boolean | null | undefined;
142
- ownsDeployment?: boolean | null | undefined;
143
- deploymentNotes?: string | null | undefined;
144
- }) {
145
- return typeof params.deploymentNotes === 'string' &&
146
- params.ownsInfrastructure === true &&
147
- params.ownsDeployment === true
148
- ? toolError(
149
- 'VALIDATION_ERROR',
150
- 'deploymentNotes describes a half Taiga does not build; both are on, so leave it out.'
151
- )
152
- : null;
153
- }
154
-
155
- export function registerSetupTools(server: McpServer, client: TaigaClient) {
156
- if (client.hasCapability('factories:write')) {
157
- server.registerTool(
158
- 'taiga_create_factory',
159
- {
160
- title: 'Create a factory',
161
- description:
162
- 'Create a factory: a group of products sharing knowledge, one instructions document and members. Owners ' +
163
- 'and admins only; members are added in the web. Then add products with taiga_create_product or ' +
164
- 'taiga_move_product.',
165
- inputSchema: z.object({
166
- name: z.string().trim().min(1).max(255).describe('The factory name'),
167
- allowDuplicateName: allowDuplicateName,
168
- }),
169
- annotations: WRITE,
170
- },
171
- (params) =>
172
- runTool(async () => {
173
- try {
174
- const made = await client.createFactory({
175
- name: params.name,
176
- refuseDuplicateName: !params.allowDuplicateName,
177
- });
178
- return toolResult(pickFields([made.data], FACTORY_FIELDS)[0]);
179
- } catch (error) {
180
- const refused = duplicateName('factory', error, 'in this organization');
181
- if (refused) return refused;
182
- throw error;
183
- }
184
- })
185
- );
186
-
187
- server.registerTool(
188
- 'taiga_rename_factory',
189
- {
190
- title: 'Rename a factory',
191
- description:
192
- 'Rename a factory. Its slug follows the new name, so links to it change; products, members, knowledge ' +
193
- 'and instructions stay as they are.',
194
- inputSchema: z.object({ factoryId, name: z.string().trim().min(1).max(255).describe('The new name') }),
195
- annotations: OPERATE_SETTING,
196
- },
197
- (params) =>
198
- runTool(async () =>
199
- toolResult(
200
- pickFields([(await client.updateFactory(params.factoryId, { name: params.name })).data], FACTORY_FIELDS)[0]
201
- )
202
- )
203
- );
204
-
205
- server.registerTool(
206
- 'taiga_preview_product_move',
207
- {
208
- title: 'Preview moving a product to another factory',
209
- description:
210
- "Show who gains and who loses access if a product moves to another factory: the source factory's members " +
211
- "lose it, the target's gain it; people in both, the product's own members, and owners and admins keep " +
212
- 'it, named with why. Show it to the person before taiga_move_product.',
213
- inputSchema: z.object({ productId, factoryId: factoryId.describe('The factory to move it to') }),
214
- annotations: READ_ONLY,
215
- },
216
- (params) =>
217
- runTool(async () => toolResult((await client.previewProductMove(params.productId, params.factoryId)).data))
218
- );
219
-
220
- server.registerTool(
221
- 'taiga_move_product',
222
- {
223
- title: 'Move a product to another factory',
224
- description:
225
- 'Move a product to another factory. Access follows the factory, so this changes who can reach it ' +
226
- "(taiga_preview_product_move says who). The product's own knowledge moves with it; factory knowledge and " +
227
- 'instructions stay. Refused (WORK_IN_FLIGHT) while a plan or run is in flight; then ask the person: ' +
228
- 'stopLine: true stops the line first, interrupting that work.',
229
- inputSchema: z.object({
230
- productId,
231
- factoryId: factoryId.describe('The factory to move it to'),
232
- stopLine: z
233
- .boolean()
234
- .optional()
235
- .describe('Stop the line first, interrupting the plan or build in flight; only when the person says so'),
236
- }),
237
- annotations: OPERATE,
238
- },
239
- (params) =>
240
- runTool(async () => {
241
- let moved;
242
- try {
243
- moved = await client.moveProduct(params.productId, {
244
- factoryId: params.factoryId,
245
- ...(params.stopLine ? { stopLine: true } : {}),
246
- });
247
- } catch (error) {
248
- // The API's own words say "Stop the line, then move the product",
249
- // which reads as an instruction to the agent; whether to interrupt
250
- // the work is the person's call (PRO-2800).
251
- if (error instanceof TaigaApiError && error.code === 'WORK_IN_FLIGHT') {
252
- return toolError(
253
- 'WORK_IN_FLIGHT',
254
- // The API's first sentence says what is in flight; its second ("Stop the line, then move the product")
255
- // is the instruction this replaces.
256
- `${withStop((error.apiMessage ?? 'Work is in flight on this product').split(/(?<=[.!?])\s/)[0]!)} Moving it now means stopping ` +
257
- 'the line, which interrupts that work: ask the person, and pass stopLine: true only if they say so.',
258
- error.details
259
- );
260
- }
261
- throw error;
262
- }
263
- return toolResult({
264
- product: await productRow(client, moved.data),
265
- changed: moved.changed,
266
- previousFactoryId: moved.previousFactoryId,
267
- lineStopped: moved.lineStopped,
268
- failedDocuments: moved.failedDocuments,
269
- });
270
- })
271
- );
272
- }
273
-
274
- if (!client.hasCapability('products:write')) return;
275
-
276
- server.registerTool(
277
- 'taiga_create_product',
278
- {
279
- title: 'Create a product',
280
- description:
281
- 'Create a product in a factory and return it with readiness (what it needs next). start cannot change ' +
282
- 'later. import (brownfield): Taiga drafts the documents from existing code; link it with ' +
283
- 'taiga_link_repository (owners and admins only), then taiga_start_import. scratch (greenfield): Discovery ' +
284
- 'is a conversation in the web that writes the specification. Infrastructure code and CI/CD ' +
285
- 'pipelines follow the organization unless set here; when one is off, deploymentNotes says how that half ' +
286
- 'is done today. Add source material first with taiga_add_knowledge (productId): Discovery writes from it.',
287
- inputSchema: z.object({
288
- factoryId,
289
- name: z.string().trim().min(1).max(255).describe('The product name'),
290
- start: z.enum(['scratch', 'import']).describe('scratch: describe it in Discovery; import: from a repository'),
291
- ...buildScope,
292
- deploymentNotes: deploymentNotes.optional(),
293
- allowDuplicateName: allowDuplicateName,
294
- }),
295
- annotations: WRITE,
296
- },
297
- (params) =>
298
- runTool(async () => {
299
- const contradiction = notesWithNothingToDescribe(params);
300
- if (contradiction) return contradiction;
301
- let product: ProductRow;
302
- try {
303
- product = (
304
- await client.createProduct({
305
- name: params.name,
306
- factoryId: params.factoryId,
307
- ...(params.start === 'import' ? { projectType: 'brownfield' as const } : {}),
308
- ...(params.ownsInfrastructure !== undefined ? { ownsInfrastructure: params.ownsInfrastructure } : {}),
309
- ...(params.ownsDeployment !== undefined ? { ownsDeployment: params.ownsDeployment } : {}),
310
- ...(params.deploymentNotes !== undefined ? { deploymentNotes: params.deploymentNotes } : {}),
311
- // Within the factory it goes into: the same name in another factory is a different product.
312
- refuseDuplicateName: !params.allowDuplicateName,
313
- })
314
- ).data;
315
- } catch (error) {
316
- const refused = duplicateName('product', error, 'in this factory');
317
- if (refused) return refused;
318
- throw error;
319
- }
320
- return toolResult(await productRow(client, product));
321
- })
322
- );
323
-
324
- server.registerTool(
325
- 'taiga_update_product',
326
- {
327
- title: "Change a product's settings",
328
- description:
329
- "Change a product's name or what Taiga builds for it. A new name changes the slug, so links change. " +
330
- 'Infrastructure code and CI/CD pipelines: null returns one to the organization default; deploymentNotes ' +
331
- 'says how a half Taiga does not build is done; emitDesignStandards: null follows the default for how the ' +
332
- 'product started. Later plans read the change; planned work is not redone. Autonomy is ' +
333
- 'taiga_set_product_autonomy.',
334
- inputSchema: z.object({
335
- productId,
336
- name: z.string().trim().min(1).max(255).optional().describe('A new name'),
337
- ...buildScope,
338
- deploymentNotes: deploymentNotes.nullable().optional(),
339
- emitDesignStandards: z
340
- .boolean()
341
- .nullable()
342
- .optional()
343
- .describe("Inherit the organization's design standards; null follows the default"),
344
- }),
345
- annotations: OPERATE_SETTING,
346
- },
347
- (params) =>
348
- runTool(async () => {
349
- const { productId: id, ...changes } = params;
350
- if (Object.values(changes).every((value) => value === undefined)) {
351
- return toolError('VALIDATION_ERROR', 'Change at least one setting.');
352
- }
353
- const contradiction = notesWithNothingToDescribe(params);
354
- if (contradiction) return contradiction;
355
- const updated = (await client.updateProduct(id, changes)).data;
356
- const warnings = missingNotesWarning(updated);
357
- return toolResult({ ...(await productRow(client, updated)), ...(warnings.length > 0 ? { warnings } : {}) });
358
- })
359
- );
360
-
361
- server.registerTool(
362
- 'taiga_refresh_reality_documents',
363
- {
364
- title: "Refresh a product's documents from its code",
365
- description:
366
- 'Derive the reality documents again from the code on the branch Taiga works on (effectiveBranch): all, ' +
367
- 'or the ones named. For a document the import misread, refresh it instead of importing again; unchanged ' +
368
- 'sections are not republished. Returns the job; taiga_wait_for_job says when it is done.',
369
- inputSchema: z.object({
370
- productId,
371
- docTypes: z
372
- .array(z.enum(REALITY_DERIVED_DOCUMENT_TYPES))
373
- .min(1)
374
- .max(REALITY_DERIVED_DOCUMENT_TYPES.length)
375
- .optional()
376
- .describe('The documents to refresh; omit for all'),
377
- }),
378
- annotations: OPERATE,
379
- },
380
- (params) =>
381
- runTool(async () => toolResult((await client.refreshRealityDocuments(params.productId, params.docTypes)).data))
382
- );
383
-
384
- server.registerTool(
385
- 'taiga_finish_discovery',
386
- {
387
- title: 'Finish Discovery',
388
- description:
389
- "Finish a product's Discovery: it locks the documents and opens the rest of the product; for an imported " +
390
- 'product the first initiatives are planned. Refused (CONFLICT) with details.reason: documents_incomplete ' +
391
- '(details.missingDocuments lists them and how they are made), not_in_setup (already finished), or a ' +
392
- 'required check not yet stamped (stamped in the web). A locked document changes only after ' +
393
- 'taiga_reopen_discovery. The person decides the documents are right: ask first.',
394
- inputSchema: z.object({ productId }),
395
- annotations: OPERATE,
396
- },
397
- (params) =>
398
- runTool(async () => {
399
- try {
400
- return toolResult(await productRow(client, (await client.completeSetup(params.productId)).data));
401
- } catch (error) {
402
- // The refusal names what is missing; it does not say how a missing
403
- // document comes to exist, which is what the agent asks next.
404
- if (!(error instanceof TaigaApiError) || error.details?.['reason'] !== 'documents_incomplete') throw error;
405
- return toolError(
406
- error.code ?? 'CONFLICT',
407
- `${withStop(error.apiMessage ?? 'Required documents are not published yet')} ${MISSING_DOCUMENTS_HOW}`,
408
- error.details
409
- );
410
- }
411
- })
412
- );
413
-
414
- server.registerTool(
415
- 'taiga_reopen_discovery',
416
- {
417
- title: 'Reopen Discovery',
418
- description:
419
- "Reopen a finished product's Discovery so its documents can be amended; finish it again afterwards. " +
420
- 'Nothing built from them is undone.',
421
- inputSchema: z.object({ productId }),
422
- annotations: OPERATE,
423
- },
424
- (params) =>
425
- runTool(async () => toolResult(await productRow(client, (await client.reopenSetup(params.productId)).data)))
426
- );
427
-
428
- if (!client.hasCapability('products:read')) return;
429
-
430
- server.registerTool(
431
- 'taiga_start_import',
432
- {
433
- title: 'Import a codebase into a product',
434
- description:
435
- 'Start importing a product created for import, and return the job. Taiga reads the linked repository and ' +
436
- 'derives the full document set from the code, citing the files. Then the scanners sweep it, the ' +
437
- 'documents are assessed against policies, and the first initiatives are planned when Discovery finishes. ' +
438
- 'Refused for a scratch product, without a linked repository, or while an import runs. taiga_wait_for_job ' +
439
- 'says when it is done (it takes a while).',
440
- inputSchema: z.object({ productId }),
441
- annotations: OPERATE,
442
- },
443
- (params) =>
444
- runTool(async () => {
445
- const product = (await client.getProduct(params.productId)).data;
446
- if (!isImportedProduct(product)) {
447
- return toolError(
448
- 'CONFLICT',
449
- 'This product was started from scratch, so it has no import: the choice is made when a product is created.'
450
- );
451
- }
452
- return toolResult((await client.startImport(params.productId)).data);
453
- })
454
- );
455
- }
@@ -1,20 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { pickFields } from './summarize.js';
3
-
4
- describe('pickFields', () => {
5
- const rows: { id: string; name?: string; content?: string }[] = [
6
- { id: 'a', name: 'A', content: 'the prose a list leaves out' },
7
- { id: 'b' },
8
- ];
9
-
10
- it('keeps the named fields and leaves out one an item does not carry', () => {
11
- expect(pickFields(rows, ['id', 'name'])).toEqual([{ id: 'a', name: 'A' }, { id: 'b' }]);
12
- });
13
-
14
- it('refuses a field the row type does not have', () => {
15
- // The *_FIELDS lists are checked against their rows, so a misspelled field
16
- // fails to compile instead of silently picking nothing (tsc covers tests).
17
- // @ts-expect-error -- 'nmae' is not a field of the row.
18
- expect(pickFields([{ id: 'a', name: 'A' }], ['id', 'nmae'])).toEqual([{ id: 'a' }]);
19
- });
20
- });
@@ -1,96 +0,0 @@
1
- /**
2
- * Keep only the named fields of each item. List tools return summaries (the
3
- * fields a model needs to pick an item) and get tools return everything;
4
- * this is how a list drops the prose and the provenance trees.
5
- */
6
- export function pickFields<T extends object, K extends keyof T>(
7
- items: readonly T[],
8
- fields: readonly K[]
9
- ): Partial<Pick<T, K>>[] {
10
- return items.map((item) => {
11
- // Partial: a field the item does not carry is left out, not set to undefined.
12
- const picked: Partial<Pick<T, K>> = {};
13
- for (const field of fields) {
14
- if (field in item) picked[field] = item[field];
15
- }
16
- return picked;
17
- });
18
- }
19
-
20
- /** The page a list tool returns: the rows the model asked for and where the next page starts. */
21
- export function page<T>(data: T[], nextCursor: string | null | undefined): { data: T[]; nextCursor: string | null } {
22
- return { data, nextCursor: nextCursor ?? null };
23
- }
24
-
25
- /**
26
- * How many of an initiative's acceptance criteria are met: one pair a row can
27
- * carry, so "done with two unmet" shows in a list without reading the item.
28
- * Null while it has none (an unplanned initiative).
29
- */
30
- export function criteriaCounts(item: { acceptanceCriteria?: unknown }): { met: number; total: number } | null {
31
- const criteria = item.acceptanceCriteria;
32
- if (!Array.isArray(criteria) || criteria.length === 0) return null;
33
- const met = criteria.filter((c) => typeof c === 'object' && c !== null && (c as { met?: unknown }).met === true);
34
- return { met: met.length, total: criteria.length };
35
- }
36
-
37
- /**
38
- * Initiative rows cut to `fields`, each with its criteria counts. A row that
39
- * does not carry `acceptanceCriteria` at all gets no count rather than a
40
- * null that would read as "has none".
41
- */
42
- export function itemRows<T extends { acceptanceCriteria?: unknown }, K extends keyof T>(
43
- items: readonly T[],
44
- fields: readonly K[]
45
- ): (Partial<Pick<T, K>> & { criteria?: { met: number; total: number } | null })[] {
46
- return pickFields(items, fields).map((row, index) =>
47
- 'acceptanceCriteria' in items[index]! ? { ...row, criteria: criteriaCounts(items[index]!) } : row
48
- );
49
- }
50
-
51
- /** Pages a name search reads before it stops and says so: 1,000 rows at the API's page ceiling. */
52
- const SEARCH_PAGE_LIMIT = 10;
53
-
54
- /**
55
- * Rows whose text contains `query`, ignoring case, read page by page until
56
- * `limit` match or the rows run out. The API has no name search, and a model
57
- * told the person's word for a thing ("the Sandbox factory") otherwise pages
58
- * the whole list to find its ID. `more` is true when it stopped before the end.
59
- */
60
- export async function searchPages<T>(
61
- fetchPage: (cursor: string | undefined) => Promise<{ data: T[]; nextCursor: string | null }>,
62
- query: string,
63
- text: (row: T) => unknown[],
64
- limit: number,
65
- /** A further condition a row must meet, besides the query. */
66
- keep: (row: T) => boolean = () => true
67
- ): Promise<{ rows: T[]; more: boolean }> {
68
- const needle = query.toLowerCase();
69
- const rows: T[] = [];
70
- let cursor: string | undefined;
71
- for (let index = 0; index < SEARCH_PAGE_LIMIT; index += 1) {
72
- const result = await fetchPage(cursor);
73
- const page = result.data;
74
- for (const [at, row] of page.entries()) {
75
- if (!keep(row)) continue;
76
- if (!text(row).some((value) => typeof value === 'string' && value.toLowerCase().includes(needle))) continue;
77
- rows.push(row);
78
- // Full: stop reading. \`more\` then means "there may be more", not
79
- // that a further match was seen; reading on to find out cost up to ten
80
- // sequential pages for one bit.
81
- if (rows.length === limit) return { rows, more: at < page.length - 1 || result.nextCursor !== null };
82
- }
83
- if (!result.nextCursor) return { rows, more: false };
84
- cursor = result.nextCursor;
85
- }
86
- return { rows, more: true };
87
- }
88
-
89
- /** The page size a search reads at: the API's ceiling. */
90
- export const SEARCH_PAGE_SIZE = 100;
91
-
92
- /** A search reads its own pages, so a cursor from a plain page has nothing to continue. */
93
- export const SEARCH_WITH_CURSOR = 'A search answers one page; narrow the query instead of paging it.';
94
-
95
- /** What a search says when it stopped before reading every row. */
96
- export const SEARCH_NOTE = 'There may be more matches than the limit carries; narrow the query or raise the limit.';