kushi-agents 6.5.0 → 6.6.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.
- package/package.json +1 -1
- package/plugin/runners/discover.mjs +72 -0
- package/plugin/runners/lib/discover-ado.mjs +73 -0
- package/plugin/runners/lib/discover-crm.mjs +60 -0
- package/plugin/runners/lib/fuzzy-match.mjs +95 -0
- package/plugin/runners/test/unit/discover-rest.test.mjs +81 -0
- package/plugin/runners/test/unit/fuzzy-match.test.mjs +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kushi-agents",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.0",
|
|
4
4
|
"description": "Install Kushi — multi-source project evidence agent with Comprehensive Structured Capture (CSC) into weekly-only files across Email, Teams, OneNote, Loop, SharePoint, Meetings, CRM, ADO. Meetings retain a sibling verbatim/ audit folder. WorkIQ-only for M365 sources (Graph / m365_* FORBIDDEN as fallbacks; user-paste is first-class). Host-agnostic.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -21,6 +21,8 @@ import { projectRoot, evidenceRoot, aliasRoot, projectSharedFile, userFile } fro
|
|
|
21
21
|
import { writeAtomic, pathExists } from './lib/evidence.mjs';
|
|
22
22
|
import { ask as workiqAsk, resolveWorkiqBin } from './lib/workiq.mjs';
|
|
23
23
|
import { loadM365Auth, scopeForSource } from './lib/m365-auth.mjs';
|
|
24
|
+
import { discoverCrm } from './lib/discover-crm.mjs';
|
|
25
|
+
import { discoverAdo } from './lib/discover-ado.mjs';
|
|
24
26
|
|
|
25
27
|
const ALL_SOURCES = ['email', 'teams', 'meetings', 'onenote', 'sharepoint', 'crm', 'ado'];
|
|
26
28
|
|
|
@@ -407,6 +409,76 @@ async function main() {
|
|
|
407
409
|
sourceResults.push({ source, asked: false, found: 0, accepted: [], skipped_reason: 'disabled-in-config' });
|
|
408
410
|
continue;
|
|
409
411
|
}
|
|
412
|
+
// CRM and ADO are NEVER WorkIQ — they use mapped REST endpoints + fuzzy match
|
|
413
|
+
// (per fuzzy-disambiguation.instructions.md, v4.4.7+). Handle them inline.
|
|
414
|
+
if (source === 'crm' || source === 'ado') {
|
|
415
|
+
const projectName = path.basename(root);
|
|
416
|
+
const t0r = Date.now();
|
|
417
|
+
log(`[${idx}/${total}] ${source}: REST fuzzy-match against ${source === 'crm' ? 'Dataverse' : 'Azure DevOps'} (mapped settings)`);
|
|
418
|
+
const result = source === 'crm'
|
|
419
|
+
? await discoverCrm(projectName, integ.crm, {})
|
|
420
|
+
: await discoverAdo(projectName, integ.ado, {});
|
|
421
|
+
const elapsed = Date.now() - t0r;
|
|
422
|
+
// Persist raw result so users can inspect candidates that didn't auto-pick.
|
|
423
|
+
if (!args.dryRun) {
|
|
424
|
+
try {
|
|
425
|
+
const discoveryDir = path.join(aliasRoot(args.project, args.alias), '_discovery');
|
|
426
|
+
await fs.mkdir(discoveryDir, { recursive: true });
|
|
427
|
+
const header = `# discover ${source} @ ${new Date().toISOString()}\n# elapsed=${elapsed}ms decision=${result.decision} confidence=${result.confidence || ''} candidates=${result.candidates?.length || 0}${result.error ? ' error=' + result.error : ''}\n`;
|
|
428
|
+
await fs.writeFile(
|
|
429
|
+
path.join(discoveryDir, `${source}-raw.txt`),
|
|
430
|
+
header + JSON.stringify({ picked: result.picked, candidates: result.candidates, error: result.error }, null, 2),
|
|
431
|
+
'utf8',
|
|
432
|
+
);
|
|
433
|
+
} catch { /* best-effort */ }
|
|
434
|
+
}
|
|
435
|
+
let accepted = [];
|
|
436
|
+
let unresolved = null;
|
|
437
|
+
let integrationsPatch = null;
|
|
438
|
+
if (result.decision === 'auto' && result.picked) {
|
|
439
|
+
if (source === 'crm') {
|
|
440
|
+
const cur = integ.crm || {};
|
|
441
|
+
const id = result.picked.id;
|
|
442
|
+
integrationsPatch = { crm: { ...cur, request_id: id } };
|
|
443
|
+
accepted.push(id);
|
|
444
|
+
log(` crm: ✓ resolved → "${result.picked.title}" (${id}) [score=${result.picked.score}, confidence=${result.confidence}]`);
|
|
445
|
+
} else {
|
|
446
|
+
const cur = integ.ado || {};
|
|
447
|
+
const id = result.picked.id;
|
|
448
|
+
integrationsPatch = { ado: { ...cur, engagement_id: id } };
|
|
449
|
+
accepted.push(id);
|
|
450
|
+
log(` ado: ✓ resolved → "${result.picked.title}" (${id}) [score=${result.picked.score}, confidence=${result.confidence}]`);
|
|
451
|
+
}
|
|
452
|
+
} else {
|
|
453
|
+
unresolved = source === 'crm' ? 'crm.request_id' : 'ado.engagement_id';
|
|
454
|
+
if (result.error) {
|
|
455
|
+
log(` ${source}: ✗ ${result.error}`);
|
|
456
|
+
} else if (result.candidates?.length) {
|
|
457
|
+
log(` ${source}: ⚠ ${result.candidates.length} candidate(s) but none confident enough — see _discovery/${source}-raw.txt`);
|
|
458
|
+
for (const c of result.candidates.slice(0, 5)) {
|
|
459
|
+
log(` • ${c.id} · "${c.title}" [score=${c.score}, kind=${c.kind}]`);
|
|
460
|
+
}
|
|
461
|
+
} else {
|
|
462
|
+
log(` ${source}: ⚠ no candidates matched project name`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
if (integrationsPatch) {
|
|
466
|
+
Object.assign(integ, mergeShallow(integ, integrationsPatch));
|
|
467
|
+
integDirty = true;
|
|
468
|
+
}
|
|
469
|
+
sourceResults.push({
|
|
470
|
+
source,
|
|
471
|
+
asked: true,
|
|
472
|
+
found: result.candidates?.length || 0,
|
|
473
|
+
accepted,
|
|
474
|
+
unresolved,
|
|
475
|
+
candidates: result.candidates?.slice(0, 5) || [],
|
|
476
|
+
error: result.error,
|
|
477
|
+
skipped_reason: result.error ? 'rest-error' : (unresolved ? 'no-confident-match' : null),
|
|
478
|
+
});
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
410
482
|
const prompt = buildPrompt(source, path.basename(root), scope);
|
|
411
483
|
let rows = [];
|
|
412
484
|
let asked = true;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// plugin/runners/lib/discover-ado.mjs
|
|
2
|
+
// Direct ADO WIQL fuzzy search for project -> Engagement work item.
|
|
3
|
+
// Replaces WorkIQ for ado discovery — uses mapped org/project from integrations.yml.
|
|
4
|
+
|
|
5
|
+
import { fetchWithRetry } from './http.mjs';
|
|
6
|
+
import { authHeader, SCOPES } from './identity.mjs';
|
|
7
|
+
import { rank, decide } from './fuzzy-match.mjs';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} projectName
|
|
11
|
+
* @param {{organization?:string, project?:string, apiVersion?:string, workItemType?:string}} adoConfig
|
|
12
|
+
* @param {{fetcher?:Function, headersOverride?:object}} [opts]
|
|
13
|
+
*/
|
|
14
|
+
export async function discoverAdo(projectName, adoConfig, opts = {}) {
|
|
15
|
+
const org = adoConfig?.organization;
|
|
16
|
+
const project = adoConfig?.project;
|
|
17
|
+
const apiVersion = adoConfig?.apiVersion || '7.1';
|
|
18
|
+
const witType = adoConfig?.workItemTypes?.engagement || 'Engagement';
|
|
19
|
+
if (!org || !project || /<.*>/.test(org) || /<.*>/.test(project)) {
|
|
20
|
+
return { decision: 'unresolved', candidates: [], error: 'ado organization/project not configured', source: 'ado' };
|
|
21
|
+
}
|
|
22
|
+
const wiql = `SELECT [System.Id],[System.Title],[System.WorkItemType] FROM workitems WHERE [System.TeamProject]='${escapeWiql(project)}' AND [System.WorkItemType]='${escapeWiql(witType)}' AND [System.Title] CONTAINS '${escapeWiql(projectName)}'`;
|
|
23
|
+
const url = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/wit/wiql?api-version=${apiVersion}`;
|
|
24
|
+
let headers;
|
|
25
|
+
try {
|
|
26
|
+
headers = opts.headersOverride || await authHeader(SCOPES.ado, { 'Content-Type': 'application/json' });
|
|
27
|
+
} catch (e) {
|
|
28
|
+
return { decision: 'unresolved', candidates: [], error: `ado auth failed: ${e.message}`, source: 'ado' };
|
|
29
|
+
}
|
|
30
|
+
let res;
|
|
31
|
+
try {
|
|
32
|
+
res = await fetchWithRetry(url, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers,
|
|
35
|
+
body: JSON.stringify({ query: wiql }),
|
|
36
|
+
fetcher: opts.fetcher,
|
|
37
|
+
retries: 2,
|
|
38
|
+
});
|
|
39
|
+
} catch (e) {
|
|
40
|
+
return { decision: 'unresolved', candidates: [], error: `ado wiql fetch failed: ${e.message}`, source: 'ado' };
|
|
41
|
+
}
|
|
42
|
+
if (!res.ok) {
|
|
43
|
+
const body = await safeText(res);
|
|
44
|
+
return { decision: 'unresolved', candidates: [], error: `ado http ${res.status}: ${body.slice(0, 200)}`, source: 'ado' };
|
|
45
|
+
}
|
|
46
|
+
const wiqlData = await res.json().catch(() => ({}));
|
|
47
|
+
const ids = (wiqlData.workItems || []).map(w => w.id).filter(Boolean).slice(0, 50);
|
|
48
|
+
if (!ids.length) return { decision: 'unresolved', candidates: [], source: 'ado' };
|
|
49
|
+
|
|
50
|
+
// Hydrate titles via /workitems?ids=...
|
|
51
|
+
const detailsUrl = `https://dev.azure.com/${encodeURIComponent(org)}/${encodeURIComponent(project)}/_apis/wit/workitems?ids=${ids.join(',')}&fields=System.Id,System.Title,System.WorkItemType,System.State&api-version=${apiVersion}`;
|
|
52
|
+
let dres;
|
|
53
|
+
try {
|
|
54
|
+
dres = await fetchWithRetry(detailsUrl, { headers, fetcher: opts.fetcher, retries: 2 });
|
|
55
|
+
} catch (e) {
|
|
56
|
+
return { decision: 'unresolved', candidates: [], error: `ado workitems fetch failed: ${e.message}`, source: 'ado' };
|
|
57
|
+
}
|
|
58
|
+
if (!dres.ok) {
|
|
59
|
+
const body = await safeText(dres);
|
|
60
|
+
return { decision: 'unresolved', candidates: [], error: `ado workitems http ${dres.status}: ${body.slice(0, 200)}`, source: 'ado' };
|
|
61
|
+
}
|
|
62
|
+
const detData = await dres.json().catch(() => ({}));
|
|
63
|
+
const candidates = (detData.value || []).map(w => ({
|
|
64
|
+
id: String(w.id),
|
|
65
|
+
title: w.fields?.['System.Title'] || '',
|
|
66
|
+
secondary: `${w.fields?.['System.WorkItemType'] || ''} · ${w.fields?.['System.State'] || ''}`,
|
|
67
|
+
})).filter(c => c.title);
|
|
68
|
+
const ranked = rank(projectName, candidates);
|
|
69
|
+
return { ...decide(ranked), source: 'ado' };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function escapeWiql(v) { return String(v).replace(/'/g, "''"); }
|
|
73
|
+
async function safeText(res) { try { return await res.text(); } catch { return ''; } }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// plugin/runners/lib/discover-crm.mjs
|
|
2
|
+
// Direct Dataverse OData fuzzy search for project -> CRM record.
|
|
3
|
+
// Replaces WorkIQ for crm discovery — uses mapped instance from integrations.yml.
|
|
4
|
+
|
|
5
|
+
import { fetchWithRetry, encodeODataOp } from './http.mjs';
|
|
6
|
+
import { authHeader } from './identity.mjs';
|
|
7
|
+
import { SCOPES } from './identity.mjs';
|
|
8
|
+
import { rank, decide } from './fuzzy-match.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {string} projectName
|
|
12
|
+
* @param {{instance?:string, table?:string}} integ.crm
|
|
13
|
+
* @param {{fetcher?:Function, headersOverride?:object}} [opts]
|
|
14
|
+
* @returns {Promise<{decision, picked?, candidates, error?, source:'crm'}>}
|
|
15
|
+
*/
|
|
16
|
+
export async function discoverCrm(projectName, crmConfig, opts = {}) {
|
|
17
|
+
const instance = (crmConfig?.instance || '').replace(/\/$/, '');
|
|
18
|
+
if (!instance || /<.*>/.test(instance)) {
|
|
19
|
+
return { decision: 'unresolved', candidates: [], error: 'crm.instance not configured', source: 'crm' };
|
|
20
|
+
}
|
|
21
|
+
const table = crmConfig?.table || 'incidents';
|
|
22
|
+
const titleField = table === 'incidents' ? 'title' : 'name';
|
|
23
|
+
const filter = `contains(${titleField},'${escapeOData(projectName)}') or contains(description,'${escapeOData(projectName)}')`;
|
|
24
|
+
const select = table === 'incidents'
|
|
25
|
+
? 'incidentid,title,ticketnumber,description'
|
|
26
|
+
: `${table}id,${titleField}`;
|
|
27
|
+
const url = `${instance}/api/data/v9.2/${table}?${encodeODataOp('$filter')}=${encodeURIComponent(filter)}&${encodeODataOp('$select')}=${encodeURIComponent(select)}&${encodeODataOp('$top')}=20`;
|
|
28
|
+
let headers;
|
|
29
|
+
try {
|
|
30
|
+
headers = opts.headersOverride || await authHeader(SCOPES.dataverse(instance), {
|
|
31
|
+
'OData-MaxVersion': '4.0',
|
|
32
|
+
'OData-Version': '4.0',
|
|
33
|
+
Prefer: 'odata.include-annotations="*"',
|
|
34
|
+
});
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { decision: 'unresolved', candidates: [], error: `crm auth failed: ${e.message}`, source: 'crm' };
|
|
37
|
+
}
|
|
38
|
+
let res;
|
|
39
|
+
try {
|
|
40
|
+
res = await fetchWithRetry(url, { headers, fetcher: opts.fetcher, retries: 2 });
|
|
41
|
+
} catch (e) {
|
|
42
|
+
return { decision: 'unresolved', candidates: [], error: `crm fetch failed: ${e.message}`, source: 'crm' };
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const body = await safeText(res);
|
|
46
|
+
return { decision: 'unresolved', candidates: [], error: `crm http ${res.status}: ${body.slice(0, 200)}`, source: 'crm' };
|
|
47
|
+
}
|
|
48
|
+
const data = await res.json().catch(() => ({}));
|
|
49
|
+
const rows = Array.isArray(data.value) ? data.value : [];
|
|
50
|
+
const candidates = rows.map(r => ({
|
|
51
|
+
id: r.ticketnumber || r.incidentid || r[`${table}id`] || '',
|
|
52
|
+
title: r.title || r[titleField] || '',
|
|
53
|
+
secondary: r.ticketnumber ? `incidentid=${r.incidentid}` : '',
|
|
54
|
+
})).filter(c => c.id && c.title);
|
|
55
|
+
const ranked = rank(projectName, candidates);
|
|
56
|
+
return { ...decide(ranked), source: 'crm' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function escapeOData(v) { return String(v).replace(/'/g, "''"); }
|
|
60
|
+
async function safeText(res) { try { return await res.text(); } catch { return ''; } }
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// plugin/runners/lib/fuzzy-match.mjs
|
|
2
|
+
// Universal fuzzy disambiguation per
|
|
3
|
+
// plugin/instructions/fuzzy-disambiguation.instructions.md (v4.4.7+).
|
|
4
|
+
//
|
|
5
|
+
// Score lexicographic order:
|
|
6
|
+
// exact (case-insensitive) ........... 1000
|
|
7
|
+
// prefix (target startsWith query) ... 800 - max(0, target.length - query.length)
|
|
8
|
+
// contains (target includes query) ... 500 - distance-from-start
|
|
9
|
+
// token-overlap (>=1 shared token) ... 300 + matchedTokens * 10
|
|
10
|
+
// levenshtein (<=2 edits) ............ 100 + (3 - distance) * 10
|
|
11
|
+
// else ............................... 0
|
|
12
|
+
//
|
|
13
|
+
// Decision rule (top 5):
|
|
14
|
+
// top >= 800 AND #2 <= top * 0.7 -> auto-pick (confidence: 'high')
|
|
15
|
+
// top >= 300 AND #2 <= top * 0.7 -> auto-pick warn (confidence: 'medium')
|
|
16
|
+
// else -> unresolved + return ranked candidates
|
|
17
|
+
|
|
18
|
+
const norm = (s) => String(s ?? '').trim().toLowerCase();
|
|
19
|
+
|
|
20
|
+
function levenshtein(a, b) {
|
|
21
|
+
if (a === b) return 0;
|
|
22
|
+
const m = a.length, n = b.length;
|
|
23
|
+
if (!m) return n;
|
|
24
|
+
if (!n) return m;
|
|
25
|
+
if (Math.abs(m - n) > 2) return Infinity; // we only care about <=2
|
|
26
|
+
const prev = new Array(n + 1);
|
|
27
|
+
const curr = new Array(n + 1);
|
|
28
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
29
|
+
for (let i = 1; i <= m; i++) {
|
|
30
|
+
curr[0] = i;
|
|
31
|
+
let row = i;
|
|
32
|
+
for (let j = 1; j <= n; j++) {
|
|
33
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
34
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
35
|
+
if (curr[j] < row) row = curr[j];
|
|
36
|
+
}
|
|
37
|
+
if (row > 2) return Infinity; // early-exit
|
|
38
|
+
for (let j = 0; j <= n; j++) prev[j] = curr[j];
|
|
39
|
+
}
|
|
40
|
+
return prev[n];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Score a single candidate name against a query.
|
|
45
|
+
* @param {string} query
|
|
46
|
+
* @param {string} target
|
|
47
|
+
* @returns {{score:number, kind:string}}
|
|
48
|
+
*/
|
|
49
|
+
export function scoreOne(query, target) {
|
|
50
|
+
const q = norm(query);
|
|
51
|
+
const t = norm(target);
|
|
52
|
+
if (!q || !t) return { score: 0, kind: 'empty' };
|
|
53
|
+
if (q === t) return { score: 1000, kind: 'exact' };
|
|
54
|
+
if (t.startsWith(q)) return { score: 800 - Math.max(0, t.length - q.length), kind: 'prefix' };
|
|
55
|
+
const idx = t.indexOf(q);
|
|
56
|
+
if (idx >= 0) return { score: 500 - idx, kind: 'contains' };
|
|
57
|
+
const qTokens = q.split(/\s+/).filter(Boolean);
|
|
58
|
+
const tTokens = t.split(/\s+/).filter(Boolean);
|
|
59
|
+
const shared = qTokens.filter(x => tTokens.includes(x)).length;
|
|
60
|
+
if (shared >= 1) return { score: 300 + shared * 10, kind: 'token-overlap' };
|
|
61
|
+
const d = levenshtein(q, t);
|
|
62
|
+
if (d <= 2) return { score: 100 + (3 - d) * 10, kind: `levenshtein-${d}` };
|
|
63
|
+
return { score: 0, kind: 'none' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Score and rank an array of candidates.
|
|
68
|
+
* @param {string} query
|
|
69
|
+
* @param {Array<{id:string, title:string, [k:string]:any}>} candidates
|
|
70
|
+
* @returns {Array} sorted desc by score (input shape preserved + score + kind)
|
|
71
|
+
*/
|
|
72
|
+
export function rank(query, candidates) {
|
|
73
|
+
const out = [];
|
|
74
|
+
for (const c of candidates) {
|
|
75
|
+
const { score, kind } = scoreOne(query, c.title || c.name || '');
|
|
76
|
+
if (score > 0) out.push({ ...c, score, kind });
|
|
77
|
+
}
|
|
78
|
+
out.sort((a, b) => b.score - a.score);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Apply the v4.4.7 decision rule.
|
|
84
|
+
* @returns {{decision:'auto'|'unresolved', confidence?:string, picked?:object, candidates:Array}}
|
|
85
|
+
*/
|
|
86
|
+
export function decide(ranked) {
|
|
87
|
+
const top5 = ranked.slice(0, 5);
|
|
88
|
+
if (!top5.length) return { decision: 'unresolved', candidates: [] };
|
|
89
|
+
const top = top5[0];
|
|
90
|
+
const second = top5[1];
|
|
91
|
+
const gapOk = !second || second.score <= top.score * 0.7;
|
|
92
|
+
if (top.score >= 800 && gapOk) return { decision: 'auto', confidence: 'high', picked: top, candidates: top5 };
|
|
93
|
+
if (top.score >= 300 && gapOk) return { decision: 'auto', confidence: 'medium', picked: top, candidates: top5 };
|
|
94
|
+
return { decision: 'unresolved', candidates: top5 };
|
|
95
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// plugin/runners/test/unit/discover-rest.test.mjs
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { discoverCrm } from '../../lib/discover-crm.mjs';
|
|
5
|
+
import { discoverAdo } from '../../lib/discover-ado.mjs';
|
|
6
|
+
|
|
7
|
+
function mockResponse({ status = 200, json = {} } = {}) {
|
|
8
|
+
return {
|
|
9
|
+
ok: status >= 200 && status < 300,
|
|
10
|
+
status,
|
|
11
|
+
statusText: 'mock',
|
|
12
|
+
headers: { get: () => null },
|
|
13
|
+
json: async () => json,
|
|
14
|
+
text: async () => JSON.stringify(json),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('discoverCrm: returns unresolved when instance not configured', async () => {
|
|
19
|
+
const r = await discoverCrm('HCA', { instance: '<__FILL_ME_IN__>' });
|
|
20
|
+
assert.equal(r.decision, 'unresolved');
|
|
21
|
+
assert.match(r.error, /not configured/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test('discoverCrm: auto-picks high-confidence match', async () => {
|
|
25
|
+
const fetcher = async () => mockResponse({
|
|
26
|
+
json: { value: [
|
|
27
|
+
{ incidentid: 'g-1', title: 'HCA Agentic Engineering', ticketnumber: 'FE-2026-001458' },
|
|
28
|
+
{ incidentid: 'g-2', title: 'unrelated', ticketnumber: 'FE-2025-000001' },
|
|
29
|
+
] },
|
|
30
|
+
});
|
|
31
|
+
const r = await discoverCrm('HCA Agentic Engineering', { instance: 'https://iscrm.crm.dynamics.com' }, {
|
|
32
|
+
fetcher, headersOverride: { Authorization: 'Bearer fake' },
|
|
33
|
+
});
|
|
34
|
+
assert.equal(r.decision, 'auto');
|
|
35
|
+
assert.equal(r.picked.id, 'FE-2026-001458');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('discoverCrm: returns unresolved on 401', async () => {
|
|
39
|
+
const fetcher = async () => mockResponse({ status: 401, json: { error: 'unauth' } });
|
|
40
|
+
const r = await discoverCrm('HCA', { instance: 'https://iscrm.crm.dynamics.com' }, {
|
|
41
|
+
fetcher, headersOverride: { Authorization: 'Bearer fake' },
|
|
42
|
+
});
|
|
43
|
+
assert.equal(r.decision, 'unresolved');
|
|
44
|
+
assert.match(r.error, /401/i);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('discoverAdo: returns unresolved when org not configured', async () => {
|
|
48
|
+
const r = await discoverAdo('HCA', { organization: '<x>', project: 'IS Engagements' });
|
|
49
|
+
assert.equal(r.decision, 'unresolved');
|
|
50
|
+
assert.match(r.error, /not configured/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('discoverAdo: auto-picks from WIQL + workitems hydrate', async () => {
|
|
54
|
+
let call = 0;
|
|
55
|
+
const fetcher = async (url) => {
|
|
56
|
+
call++;
|
|
57
|
+
if (call === 1) {
|
|
58
|
+
// WIQL POST -> ids only
|
|
59
|
+
return mockResponse({ json: { workItems: [{ id: 12345 }, { id: 67890 }] } });
|
|
60
|
+
}
|
|
61
|
+
// hydrate
|
|
62
|
+
return mockResponse({ json: { value: [
|
|
63
|
+
{ id: 12345, fields: { 'System.Title': 'HCA Engagement', 'System.WorkItemType': 'Engagement', 'System.State': 'Active' } },
|
|
64
|
+
{ id: 67890, fields: { 'System.Title': 'Different Thing', 'System.WorkItemType': 'Engagement', 'System.State': 'Closed' } },
|
|
65
|
+
] } });
|
|
66
|
+
};
|
|
67
|
+
const r = await discoverAdo('HCA', {
|
|
68
|
+
organization: 'IndustrySolutions', project: 'IS Engagements', apiVersion: '7.1',
|
|
69
|
+
}, { fetcher, headersOverride: { Authorization: 'Bearer fake', 'Content-Type': 'application/json' } });
|
|
70
|
+
assert.equal(r.decision, 'auto');
|
|
71
|
+
assert.equal(r.picked.id, '12345');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('discoverAdo: empty WIQL result -> unresolved', async () => {
|
|
75
|
+
const fetcher = async () => mockResponse({ json: { workItems: [] } });
|
|
76
|
+
const r = await discoverAdo('HCA', {
|
|
77
|
+
organization: 'IndustrySolutions', project: 'IS Engagements',
|
|
78
|
+
}, { fetcher, headersOverride: { Authorization: 'Bearer fake', 'Content-Type': 'application/json' } });
|
|
79
|
+
assert.equal(r.decision, 'unresolved');
|
|
80
|
+
assert.equal(r.candidates.length, 0);
|
|
81
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// plugin/runners/test/unit/fuzzy-match.test.mjs
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { scoreOne, rank, decide } from '../../lib/fuzzy-match.mjs';
|
|
5
|
+
|
|
6
|
+
test('scoreOne: exact match scores 1000', () => {
|
|
7
|
+
assert.equal(scoreOne('HCA', 'hca').score, 1000);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test('scoreOne: prefix beats contains', () => {
|
|
11
|
+
const p = scoreOne('HCA', 'HCA Agentic Engineering').score;
|
|
12
|
+
const c = scoreOne('Agentic', 'HCA Agentic Engineering').score;
|
|
13
|
+
assert.ok(p > c, `prefix=${p} should be > contains=${c}`);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('scoreOne: contains scores below prefix but above token-overlap', () => {
|
|
17
|
+
assert.ok(scoreOne('foo', 'bar foo baz').score >= 300);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('scoreOne: token-overlap when no substring match', () => {
|
|
21
|
+
const r = scoreOne('northwind acme', 'acme corp engagement');
|
|
22
|
+
assert.ok(r.score >= 300 && r.score < 500, `score=${r.score} kind=${r.kind}`);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('scoreOne: levenshtein within 2 edits', () => {
|
|
26
|
+
const r = scoreOne('hca', 'hsa');
|
|
27
|
+
assert.ok(r.score >= 100 && r.score <= 130, `score=${r.score}`);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('scoreOne: zero on no relation', () => {
|
|
31
|
+
assert.equal(scoreOne('aaaa', 'zzzzzzzz').score, 0);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('rank: drops zero scores and sorts desc', () => {
|
|
35
|
+
const out = rank('HCA', [
|
|
36
|
+
{ id: '1', title: 'unrelated thing' },
|
|
37
|
+
{ id: '2', title: 'HCA Agentic Engineering' },
|
|
38
|
+
{ id: '3', title: 'hca' },
|
|
39
|
+
]);
|
|
40
|
+
assert.equal(out.length, 2);
|
|
41
|
+
assert.equal(out[0].id, '3'); // exact
|
|
42
|
+
assert.equal(out[1].id, '2'); // prefix
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('decide: auto-pick high when top exact and gap big', () => {
|
|
46
|
+
const ranked = rank('HCA', [
|
|
47
|
+
{ id: 'A', title: 'hca' },
|
|
48
|
+
{ id: 'B', title: 'something else' },
|
|
49
|
+
]);
|
|
50
|
+
const d = decide(ranked);
|
|
51
|
+
assert.equal(d.decision, 'auto');
|
|
52
|
+
assert.equal(d.confidence, 'high');
|
|
53
|
+
assert.equal(d.picked.id, 'A');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('decide: unresolved when two strong candidates close', () => {
|
|
57
|
+
const ranked = rank('HCA', [
|
|
58
|
+
{ id: 'A', title: 'HCA West' },
|
|
59
|
+
{ id: 'B', title: 'HCA East' },
|
|
60
|
+
]);
|
|
61
|
+
const d = decide(ranked);
|
|
62
|
+
// both are prefix-scored equally (or close) -> gap fails -> unresolved
|
|
63
|
+
assert.equal(d.decision, 'unresolved');
|
|
64
|
+
assert.equal(d.candidates.length, 2);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('decide: unresolved when no candidates', () => {
|
|
68
|
+
const d = decide([]);
|
|
69
|
+
assert.equal(d.decision, 'unresolved');
|
|
70
|
+
assert.equal(d.candidates.length, 0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('decide: medium-confidence auto for token-overlap with clear gap', () => {
|
|
74
|
+
const ranked = rank('northwind project', [
|
|
75
|
+
{ id: 'A', title: 'northwind delivery' }, // token-overlap on northwind
|
|
76
|
+
{ id: 'B', title: 'totally different thing' }, // 0
|
|
77
|
+
]);
|
|
78
|
+
const d = decide(ranked);
|
|
79
|
+
assert.equal(d.decision, 'auto');
|
|
80
|
+
assert.equal(d.confidence, 'medium');
|
|
81
|
+
assert.equal(d.picked.id, 'A');
|
|
82
|
+
});
|