common-qa-mcp 0.12.0 → 0.13.0

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 (80) hide show
  1. package/dist/adapters/remoteMcp/adapter.js +1 -1
  2. package/dist/adapters/simplifyqa/adapter.d.ts +29 -0
  3. package/dist/adapters/simplifyqa/adapter.d.ts.map +1 -1
  4. package/dist/adapters/simplifyqa/adapter.js +143 -17
  5. package/dist/adapters/simplifyqa/adapter.js.map +1 -1
  6. package/dist/adapters/simplifyqa/executionFailures.d.ts +59 -0
  7. package/dist/adapters/simplifyqa/executionFailures.d.ts.map +1 -0
  8. package/dist/adapters/simplifyqa/executionFailures.js +638 -0
  9. package/dist/adapters/simplifyqa/executionFailures.js.map +1 -0
  10. package/dist/adapters/simplifyqa/failureCategory.d.ts +26 -0
  11. package/dist/adapters/simplifyqa/failureCategory.d.ts.map +1 -0
  12. package/dist/adapters/simplifyqa/failureCategory.js +208 -0
  13. package/dist/adapters/simplifyqa/failureCategory.js.map +1 -0
  14. package/dist/adapters/simplifyqa/failureReport.d.ts +50 -0
  15. package/dist/adapters/simplifyqa/failureReport.d.ts.map +1 -0
  16. package/dist/adapters/simplifyqa/failureReport.js +353 -0
  17. package/dist/adapters/simplifyqa/failureReport.js.map +1 -0
  18. package/dist/adapters/simplifyqa/failureTypes.d.ts +193 -0
  19. package/dist/adapters/simplifyqa/failureTypes.d.ts.map +1 -0
  20. package/dist/adapters/simplifyqa/failureTypes.js +8 -0
  21. package/dist/adapters/simplifyqa/failureTypes.js.map +1 -0
  22. package/dist/adapters/simplifyqa/flow.d.ts +55 -0
  23. package/dist/adapters/simplifyqa/flow.d.ts.map +1 -0
  24. package/dist/adapters/simplifyqa/flow.js +325 -0
  25. package/dist/adapters/simplifyqa/flow.js.map +1 -0
  26. package/dist/adapters/simplifyqa/mapLimit.d.ts +3 -0
  27. package/dist/adapters/simplifyqa/mapLimit.d.ts.map +1 -0
  28. package/dist/adapters/simplifyqa/mapLimit.js +14 -0
  29. package/dist/adapters/simplifyqa/mapLimit.js.map +1 -0
  30. package/dist/adapters/simplifyqa/mapper.d.ts +3 -0
  31. package/dist/adapters/simplifyqa/mapper.d.ts.map +1 -1
  32. package/dist/adapters/simplifyqa/mapper.js +6 -7
  33. package/dist/adapters/simplifyqa/mapper.js.map +1 -1
  34. package/dist/adapters/simplifyqa/planTree.d.ts +31 -0
  35. package/dist/adapters/simplifyqa/planTree.d.ts.map +1 -0
  36. package/dist/adapters/simplifyqa/planTree.js +110 -0
  37. package/dist/adapters/simplifyqa/planTree.js.map +1 -0
  38. package/dist/adapters/simplifyqa/searchEngine.d.ts +237 -0
  39. package/dist/adapters/simplifyqa/searchEngine.d.ts.map +1 -0
  40. package/dist/adapters/simplifyqa/searchEngine.js +1206 -0
  41. package/dist/adapters/simplifyqa/searchEngine.js.map +1 -0
  42. package/dist/adapters/simplifyqa/searchRegistry.d.ts +112 -0
  43. package/dist/adapters/simplifyqa/searchRegistry.d.ts.map +1 -0
  44. package/dist/adapters/simplifyqa/searchRegistry.js +1413 -0
  45. package/dist/adapters/simplifyqa/searchRegistry.js.map +1 -0
  46. package/dist/adapters/simplifyqa/searchResolve.d.ts +84 -0
  47. package/dist/adapters/simplifyqa/searchResolve.d.ts.map +1 -0
  48. package/dist/adapters/simplifyqa/searchResolve.js +319 -0
  49. package/dist/adapters/simplifyqa/searchResolve.js.map +1 -0
  50. package/dist/adapters/simplifyqa/stepRules.d.ts +91 -0
  51. package/dist/adapters/simplifyqa/stepRules.d.ts.map +1 -0
  52. package/dist/adapters/simplifyqa/stepRules.js +228 -0
  53. package/dist/adapters/simplifyqa/stepRules.js.map +1 -0
  54. package/dist/domain/types.d.ts +4 -2
  55. package/dist/domain/types.d.ts.map +1 -1
  56. package/dist/initAgents.d.ts.map +1 -1
  57. package/dist/initAgents.js +32 -9
  58. package/dist/initAgents.js.map +1 -1
  59. package/dist/mcpResult.d.ts +19 -0
  60. package/dist/mcpResult.d.ts.map +1 -1
  61. package/dist/mcpResult.js +11 -0
  62. package/dist/mcpResult.js.map +1 -1
  63. package/dist/server.d.ts.map +1 -1
  64. package/dist/server.js +70 -16
  65. package/dist/server.js.map +1 -1
  66. package/dist/tools/discovery.tools.d.ts.map +1 -1
  67. package/dist/tools/discovery.tools.js +36 -10
  68. package/dist/tools/discovery.tools.js.map +1 -1
  69. package/dist/tools/failure.tools.d.ts +14 -0
  70. package/dist/tools/failure.tools.d.ts.map +1 -0
  71. package/dist/tools/failure.tools.js +65 -0
  72. package/dist/tools/failure.tools.js.map +1 -0
  73. package/dist/tools/query.tools.d.ts +20 -0
  74. package/dist/tools/query.tools.d.ts.map +1 -0
  75. package/dist/tools/query.tools.js +310 -0
  76. package/dist/tools/query.tools.js.map +1 -0
  77. package/dist/tools/testcase.tools.d.ts.map +1 -1
  78. package/dist/tools/testcase.tools.js +1 -2
  79. package/dist/tools/testcase.tools.js.map +1 -1
  80. package/package.json +1 -1
@@ -0,0 +1,1206 @@
1
+ /**
2
+ * Generic SQA query engine — the implementation behind the single `sqa_query` tool.
3
+ *
4
+ * Responsibilities:
5
+ * - resolve a collection alias to its registry entry
6
+ * - validate fields/operators (the backend silently ignores unknown columns, so we must not)
7
+ * - inject `deleted:false` + the active project scope on every read
8
+ * - resolve custom-field names -> `customFields.<key>` via `/pm/fields/{parentId}`
9
+ * - compile `where`/`sort`/`select` into the SQA `searchFields` payload
10
+ * - normalize `{ totalCount, data }` and paginate with `skipStartIndex` (row offset, not page)
11
+ */
12
+ import { notDeleted, pickArray, str, numericId } from './mapper.js';
13
+ import { dateWindow, IST_OFFSET_MINUTES, latestById, matchEnumWord, matchLifecycleWord, matchNamed, matchPeople, normaliseCode, personQuery, SELF_WORDS, spellings, toLifecycleOptions, VERSION_RANK_FIELDS, } from './searchResolve.js';
14
+ import { childrenOf, descendants, folderStats, memberIds } from './planTree.js';
15
+ import { mapLimit } from './mapLimit.js';
16
+ import { resolveCollection, SQA_COLLECTIONS, } from './searchRegistry.js';
17
+ const DEFAULT_LIMIT = 50;
18
+ const HARD_LIMIT = 500;
19
+ /** op:"count" — rows read at most when a breakdown has to be built from rows (10 calls). */
20
+ const COUNT_SCAN_CAP = 5000;
21
+ /** op:"count" — a groupBy with more configured values than this is counted from rows instead. */
22
+ const MAX_VALUE_COUNTS = 30;
23
+ const COUNT_CONCURRENCY = 5;
24
+ /** Groups returned, largest first. */
25
+ const MAX_GROUPS = 50;
26
+ /** Folders read for a folder tree (test plans). */
27
+ const MAX_FOLDERS = 2000;
28
+ /** Users read for name lookups and display names (the directory is customer-wide). */
29
+ const MAX_USERS = 10000;
30
+ /** How long a reference lookup (users, releases, sprints) is reused before it is read again. */
31
+ const REF_TTL_MS = 5 * 60000;
32
+ /** Largest first, "(unset)" last, capped at MAX_GROUPS. */
33
+ function sortGroups(groups) {
34
+ return [...groups]
35
+ .sort((a, b) => (a.value === '') !== (b.value === '') ? (a.value === '' ? 1 : -1) : b.count - a.count)
36
+ .slice(0, MAX_GROUPS);
37
+ }
38
+ function fail(msg) {
39
+ throw new Error(msg);
40
+ }
41
+ function asArray(raw) {
42
+ const rows = pickArray(raw, 'data', 'content');
43
+ return rows.filter((r) => !!r && typeof r === 'object' && !Array.isArray(r));
44
+ }
45
+ function totalOf(raw, rows) {
46
+ const t = raw?.totalCount;
47
+ return typeof t === 'number' ? t : rows.length;
48
+ }
49
+ function unwrapOne(raw) {
50
+ const doc = raw?.data ?? raw;
51
+ if (Array.isArray(doc)) {
52
+ const first = doc[0];
53
+ return first && typeof first === 'object' ? first : {};
54
+ }
55
+ return doc && typeof doc === 'object' ? doc : {};
56
+ }
57
+ /** Every value a row holds for a field: scalars, arrays, and `obj.field` over an array of objects. */
58
+ function valuesAt(row, key) {
59
+ const keep = (v) => (typeof v === 'string' && v !== '') || typeof v === 'number';
60
+ const dot = key.indexOf('.');
61
+ if (dot >= 0) {
62
+ const head = row[key.slice(0, dot)];
63
+ const tail = key.slice(dot + 1);
64
+ const list = Array.isArray(head) ? head : head && typeof head === 'object' ? [head] : [];
65
+ return list.map((e) => (e && typeof e === 'object' ? e[tail] : undefined)).filter(keep);
66
+ }
67
+ const v = row[key];
68
+ return Array.isArray(v) ? v.filter(keep) : keep(v) ? [v] : [];
69
+ }
70
+ /** `array[].field` support for `through` relations. */
71
+ function pluckPath(row, path) {
72
+ const marker = '[].';
73
+ const idx = path.indexOf(marker);
74
+ if (idx >= 0) {
75
+ const head = path.slice(0, idx);
76
+ const tail = path.slice(idx + marker.length);
77
+ const value = row[head];
78
+ if (!Array.isArray(value))
79
+ return [];
80
+ return value
81
+ .map((entry) => (entry && typeof entry === 'object' ? entry[tail] : undefined))
82
+ .filter((v) => typeof v === 'string' || typeof v === 'number');
83
+ }
84
+ const value = row[path];
85
+ if (Array.isArray(value)) {
86
+ return value.filter((v) => typeof v === 'string' || typeof v === 'number');
87
+ }
88
+ return value === undefined || value === null ? [] : [value];
89
+ }
90
+ function versionIdFromRow(row) {
91
+ const direct = str(row, 'versionId', 'selectedVersionId');
92
+ if (direct)
93
+ return direct;
94
+ const details = row['versionsDetails'];
95
+ if (details && typeof details === 'object') {
96
+ const v = str(details, 'id', '_id', 'versionId');
97
+ if (v)
98
+ return v;
99
+ }
100
+ const versions = row['versions'];
101
+ if (Array.isArray(versions) && versions.length)
102
+ return str(versions[0], 'id', '_id');
103
+ return undefined;
104
+ }
105
+ /** Value resolution is on by default; `QA_MCP_SQA_RESOLVE=off|0|false|no` turns it off (kill switch). */
106
+ export function resolveValuesFromEnv(env) {
107
+ return !/^(off|0|false|no)$/i.test((env['QA_MCP_SQA_RESOLVE'] ?? '').trim());
108
+ }
109
+ export class SqaSearchEngine {
110
+ constructor(http, projectId, options = {}) {
111
+ this.http = http;
112
+ this.projectId = projectId;
113
+ this.options = options;
114
+ this.customFieldCache = new Map();
115
+ this.fieldRowsCache = new Map();
116
+ /** Reference lookups (users, a project's releases/sprints), reused for REF_TTL_MS so a record
117
+ * created mid-session resolves soon after; failures are not kept. */
118
+ this.refCache = new Map();
119
+ }
120
+ get resolvesValues() {
121
+ return this.options.resolveValues === true;
122
+ }
123
+ // ------------------------------------------------------------------ catalog
124
+ catalog(collection) {
125
+ const names = collection
126
+ ? [this.requireCollection(collection).name]
127
+ : Object.keys(SQA_COLLECTIONS);
128
+ return names.map((name) => {
129
+ const c = SQA_COLLECTIONS[name];
130
+ return {
131
+ name: c.name,
132
+ aliases: c.aliases,
133
+ description: c.description,
134
+ usedFor: c.usedFor,
135
+ ...(c.parentId !== undefined ? { parentId: c.parentId } : {}),
136
+ searchPath: c.searchPath,
137
+ ...(c.getPath ? { getPath: c.getPath } : {}),
138
+ scope: c.scope,
139
+ idField: c.idField,
140
+ ...(c.keyField ? { keyField: c.keyField } : {}),
141
+ labelField: c.labelField,
142
+ defaultSelect: c.defaultSelect,
143
+ fields: Object.values(c.fields).map((f) => ({
144
+ key: f.key,
145
+ type: f.type,
146
+ ops: f.ops,
147
+ ...(f.label ? { label: f.label } : {}),
148
+ })),
149
+ relations: Object.entries(c.relations ?? {}).map(([rel, r]) => ({
150
+ name: rel,
151
+ kind: r.kind,
152
+ ...(r.to ? { to: r.to } : {}),
153
+ description: r.description,
154
+ })),
155
+ };
156
+ });
157
+ }
158
+ // ------------------------------------------------------------- custom fields
159
+ /** Every field row (default + custom) for a collection's parentId — one cached call that serves
160
+ * both custom-field resolution and lifecycle options. A failed read is not cached. */
161
+ fieldRows(c) {
162
+ const cacheKey = `${this.projectId}:${c.parentId}`;
163
+ const cached = this.fieldRowsCache.get(cacheKey);
164
+ if (cached)
165
+ return cached;
166
+ const promise = (async () => {
167
+ const raw = await this.http.getJson(`/pm/fields/${c.parentId}?fieldType=allFields&projectId=${this.projectId}`);
168
+ const rows = Array.isArray(raw)
169
+ ? raw
170
+ : Array.isArray(raw.data)
171
+ ? (raw.data)
172
+ : [];
173
+ return rows.filter((r) => !!r && typeof r === 'object');
174
+ })();
175
+ this.fieldRowsCache.set(cacheKey, promise);
176
+ promise.catch(() => this.fieldRowsCache.delete(cacheKey));
177
+ return promise;
178
+ }
179
+ /** The configured options of one default field (state / priority / severity). */
180
+ async lifecycleOptions(c, key) {
181
+ if (c.parentId === undefined)
182
+ return [];
183
+ const row = (await this.fieldRows(c)).find((r) => String(r['fieldType'] ?? '') === 'defaultFld' && r['key'] === key);
184
+ return toLifecycleOptions(row?.['values']);
185
+ }
186
+ async customFields(collection) {
187
+ const c = this.requireCollection(collection);
188
+ if (c.parentId === undefined)
189
+ return [];
190
+ const cached = this.customFieldCache.get(`${this.projectId}:${c.parentId}`);
191
+ if (cached)
192
+ return cached;
193
+ const promise = (async () => {
194
+ const rows = await this.fieldRows(c);
195
+ return rows
196
+ .filter((r) => String(r['fieldType'] ?? '') !== 'defaultFld')
197
+ .map((r) => {
198
+ const key = String(r['key'] ?? '');
199
+ const label = String(r['label'] ?? r['originalLabel'] ?? key);
200
+ const values = Array.isArray(r['values'])
201
+ ? r['values'].map((v) => ({
202
+ ...(v['key'] !== undefined ? { key: String(v['key']) } : {}),
203
+ ...(v['value'] !== undefined ? { value: String(v['value']) } : {}),
204
+ }))
205
+ : undefined;
206
+ return {
207
+ key,
208
+ label,
209
+ ...(r['originalLabel'] !== undefined ? { originalLabel: String(r['originalLabel']) } : {}),
210
+ column: `customFields.${key}`,
211
+ fieldType: String(r['fieldType'] ?? 'customFld'),
212
+ ...(r['controlType'] !== undefined ? { controlType: String(r['controlType']) } : {}),
213
+ ...(typeof r['mandatory'] === 'boolean' ? { mandatory: r['mandatory'] } : {}),
214
+ ...(values && values.length ? { values } : {}),
215
+ };
216
+ })
217
+ .filter((f) => !!f.key);
218
+ })();
219
+ this.customFieldCache.set(`${this.projectId}:${c.parentId}`, promise);
220
+ try {
221
+ return await promise;
222
+ }
223
+ catch (e) {
224
+ this.customFieldCache.delete(`${this.projectId}:${c.parentId}`);
225
+ throw e;
226
+ }
227
+ }
228
+ async resolveCustomField(collection, name) {
229
+ const all = await this.customFields(collection);
230
+ if (!all.length) {
231
+ fail(`Collection "${collection}" has no custom fields configured in this project.`);
232
+ }
233
+ const needle = name.trim().toLowerCase();
234
+ const matches = all.filter((f) => f.label.trim().toLowerCase() === needle
235
+ || f.key.trim().toLowerCase() === needle
236
+ || (f.originalLabel ?? '').trim().toLowerCase() === needle
237
+ || f.column.toLowerCase() === needle);
238
+ if (!matches.length) {
239
+ fail(`Custom field "${name}" not found on ${collection}. Available: ` +
240
+ all.map((f) => f.label).join(', '));
241
+ }
242
+ if (matches.length > 1) {
243
+ fail(`Custom field "${name}" is ambiguous on ${collection}: ` +
244
+ matches.map((f) => `${f.label} (${f.key})`).join(', ') +
245
+ '. Use the exact key or a more specific label.');
246
+ }
247
+ return matches[0];
248
+ }
249
+ // -------------------------------------------------------------- search/related
250
+ async search(req) {
251
+ const c = this.requireCollection(req.collection);
252
+ const offset = Math.max(0, req.offset ?? 0);
253
+ const limit = Math.min(Math.max(1, req.limit ?? DEFAULT_LIMIT), c.maxLimit ?? HARD_LIMIT);
254
+ if (c.method === 'GET')
255
+ return this.searchGet(c, req, offset, limit);
256
+ const resolutions = [];
257
+ const payload = await this.compile(c, req, offset, limit, resolutions);
258
+ const resolved = resolutions.length ? { resolved: resolutions } : {};
259
+ const collapse = this.resolvesValues && c.versioned === true && !req.allVersions;
260
+ if (collapse && Array.isArray(payload['selectFields'])) {
261
+ // The rank fields decide which version row is the entity.
262
+ payload['selectFields'] = [...new Set([...payload['selectFields'], 'id', ...VERSION_RANK_FIELDS])];
263
+ }
264
+ if (req.dryRun) {
265
+ return { collection: c.name, rows: [], returned: 0, offset, payload, ...resolved };
266
+ }
267
+ const raw = await this.http.postJson(c.searchPath, payload);
268
+ const fetched = asArray(raw);
269
+ // Paging stays in stored rows (nextOffset is exact); a page may hold fewer entities than rows
270
+ // when versions of one entity sit on it.
271
+ const rows = collapse ? latestById(fetched) : fetched;
272
+ if (this.resolvesValues)
273
+ await this.decorate(c, rows);
274
+ const rawTotal = totalOf(raw, fetched);
275
+ const nextOffset = typeof rawTotal === 'number' && rawTotal > offset + fetched.length
276
+ ? offset + fetched.length
277
+ : undefined;
278
+ // The backend counts stored rows. With versions collapsed that is the item count only when this
279
+ // page holds every row; otherwise it is flagged so it is never read as a number of items.
280
+ const wholeSet = offset === 0 && nextOffset === undefined;
281
+ const totalCount = collapse && wholeSet ? rows.length : rawTotal;
282
+ const totalIsRows = collapse && !wholeSet && rawTotal !== undefined;
283
+ return {
284
+ collection: c.name,
285
+ rows,
286
+ ...(totalCount !== undefined ? { totalCount } : {}),
287
+ ...(totalIsRows ? { totalCountIsRows: true } : {}),
288
+ returned: rows.length,
289
+ offset,
290
+ ...(nextOffset !== undefined ? { nextOffset } : {}),
291
+ ...(fetched.length > rows.length ? { collapsedVersions: fetched.length - rows.length } : {}),
292
+ ...resolved,
293
+ };
294
+ }
295
+ async related(req) {
296
+ const c = this.requireCollection(req.collection);
297
+ const relName = req.relation ?? fail('`relation` is required for op:"related".');
298
+ const rel = c.relations?.[relName];
299
+ if (!rel) {
300
+ fail(`Unknown relation "${relName}" on ${c.name}. Available: ` +
301
+ (c.relations ? Object.keys(c.relations).join(', ') : 'none'));
302
+ }
303
+ const id = req.id ?? fail('`id` is required for op:"related".');
304
+ // The parent is read first, through the soft-delete rule: a deleted record has no related rows
305
+ // (its steps, its test cases …) — `get` reports it as not found, as the AI assistant does.
306
+ const parent = await this.get(c.name, id);
307
+ if (rel.kind === 'subresource') {
308
+ const rows = await this.fetchSubresource(c, rel, id, req.versionId, parent);
309
+ return { collection: rel.to ?? c.name, rows, totalCount: rows.length, returned: rows.length, offset: 0 };
310
+ }
311
+ if (rel.kind === 'query') {
312
+ const target = rel.to ?? fail(`Relation ${relName} has no target.`);
313
+ const where = [
314
+ ...Object.entries(rel.where ?? {}).map(([field, value]) => ({ field, op: 'eq', value })),
315
+ {
316
+ field: rel.remoteField ?? fail(`Relation ${relName} has no remoteField.`),
317
+ op: rel.arrayContains ? 'in' : 'eq',
318
+ value: rel.arrayContains ? [id] : id,
319
+ },
320
+ ...(req.where ?? []),
321
+ ];
322
+ const out = await this.search({
323
+ ...req,
324
+ op: 'search',
325
+ collection: target,
326
+ where,
327
+ });
328
+ return out;
329
+ }
330
+ // through: extract the parent's local ids, query the target
331
+ let ids = pluckPath(parent, rel.localField);
332
+ if (rel.tree) {
333
+ // Folder trees: sub-folders come from both edge directions; 'subtree' also gathers the
334
+ // members filed in every folder below this one (a plan often holds nothing directly).
335
+ const folders = await this.allFolders(c);
336
+ const self = Number(parent['id'] ?? numericId(id));
337
+ const children = childrenOf(folders);
338
+ if (rel.tree === 'children') {
339
+ ids = children.get(self) ?? [];
340
+ }
341
+ else {
342
+ const scope = new Set([self, ...descendants(self, children)]);
343
+ const field = rel.localField.replace('[].id', '');
344
+ ids = [...new Set(folders.filter((f) => scope.has(Number(f['id']))).flatMap((f) => memberIds(f[field])))];
345
+ }
346
+ }
347
+ if (!ids.length) {
348
+ return { collection: rel.to ?? c.name, rows: [], totalCount: 0, returned: 0, offset: 0 };
349
+ }
350
+ const targetName = rel.to ?? fail(`Relation ${relName} has no target.`);
351
+ const target = this.requireCollection(targetName);
352
+ const out = await this.search({
353
+ ...req,
354
+ op: 'search',
355
+ collection: targetName,
356
+ where: [
357
+ { field: target.idField, op: 'in', value: ids },
358
+ ...(req.where ?? []),
359
+ ],
360
+ });
361
+ return out;
362
+ }
363
+ // ---------------------------------------------------------------------- get
364
+ async get(collection, id) {
365
+ const c = this.requireCollection(collection);
366
+ const idType = c.fields[c.idField]?.type;
367
+ if (c.getPath) {
368
+ let lookup = id.trim();
369
+ if (idType === 'number' && !/^\d+$/.test(lookup)) {
370
+ // Business key (TC-240 / US_123): resolve to the numeric id first.
371
+ if (c.keyField) {
372
+ const found = await this.search({
373
+ op: 'search',
374
+ collection: c.name,
375
+ where: [{ field: c.keyField, op: 'eq', value: id }],
376
+ limit: 1,
377
+ });
378
+ const row = found.rows[0];
379
+ if (row)
380
+ lookup = String(row[c.idField] ?? row['id'] ?? numericId(id));
381
+ else
382
+ lookup = numericId(id);
383
+ }
384
+ else {
385
+ lookup = numericId(id);
386
+ }
387
+ }
388
+ const path = c.getPath.replace('{id}', encodeURIComponent(lookup)).replace('{projectId}', String(this.projectId));
389
+ const row = unwrapOne(await this.http.getJson(path));
390
+ // A direct GET by id does not apply the soft-delete filter the searches do.
391
+ if (c.softDelete && row[c.softDelete] === true)
392
+ fail(`${c.name} ${id} not found`);
393
+ return row;
394
+ }
395
+ // No direct GET: search by id first (string ids such as versions/testdata work), then by key.
396
+ const numeric = idType === 'number' ? Number.parseInt(numericId(id), 10) : undefined;
397
+ const byId = await this.search({
398
+ op: 'search',
399
+ collection: c.name,
400
+ where: [{
401
+ field: c.idField,
402
+ op: 'eq',
403
+ value: numeric !== undefined && Number.isFinite(numeric) ? numeric : id,
404
+ }],
405
+ limit: 1,
406
+ });
407
+ if (byId.rows[0])
408
+ return byId.rows[0];
409
+ if (c.keyField) {
410
+ const byKey = await this.search({
411
+ op: 'search',
412
+ collection: c.name,
413
+ where: [{ field: c.keyField, op: 'eq', value: id }],
414
+ limit: 1,
415
+ });
416
+ if (byKey.rows[0])
417
+ return byKey.rows[0];
418
+ }
419
+ return fail(`${c.name} ${id} not found`);
420
+ }
421
+ async getWithExpand(req) {
422
+ const c = this.requireCollection(req.collection);
423
+ const id = req.id ?? fail('`id` is required for op:"get".');
424
+ const row = await this.get(c.name, id);
425
+ const expanded = {};
426
+ for (const name of req.expand ?? []) {
427
+ const rel = c.relations?.[name];
428
+ if (!rel) {
429
+ fail(`Unknown expansion "${name}" on ${c.name}. Available: ${c.relations ? Object.keys(c.relations).join(', ') : 'none'}`);
430
+ }
431
+ if (rel.kind === 'subresource') {
432
+ expanded[name] = await this.fetchSubresource(c, rel, id, req.versionId ?? versionIdFromRow(row), row);
433
+ }
434
+ else {
435
+ const out = await this.related({ ...req, op: 'related', relation: name, id });
436
+ expanded[name] = out.rows;
437
+ }
438
+ }
439
+ return {
440
+ op: 'get',
441
+ collection: c.name,
442
+ row,
443
+ ...(Object.keys(expanded).length ? { expanded } : {}),
444
+ };
445
+ }
446
+ // ----------------------------------------------------------------- compile
447
+ async compile(c, req, offset, limit, resolutions) {
448
+ const searchFields = [];
449
+ // Hard-coded soft-delete filter. The backend does NOT add it (searchUtil only adds customerId).
450
+ if (c.softDelete)
451
+ searchFields.push({ column: c.softDelete, value: false });
452
+ // Project scope: always the active project; a caller-supplied project filter is overridden.
453
+ if (c.scope === 'project')
454
+ searchFields.push({ column: 'projectId', value: this.projectId });
455
+ if (c.scope === 'projectIds')
456
+ searchFields.push({ column: 'projectIds', value: this.projectId });
457
+ // Rows the product hides unless asked (obsolete test cases): `$nor {field:true}` keeps
458
+ // false AND missing, as the UI does. Naming the field in a filter turns this off.
459
+ if (this.resolvesValues && !req.includeHidden) {
460
+ for (const hidden of c.hiddenUnlessAsked ?? []) {
461
+ if (!(req.where ?? []).some((w) => w.field === hidden)) {
462
+ searchFields.push({ column: hidden, value: true, operator: 'nor' });
463
+ }
464
+ }
465
+ const df = c.defaultFilter;
466
+ if (df && !(req.where ?? []).some((w) => df.unless.includes(w.field))) {
467
+ for (const [column, value] of Object.entries(df.where))
468
+ searchFields.push({ column, value });
469
+ }
470
+ }
471
+ for (const w of req.where ?? []) {
472
+ if (w.field === 'deleted' || w.field === 'projectId' || w.field === 'projectIds')
473
+ continue;
474
+ const resolved = await this.resolveWhere(c, w);
475
+ if (this.resolvesValues && resolved.type === 'date') {
476
+ searchFields.push(this.dateWire(resolved.field, resolved.op, w.value, w.condition, resolutions));
477
+ continue;
478
+ }
479
+ // Convenience alias: the UI/adapter historically said type:"TC"; the backend stores TESTCASE.
480
+ let value = coerceValue(resolved.type, w.value);
481
+ if (c.name === 'executions' && resolved.field === 'type' && value === 'TC')
482
+ value = 'TESTCASE';
483
+ let op = resolved.op;
484
+ const def = c.fields[w.field];
485
+ if (this.resolvesValues && def?.resolve && w.custom !== true) {
486
+ ({ op, value } = await this.translate(c, def, op, value, resolutions));
487
+ }
488
+ searchFields.push(this.toWire(resolved.field, op, value, w.condition));
489
+ }
490
+ const sortFields = [];
491
+ for (const s of req.sort ?? []) {
492
+ const field = this.resolveSortField(c, s.field);
493
+ sortFields.push({ column: field, sort: s.dir === 'desc' ? 'dsc' : 'asc' });
494
+ }
495
+ const body = {
496
+ searchFields: [...searchFields, ...sortFields],
497
+ startIndex: offset,
498
+ limit,
499
+ // The backend treats startIndex as a PAGE unless this is set. Always use row offsets.
500
+ // Joi requires a NUMBER here (1), not a boolean.
501
+ skipStartIndex: 1,
502
+ };
503
+ if (req.select?.length) {
504
+ if (!(req.select.length === 1 && req.select[0] === '*')) {
505
+ body['selectFields'] = req.select;
506
+ }
507
+ }
508
+ else if (c.defaultSelect.length) {
509
+ body['selectFields'] = c.defaultSelect;
510
+ }
511
+ return body;
512
+ }
513
+ async resolveWhere(c, w) {
514
+ if (!w.field || !w.field.trim())
515
+ fail('Each filter needs a non-empty `field`.');
516
+ const isCustom = w.custom === true || w.field.startsWith('custom:') || w.field.startsWith('customFields.');
517
+ let field;
518
+ let allowed;
519
+ let type;
520
+ if (isCustom) {
521
+ if (w.field.startsWith('customFields.')) {
522
+ field = w.field;
523
+ allowed = ['eq', 'ne', 'in', 'not_in', 'contains', 'gt', 'gte', 'lt', 'lte', 'exists'];
524
+ type = 'string';
525
+ }
526
+ else {
527
+ const name = w.field.startsWith('custom:') ? w.field.slice('custom:'.length) : w.field;
528
+ const cf = await this.resolveCustomField(c.name, name);
529
+ field = cf.column;
530
+ allowed = allowedOpsForControl(cf.controlType);
531
+ type = controlTypeToFieldType(cf.controlType);
532
+ }
533
+ }
534
+ else {
535
+ const def = c.fields[w.field];
536
+ if (!def) {
537
+ fail(`Unknown field "${w.field}" on ${c.name}. ` +
538
+ `Fields: ${Object.keys(c.fields).join(', ')}. ` +
539
+ 'For a custom field pass `custom: true` (or `field: "custom:<label>"`).');
540
+ }
541
+ field = def.key;
542
+ allowed = def.ops;
543
+ type = def.type;
544
+ }
545
+ const op = w.op ?? (Array.isArray(w.value) ? 'in' : 'eq');
546
+ if (!allowed.includes(op)) {
547
+ fail(`Operator "${op}" is not supported for ${c.name}.${w.field}. Supported: ${allowed.join(', ')}.`);
548
+ }
549
+ return { field, op, type };
550
+ }
551
+ /**
552
+ * Translate filter words to stored values for a field with `resolve` set. Only eq/ne/in/not_in
553
+ * are translated (`contains` stays a literal match). Values already stored exactly as given are
554
+ * returned untouched — so an existing query's payload never changes — and when the options
555
+ * cannot be read the filter is sent as given (today's behaviour), never dropped. A word that
556
+ * matches nothing is an error naming the valid values, never a silent empty result.
557
+ */
558
+ async translate(c, def, op, value, resolutions) {
559
+ if (value === undefined || !['eq', 'ne', 'in', 'not_in'].includes(op))
560
+ return { op, value };
561
+ const words = (Array.isArray(value) ? value : [value]).map((v) => String(v));
562
+ const out = [];
563
+ const unknown = [];
564
+ if (def.resolve === 'ref') {
565
+ // Ids stay ids: only words that are not numbers are looked up.
566
+ const isId = (w) => /^\d+$/.test(w.trim());
567
+ if (words.every(isId))
568
+ return { op, value };
569
+ for (const w of words) {
570
+ if (isId(w)) {
571
+ out.push(Number(w));
572
+ continue;
573
+ }
574
+ const ids = await this.resolveRef(def.refTo, w);
575
+ if (ids.length)
576
+ out.push(...ids);
577
+ else
578
+ unknown.push(w);
579
+ }
580
+ if (unknown.length) {
581
+ fail(`${c.name}.${def.key}: ${unknown.map((u) => this.noMatchText(def.refTo, u)).join(' ')}`);
582
+ }
583
+ }
584
+ else if (def.resolve === 'enum') {
585
+ const values = def.enumValues ?? [];
586
+ if (words.every((w) => values.includes(w)))
587
+ return { op, value };
588
+ for (const w of words) {
589
+ const hit = matchEnumWord(w, values, def.enumGroups);
590
+ if (hit.length)
591
+ out.push(...spellings(hit));
592
+ else
593
+ unknown.push(w);
594
+ }
595
+ if (unknown.length) {
596
+ const groups = Object.entries(def.enumGroups ?? {}).map(([g, vs]) => `${g} (= ${vs.join(' + ')})`);
597
+ fail(`${c.name}.${def.key} has no value ${unknown.map((u) => `"${u}"`).join(', ')}. ` +
598
+ `Valid: ${[...values, ...groups].join(', ')}.`);
599
+ }
600
+ }
601
+ else {
602
+ let options;
603
+ try {
604
+ options = await this.lifecycleOptions(c, def.key);
605
+ }
606
+ catch {
607
+ return { op, value };
608
+ }
609
+ if (!options.length || words.every((w) => options.some((o) => o.key === w)))
610
+ return { op, value };
611
+ for (const w of words) {
612
+ const hit = options.some((o) => o.key === w) ? [w] : matchLifecycleWord(w, options, def.key);
613
+ if (hit.length)
614
+ out.push(...hit);
615
+ else
616
+ unknown.push(w);
617
+ }
618
+ if (unknown.length) {
619
+ fail(`${c.name}.${def.key} has no value ${unknown.map((u) => `"${u}"`).join(', ')} in this project. ` +
620
+ `Valid: ${options.map((o) => (o.label === o.key ? o.key : `${o.label} (${o.key})`)).join(', ')}.`);
621
+ }
622
+ }
623
+ const to = [...new Set(out)];
624
+ resolutions.push({ field: def.key, from: words, to: to.map(String) });
625
+ // Keep the caller's shape: a single eq/ne stays scalar; several values become in/not_in.
626
+ if (op === 'eq' || op === 'ne') {
627
+ if (to.length === 1)
628
+ return { op, value: to[0] };
629
+ return { op: op === 'eq' ? 'in' : 'not_in', value: to };
630
+ }
631
+ return { op, value: to };
632
+ }
633
+ /**
634
+ * A date filter as SQA's search reads one: `YYYY-MM-DD` + `offset` (minutes, IST) so the backend
635
+ * builds day boundaries — without `offset` the value is compared as a plain string and range
636
+ * operators are ignored. A window of several days uses the backend's `range` operator.
637
+ */
638
+ dateWire(column, op, value, condition, resolutions) {
639
+ if (op === 'exists')
640
+ return this.toWire(column, op, value, condition);
641
+ if (typeof value !== 'string')
642
+ fail(`${column}: give one date or window as text (e.g. "2026-10-01", "2026-09", "last 7 days").`);
643
+ const w = dateWindow(value);
644
+ if (!w) {
645
+ fail(`Cannot read "${value}" as a date for ${column}. Use YYYY-MM-DD, YYYY-MM, "A..B", today, ` +
646
+ 'yesterday, this week / month / year, or last N days / weeks / months / years.');
647
+ }
648
+ const oneDay = w.from === w.to;
649
+ resolutions.push({ field: column, from: [value], to: [oneDay ? w.from : `${w.from}..${w.to}`] });
650
+ const out = { column, offset: IST_OFFSET_MINUTES };
651
+ switch (op) {
652
+ case 'eq':
653
+ if (oneDay)
654
+ out['value'] = w.from;
655
+ else {
656
+ out['value'] = { fromDate: w.from, toDate: w.to };
657
+ out['operator'] = 'range';
658
+ }
659
+ break;
660
+ case 'ne':
661
+ if (!oneDay)
662
+ fail(`${column}: "not on" works for one day only; use lt / gt for a window.`);
663
+ out['value'] = w.from;
664
+ out['operator'] = 'nor';
665
+ break;
666
+ case 'gt':
667
+ out['value'] = w.to;
668
+ out['operator'] = 'gt';
669
+ break; // after the window
670
+ case 'gte':
671
+ out['value'] = w.from;
672
+ out['operator'] = 'gte';
673
+ break; // from its first day
674
+ case 'lt':
675
+ out['value'] = w.from;
676
+ out['operator'] = 'lt';
677
+ break; // before the window
678
+ case 'lte':
679
+ out['value'] = w.to;
680
+ out['operator'] = 'lte';
681
+ break; // up to its last day
682
+ default: fail(`Operator "${op}" is not supported on the date field ${column}.`);
683
+ }
684
+ if (condition === 'or')
685
+ out['condition'] = 'or';
686
+ return out;
687
+ }
688
+ // ------------------------------------------------------------ reference lookups
689
+ cachedRef(key, load) {
690
+ const hit = this.refCache.get(key);
691
+ if (hit && Date.now() - hit.at < REF_TTL_MS)
692
+ return hit.value;
693
+ const entry = { at: Date.now(), value: load() };
694
+ this.refCache.set(key, entry);
695
+ entry.value.catch(() => {
696
+ if (this.refCache.get(key) === entry)
697
+ this.refCache.delete(key);
698
+ });
699
+ return entry.value;
700
+ }
701
+ /** Rows of a registry collection, read with stored values only (never re-translated). */
702
+ async rowsOf(collection, where, select, limit = HARD_LIMIT, offset = 0, includeHidden = false) {
703
+ const c = this.requireCollection(collection);
704
+ const req = { op: 'search', collection, where, select, limit, ...(includeHidden ? { includeHidden } : {}) };
705
+ const payload = await this.compile(c, req, offset, Math.min(limit, c.maxLimit ?? HARD_LIMIT), []);
706
+ return asArray(await this.http.postJson(c.searchPath, payload));
707
+ }
708
+ /** Every matching row of a registry collection, page by page (the collection's own page size),
709
+ * up to `cap` rows. */
710
+ async allRowsOf(collection, select, cap, includeHidden = false) {
711
+ const pageSize = Math.min(HARD_LIMIT, this.requireCollection(collection).maxLimit ?? HARD_LIMIT);
712
+ const out = [];
713
+ for (let offset = 0; offset < cap; offset += pageSize) {
714
+ const page = await this.rowsOf(collection, [], select, pageSize, offset, includeHidden);
715
+ out.push(...page);
716
+ if (page.length < pageSize)
717
+ break;
718
+ }
719
+ return out;
720
+ }
721
+ /** Every folder of a folder-tree collection in this project (no defaults: sub-folders too),
722
+ * read once per request — the busiest project measured has 26. */
723
+ allFolders(c) {
724
+ return this.allRowsOf(c.name, ['id', 'code', 'name', 'isRoot', 'parentId', 'childIds', 'suiteIds', 'testcaseIds'], MAX_FOLDERS, true);
725
+ }
726
+ /** The customer's user directory (users carry no project) — every page, not just the first. */
727
+ people() {
728
+ return this.cachedRef('people', async () => (await this.allRowsOf('users', ['id', 'name', 'username', 'email'], MAX_USERS))
729
+ .filter((r) => typeof r['id'] === 'number')
730
+ .map((r) => ({
731
+ id: r['id'],
732
+ ...(typeof r['name'] === 'string' ? { name: r['name'] } : {}),
733
+ ...(typeof r['username'] === 'string' ? { username: r['username'] } : {}),
734
+ ...(typeof r['email'] === 'string' ? { email: r['email'] } : {}),
735
+ })));
736
+ }
737
+ named(rows, labelField = 'name') {
738
+ return rows
739
+ .filter((r) => typeof r['id'] === 'number')
740
+ .map((r) => ({
741
+ id: r['id'],
742
+ ...(typeof r[labelField] === 'string' ? { name: r[labelField] } : {}),
743
+ ...(typeof r['code'] === 'string' ? { code: r['code'] } : {}),
744
+ }));
745
+ }
746
+ /** This project's releases, and the sprints under them (sprints carry no project field). */
747
+ projectReleases() {
748
+ return this.cachedRef('releases', async () => this.named(await this.rowsOf('releases', [], ['id', 'code', 'name'])));
749
+ }
750
+ projectSprints() {
751
+ return this.cachedRef('sprints', async () => {
752
+ const releaseIds = (await this.projectReleases()).map((r) => r.id);
753
+ if (!releaseIds.length)
754
+ return [];
755
+ return this.named(await this.rowsOf('sprints', [{ field: 'releaseId', op: 'in', value: releaseIds }], ['id', 'code', 'name']));
756
+ });
757
+ }
758
+ /** Label rows: one per tagging, so one label name is many ids — all of them count. */
759
+ async tagRows(where) {
760
+ return asArray(await this.http.postJson('/pm/tags/search', {
761
+ searchFields: [{ column: 'deleted', value: false }, { column: 'projectId', value: this.projectId }, ...where],
762
+ startIndex: 0, limit: HARD_LIMIT, skipStartIndex: 1,
763
+ }));
764
+ }
765
+ /** A word naming a `refTo` record → every id it means. [] = nothing matches. */
766
+ async resolveRef(refTo, word) {
767
+ const text = word.trim();
768
+ switch (refTo) {
769
+ case 'users': {
770
+ const q = personQuery(text);
771
+ if (SELF_WORDS.has(q)) {
772
+ if (this.options.currentUserId === undefined) {
773
+ fail('Cannot tell who "me" is on this connection — set QA_MCP_USER_ID or use a session token.');
774
+ }
775
+ return [this.options.currentUserId];
776
+ }
777
+ return q ? matchPeople(q, await this.people()) : [];
778
+ }
779
+ case 'tags': {
780
+ const rows = await this.tagRows([{ column: 'name', value: text, regEx: true }]);
781
+ return [...new Set(rows
782
+ .filter((r) => typeof r['id'] === 'number' && String(r['name'] ?? '').trim().toLowerCase() === text.toLowerCase())
783
+ .map((r) => r['id']))];
784
+ }
785
+ case 'releases':
786
+ return matchNamed(text, await this.projectReleases(), true);
787
+ case 'sprints': {
788
+ // A sprint NAME matches across the customer (the boards match by name, and four sprints are
789
+ // called "Sprint 1"); a code or a near-spelling only within this project's releases.
790
+ const byName = this.named(await this.rowsOf('sprints', [{ field: 'name', op: 'contains', value: text }], ['id', 'code', 'name']));
791
+ const exact = matchNamed(text, byName.map((r) => ({ id: r.id, ...(r.name ? { name: r.name } : {}) })));
792
+ return exact.length ? exact : matchNamed(text, await this.projectSprints(), true);
793
+ }
794
+ default: {
795
+ // modules and linked work items / test cases: code first, else exact name ("Web 2" reads
796
+ // like a code but may be a module's name).
797
+ const target = this.requireCollection(refTo);
798
+ const code = normaliseCode(text);
799
+ if (code && target.keyField) {
800
+ const byCode = await this.rowsOf(refTo, [{ field: target.keyField, op: 'eq', value: code }], ['id', target.keyField, target.labelField]);
801
+ const ids = matchNamed(code, this.named(byCode, target.labelField));
802
+ if (ids.length)
803
+ return ids;
804
+ }
805
+ const byName = await this.rowsOf(refTo, [{ field: target.labelField, op: 'contains', value: text }], ['id', target.labelField]);
806
+ return matchNamed(text, this.named(byName, target.labelField).map((r) => ({ id: r.id, ...(r.name ? { name: r.name } : {}) })));
807
+ }
808
+ }
809
+ }
810
+ // ------------------------------------------------------------------ display values
811
+ /**
812
+ * Add `_display: { field: "readable value" }` to each row — state/priority/severity labels, user
813
+ * names, module/release/sprint/label names, linked item codes. Raw fields are never changed.
814
+ * Best-effort: a lookup that fails leaves that field without a display value.
815
+ */
816
+ async decorate(c, rows) {
817
+ if (!rows.length)
818
+ return;
819
+ if (c.folderTree) {
820
+ // Direct and with-sub-folder counts + path: a parent plan that files everything in
821
+ // sub-folders otherwise reads as empty.
822
+ try {
823
+ const stats = folderStats(await this.allFolders(c));
824
+ for (const row of rows) {
825
+ const s = stats.get(Number(row['id']));
826
+ if (s)
827
+ row['_folder'] = s;
828
+ }
829
+ }
830
+ catch { /* counts are best-effort */ }
831
+ }
832
+ const defs = Object.values(c.fields).filter((f) => f.resolve === 'lifecycle' || f.resolve === 'ref');
833
+ const present = defs.filter((f) => rows.some((r) => valuesAt(r, f.key).length));
834
+ const labels = await this.labelMaps(c, present, rows);
835
+ for (const row of rows) {
836
+ const display = {};
837
+ for (const f of present) {
838
+ const map = labels.get(f.key);
839
+ const vals = valuesAt(row, f.key);
840
+ if (!map || !vals.length)
841
+ continue;
842
+ display[f.key] = vals.map((v) => map.get(String(v)) ?? (f.resolve === 'ref' ? `#${v}` : String(v))).join(', ');
843
+ }
844
+ if (Object.keys(display).length)
845
+ row['_display'] = display;
846
+ }
847
+ }
848
+ /** field key → stored value → readable label, for the lifecycle/ref fields given. One lookup per
849
+ * reference kind; a failed lookup just leaves that field without labels. */
850
+ async labelMaps(c, present, rows) {
851
+ const labels = new Map(); // field or refTo → raw → label
852
+ const idsByRef = new Map();
853
+ for (const f of present.filter((p) => p.resolve === 'ref')) {
854
+ const set = idsByRef.get(f.refTo) ?? new Set();
855
+ for (const r of rows)
856
+ for (const v of valuesAt(r, f.key))
857
+ if (/^\d+$/.test(String(v)))
858
+ set.add(Number(v));
859
+ idsByRef.set(f.refTo, set);
860
+ }
861
+ await Promise.all([
862
+ ...present.filter((f) => f.resolve === 'lifecycle').map(async (f) => {
863
+ try {
864
+ labels.set(f.key, new Map((await this.lifecycleOptions(c, f.key)).map((o) => [o.key, o.label])));
865
+ }
866
+ catch { /* no labels for this field */ }
867
+ }),
868
+ ...[...idsByRef].map(async ([refTo, set]) => {
869
+ const ids = [...set].slice(0, HARD_LIMIT);
870
+ if (!ids.length)
871
+ return;
872
+ try {
873
+ let pairs;
874
+ if (refTo === 'users') {
875
+ pairs = (await this.people()).map((p) => [p.id, p.name ?? p.username ?? p.email ?? `#${p.id}`]);
876
+ }
877
+ else if (refTo === 'tags') {
878
+ pairs = (await this.tagRows([{ column: 'id', value: ids }]))
879
+ .map((r) => [Number(r['id']), String(r['name'] ?? '').trim()]);
880
+ }
881
+ else {
882
+ const t = this.requireCollection(refTo);
883
+ // Linked work items / test cases read best by code; modules, releases, sprints by name.
884
+ const linked = !['modules', 'releases', 'sprints'].includes(refTo);
885
+ pairs = (await this.rowsOf(refTo, [{ field: t.idField, op: 'in', value: ids }], ['id', 'code', t.labelField], ids.length))
886
+ .map((r) => [Number(r['id']), String((linked ? r['code'] : undefined) ?? r[t.labelField] ?? r['code'] ?? '')]);
887
+ }
888
+ labels.set(refTo, new Map(pairs.filter(([, l]) => l).map(([id, l]) => [String(id), l])));
889
+ }
890
+ catch { /* no names for this reference */ }
891
+ }),
892
+ ]);
893
+ const byField = new Map();
894
+ for (const f of present) {
895
+ const map = labels.get(f.resolve === 'ref' ? f.refTo : f.key);
896
+ if (map)
897
+ byField.set(f.key, map);
898
+ }
899
+ return byField;
900
+ }
901
+ // ------------------------------------------------------------------------- count
902
+ /** How many rows a request matches — one call, `limit:1`, no display lookups. */
903
+ async countWhere(c, req) {
904
+ const resolved = [];
905
+ const payload = await this.compile(c, { ...req, select: [c.idField] }, 0, 1, resolved);
906
+ const raw = await this.http.postJson(c.searchPath, payload);
907
+ return { total: totalOf(raw, asArray(raw)) ?? 0, resolved };
908
+ }
909
+ /**
910
+ * op:"count": the number of rows a search matches, optionally per value of one field.
911
+ * - plain count → the backend's totalCount (one call);
912
+ * - groupBy a state/priority/severity/enum field → one count call per configured value, plus
913
+ * "(unset)" for the remainder;
914
+ * - any other groupBy, and versioned collections (one entity, several rows) → read the matching
915
+ * rows (up to COUNT_SCAN_CAP) and count them here; past the cap the result is `approximate`.
916
+ */
917
+ async count(req) {
918
+ const c = this.requireCollection(req.collection);
919
+ const versioned = this.resolvesValues && c.versioned === true && !req.allVersions;
920
+ let def;
921
+ if (req.groupBy) {
922
+ def = c.fields[req.groupBy];
923
+ if (!def)
924
+ fail(`Cannot group ${c.name} by "${req.groupBy}". Fields: ${Object.keys(c.fields).join(', ')}.`);
925
+ if (def.type === 'object' || def.type === 'date')
926
+ fail(`Cannot group by ${c.name}.${def.key} (${def.type}).`);
927
+ }
928
+ const base = { ...req, op: 'search', offset: 0 };
929
+ if (c.method === 'GET') {
930
+ if (def)
931
+ fail(`Grouping is not supported on ${c.name}.`);
932
+ const r = await this.search({ ...base, limit: HARD_LIMIT });
933
+ return { collection: c.name, total: r.totalCount ?? r.returned };
934
+ }
935
+ if (!def && !versioned) {
936
+ const r = await this.countWhere(c, base);
937
+ return { collection: c.name, total: r.total, ...(r.resolved.length ? { resolved: r.resolved } : {}) };
938
+ }
939
+ const perValue = def && !versioned && (def.resolve === 'lifecycle' || def.resolve === 'enum');
940
+ if (def && perValue) {
941
+ const values = def.resolve === 'enum'
942
+ ? (def.enumValues ?? []).map((v) => ({ value: v, label: v }))
943
+ : (await this.lifecycleOptions(c, def.key).catch(() => [])).map((o) => ({ value: o.key, label: o.label }));
944
+ if (values.length && values.length <= MAX_VALUE_COUNTS) {
945
+ const first = await this.countWhere(c, base);
946
+ const counts = await mapLimit(values, COUNT_CONCURRENCY, async (v) => {
947
+ const r = await this.countWhere(c, {
948
+ ...base,
949
+ where: [...(req.where ?? []), { field: def.key, op: 'in', value: def.resolve === 'enum' ? spellings([v.value]) : [v.value] }],
950
+ });
951
+ return { ...v, count: r.total };
952
+ });
953
+ const groups = counts.filter((g) => g.count > 0);
954
+ const unset = first.total - groups.reduce((n, g) => n + g.count, 0);
955
+ if (unset > 0)
956
+ groups.push({ value: '', label: '(unset)', count: unset });
957
+ return {
958
+ collection: c.name, total: first.total, groupBy: def.key, groups: sortGroups(groups),
959
+ ...(first.resolved.length ? { resolved: first.resolved } : {}),
960
+ };
961
+ }
962
+ }
963
+ // Read the matching rows and count them here.
964
+ const select = [c.idField, ...(def ? [def.key.split('.')[0]] : []), ...(versioned ? VERSION_RANK_FIELDS : [])];
965
+ const fetched = [];
966
+ let rawTotal = 0;
967
+ const resolved = [];
968
+ for (let offset = 0; offset < COUNT_SCAN_CAP; offset += HARD_LIMIT) {
969
+ const payload = await this.compile(c, { ...base, select }, offset, HARD_LIMIT, offset === 0 ? resolved : []);
970
+ const raw = await this.http.postJson(c.searchPath, payload);
971
+ const page = asArray(raw);
972
+ fetched.push(...page);
973
+ rawTotal = totalOf(raw, page) ?? fetched.length;
974
+ if (page.length < HARD_LIMIT || fetched.length >= rawTotal)
975
+ break;
976
+ }
977
+ const complete = fetched.length >= rawTotal;
978
+ const rows = versioned ? latestById(fetched) : fetched;
979
+ const total = complete ? rows.length : rawTotal;
980
+ const out = {
981
+ collection: c.name, total, scanned: fetched.length,
982
+ ...(complete ? {} : { approximate: true }),
983
+ ...(resolved.length ? { resolved } : {}),
984
+ };
985
+ if (!def)
986
+ return out;
987
+ const counts = new Map();
988
+ for (const row of rows) {
989
+ const vals = valuesAt(row, def.key);
990
+ for (const v of vals.length ? vals : [''])
991
+ counts.set(String(v), (counts.get(String(v)) ?? 0) + 1);
992
+ }
993
+ const labels = (await this.labelMaps(c, def.resolve ? [def] : [], rows)).get(def.key);
994
+ const groups = [...counts].map(([value, count]) => ({
995
+ value,
996
+ label: value === '' ? '(unset)' : labels?.get(value) ?? (def.resolve === 'ref' ? `#${value}` : value),
997
+ count,
998
+ }));
999
+ return { ...out, groupBy: def.key, groups: sortGroups(groups) };
1000
+ }
1001
+ noMatchText(refTo, word) {
1002
+ const noun = {
1003
+ users: 'user', tags: 'label', releases: 'release', sprints: 'sprint', modules: 'module',
1004
+ stories: 'user story', epics: 'epic', features: 'feature', defects: 'defect', testcases: 'test case',
1005
+ };
1006
+ const what = noun[refTo] ?? refTo;
1007
+ const hint = refTo === 'users' ? 'Use their name, username or email.' : 'Use its exact name, its code, or its id.';
1008
+ return `No ${what} matches "${word}" in this project. ${hint}`;
1009
+ }
1010
+ resolveSortField(c, field) {
1011
+ if (field.startsWith('customFields.'))
1012
+ return field;
1013
+ const def = c.fields[field];
1014
+ if (!def) {
1015
+ fail(`Unknown sort field "${field}" on ${c.name}. Fields: ${Object.keys(c.fields).join(', ')}.`);
1016
+ }
1017
+ if (def.sort === false)
1018
+ fail(`Field "${field}" on ${c.name} is not sortable.`);
1019
+ if (def.type === 'array' || def.type === 'object') {
1020
+ fail(`Field "${field}" on ${c.name} is not sortable (${def.type}).`);
1021
+ }
1022
+ return def.key;
1023
+ }
1024
+ toWire(column, op, value, condition) {
1025
+ const out = { column };
1026
+ switch (op) {
1027
+ case 'eq':
1028
+ out['value'] = value;
1029
+ break;
1030
+ case 'contains':
1031
+ out['value'] = value;
1032
+ out['regEx'] = true;
1033
+ break;
1034
+ case 'in':
1035
+ out['value'] = Array.isArray(value) ? value : [value];
1036
+ break;
1037
+ case 'not_in':
1038
+ out['value'] = Array.isArray(value) ? value : [value];
1039
+ out['operator'] = 'nor';
1040
+ break;
1041
+ case 'ne':
1042
+ out['value'] = value;
1043
+ out['operator'] = 'nor';
1044
+ break;
1045
+ case 'gt':
1046
+ case 'gte':
1047
+ case 'lt':
1048
+ case 'lte':
1049
+ case 'exists':
1050
+ out['value'] = value;
1051
+ out['operator'] = op;
1052
+ break;
1053
+ }
1054
+ if (condition === 'or')
1055
+ out['condition'] = 'or';
1056
+ return out;
1057
+ }
1058
+ // --------------------------------------------------------------- subresource
1059
+ async fetchSubresource(c, rel, id, versionId, parentRow) {
1060
+ const path = rel.subresourcePath ?? fail(`Relation on ${c.name} has no subresourcePath.`);
1061
+ if (path.includes('getstepResult')) {
1062
+ const row = parentRow ?? (await this.get(c.name, id));
1063
+ const executionId = Number(row['id'] ?? id);
1064
+ const executionRunId = str(row, 'executionRunId', 'runId', 'uuid');
1065
+ const parentId = Number(row['executionItemId'] ?? row['parentId'] ?? 0);
1066
+ if (!executionRunId) {
1067
+ fail('This execution row has no executionRunId — step results need the run UUID. ' +
1068
+ 'Use the execution tool (`run_status`) for step-level results.');
1069
+ }
1070
+ const raw = await this.http.postJson('/reports/execution/getstepResult', {
1071
+ executionId,
1072
+ executionRunId,
1073
+ parentId,
1074
+ projectId: this.projectId,
1075
+ });
1076
+ return notDeleted(asArray(raw));
1077
+ }
1078
+ const params = new URLSearchParams();
1079
+ params.set('projectId', String(this.projectId));
1080
+ for (const [key, value] of Object.entries(rel.subresourceParams ?? {})) {
1081
+ params.set(key, value === 'id' ? id : value);
1082
+ }
1083
+ let vid = versionId;
1084
+ if (!vid && (c.name === 'testcases' || c.name === 'functions')) {
1085
+ const row = parentRow ?? (await this.get(c.name, id));
1086
+ vid = versionIdFromRow(row);
1087
+ }
1088
+ if (!vid && c.name === 'functions') {
1089
+ vid = await this.newestFunctionVersion(id);
1090
+ }
1091
+ if (vid && !params.has('versionId'))
1092
+ params.set('versionId', vid);
1093
+ const raw = await this.http.getJson(`${path}?${params.toString()}`);
1094
+ return notDeleted(asArray(raw));
1095
+ }
1096
+ async newestFunctionVersion(id) {
1097
+ const raw = await this.http.getJson(`/tm/function/version/${encodeURIComponent(id)}/${this.projectId}`);
1098
+ const rows = asArray(raw);
1099
+ if (!rows.length)
1100
+ return undefined;
1101
+ const sorted = [...rows].sort((a, b) => Number(b['versionNumber'] ?? 0) - Number(a['versionNumber'] ?? 0));
1102
+ return str(sorted[0], 'id', '_id', 'versionId');
1103
+ }
1104
+ // ---------------------------------------------------------------- GET search
1105
+ async searchGet(c, req, offset, limit) {
1106
+ const path = c.searchPath.replace('{projectId}', String(this.projectId));
1107
+ const raw = await this.http.getJson(path);
1108
+ let rows = asArray(raw);
1109
+ // Client-side filters for GET collections (only environments today).
1110
+ for (const w of req.where ?? []) {
1111
+ if (w.field === 'deleted' || w.field === 'projectId')
1112
+ continue;
1113
+ const def = c.fields[w.field] ?? fail(`Unknown field "${w.field}" on ${c.name}.`);
1114
+ const op = w.op ?? 'eq';
1115
+ rows = rows.filter((r) => matchClient(r[def.key], op, w.value));
1116
+ }
1117
+ if (c.softDelete)
1118
+ rows = rows.filter((r) => r[c.softDelete] !== true);
1119
+ const totalCount = rows.length;
1120
+ const page = rows.slice(offset, offset + limit);
1121
+ return {
1122
+ collection: c.name,
1123
+ rows: page,
1124
+ totalCount,
1125
+ returned: page.length,
1126
+ offset,
1127
+ ...(totalCount > offset + page.length ? { nextOffset: offset + page.length } : {}),
1128
+ };
1129
+ }
1130
+ requireCollection(name) {
1131
+ if (!name)
1132
+ fail('`collection` is required. Call sqa_query({op:"catalog"}) to list collections.');
1133
+ const c = resolveCollection(name);
1134
+ if (!c) {
1135
+ fail(`Unknown collection "${name}". Available: ${Object.keys(SQA_COLLECTIONS).join(', ')}.`);
1136
+ }
1137
+ return c;
1138
+ }
1139
+ }
1140
+ function controlTypeToFieldType(controlType) {
1141
+ switch ((controlType ?? '').toUpperCase()) {
1142
+ case 'NUMBER': return 'number';
1143
+ case 'DATE': return 'date';
1144
+ case 'CHECKBOX':
1145
+ case 'LIST':
1146
+ case 'RADIO': return 'array';
1147
+ case 'USER':
1148
+ case 'CLONE':
1149
+ case 'RELEASE-SPRINT': return 'number';
1150
+ default: return 'string';
1151
+ }
1152
+ }
1153
+ /** Coerce values by registry/control type so a numeric id passed as "42" still matches a number field. */
1154
+ function coerceValue(type, value) {
1155
+ if (value === undefined)
1156
+ return value;
1157
+ const toNumber = (v) => typeof v === 'string' && v.trim() !== '' && Number.isFinite(Number(v)) ? Number(v) : v;
1158
+ if (type === 'number') {
1159
+ if (Array.isArray(value))
1160
+ return value.map(toNumber);
1161
+ return toNumber(value);
1162
+ }
1163
+ if (type === 'boolean' && typeof value === 'string') {
1164
+ if (value === 'true')
1165
+ return true;
1166
+ if (value === 'false')
1167
+ return false;
1168
+ }
1169
+ if (type === 'array' && Array.isArray(value))
1170
+ return value.map(toNumber);
1171
+ return value;
1172
+ }
1173
+ function allowedOpsForControl(controlType) {
1174
+ switch ((controlType ?? '').toUpperCase()) {
1175
+ case 'NUMBER':
1176
+ return ['eq', 'ne', 'in', 'not_in', 'gt', 'gte', 'lt', 'lte'];
1177
+ case 'DATE':
1178
+ return ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
1179
+ case 'LIST':
1180
+ case 'CHECKBOX':
1181
+ case 'RADIO':
1182
+ return ['in', 'not_in'];
1183
+ case 'USER':
1184
+ case 'CLONE':
1185
+ case 'RELEASE-SPRINT':
1186
+ return ['eq', 'in', 'ne'];
1187
+ case 'TEXT':
1188
+ case 'TEXT-EDITOR':
1189
+ default:
1190
+ return ['eq', 'contains', 'ne'];
1191
+ }
1192
+ }
1193
+ function matchClient(value, op, expected) {
1194
+ if (op === 'eq')
1195
+ return value === expected;
1196
+ if (op === 'ne')
1197
+ return value !== expected;
1198
+ if (op === 'contains')
1199
+ return String(value ?? '').toLowerCase().includes(String(expected ?? '').toLowerCase());
1200
+ if (op === 'in')
1201
+ return Array.isArray(expected) && expected.includes(value);
1202
+ if (op === 'not_in')
1203
+ return Array.isArray(expected) && !expected.includes(value);
1204
+ return false;
1205
+ }
1206
+ //# sourceMappingURL=searchEngine.js.map