@testmo/testmo-link 1.0.0-beta.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.
@@ -0,0 +1,636 @@
1
+ "use strict";
2
+ /*
3
+ * Copyright (c) Testmo GmbH (Berlin, Germany)
4
+ * All rights reserved.
5
+ * contact@testmo.com - www.testmo.com
6
+ */
7
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
8
+ if (k2 === undefined) k2 = k;
9
+ var desc = Object.getOwnPropertyDescriptor(m, k);
10
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
11
+ desc = { enumerable: true, get: function() { return m[k]; } };
12
+ }
13
+ Object.defineProperty(o, k2, desc);
14
+ }) : (function(o, m, k, k2) {
15
+ if (k2 === undefined) k2 = k;
16
+ o[k2] = m[k];
17
+ }));
18
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
19
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
20
+ }) : function(o, v) {
21
+ o["default"] = v;
22
+ });
23
+ var __importStar = (this && this.__importStar) || (function () {
24
+ var ownKeys = function(o) {
25
+ ownKeys = Object.getOwnPropertyNames || function (o) {
26
+ var ar = [];
27
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
28
+ return ar;
29
+ };
30
+ return ownKeys(o);
31
+ };
32
+ return function (mod) {
33
+ if (mod && mod.__esModule) return mod;
34
+ var result = {};
35
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
36
+ __setModuleDefault(result, mod);
37
+ return result;
38
+ };
39
+ })();
40
+ var __importDefault = (this && this.__importDefault) || function (mod) {
41
+ return (mod && mod.__esModule) ? mod : { "default": mod };
42
+ };
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.AutomationLink = void 0;
45
+ const controller_1 = require("../core/controller");
46
+ const logger_1 = require("../lib/logger");
47
+ const errors_1 = require("../lib/errors");
48
+ const fs_1 = __importDefault(require("fs"));
49
+ const path_1 = __importDefault(require("path"));
50
+ const validator_1 = __importDefault(require("validator"));
51
+ // @ts-ignore
52
+ const testmo = __importStar(require("@testmo/testmo-api"));
53
+ // @ts-ignore
54
+ const packageInfo = __importStar(require("../../package.json"));
55
+ const retry_1 = require("../lib/retry");
56
+ const annotationScanner_1 = require("../linking/annotationScanner");
57
+ const patternMatcher_1 = require("../linking/patternMatcher");
58
+ const DEFAULT_PRECEDENCE = [
59
+ 'annotation',
60
+ 'config',
61
+ 'pattern',
62
+ ];
63
+ class AutomationLink extends controller_1.Controller {
64
+ validate(options) {
65
+ if (!validator_1.default.isInt(options.projectId, { min: 1 })) {
66
+ throw new errors_1.ValidationError('Project ID needs to be a positive number');
67
+ }
68
+ if (!validator_1.default.isInt(options.runId, { min: 1 })) {
69
+ throw new errors_1.ValidationError('Run ID needs to be a positive number');
70
+ }
71
+ if (options.configFile !== undefined && !fs_1.default.existsSync(options.configFile)) {
72
+ throw new errors_1.ConfigError(`Config file not found: ${options.configFile}\n` +
73
+ ` Resolved path: ${path_1.default.resolve(options.configFile)}\n` +
74
+ ` Working directory: ${process.cwd()}`, options.configFile);
75
+ }
76
+ if (options.conflictResolution !== undefined &&
77
+ !['error', 'warn', 'silent'].includes(options.conflictResolution)) {
78
+ throw new errors_1.ValidationError('conflict-resolution must be one of: error, warn, silent');
79
+ }
80
+ this.validateProxy(options.proxy);
81
+ }
82
+ loadConfig(configFile) {
83
+ var _a;
84
+ if (!configFile) {
85
+ return {};
86
+ }
87
+ // T111 — warn on large config files; T110 — inform about memory implications > 10MB
88
+ try {
89
+ const stat = fs_1.default.statSync(configFile);
90
+ const sizeKB = Math.round(stat.size / 1024);
91
+ if (stat.size > 10 * 1024 * 1024) {
92
+ logger_1.logger.warn(`Config file is very large (${sizeKB}KB, >10MB). ` +
93
+ `High memory usage expected. Consider using annotations or pattern matching instead.`);
94
+ }
95
+ else if (stat.size > 1024 * 1024) {
96
+ logger_1.logger.warn(`Config file is large (${sizeKB}KB, >1MB). ` +
97
+ `For this many mappings, consider using annotations or pattern matching instead.`);
98
+ }
99
+ }
100
+ catch (_b) {
101
+ // Let readFileSync below report the real I/O error
102
+ }
103
+ let content;
104
+ try {
105
+ content = fs_1.default.readFileSync(configFile, 'utf8');
106
+ }
107
+ catch (e) {
108
+ throw new errors_1.ConfigError(`Cannot read config file: ${e.message}`, configFile);
109
+ }
110
+ const ext = path_1.default.extname(configFile).toLowerCase();
111
+ if (ext === '.json') {
112
+ try {
113
+ return JSON.parse(content);
114
+ }
115
+ catch (e) {
116
+ throw new errors_1.ConfigError(`Config file contains invalid JSON: ${e.message}\n` +
117
+ ` File: ${path_1.default.resolve(configFile)}`, configFile);
118
+ }
119
+ }
120
+ // YAML (.yml or .yaml)
121
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
122
+ const yaml = require('js-yaml');
123
+ try {
124
+ return ((_a = yaml.load(content)) !== null && _a !== void 0 ? _a : {});
125
+ }
126
+ catch (e) {
127
+ throw new errors_1.ConfigError(`Config file contains invalid YAML: ${e.message}\n` +
128
+ ` File: ${path_1.default.resolve(configFile)}`, configFile);
129
+ }
130
+ }
131
+ buildConfigLookup(config) {
132
+ var _a;
133
+ const lookup = new Map();
134
+ if (config.config_mappings) {
135
+ for (const mapping of config.config_mappings) {
136
+ const existing = (_a = lookup.get(mapping.test_name)) !== null && _a !== void 0 ? _a : [];
137
+ existing.push(mapping);
138
+ lookup.set(mapping.test_name, existing);
139
+ }
140
+ }
141
+ return lookup;
142
+ }
143
+ resolveConfigMapping(lookup, test) {
144
+ const candidates = lookup.get(test.name);
145
+ if (!candidates || candidates.length === 0)
146
+ return undefined;
147
+ if (candidates.length === 1)
148
+ return candidates[0].case_id;
149
+ // Multiple candidates — try folder disambiguation first
150
+ let filtered = candidates;
151
+ if (test.folder !== null) {
152
+ // Test has a folder — prefer entries that match it
153
+ const byFolder = filtered.filter(c => c.folder === test.folder);
154
+ if (byFolder.length > 0) {
155
+ filtered = byFolder;
156
+ }
157
+ else {
158
+ // No folder match — fall back to unscoped (no folder) entries
159
+ const unscoped = filtered.filter(c => !c.folder);
160
+ if (unscoped.length > 0) {
161
+ filtered = unscoped;
162
+ }
163
+ else {
164
+ // All entries are folder-scoped and none match this test's folder
165
+ return undefined;
166
+ }
167
+ }
168
+ }
169
+ else {
170
+ // Test has no folder — prefer entries that don't restrict by folder
171
+ const unscoped = filtered.filter(c => !c.folder);
172
+ if (unscoped.length > 0)
173
+ filtered = unscoped;
174
+ }
175
+ // Then try file disambiguation
176
+ if (filtered.length > 1 && test.file !== null) {
177
+ const normFile = test.file.replace(/\\/g, '/');
178
+ const byFile = filtered.filter(c => {
179
+ if (!c.file)
180
+ return false;
181
+ const normC = c.file.replace(/\\/g, '/');
182
+ return normC === normFile
183
+ || normFile.endsWith('/' + normC)
184
+ || normC.endsWith('/' + normFile);
185
+ });
186
+ if (byFile.length > 0) {
187
+ filtered = byFile;
188
+ }
189
+ else {
190
+ // No file match — fall back to unscoped (no file) entries
191
+ const unscopedFile = filtered.filter(c => !c.file);
192
+ if (unscopedFile.length > 0) {
193
+ filtered = unscopedFile;
194
+ }
195
+ else {
196
+ // All remaining entries specify a file but none match
197
+ return undefined;
198
+ }
199
+ }
200
+ }
201
+ if (filtered.length === 1)
202
+ return filtered[0].case_id;
203
+ // Still ambiguous — warn and skip
204
+ logger_1.logger.warn(`config_mappings ambiguous for "${test.name}": ` +
205
+ `${filtered.length} entries still match after applying folder/file disambiguation. ` +
206
+ `Add more specific "folder" or "file" values to distinguish them.`);
207
+ return undefined;
208
+ }
209
+ createRawClient(address, proxy, token) {
210
+ if (!token || validator_1.default.isEmpty(token)) {
211
+ throw new errors_1.ValidationError('Missing Testmo API token (TESTMO_TOKEN environment variable)');
212
+ }
213
+ const requestLogger = (loggingRequest) => {
214
+ logger_1.logger.debug(`Request: ${loggingRequest.method} ${loggingRequest.url}`);
215
+ };
216
+ const client = new testmo.ApiClientEx();
217
+ client.plugins.push(requestLogger);
218
+ client.basePath = address;
219
+ client.authentications.bearerAuth.accessToken = token;
220
+ client.defaultHeaders = {
221
+ 'Accept': 'application/json',
222
+ 'User-Agent': `Testmo Link/${packageInfo.version} (www.testmo.com)`,
223
+ };
224
+ if (proxy) {
225
+ client.proxy = proxy;
226
+ }
227
+ return client;
228
+ }
229
+ async getAllRunTests(client, runId) {
230
+ var _a;
231
+ const allTests = [];
232
+ let page = 1;
233
+ const perPage = 1000;
234
+ while (true) {
235
+ const result = await (0, retry_1.withRetry)(() => client.callApi('/api/v1/automation/runs/{run_id}/tests', 'GET', { run_id: runId }, { page, per_page: perPage }, {}, {}, null, ['bearerAuth'], ['application/json'], ['application/json'], Object, null));
236
+ const data = result.data;
237
+ const tests = (_a = data === null || data === void 0 ? void 0 : data.result) !== null && _a !== void 0 ? _a : [];
238
+ allTests.push(...tests);
239
+ if (!(data === null || data === void 0 ? void 0 : data.next_page)) {
240
+ break;
241
+ }
242
+ page = data.next_page;
243
+ }
244
+ return allTests;
245
+ }
246
+ async createLinkBulk(client, projectId, links) {
247
+ await (0, retry_1.withRetry)(() => client.callApi('/api/v1/projects/{project_id}/automation-links/bulk', 'POST', { project_id: projectId }, {}, {}, {}, {
248
+ links: links.map(l => ({
249
+ case_id: l.repositoryCaseId,
250
+ automation_case_id: l.automationCaseId,
251
+ })),
252
+ }, ['bearerAuth'], ['application/json'], ['application/json'], Object, null));
253
+ }
254
+ async run(options) {
255
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q, _r, _s, _t, _u, _v, _w, _x, _y, _z, _0, _1, _2;
256
+ this.logControllerOptions(options);
257
+ options.testmoInstanceAddress = this.validateAndTruncateInstanceAddress(options.testmoInstanceAddress);
258
+ this.validate(options);
259
+ // --- Load config ---
260
+ const config = this.loadConfig(options.configFile);
261
+ // Warn on unrecognized top-level config keys (catches typos like "include", "output.verbose")
262
+ if (options.configFile) {
263
+ const knownKeys = new Set([
264
+ 'config_mappings', 'annotations', 'pattern_matching', 'precedence', 'settings',
265
+ ]);
266
+ for (const key of Object.keys(config)) {
267
+ if (!knownKeys.has(key)) {
268
+ logger_1.logger.warn(`Unknown config key "${key}" in ${options.configFile} — ` +
269
+ `not recognized and will be ignored. ` +
270
+ `Valid top-level keys: ${[...knownKeys].join(', ')}`);
271
+ }
272
+ }
273
+ }
274
+ // Apply config file settings (CLI flags take priority over config settings)
275
+ if (((_a = config.settings) === null || _a === void 0 ? void 0 : _a.verbose) && logger_1.logger.level > logger_1.LogLevel.Debug) {
276
+ logger_1.logger.level = logger_1.LogLevel.Debug;
277
+ }
278
+ if (((_b = config.settings) === null || _b === void 0 ? void 0 : _b.output_file) && !logger_1.logger.outputFile) {
279
+ logger_1.logger.outputFile = config.settings.output_file;
280
+ }
281
+ // CLI flags override config file settings
282
+ const conflictResolution = (_e = (_c = options.conflictResolution) !== null && _c !== void 0 ? _c : (_d = config.settings) === null || _d === void 0 ? void 0 : _d.conflict_resolution) !== null && _e !== void 0 ? _e : 'error';
283
+ const precedence = ((_f = config.precedence) === null || _f === void 0 ? void 0 : _f.length) ? config.precedence : DEFAULT_PRECEDENCE;
284
+ // --- Config mappings ---
285
+ const configLookup = this.buildConfigLookup(config);
286
+ const configMappingEnabled = precedence.includes('config') && configLookup.size > 0;
287
+ if (configMappingEnabled) {
288
+ const totalMappings = [...configLookup.values()].reduce((n, arr) => n + arr.length, 0);
289
+ logger_1.logger.info(`Loaded ${totalMappings} config mapping(s)`);
290
+ }
291
+ else if (!options.configFile) {
292
+ logger_1.logger.warn('No config file specified and no mappings loaded. ' +
293
+ 'Use --config <file> to specify a YAML/JSON config with mappings.');
294
+ }
295
+ else if (configLookup.size === 0 &&
296
+ !((_g = config.annotations) === null || _g === void 0 ? void 0 : _g.enabled) &&
297
+ !((_h = config.pattern_matching) === null || _h === void 0 ? void 0 : _h.enabled)) {
298
+ logger_1.logger.warn('No config_mappings found in config file. No links will be created.');
299
+ }
300
+ // --- API client ---
301
+ const client = this.createRawClient(options.testmoInstanceAddress, options.proxy, options.testmoApiToken);
302
+ // T113 — phase performance timers (reported in debug/verbose mode)
303
+ const phaseMs = { scan: 0, caseLoad: 0, fetch: 0, match: 0, link: 0 };
304
+ // --- Annotation scanner setup ---
305
+ let annotationScanner;
306
+ if (((_j = config.annotations) === null || _j === void 0 ? void 0 : _j.enabled) === true &&
307
+ ((_l = (_k = config.annotations.paths) === null || _k === void 0 ? void 0 : _k.length) !== null && _l !== void 0 ? _l : 0) === 0) {
308
+ logger_1.logger.warn('Annotation scanning is enabled but no paths are configured — skipping annotation scan.\n' +
309
+ 'Add a "paths" key to your annotations configuration to specify which files to scan.');
310
+ }
311
+ const annotationEnabled = precedence.includes('annotation') &&
312
+ ((_m = config.annotations) === null || _m === void 0 ? void 0 : _m.enabled) === true &&
313
+ ((_p = (_o = config.annotations.paths) === null || _o === void 0 ? void 0 : _o.length) !== null && _p !== void 0 ? _p : 0) > 0;
314
+ if (annotationEnabled) {
315
+ annotationScanner = new annotationScanner_1.AnnotationScanner(config.annotations);
316
+ logger_1.logger.info(`Scanning files for annotations...`);
317
+ const t0 = Date.now();
318
+ await annotationScanner.scan(config.annotations.paths);
319
+ phaseMs.scan = Date.now() - t0;
320
+ }
321
+ // --- Pattern matcher setup ---
322
+ let patternMatcher;
323
+ const patternEnabled = precedence.includes('pattern') &&
324
+ ((_q = config.pattern_matching) === null || _q === void 0 ? void 0 : _q.enabled) === true;
325
+ if (patternEnabled) {
326
+ const rules = (_r = config.pattern_matching.rules) !== null && _r !== void 0 ? _r : [];
327
+ patternMatcher = new patternMatcher_1.PatternMatcher(rules);
328
+ logger_1.logger.info(`Loading repository cases for pattern matching...`);
329
+ const projectId = parseInt(options.projectId, 10);
330
+ const t0 = Date.now();
331
+ await patternMatcher.loadCases(client, projectId);
332
+ phaseMs.caseLoad = Date.now() - t0;
333
+ logger_1.logger.info(`Loaded ${patternMatcher.getCaseCount()} case(s) for pattern matching`);
334
+ }
335
+ // --- Fetch run tests ---
336
+ logger_1.logger.info(`Fetching test results for run ${options.runId}...`);
337
+ const fetchStart = Date.now();
338
+ const tests = await this.getAllRunTests(client, parseInt(options.runId, 10));
339
+ phaseMs.fetch = Date.now() - fetchStart;
340
+ logger_1.logger.info(`Found ${tests.length} test result(s)`);
341
+ // Build a set of all case IDs already linked across the run so that
342
+ // pattern matching can filter them out before the ambiguity check.
343
+ const allLinkedCaseIds = new Set();
344
+ for (const t of tests) {
345
+ if (t.repository_case_id !== null)
346
+ allLinkedCaseIds.add(t.repository_case_id);
347
+ for (const id of ((_s = t.repository_case_ids) !== null && _s !== void 0 ? _s : []))
348
+ allLinkedCaseIds.add(id);
349
+ }
350
+ // --- Matching phase ---
351
+ let matched = 0;
352
+ let alreadyLinked = 0;
353
+ let unmatched = 0;
354
+ let errors = 0;
355
+ const methodCounts = {
356
+ annotation: 0,
357
+ config: 0,
358
+ pattern: 0,
359
+ };
360
+ const startTime = Date.now();
361
+ const matchStart = Date.now();
362
+ const projectId = parseInt(options.projectId, 10);
363
+ // T102 — error counts by category for aggregated reporting.
364
+ // The bulk endpoint returns 422 for validation failures (not per-case 404s),
365
+ // so only two categories are needed.
366
+ const errorCounts = { validation: 0, other: 0 };
367
+ const conflictRecords = [];
368
+ const pendingLinks = [];
369
+ for (const test of tests) {
370
+ // Skip if already linked (unless --force).
371
+ // When annotation scanning is enabled, defer the skip — the annotation path
372
+ // handles per-ID filtering via repository_case_ids, so a test with one existing
373
+ // link may still need additional IDs linked.
374
+ if (test.repository_case_id !== null && !options.force && !annotationEnabled) {
375
+ alreadyLinked++;
376
+ logger_1.logger.debug(`Skipping already-linked: "${test.name}" → case ${test.repository_case_id}`);
377
+ continue;
378
+ }
379
+ // Try each method in precedence order, collect all results for conflict detection
380
+ const candidates = [];
381
+ let multiCaseDone = false;
382
+ // Tracks whether the annotation single-ID was suppressed because it's already linked
383
+ // (used to count the test as alreadyLinked rather than unmatched at the end)
384
+ let filteredByExistingLink = false;
385
+ for (const method of precedence) {
386
+ let caseId;
387
+ if (method === 'annotation' && annotationScanner) {
388
+ const ids = annotationScanner.findCaseIds(test.name, test.file, test.folder);
389
+ if (ids.length > 1 && candidates.length === 0) {
390
+ // Multiple case IDs and no higher-precedence method matched —
391
+ // filter out already-linked IDs, then create pending links for the rest.
392
+ const existingIds = new Set([
393
+ ...((_t = test.repository_case_ids) !== null && _t !== void 0 ? _t : []),
394
+ ...(test.repository_case_id !== null ? [test.repository_case_id] : []),
395
+ ]);
396
+ const idsToLink = options.force
397
+ ? ids
398
+ : ids.filter(id => !existingIds.has(id));
399
+ if (idsToLink.length === 0) {
400
+ // All annotation IDs are already linked
401
+ alreadyLinked++;
402
+ logger_1.logger.debug(`Skipping already-linked: "${test.name}" → all ${ids.length} annotation IDs already linked`);
403
+ multiCaseDone = true;
404
+ break;
405
+ }
406
+ if (options.dryRun) {
407
+ for (const id of idsToLink) {
408
+ logger_1.logger.info(`[DRY RUN] Would link: "${test.name}" ` +
409
+ `(automation_case_id=${test.automation_case_id}) → case ${id}` +
410
+ ` [annotation]`);
411
+ matched++;
412
+ methodCounts.annotation++;
413
+ }
414
+ }
415
+ else {
416
+ for (const id of idsToLink) {
417
+ pendingLinks.push({ test, caseId: id, method: 'annotation' });
418
+ }
419
+ }
420
+ multiCaseDone = true;
421
+ break;
422
+ }
423
+ // Single or no match — fall through to normal conflict-detection flow
424
+ caseId = ids[0];
425
+ // If this single ID is already linked, skip it to avoid a redundant POST
426
+ if (caseId !== undefined && !options.force) {
427
+ const existingIds = new Set([
428
+ ...((_u = test.repository_case_ids) !== null && _u !== void 0 ? _u : []),
429
+ ...(test.repository_case_id !== null ? [test.repository_case_id] : []),
430
+ ]);
431
+ if (existingIds.has(caseId)) {
432
+ filteredByExistingLink = true;
433
+ caseId = undefined;
434
+ }
435
+ }
436
+ }
437
+ else if (method === 'config' && configMappingEnabled) {
438
+ // Skip non-annotation methods for already-linked tests (unless --force).
439
+ // The early-exit above is deferred when annotationEnabled, but that deferral
440
+ // is only needed for the annotation path — config/pattern should still respect
441
+ // the already-linked state.
442
+ if (test.repository_case_id !== null && !options.force)
443
+ continue;
444
+ caseId = this.resolveConfigMapping(configLookup, test);
445
+ }
446
+ else if (method === 'pattern' && patternMatcher) {
447
+ if (test.repository_case_id !== null && !options.force)
448
+ continue;
449
+ caseId = patternMatcher.match(test.name, allLinkedCaseIds);
450
+ }
451
+ if (caseId !== undefined) {
452
+ candidates.push({ method, caseId });
453
+ }
454
+ }
455
+ if (multiCaseDone)
456
+ continue;
457
+ if (candidates.length === 0) {
458
+ // Count as alreadyLinked if: the annotation single-ID was filtered because it's
459
+ // already linked, or the test has an existing link and annotations found nothing
460
+ // new to add (annotationEnabled bypassed the early-exit above).
461
+ if (filteredByExistingLink ||
462
+ (test.repository_case_id !== null && !options.force)) {
463
+ alreadyLinked++;
464
+ logger_1.logger.debug(`Skipping already-linked: "${test.name}"`);
465
+ }
466
+ else {
467
+ unmatched++;
468
+ // T098 — per-test hint at debug level; summary-level suggestions follow
469
+ logger_1.logger.debug(`No match found for: "${test.name}"`);
470
+ }
471
+ continue;
472
+ }
473
+ // Check for conflicts (same test matched to different case IDs by different methods)
474
+ const uniqueCaseIds = new Set(candidates.map(c => c.caseId));
475
+ let resolvedCaseId;
476
+ let resolvedMethod;
477
+ if (uniqueCaseIds.size > 1) {
478
+ const detail = candidates
479
+ .map(c => `${c.method}→case ${c.caseId}`)
480
+ .join(', ');
481
+ if (conflictResolution === 'error') {
482
+ errors++;
483
+ logger_1.logger.error(`Conflict for "${test.name}": multiple methods returned different case IDs (${detail})`);
484
+ conflictRecords.push({
485
+ testName: test.name,
486
+ candidates,
487
+ resolvedCaseId: null,
488
+ resolvedMethod: null,
489
+ });
490
+ continue;
491
+ }
492
+ else if (conflictResolution === 'warn') {
493
+ logger_1.logger.warn(`Conflict for "${test.name}": using first match (${detail})`);
494
+ }
495
+ // Both warn and silent: use the first match (highest precedence)
496
+ resolvedCaseId = candidates[0].caseId;
497
+ resolvedMethod = candidates[0].method;
498
+ conflictRecords.push({
499
+ testName: test.name,
500
+ candidates,
501
+ resolvedCaseId,
502
+ resolvedMethod,
503
+ });
504
+ }
505
+ else {
506
+ // All candidates agree — use the first (highest precedence method)
507
+ resolvedCaseId = candidates[0].caseId;
508
+ resolvedMethod = candidates[0].method;
509
+ }
510
+ if (options.dryRun) {
511
+ logger_1.logger.info(`[DRY RUN] Would link: "${test.name}" ` +
512
+ `(automation_case_id=${test.automation_case_id}) → case ${resolvedCaseId}` +
513
+ ` [${resolvedMethod}]`);
514
+ matched++;
515
+ methodCounts[resolvedMethod]++;
516
+ continue;
517
+ }
518
+ pendingLinks.push({ test, caseId: resolvedCaseId, method: resolvedMethod });
519
+ }
520
+ phaseMs.match = Date.now() - matchStart;
521
+ // --- Bulk link creation phase (up to 500 links per request) ---
522
+ // Each batch is a single POST to the bulk endpoint, reducing N individual
523
+ // requests to ceil(N/500) requests and eliminating the burst that triggers
524
+ // rate limiting.
525
+ const linkStart = Date.now();
526
+ const BULK_BATCH_SIZE = 500;
527
+ for (let i = 0; i < pendingLinks.length; i += BULK_BATCH_SIZE) {
528
+ const batch = pendingLinks.slice(i, i + BULK_BATCH_SIZE);
529
+ try {
530
+ await this.createLinkBulk(client, projectId, batch.map(({ test, caseId }) => ({
531
+ repositoryCaseId: caseId,
532
+ automationCaseId: test.automation_case_id,
533
+ })));
534
+ for (const { test, caseId, method } of batch) {
535
+ matched++;
536
+ methodCounts[method]++;
537
+ logger_1.logger.debug(`Linked: "${test.name}" ` +
538
+ `(automation_case_id=${test.automation_case_id}) → case ${caseId}` +
539
+ ` [${method}]`);
540
+ }
541
+ }
542
+ catch (e) {
543
+ errors += batch.length;
544
+ const status = (_w = (_v = e === null || e === void 0 ? void 0 : e.response) === null || _v === void 0 ? void 0 : _v.status) !== null && _w !== void 0 ? _w : e === null || e === void 0 ? void 0 : e.status;
545
+ const msg = (_0 = (_z = (_y = (_x = e === null || e === void 0 ? void 0 : e.response) === null || _x === void 0 ? void 0 : _x.body) === null || _y === void 0 ? void 0 : _y.message) !== null && _z !== void 0 ? _z : e === null || e === void 0 ? void 0 : e.message) !== null && _0 !== void 0 ? _0 : (e != null && typeof e === 'object' ? JSON.stringify(e) : String(e));
546
+ if (status === 422 && ((_2 = (_1 = e === null || e === void 0 ? void 0 : e.response) === null || _1 === void 0 ? void 0 : _1.body) === null || _2 === void 0 ? void 0 : _2.errors)) {
547
+ errorCounts.validation += batch.length;
548
+ const fields = Object.entries(e.response.body.errors)
549
+ .map(([f, msgs]) => `${f}: ${msgs[0]}`)
550
+ .join(', ');
551
+ logger_1.logger.error(`Error linking batch of ${batch.length}: validation failed — ${fields}`);
552
+ }
553
+ else {
554
+ errorCounts.other += batch.length;
555
+ logger_1.logger.error(`Error linking batch of ${batch.length}: ${msg}`);
556
+ }
557
+ }
558
+ }
559
+ phaseMs.link = Date.now() - linkStart;
560
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
561
+ // T113 — performance metrics in debug mode
562
+ logger_1.logger.debug(`Performance: scan=${phaseMs.scan}ms, caseLoad=${phaseMs.caseLoad}ms, ` +
563
+ `fetch=${phaseMs.fetch}ms, match=${phaseMs.match}ms, link=${phaseMs.link}ms`);
564
+ // --- Summary ---
565
+ logger_1.logger.info('');
566
+ logger_1.logger.info(`Automation linking complete (${elapsed}s):`);
567
+ logger_1.logger.info(` Matched/linked: ${matched}`);
568
+ logger_1.logger.info(` Already linked: ${alreadyLinked} (skipped)`);
569
+ logger_1.logger.info(` Unmatched: ${unmatched}`);
570
+ if (errors > 0) {
571
+ logger_1.logger.info(` Errors: ${errors}`);
572
+ // T102 — aggregate error breakdown by category
573
+ if (errorCounts.validation > 0) {
574
+ logger_1.logger.info(` Validation failures: ${errorCounts.validation}`);
575
+ }
576
+ if (errorCounts.other > 0) {
577
+ logger_1.logger.info(` Network/API errors: ${errorCounts.other}`);
578
+ }
579
+ }
580
+ // Show method breakdown if more than one method was active
581
+ const activeMethods = Object.keys(methodCounts).filter(m => methodCounts[m] > 0);
582
+ if (activeMethods.length > 1) {
583
+ logger_1.logger.info(` By method:`);
584
+ for (const m of activeMethods) {
585
+ logger_1.logger.info(` ${m}: ${methodCounts[m]}`);
586
+ }
587
+ }
588
+ // Show conflict details if any conflicts occurred
589
+ if (conflictRecords.length > 0) {
590
+ logger_1.logger.info('');
591
+ logger_1.logger.info(`Conflict details (${conflictRecords.length}):`);
592
+ for (const conflict of conflictRecords) {
593
+ const candidateStr = conflict.candidates
594
+ .map(c => `${c.method}→case ${c.caseId}`)
595
+ .join(', ');
596
+ if (conflict.resolvedCaseId !== null) {
597
+ logger_1.logger.info(` "${conflict.testName}": ${candidateStr}` +
598
+ ` → resolved: ${conflict.resolvedCaseId} [${conflict.resolvedMethod}]`);
599
+ }
600
+ else {
601
+ logger_1.logger.info(` "${conflict.testName}": ${candidateStr}` +
602
+ ` → skipped (conflict error)`);
603
+ }
604
+ }
605
+ }
606
+ // T098 — resolution suggestions for unmatched tests
607
+ if (unmatched > 0) {
608
+ logger_1.logger.info('');
609
+ logger_1.logger.info(`Resolution suggestions for ${unmatched} unmatched test(s):`);
610
+ if (annotationEnabled) {
611
+ logger_1.logger.info(' - Annotation: ensure @TestmoId:<case_id> is on the line immediately above the test function/method');
612
+ }
613
+ if (configMappingEnabled) {
614
+ logger_1.logger.info(' - Config mapping: add an entry to config_mappings:');
615
+ logger_1.logger.info(' test_name: "<test name>"');
616
+ logger_1.logger.info(' case_id: <case_id>');
617
+ }
618
+ if (patternEnabled) {
619
+ logger_1.logger.info(' - Pattern matching: add or refine a pattern_matching rule');
620
+ }
621
+ if (!annotationEnabled && !configMappingEnabled && !patternEnabled) {
622
+ logger_1.logger.info(' - Enable at least one linking method in your config file:');
623
+ logger_1.logger.info(' config_mappings, annotations, or pattern_matching');
624
+ }
625
+ }
626
+ if (options.failOnUnmatched && unmatched > 0) {
627
+ logger_1.logger.error(`Failing: ${unmatched} unmatched test(s) (--fail-on-unmatched)`);
628
+ return 1;
629
+ }
630
+ if (errors > 0) {
631
+ return 1;
632
+ }
633
+ return 0;
634
+ }
635
+ }
636
+ exports.AutomationLink = AutomationLink;