kushi-agents 6.2.0 → 6.3.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/pull-email.mjs +3 -194
- package/plugin/runners/pull-meetings.mjs +4 -207
- package/plugin/runners/pull-onenote.mjs +3 -239
- package/plugin/runners/pull-sharepoint.mjs +3 -284
- package/plugin/runners/pull-teams.mjs +3 -170
- package/plugin/runners/test/fixtures/refresh-dir/email.json +4 -7
- package/plugin/runners/test/fixtures/refresh-dir/teams.json +4 -6
- package/plugin/runners/test/integration/csc-pull.integration.test.mjs +160 -0
- package/plugin/runners/test/fixtures/email-abn-amro.json +0 -13
- package/plugin/runners/test/fixtures/email-novel-error.json +0 -9
- package/plugin/runners/test/fixtures/meetings-abn-amro.json +0 -10
- package/plugin/runners/test/fixtures/meetings-body-unavailable.json +0 -10
- package/plugin/runners/test/fixtures/onenote-abn-amro.json +0 -30
- package/plugin/runners/test/fixtures/onenote-partial.json +0 -21
- package/plugin/runners/test/fixtures/sharepoint-abn-amro.json +0 -12
- package/plugin/runners/test/fixtures/teams-abn-amro.json +0 -11
- package/plugin/runners/test/integration/pull-email.integration.test.mjs +0 -149
- package/plugin/runners/test/integration/pull-meetings.integration.test.mjs +0 -92
- package/plugin/runners/test/integration/pull-onenote.integration.test.mjs +0 -86
- package/plugin/runners/test/integration/pull-sharepoint.integration.test.mjs +0 -93
- package/plugin/runners/test/integration/pull-teams.integration.test.mjs +0 -91
|
@@ -1,288 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// plugin/runners/pull-sharepoint.mjs
|
|
3
|
-
// Deterministic
|
|
4
|
-
// - Cross-tenant filter via isAllowedTenant (#29)
|
|
5
|
-
// - One file per item (#28)
|
|
6
|
-
// - Depth-1+ traversal: drive-wide search PLUS root + each top-level folder children
|
|
7
|
-
// - External-link extraction: harvests http(s) URLs from item descriptions/webUrls
|
|
8
|
-
// Writes to Evidence/_shared/sharepoint/<site-hash>/<week>/items/<safe-id>.yml
|
|
9
|
-
// + Evidence/_shared/sharepoint/<site-hash>/<week>/index.md
|
|
10
|
-
// + Evidence/_shared/sharepoint/<site-hash>/<week>/external-links.md
|
|
3
|
+
// Deterministic sharepoint pull via WorkIQ (HARD RULE per workiq-only.instructions.md).
|
|
11
4
|
|
|
12
|
-
import
|
|
13
|
-
import { promises as fs } from 'node:fs';
|
|
14
|
-
import YAML from 'yaml';
|
|
15
|
-
import { assertProject, loadConfig } from './lib/config.mjs';
|
|
16
|
-
import { sourceDir } from './lib/layout.mjs';
|
|
17
|
-
import { writeAtomic, safeSegment } from './lib/evidence.mjs';
|
|
18
|
-
import { fetchAllPages, isAllowedTenant } from './lib/http.mjs';
|
|
19
|
-
import { getToken, SCOPES } from './lib/identity.mjs';
|
|
20
|
-
import { updateCell } from './lib/ledger.mjs';
|
|
21
|
-
import { appendRunLog } from './lib/runlog.mjs';
|
|
22
|
-
import { enqueue, clear } from './lib/deferred.mjs';
|
|
23
|
-
import { shortHash } from './lib/dedup.mjs';
|
|
24
|
-
import { currentIsoMonday, ymd, parseYmd } from './lib/weeks.mjs';
|
|
25
|
-
import { emitLearningCandidate } from './lib/learnings.mjs';
|
|
5
|
+
import { runCli } from './lib/csc-pull.mjs';
|
|
26
6
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
function parseArgs(argv) {
|
|
30
|
-
const args = { dryRun: false, force: false };
|
|
31
|
-
for (let i = 0; i < argv.length; i++) {
|
|
32
|
-
const a = argv[i];
|
|
33
|
-
if (a === '--project') args.project = argv[++i];
|
|
34
|
-
else if (a === '--alias') args.alias = argv[++i];
|
|
35
|
-
else if (a === '--entity') args.entity = argv[++i];
|
|
36
|
-
else if (a === '--allowed-tenants') args.allowedTenants = argv[++i];
|
|
37
|
-
else if (a === '--week') args.week = argv[++i];
|
|
38
|
-
else if (a === '--dry-run') args.dryRun = true;
|
|
39
|
-
else if (a === '--force') args.force = true;
|
|
40
|
-
else if (a === '--fixture') args.fixture = argv[++i];
|
|
41
|
-
else if (a === '--help' || a === '-h') args.help = true;
|
|
42
|
-
}
|
|
43
|
-
return args;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function help() {
|
|
47
|
-
return `Usage: node pull-sharepoint.mjs --project <P> --alias <A> --entity <site-url>
|
|
48
|
-
[--allowed-tenants <comma,list>] [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
|
52
|
-
function configError(msg) { const e = new Error(msg); e.exitCode = 2; return e; }
|
|
53
|
-
function authError(msg) { const e = new Error(msg); e.exitCode = 3; return e; }
|
|
54
|
-
|
|
55
|
-
async function buildClient({ fixture }) {
|
|
56
|
-
if (fixture) {
|
|
57
|
-
const data = JSON.parse(await fs.readFile(fixture, 'utf8'));
|
|
58
|
-
return makeFixtureClient(data);
|
|
59
|
-
}
|
|
60
|
-
const token = await getToken(SCOPES.graph).catch(e => { throw authError(`token fetch failed: ${e.message}`); });
|
|
61
|
-
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
|
|
62
|
-
return {
|
|
63
|
-
async listSiteItems(siteUrl, fromIso, toIso) {
|
|
64
|
-
const u = new URL(siteUrl);
|
|
65
|
-
const sitePath = `${u.hostname}:${u.pathname}`;
|
|
66
|
-
const siteRes = await (await fetch(`https://graph.microsoft.com/v1.0/sites/${encodeURIComponent(sitePath)}`, { headers })).json();
|
|
67
|
-
if (!siteRes.id) return [];
|
|
68
|
-
|
|
69
|
-
const drivesRes = await (await fetch(`https://graph.microsoft.com/v1.0/sites/${siteRes.id}/drives?$top=50`, { headers })).json();
|
|
70
|
-
const drives = drivesRes.value || [];
|
|
71
|
-
|
|
72
|
-
const inWindow = (m) => m && m >= fromIso && m < toIso;
|
|
73
|
-
const collected = new Map();
|
|
74
|
-
|
|
75
|
-
for (const drv of drives) {
|
|
76
|
-
try {
|
|
77
|
-
const searchUrl = `https://graph.microsoft.com/v1.0/drives/${drv.id}/root/search(q='''')?$top=200`;
|
|
78
|
-
const { items } = await fetchAllPages(searchUrl, { headers });
|
|
79
|
-
for (const it of items) {
|
|
80
|
-
if (inWindow(it.lastModifiedDateTime)) {
|
|
81
|
-
it.__drive_id = drv.id; it.__drive_name = drv.name;
|
|
82
|
-
collected.set(it.id, it);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
} catch (_) { /* fall through to depth-1 walk */ }
|
|
86
|
-
|
|
87
|
-
try {
|
|
88
|
-
const rootUrl = `https://graph.microsoft.com/v1.0/drives/${drv.id}/root/children?$top=200`;
|
|
89
|
-
const { items: rootChildren } = await fetchAllPages(rootUrl, { headers });
|
|
90
|
-
for (const child of rootChildren) {
|
|
91
|
-
child.__drive_id = drv.id; child.__drive_name = drv.name;
|
|
92
|
-
if (inWindow(child.lastModifiedDateTime)) collected.set(child.id, child);
|
|
93
|
-
if (child.folder) {
|
|
94
|
-
try {
|
|
95
|
-
const subUrl = `https://graph.microsoft.com/v1.0/drives/${drv.id}/items/${child.id}/children?$top=200`;
|
|
96
|
-
const { items: subItems } = await fetchAllPages(subUrl, { headers });
|
|
97
|
-
for (const it of subItems) {
|
|
98
|
-
it.__drive_id = drv.id; it.__drive_name = drv.name;
|
|
99
|
-
it.__parent_folder = child.name;
|
|
100
|
-
if (inWindow(it.lastModifiedDateTime)) collected.set(it.id, it);
|
|
101
|
-
}
|
|
102
|
-
} catch (_) { /* skip subfolder on error */ }
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
} catch (_) { /* drive may be empty/unreachable */ }
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
return [...collected.values()];
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function makeFixtureClient(data) {
|
|
114
|
-
return {
|
|
115
|
-
async listSiteItems(siteUrl, fromIso, toIso) {
|
|
116
|
-
const items = (data.itemsBySite && data.itemsBySite[siteUrl]) || [];
|
|
117
|
-
return items.filter(i => i.lastModifiedDateTime >= fromIso && i.lastModifiedDateTime < toIso);
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function weekBounds(weekStartYmd) {
|
|
123
|
-
const start = parseYmd(weekStartYmd);
|
|
124
|
-
const end = new Date(start);
|
|
125
|
-
end.setDate(end.getDate() + 7);
|
|
126
|
-
return { fromIso: start.toISOString(), toIso: end.toISOString() };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const URL_RE = /https?:\/\/[^\s<>"`)\]]+/g;
|
|
130
|
-
|
|
131
|
-
function extractLinks(item) {
|
|
132
|
-
const blobs = [
|
|
133
|
-
item.webUrl, item.description, item.name,
|
|
134
|
-
item.parentReference && item.parentReference.path,
|
|
135
|
-
].filter(Boolean).join('\n');
|
|
136
|
-
const set = new Set();
|
|
137
|
-
for (const m of blobs.matchAll(URL_RE)) {
|
|
138
|
-
let u = m[0].replace(/[).,;]+$/, '');
|
|
139
|
-
set.add(u);
|
|
140
|
-
}
|
|
141
|
-
return [...set];
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function classifyExternal(siteUrl, link) {
|
|
145
|
-
try {
|
|
146
|
-
const a = new URL(siteUrl);
|
|
147
|
-
const b = new URL(link);
|
|
148
|
-
return b.hostname.toLowerCase() !== a.hostname.toLowerCase();
|
|
149
|
-
} catch { return false; }
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
async function main() {
|
|
153
|
-
const args = parseArgs(process.argv.slice(2));
|
|
154
|
-
if (args.help) { console.log(help()); return 0; }
|
|
155
|
-
if (!args.project || !args.alias || !args.entity) {
|
|
156
|
-
console.error(help());
|
|
157
|
-
emit({ source: SOURCE, status: 'failed', errors: [{ signature: 'bad-args', message: 'required: --project --alias --entity' }] });
|
|
158
|
-
return 2;
|
|
159
|
-
}
|
|
160
|
-
const projectRoot = await assertProject(args.project).catch(e => { throw configError(e.message); });
|
|
161
|
-
const cfg = await loadConfig(projectRoot, args.alias);
|
|
162
|
-
const weekStart = args.week || ymd(currentIsoMonday());
|
|
163
|
-
const { fromIso, toIso } = weekBounds(weekStart);
|
|
164
|
-
|
|
165
|
-
const tenantList = (args.allowedTenants || (cfg.merged && cfg.merged.sharepoint && cfg.merged.sharepoint.allowed_tenants && cfg.merged.sharepoint.allowed_tenants.join(',')) || '')
|
|
166
|
-
.split(',').map(s => s.trim()).filter(Boolean);
|
|
167
|
-
|
|
168
|
-
if (tenantList.length > 0 && !isAllowedTenant(args.entity, tenantList)) {
|
|
169
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
|
|
170
|
-
last_status: 'failed',
|
|
171
|
-
last_error: `site tenant not in allowed list: ${tenantList.join(',')}`,
|
|
172
|
-
});
|
|
173
|
-
emit({
|
|
174
|
-
source: SOURCE, entity: args.entity, week: weekStart, status: 'failed',
|
|
175
|
-
errors: [{ signature: 'cross-tenant-blocked', message: 'site host not allowed', allowed_tenants: tenantList }],
|
|
176
|
-
});
|
|
177
|
-
return 0;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
const client = await buildClient({ fixture: args.fixture });
|
|
181
|
-
const startedAt = new Date().toISOString();
|
|
182
|
-
|
|
183
|
-
let items;
|
|
184
|
-
try { items = await client.listSiteItems(args.entity, fromIso, toIso); }
|
|
185
|
-
catch (e) {
|
|
186
|
-
const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
|
|
187
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
|
|
188
|
-
if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
|
|
189
|
-
if (!retryable && !args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'fetch-failed', message: e.message, status: e.status }, context: { runner: 'pull-sharepoint' } });
|
|
190
|
-
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
|
|
191
|
-
return retryable ? 1 : 0;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const siteHash = shortHash(args.entity);
|
|
195
|
-
|
|
196
|
-
if (items.length === 0) {
|
|
197
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0, site_hash: siteHash });
|
|
198
|
-
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
|
|
199
|
-
return 0;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
const outDir = path.join(sourceDir(projectRoot, args.alias, SOURCE), siteHash, weekStart);
|
|
203
|
-
const filesWritten = [];
|
|
204
|
-
const linkAgg = new Map();
|
|
205
|
-
|
|
206
|
-
if (!args.dryRun) {
|
|
207
|
-
for (const it of items) {
|
|
208
|
-
const id = it.id || shortHash(it.webUrl || it.name || JSON.stringify(it));
|
|
209
|
-
const links = extractLinks(it);
|
|
210
|
-
const itemRecord = { ...it, _links: links };
|
|
211
|
-
const r = await writeAtomic(path.join(outDir, 'items', `${safeSegment(id)}.yml`), YAML.stringify(itemRecord));
|
|
212
|
-
if (r.written !== false) filesWritten.push(path.relative(projectRoot, r.path));
|
|
213
|
-
for (const lk of links) {
|
|
214
|
-
if (!classifyExternal(args.entity, lk)) continue;
|
|
215
|
-
if (!linkAgg.has(lk)) linkAgg.set(lk, new Set());
|
|
216
|
-
linkAgg.get(lk).add(it.name || it.id);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
const r2 = await writeAtomic(path.join(outDir, 'index.md'), renderIndexMd({ siteUrl: args.entity, items, weekStart, pulledAt: startedAt }), { skipIfUnchanged: false });
|
|
220
|
-
if (r2.written !== false) filesWritten.push(path.relative(projectRoot, r2.path));
|
|
221
|
-
|
|
222
|
-
if (linkAgg.size > 0) {
|
|
223
|
-
const r3 = await writeAtomic(path.join(outDir, 'external-links.md'), renderLinksMd({ siteUrl: args.entity, linkAgg, weekStart }), { skipIfUnchanged: false });
|
|
224
|
-
if (r3.written !== false) filesWritten.push(path.relative(projectRoot, r3.path));
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
await clear(projectRoot, args.alias, SOURCE, args.entity).catch(() => {});
|
|
229
|
-
|
|
230
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
|
|
231
|
-
last_status: 'captured',
|
|
232
|
-
items_pulled: items.length,
|
|
233
|
-
site_hash: siteHash,
|
|
234
|
-
external_links: linkAgg.size,
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
if (!args.dryRun) {
|
|
238
|
-
await appendRunLog(projectRoot, { runner: 'pull-sharepoint', alias: args.alias, entity: args.entity, week: weekStart, status: 'captured', items_pulled: items.length, external_links: linkAgg.size });
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
emit({
|
|
242
|
-
source: SOURCE, entity: args.entity, week: weekStart, status: 'captured',
|
|
243
|
-
items_pulled: items.length, site_hash: siteHash, external_links: linkAgg.size,
|
|
244
|
-
files_written: filesWritten, ledger_key: `sharepoint::${args.entity}::${weekStart}`,
|
|
245
|
-
});
|
|
246
|
-
return 0;
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function renderIndexMd({ siteUrl, items, weekStart, pulledAt }) {
|
|
250
|
-
const lines = [
|
|
251
|
-
`# SharePoint - ${siteUrl} - week ${weekStart}`,
|
|
252
|
-
'',
|
|
253
|
-
`- site_url: ${siteUrl}`,
|
|
254
|
-
`- week_start: ${weekStart}`,
|
|
255
|
-
`- items: ${items.length}`,
|
|
256
|
-
`- pulled_at: ${pulledAt}`,
|
|
257
|
-
'',
|
|
258
|
-
'## Items',
|
|
259
|
-
];
|
|
260
|
-
for (const it of items) {
|
|
261
|
-
const drv = it.__drive_name ? ` _[${it.__drive_name}${it.__parent_folder ? '/' + it.__parent_folder : ''}]_` : '';
|
|
262
|
-
lines.push(`- [${it.lastModifiedDateTime}] **${it.name || it.id}**${drv} - ${it.webUrl || ''}`);
|
|
263
|
-
}
|
|
264
|
-
lines.push('');
|
|
265
|
-
return lines.join('\n');
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function renderLinksMd({ siteUrl, linkAgg, weekStart }) {
|
|
269
|
-
const lines = [
|
|
270
|
-
`# SharePoint external links - ${siteUrl} - week ${weekStart}`,
|
|
271
|
-
'',
|
|
272
|
-
`- site_url: ${siteUrl}`,
|
|
273
|
-
`- week_start: ${weekStart}`,
|
|
274
|
-
`- links: ${linkAgg.size}`,
|
|
275
|
-
'',
|
|
276
|
-
'## External links',
|
|
277
|
-
];
|
|
278
|
-
for (const [link, sources] of [...linkAgg.entries()].sort()) {
|
|
279
|
-
lines.push(`- ${link} - referenced by: ${[...sources].join(', ')}`);
|
|
280
|
-
}
|
|
281
|
-
lines.push('');
|
|
282
|
-
return lines.join('\n');
|
|
283
|
-
}
|
|
284
|
-
|
|
285
|
-
main().then(code => { process.exitCode = code; }).catch(e => {
|
|
286
|
-
emit({ source: SOURCE, status: 'failed', errors: [{ message: e.message, code: e.exitCode || 'unknown' }] });
|
|
287
|
-
process.exitCode = e.exitCode || 1;
|
|
288
|
-
});
|
|
7
|
+
runCli('sharepoint').then(code => { process.exitCode = code; });
|
|
@@ -1,174 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// plugin/runners/pull-teams.mjs
|
|
3
|
-
// Deterministic
|
|
4
|
-
// Writes to Evidence/<alias>/teams/<chat-hash>/<week>/.
|
|
5
|
-
//
|
|
6
|
-
// Usage:
|
|
7
|
-
// node plugin/runners/pull-teams.mjs --project <P> --alias <A> --entity <chat-id>
|
|
8
|
-
// [--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]
|
|
3
|
+
// Deterministic teams pull via WorkIQ (HARD RULE per workiq-only.instructions.md).
|
|
9
4
|
|
|
10
|
-
import
|
|
11
|
-
import { promises as fs } from 'node:fs';
|
|
12
|
-
import YAML from 'yaml';
|
|
13
|
-
import { assertProject, loadConfig } from './lib/config.mjs';
|
|
14
|
-
import { sourceDir } from './lib/layout.mjs';
|
|
15
|
-
import { writeAtomic } from './lib/evidence.mjs';
|
|
16
|
-
import { fetchAllPages, encodeODataOp } from './lib/http.mjs';
|
|
17
|
-
import { getToken, SCOPES } from './lib/identity.mjs';
|
|
18
|
-
import { updateCell } from './lib/ledger.mjs';
|
|
19
|
-
import { appendRunLog } from './lib/runlog.mjs';
|
|
20
|
-
import { enqueue, clear } from './lib/deferred.mjs';
|
|
21
|
-
import { shortHash } from './lib/dedup.mjs';
|
|
22
|
-
import { currentIsoMonday, ymd, parseYmd } from './lib/weeks.mjs';
|
|
23
|
-
import { emitLearningCandidate } from './lib/learnings.mjs';
|
|
5
|
+
import { runCli } from './lib/csc-pull.mjs';
|
|
24
6
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
function parseArgs(argv) {
|
|
28
|
-
const args = { dryRun: false, force: false };
|
|
29
|
-
for (let i = 0; i < argv.length; i++) {
|
|
30
|
-
const a = argv[i];
|
|
31
|
-
if (a === '--project') args.project = argv[++i];
|
|
32
|
-
else if (a === '--alias') args.alias = argv[++i];
|
|
33
|
-
else if (a === '--entity') args.entity = argv[++i];
|
|
34
|
-
else if (a === '--week') args.week = argv[++i];
|
|
35
|
-
else if (a === '--dry-run') args.dryRun = true;
|
|
36
|
-
else if (a === '--force') args.force = true;
|
|
37
|
-
else if (a === '--fixture') args.fixture = argv[++i];
|
|
38
|
-
else if (a === '--help' || a === '-h') args.help = true;
|
|
39
|
-
}
|
|
40
|
-
return args;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function help() {
|
|
44
|
-
return `Usage: node pull-teams.mjs --project <P> --alias <A> --entity <chat-id>
|
|
45
|
-
[--week YYYY-MM-DD] [--dry-run] [--force] [--fixture <path>]`;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function emit(obj) { process.stdout.write(JSON.stringify(obj) + '\n'); }
|
|
49
|
-
function configError(msg) { const e = new Error(msg); e.exitCode = 2; return e; }
|
|
50
|
-
function authError(msg) { const e = new Error(msg); e.exitCode = 3; return e; }
|
|
51
|
-
|
|
52
|
-
async function buildClient({ fixture }) {
|
|
53
|
-
if (fixture) {
|
|
54
|
-
const data = JSON.parse(await fs.readFile(fixture, 'utf8'));
|
|
55
|
-
return makeFixtureClient(data);
|
|
56
|
-
}
|
|
57
|
-
const token = await getToken(SCOPES.graph).catch(e => { throw authError(`token fetch failed: ${e.message}`); });
|
|
58
|
-
const headers = { Authorization: `Bearer ${token}`, Accept: 'application/json' };
|
|
59
|
-
return {
|
|
60
|
-
async listMessages(chatId, fromIso, toIso) {
|
|
61
|
-
// Graph chats messages: server filters createdDateTime; some tenants ignore. We re-filter locally.
|
|
62
|
-
const filter = `createdDateTime ge ${fromIso} and createdDateTime lt ${toIso}`;
|
|
63
|
-
const url = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(chatId)}/messages?${encodeODataOp('$filter')}=${encodeURIComponent(filter)}&${encodeODataOp('$orderby')}=createdDateTime asc&${encodeODataOp('$top')}=50`;
|
|
64
|
-
const { items } = await fetchAllPages(url, { headers });
|
|
65
|
-
return items.filter(m => m.createdDateTime >= fromIso && m.createdDateTime < toIso);
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function makeFixtureClient(data) {
|
|
71
|
-
return {
|
|
72
|
-
async listMessages(chatId, fromIso, toIso) {
|
|
73
|
-
const all = (data.messagesByChat && data.messagesByChat[chatId]) || [];
|
|
74
|
-
return all.filter(m => m.createdDateTime >= fromIso && m.createdDateTime < toIso);
|
|
75
|
-
},
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function weekBounds(weekStartYmd) {
|
|
80
|
-
const start = parseYmd(weekStartYmd);
|
|
81
|
-
const end = new Date(start);
|
|
82
|
-
end.setDate(end.getDate() + 7);
|
|
83
|
-
return { fromIso: start.toISOString(), toIso: end.toISOString() };
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function main() {
|
|
87
|
-
const args = parseArgs(process.argv.slice(2));
|
|
88
|
-
if (args.help) { console.log(help()); return 0; }
|
|
89
|
-
if (!args.project || !args.alias || !args.entity) {
|
|
90
|
-
console.error(help());
|
|
91
|
-
emit({ source: SOURCE, status: 'failed', errors: [{ signature: 'bad-args', message: 'required: --project --alias --entity' }] });
|
|
92
|
-
return 2;
|
|
93
|
-
}
|
|
94
|
-
const projectRoot = await assertProject(args.project).catch(e => { throw configError(e.message); });
|
|
95
|
-
await loadConfig(projectRoot, args.alias);
|
|
96
|
-
const weekStart = args.week || ymd(currentIsoMonday());
|
|
97
|
-
const { fromIso, toIso } = weekBounds(weekStart);
|
|
98
|
-
|
|
99
|
-
const client = await buildClient({ fixture: args.fixture });
|
|
100
|
-
const startedAt = new Date().toISOString();
|
|
101
|
-
|
|
102
|
-
let messages;
|
|
103
|
-
try {
|
|
104
|
-
messages = await client.listMessages(args.entity, fromIso, toIso);
|
|
105
|
-
} catch (e) {
|
|
106
|
-
const retryable = !e.status || [429, 502, 503, 504].includes(e.status);
|
|
107
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: retryable ? 'deferred' : 'failed', last_error: e.message });
|
|
108
|
-
if (retryable && !args.dryRun) await enqueue(projectRoot, args.alias, { source: SOURCE, entity: args.entity, weekStart, signature: 'fetch-failed', reason: e.message });
|
|
109
|
-
if (!retryable && !args.dryRun) await emitLearningCandidate({ projectRoot, alias: args.alias, source: SOURCE, entity: args.entity, week: weekStart, error: { signature: 'fetch-failed', message: e.message, status: e.status }, context: { runner: 'pull-teams' } });
|
|
110
|
-
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: retryable ? 'deferred' : 'failed', errors: [{ message: e.message, status: e.status }] });
|
|
111
|
-
return retryable ? 1 : 0;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const chatHash = shortHash(args.entity);
|
|
115
|
-
|
|
116
|
-
if (messages.length === 0) {
|
|
117
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, { last_status: 'no-activity', items_pulled: 0, chat_hash: chatHash });
|
|
118
|
-
emit({ source: SOURCE, entity: args.entity, week: weekStart, status: 'no-activity', items_pulled: 0, files_written: [] });
|
|
119
|
-
return 0;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const outDir = path.join(sourceDir(projectRoot, args.alias, SOURCE), chatHash, weekStart);
|
|
123
|
-
const filesWritten = [];
|
|
124
|
-
if (!args.dryRun) {
|
|
125
|
-
const r1 = await writeAtomic(path.join(outDir, 'messages.yml'), YAML.stringify(messages));
|
|
126
|
-
const r2 = await writeAtomic(path.join(outDir, 'chat.yml'), YAML.stringify({ chat_id: args.entity, hash: chatHash }));
|
|
127
|
-
const r3 = await writeAtomic(path.join(outDir, 'index.md'), renderIndexMd({ chatId: args.entity, messages, weekStart, pulledAt: startedAt }), { skipIfUnchanged: false });
|
|
128
|
-
for (const r of [r1, r2, r3]) if (r.written !== false) filesWritten.push(path.relative(projectRoot, r.path));
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
await clear(projectRoot, args.alias, SOURCE, args.entity).catch(() => {});
|
|
132
|
-
|
|
133
|
-
await updateCell(projectRoot, args.alias, SOURCE, args.entity, weekStart, {
|
|
134
|
-
last_status: 'captured',
|
|
135
|
-
items_pulled: messages.length,
|
|
136
|
-
chat_hash: chatHash,
|
|
137
|
-
});
|
|
138
|
-
|
|
139
|
-
if (!args.dryRun) {
|
|
140
|
-
await appendRunLog(projectRoot, { runner: 'pull-teams', alias: args.alias, entity: args.entity, week: weekStart, status: 'captured', items_pulled: messages.length });
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
emit({
|
|
144
|
-
source: SOURCE, entity: args.entity, week: weekStart, status: 'captured',
|
|
145
|
-
items_pulled: messages.length, chat_hash: chatHash,
|
|
146
|
-
files_written: filesWritten, ledger_key: `teams::${args.entity}::${weekStart}`,
|
|
147
|
-
});
|
|
148
|
-
return 0;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function renderIndexMd({ chatId, messages, weekStart, pulledAt }) {
|
|
152
|
-
const lines = [
|
|
153
|
-
`# Teams chat — ${chatId} — week ${weekStart}`,
|
|
154
|
-
'',
|
|
155
|
-
`- chat_id: ${chatId}`,
|
|
156
|
-
`- week_start: ${weekStart}`,
|
|
157
|
-
`- messages: ${messages.length}`,
|
|
158
|
-
`- pulled_at: ${pulledAt}`,
|
|
159
|
-
'',
|
|
160
|
-
'## Messages',
|
|
161
|
-
];
|
|
162
|
-
for (const m of messages) {
|
|
163
|
-
const from = (m.from && m.from.user && m.from.user.displayName) || (m.from && m.from.application && m.from.application.displayName) || '?';
|
|
164
|
-
const preview = (m.body && m.body.content || '').replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').slice(0, 120);
|
|
165
|
-
lines.push(`- [${m.createdDateTime}] **${from}**: ${preview}`);
|
|
166
|
-
}
|
|
167
|
-
lines.push('');
|
|
168
|
-
return lines.join('\n');
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
main().then(code => { process.exitCode = code; }).catch(e => {
|
|
172
|
-
emit({ source: SOURCE, status: 'failed', errors: [{ message: e.message, code: e.exitCode || 'unknown' }] });
|
|
173
|
-
process.exitCode = e.exitCode || 1;
|
|
174
|
-
});
|
|
7
|
+
runCli('teams').then(code => { process.exitCode = code; });
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
]
|
|
7
|
-
}
|
|
8
|
-
}
|
|
2
|
+
"stdout": "> [block: csc]\n> entity_id: conv-001\n> display_name: Kickoff\n> last_touched: 2026-05-26T10:00:00Z\n> participants: Alice\n> topics: kickoff\n> summary: Kickoff happened.\n",
|
|
3
|
+
"exitCode": 0,
|
|
4
|
+
"stderr": ""
|
|
5
|
+
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
}
|
|
7
|
-
}
|
|
2
|
+
"stdout": "> [block: csc]\n> entity_id: conv-001\n> display_name: Kickoff\n> last_touched: 2026-05-26T10:00:00Z\n> participants: Alice\n> topics: kickoff\n> summary: Kickoff happened.\n",
|
|
3
|
+
"exitCode": 0,
|
|
4
|
+
"stderr": ""
|
|
5
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// plugin/runners/test/integration/csc-pull.integration.test.mjs
|
|
2
|
+
//
|
|
3
|
+
// Integration tests for the WorkIQ-only CSC pull pipeline (kushi v6.1.0+).
|
|
4
|
+
// One table-driven suite covers all 5 user-scoped sources
|
|
5
|
+
// (email, teams, meetings, onenote, sharepoint) — they share lib/csc-pull.mjs,
|
|
6
|
+
// so a single contract test fixture set proves them all.
|
|
7
|
+
//
|
|
8
|
+
// Fixture shape (WorkIQ stdout, NOT Graph JSON):
|
|
9
|
+
// { "stdout": "> [block: csc]\n> entity_id: ...", "stderr": "", "exitCode": 0 }
|
|
10
|
+
|
|
11
|
+
import { test, beforeEach, afterEach } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { promises as fs } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import YAML from 'yaml';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const REPO_ROOT = path.resolve(HERE, '..', '..', '..', '..');
|
|
22
|
+
const FIXTURE_DIR = path.join(HERE, '..', 'fixtures');
|
|
23
|
+
|
|
24
|
+
const RUNNERS = {
|
|
25
|
+
email: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-email.mjs'),
|
|
26
|
+
teams: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-teams.mjs'),
|
|
27
|
+
meetings: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-meetings.mjs'),
|
|
28
|
+
onenote: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-onenote.mjs'),
|
|
29
|
+
sharepoint: path.join(REPO_ROOT, 'plugin', 'runners', 'pull-sharepoint.mjs'),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ENTITIES = {
|
|
33
|
+
email: 'Inbox/102. Test Project',
|
|
34
|
+
teams: '19:abc123@thread.v2',
|
|
35
|
+
meetings: 'https://teams.microsoft.com/l/meetup-join/abc',
|
|
36
|
+
onenote: 'sectionFileId-deadbeef',
|
|
37
|
+
sharepoint: 'https://contoso.sharepoint.com/sites/test',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const WEEK = '2026-05-25';
|
|
41
|
+
|
|
42
|
+
let projectRoot;
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
projectRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'kushi-csc-int-'));
|
|
45
|
+
await fs.mkdir(path.join(projectRoot, 'Evidence', 'ushak'), { recursive: true });
|
|
46
|
+
});
|
|
47
|
+
afterEach(async () => { await fs.rm(projectRoot, { recursive: true, force: true }); });
|
|
48
|
+
|
|
49
|
+
function runRunner(source, fixtureName, extra = []) {
|
|
50
|
+
const fixture = path.join(FIXTURE_DIR, fixtureName);
|
|
51
|
+
return spawnSync(process.execPath, [RUNNERS[source],
|
|
52
|
+
'--project', projectRoot, '--alias', 'ushak',
|
|
53
|
+
'--entity', ENTITIES[source],
|
|
54
|
+
'--week', WEEK, '--fixture', fixture,
|
|
55
|
+
...extra,
|
|
56
|
+
], { encoding: 'utf8' });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const source of Object.keys(RUNNERS)) {
|
|
60
|
+
test(`[${source}] captured: writes weekly CSC + index + raw + ledger`, async () => {
|
|
61
|
+
const res = runRunner(source, 'csc-captured.json');
|
|
62
|
+
assert.equal(res.status, 0, `stderr: ${res.stderr}`);
|
|
63
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
64
|
+
assert.equal(result.status, 'captured');
|
|
65
|
+
assert.equal(result.items_pulled, 2);
|
|
66
|
+
assert.equal(result.source, source);
|
|
67
|
+
|
|
68
|
+
const sourceDir = path.join(projectRoot, 'Evidence', 'ushak', source);
|
|
69
|
+
const weeklyFile = path.join(sourceDir, 'weekly', `${WEEK}_${source}-csc.md`);
|
|
70
|
+
const md = await fs.readFile(weeklyFile, 'utf8');
|
|
71
|
+
assert.match(md, /Kickoff agenda/);
|
|
72
|
+
assert.match(md, /Architecture review notes/);
|
|
73
|
+
assert.match(md, new RegExp(`# ${source.toUpperCase()} CSC`));
|
|
74
|
+
|
|
75
|
+
const idx = YAML.parse(await fs.readFile(path.join(sourceDir, '_index', 'entities.yml'), 'utf8'));
|
|
76
|
+
assert.equal(idx.entities.length, 2);
|
|
77
|
+
assert.ok(idx.entities.every(e => e.id.startsWith(`${source}://`)));
|
|
78
|
+
assert.ok(idx.entities.every(e => e.weeks_touched.includes(WEEK)));
|
|
79
|
+
|
|
80
|
+
const ledger = YAML.parse(await fs.readFile(path.join(projectRoot, 'Evidence', 'ushak', '_ledger.yml'), 'utf8'));
|
|
81
|
+
const cell = ledger.entries[`${source}::${ENTITIES[source]}::${WEEK}`];
|
|
82
|
+
assert.ok(cell);
|
|
83
|
+
assert.equal(cell.last_status, 'captured');
|
|
84
|
+
assert.equal(cell.items_pulled, 2);
|
|
85
|
+
|
|
86
|
+
const rawFiles = await fs.readdir(path.join(sourceDir, '_raw'));
|
|
87
|
+
assert.equal(rawFiles.length, 1);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test(`[${source}] no-activity: empty stdout → status no-activity, no weekly file`, async () => {
|
|
91
|
+
const res = runRunner(source, 'csc-empty.json');
|
|
92
|
+
assert.equal(res.status, 0);
|
|
93
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
94
|
+
assert.equal(result.status, 'no-activity');
|
|
95
|
+
assert.equal(result.items_pulled, 0);
|
|
96
|
+
let exists = true;
|
|
97
|
+
try { await fs.access(path.join(projectRoot, 'Evidence', 'ushak', source, 'weekly', `${WEEK}_${source}-csc.md`)); }
|
|
98
|
+
catch { exists = false; }
|
|
99
|
+
assert.equal(exists, false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test(`[${source}] throttled: deferred + no learning candidate`, async () => {
|
|
103
|
+
const res = runRunner(source, 'csc-throttled.json');
|
|
104
|
+
assert.equal(res.status, 0);
|
|
105
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
106
|
+
assert.equal(result.status, 'deferred');
|
|
107
|
+
assert.match(result.errors[0].signature, /throttled/);
|
|
108
|
+
let learningExists = true;
|
|
109
|
+
try { await fs.access(path.join(projectRoot, 'Evidence', '_learnings-candidates')); }
|
|
110
|
+
catch { learningExists = false; }
|
|
111
|
+
assert.equal(learningExists, false, 'throttled is retryable; should NOT emit learning candidate');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test(`[${source}] novel error: failed + learning candidate`, async () => {
|
|
115
|
+
const res = runRunner(source, 'csc-novel-error.json');
|
|
116
|
+
assert.equal(res.status, 0);
|
|
117
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
118
|
+
assert.equal(result.status, 'failed');
|
|
119
|
+
const candDir = path.join(projectRoot, 'Evidence', '_learnings-candidates');
|
|
120
|
+
const entries = await fs.readdir(candDir);
|
|
121
|
+
const md = entries.filter(f => f.endsWith('.md'));
|
|
122
|
+
assert.equal(md.length, 1, `expected 1 candidate, got: ${entries.join(', ')}`);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test(`[${source}] citation tokens / placeholders are filtered → no-activity`, async () => {
|
|
126
|
+
const res = runRunner(source, 'csc-citation-tokens.json');
|
|
127
|
+
assert.equal(res.status, 0);
|
|
128
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
129
|
+
assert.equal(result.status, 'no-activity', `bogus turn1searchN / <value> blocks must be dropped`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test(`[${source}] dry-run: no files written, ledger empty`, async () => {
|
|
133
|
+
const res = runRunner(source, 'csc-captured.json', ['--dry-run']);
|
|
134
|
+
assert.equal(res.status, 0);
|
|
135
|
+
const result = JSON.parse(res.stdout.trim().split('\n').pop());
|
|
136
|
+
assert.deepEqual(result.files_written, []);
|
|
137
|
+
let ledgerExists = true;
|
|
138
|
+
try { await fs.access(path.join(projectRoot, 'Evidence', 'ushak', '_ledger.yml')); }
|
|
139
|
+
catch { ledgerExists = false; }
|
|
140
|
+
assert.equal(ledgerExists, false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test(`[${source}] missing --entity exits 2`, () => {
|
|
144
|
+
const res = spawnSync(process.execPath, [RUNNERS[source],
|
|
145
|
+
'--project', projectRoot, '--alias', 'ushak',
|
|
146
|
+
'--fixture', path.join(FIXTURE_DIR, 'csc-captured.json'),
|
|
147
|
+
], { encoding: 'utf8' });
|
|
148
|
+
assert.equal(res.status, 2);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test(`[${source}] idempotent: re-running same fixture is a no-op rewrite`, async () => {
|
|
152
|
+
const r1 = runRunner(source, 'csc-captured.json');
|
|
153
|
+
assert.equal(r1.status, 0);
|
|
154
|
+
const r2 = runRunner(source, 'csc-captured.json');
|
|
155
|
+
assert.equal(r2.status, 0);
|
|
156
|
+
const idx = YAML.parse(await fs.readFile(path.join(projectRoot, 'Evidence', 'ushak', source, '_index', 'entities.yml'), 'utf8'));
|
|
157
|
+
assert.equal(idx.entities.length, 2, 'rerun must not duplicate entities');
|
|
158
|
+
assert.equal(idx.entities[0].weeks_touched.length, 1, 'rerun same week must not duplicate weeks_touched');
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"folders": [
|
|
3
|
-
{ "id": "AAMkAGI=", "displayName": "23. ABN AMRO" }
|
|
4
|
-
],
|
|
5
|
-
"messagesByFolder": {
|
|
6
|
-
"AAMkAGI=": [
|
|
7
|
-
{ "id": "m1", "subject": "Kickoff agenda", "receivedDateTime": "2026-05-25T08:00:00Z", "from": { "emailAddress": { "address": "ushak@abnamro.com", "name": "Alice" } } },
|
|
8
|
-
{ "id": "m2", "subject": "Architecture review notes", "receivedDateTime": "2026-05-27T14:30:00Z", "from": { "emailAddress": { "address": "bob@abnamro.com", "name": "Bob" } } },
|
|
9
|
-
{ "id": "m3", "subject": "Out of window — prior week", "receivedDateTime": "2026-05-18T09:00:00Z", "from": { "emailAddress": { "address": "carol@abnamro.com", "name": "Carol" } } },
|
|
10
|
-
{ "id": "m4", "subject": "Out of window — next week", "receivedDateTime": "2026-06-01T09:00:00Z", "from": { "emailAddress": { "address": "dave@abnamro.com", "name": "Dave" } } }
|
|
11
|
-
]
|
|
12
|
-
}
|
|
13
|
-
}
|