github-issue-tower-defence-management 1.148.24 → 1.149.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 (81) hide show
  1. package/.github/workflows/publish.yml +40 -7
  2. package/.prettierignore +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/README.md +4 -4
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +2 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js +1 -0
  8. package/bin/adapter/repositories/ConfigurableSilentSessionMessageComposer.js.map +1 -1
  9. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js +126 -0
  10. package/bin/adapter/repositories/FileSystemKevReportWatermarkRepository.js.map +1 -0
  11. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js +1 -0
  12. package/bin/adapter/repositories/ProcessListSessionSubAgentActivityRepository.js.map +1 -1
  13. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js +5 -3
  14. package/bin/adapter/repositories/TranscriptSessionSubAgentActivityRepository.js.map +1 -1
  15. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +6 -2
  16. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  17. package/bin/domain/entities/KevReportWatermark.js +3 -0
  18. package/bin/domain/entities/KevReportWatermark.js.map +1 -0
  19. package/bin/domain/usecases/DailySecurityScanUseCase.js +56 -7
  20. package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
  21. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js +9 -0
  22. package/bin/domain/usecases/DefaultSilentSessionMessageComposer.js.map +1 -1
  23. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js +12 -11
  24. package/bin/domain/usecases/NotifySilentLiveSessionsUseCase.js.map +1 -1
  25. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
  26. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  27. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js +3 -0
  28. package/bin/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.js.map +1 -0
  29. package/package.json +1 -1
  30. package/scripts/defaultBranchTipVerify.sh +23 -0
  31. package/scripts/testWorkflowRunVerify.sh +47 -0
  32. package/src/adapter/ci/publishTestWorkflowGate.test.ts +653 -0
  33. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -0
  34. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.test.ts +35 -0
  35. package/src/adapter/repositories/ConfigurableSilentSessionMessageComposer.ts +7 -0
  36. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.test.ts +253 -0
  37. package/src/adapter/repositories/FileSystemKevReportWatermarkRepository.ts +130 -0
  38. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.test.ts +2 -0
  39. package/src/adapter/repositories/ProcessListSessionSubAgentActivityRepository.ts +1 -0
  40. package/src/adapter/repositories/TranscriptOwnerCallStatusProvider.test.ts +3 -0
  41. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.test.ts +159 -0
  42. package/src/adapter/repositories/TranscriptSessionSubAgentActivityRepository.ts +7 -1
  43. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +108 -0
  44. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +10 -2
  45. package/src/domain/entities/KevReportWatermark.ts +4 -0
  46. package/src/domain/entities/LiveSessionActivitySnapshot.ts +1 -0
  47. package/src/domain/usecases/DailySecurityScanUseCase.test.ts +210 -0
  48. package/src/domain/usecases/DailySecurityScanUseCase.ts +84 -7
  49. package/src/domain/usecases/DefaultSilentSessionMessageComposer.test.ts +58 -1
  50. package/src/domain/usecases/DefaultSilentSessionMessageComposer.ts +21 -0
  51. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +28 -0
  52. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.test.ts +146 -8
  53. package/src/domain/usecases/NotifySilentLiveSessionsUseCase.ts +19 -10
  54. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +212 -0
  55. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +32 -0
  56. package/src/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.ts +11 -0
  57. package/src/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.ts +3 -0
  58. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  59. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts +2 -0
  60. package/types/adapter/repositories/ConfigurableSilentSessionMessageComposer.d.ts.map +1 -1
  61. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts +10 -0
  62. package/types/adapter/repositories/FileSystemKevReportWatermarkRepository.d.ts.map +1 -0
  63. package/types/adapter/repositories/ProcessListSessionSubAgentActivityRepository.d.ts.map +1 -1
  64. package/types/adapter/repositories/TranscriptSessionSubAgentActivityRepository.d.ts.map +1 -1
  65. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -1
  66. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  67. package/types/domain/entities/KevReportWatermark.d.ts +5 -0
  68. package/types/domain/entities/KevReportWatermark.d.ts.map +1 -0
  69. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts +1 -0
  70. package/types/domain/entities/LiveSessionActivitySnapshot.d.ts.map +1 -1
  71. package/types/domain/usecases/DailySecurityScanUseCase.d.ts +3 -1
  72. package/types/domain/usecases/DailySecurityScanUseCase.d.ts.map +1 -1
  73. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts +2 -0
  74. package/types/domain/usecases/DefaultSilentSessionMessageComposer.d.ts.map +1 -1
  75. package/types/domain/usecases/NotifySilentLiveSessionsUseCase.d.ts.map +1 -1
  76. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +3 -2
  77. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  78. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts +15 -0
  79. package/types/domain/usecases/adapter-interfaces/KevReportWatermarkRepository.d.ts.map +1 -0
  80. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts +1 -0
  81. package/types/domain/usecases/adapter-interfaces/SilentSessionMessageComposer.d.ts.map +1 -1
@@ -279,6 +279,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
279
279
  silentSeconds: 120,
280
280
  runningSeconds: 900,
281
281
  waitingOnExternalProcess: false,
282
+ finishedResultUnconsumed: false,
282
283
  },
283
284
  ]);
284
285
  });
@@ -400,6 +401,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
400
401
  silentSeconds: 60,
401
402
  runningSeconds: 600,
402
403
  waitingOnExternalProcess: false,
404
+ finishedResultUnconsumed: false,
403
405
  },
404
406
  ]);
405
407
  });
@@ -474,6 +476,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
474
476
  silentSeconds: 60,
475
477
  runningSeconds: 600,
476
478
  waitingOnExternalProcess: false,
479
+ finishedResultUnconsumed: false,
477
480
  },
478
481
  ]);
479
482
  });
@@ -525,6 +528,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
525
528
  silentSeconds: 600,
526
529
  runningSeconds: 900,
527
530
  waitingOnExternalProcess: false,
531
+ finishedResultUnconsumed: false,
528
532
  },
529
533
  ]);
530
534
  });
@@ -555,6 +559,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
555
559
  silentSeconds: fourDaysSeconds,
556
560
  runningSeconds: fourDaysSeconds,
557
561
  waitingOnExternalProcess: false,
562
+ finishedResultUnconsumed: false,
558
563
  },
559
564
  ]);
560
565
  });
@@ -592,6 +597,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
592
597
  silentSeconds: 60,
593
598
  runningSeconds: 600,
594
599
  waitingOnExternalProcess: false,
600
+ finishedResultUnconsumed: false,
595
601
  },
596
602
  ]);
597
603
  fs.rmSync(targetDir, { force: true, recursive: true });
@@ -680,6 +686,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
680
686
  silentSeconds: 0,
681
687
  runningSeconds: 0,
682
688
  waitingOnExternalProcess: false,
689
+ finishedResultUnconsumed: false,
683
690
  },
684
691
  ]);
685
692
  });
@@ -731,6 +738,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
731
738
  silentSeconds: 120,
732
739
  runningSeconds: 900,
733
740
  waitingOnExternalProcess: false,
741
+ finishedResultUnconsumed: false,
734
742
  },
735
743
  ]);
736
744
  });
@@ -767,6 +775,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
767
775
  silentSeconds: twoDaysSeconds,
768
776
  runningSeconds: twoDaysSeconds,
769
777
  waitingOnExternalProcess: false,
778
+ finishedResultUnconsumed: false,
770
779
  },
771
780
  ]);
772
781
  });
@@ -821,6 +830,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
821
830
  silentSeconds: 600,
822
831
  runningSeconds: 900,
823
832
  waitingOnExternalProcess: true,
833
+ finishedResultUnconsumed: false,
824
834
  },
825
835
  ]);
826
836
  },
@@ -956,6 +966,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
956
966
  silentSeconds: 600,
957
967
  runningSeconds: 900,
958
968
  waitingOnExternalProcess: true,
969
+ finishedResultUnconsumed: false,
959
970
  },
960
971
  ]);
961
972
  });
@@ -987,6 +998,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
987
998
  silentSeconds: 600,
988
999
  runningSeconds: 900,
989
1000
  waitingOnExternalProcess: false,
1001
+ finishedResultUnconsumed: false,
990
1002
  },
991
1003
  ]);
992
1004
  });
@@ -1282,6 +1294,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1282
1294
  silentSeconds: 120,
1283
1295
  runningSeconds: 900,
1284
1296
  waitingOnExternalProcess: false,
1297
+ finishedResultUnconsumed: false,
1285
1298
  },
1286
1299
  ]);
1287
1300
  });
@@ -1337,6 +1350,7 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1337
1350
  silentSeconds: 600,
1338
1351
  runningSeconds: 900,
1339
1352
  waitingOnExternalProcess: false,
1353
+ finishedResultUnconsumed: false,
1340
1354
  },
1341
1355
  ]);
1342
1356
  });
@@ -1367,6 +1381,151 @@ describe('TranscriptSessionSubAgentActivityRepository', () => {
1367
1381
  silentSeconds: 120,
1368
1382
  runningSeconds: 900,
1369
1383
  waitingOnExternalProcess: false,
1384
+ finishedResultUnconsumed: false,
1385
+ },
1386
+ ]);
1387
+ });
1388
+
1389
+ it('reports a finished sub-agent still listed in the running-subagents record as an unconsumed result carrying its silence age', async () => {
1390
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1391
+ const unconsumedAgentId = 'aaabbbbcccc30001';
1392
+ writeAgentTranscript(
1393
+ sessionName,
1394
+ unconsumedAgentId,
1395
+ finishedEntries('2026-06-27T10:30:00.000Z'),
1396
+ nowEpochSeconds - 5220,
1397
+ );
1398
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1399
+ createResolver(),
1400
+ emptyProcessLister(),
1401
+ now,
1402
+ livenessResolverWith([unconsumedAgentId]),
1403
+ );
1404
+
1405
+ const result = await repository.listSubAgentActivitiesBySessionName(
1406
+ [sessionName],
1407
+ transcriptMapFor([sessionName]),
1408
+ );
1409
+
1410
+ expect(result.get(sessionName)).toEqual([
1411
+ {
1412
+ label: `agent-${unconsumedAgentId}`,
1413
+ silentSeconds: 5220,
1414
+ runningSeconds: 5400,
1415
+ waitingOnExternalProcess: false,
1416
+ finishedResultUnconsumed: true,
1417
+ },
1418
+ ]);
1419
+ });
1420
+
1421
+ it('reports a finished sub-agent whose tail is a final text answer as an unconsumed result while it stays in the running-subagents record', async () => {
1422
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1423
+ const unconsumedAgentId = 'aaabbbbcccc30002';
1424
+ writeAgentTranscript(
1425
+ sessionName,
1426
+ unconsumedAgentId,
1427
+ finalTextAnswerEntries('2026-06-27T11:00:00.000Z'),
1428
+ nowEpochSeconds - 360,
1429
+ );
1430
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1431
+ createResolver(),
1432
+ emptyProcessLister(),
1433
+ now,
1434
+ livenessResolverWith([unconsumedAgentId]),
1435
+ );
1436
+
1437
+ const result = await repository.listSubAgentActivitiesBySessionName(
1438
+ [sessionName],
1439
+ transcriptMapFor([sessionName]),
1440
+ );
1441
+
1442
+ expect(result.get(sessionName)).toEqual([
1443
+ {
1444
+ label: `agent-${unconsumedAgentId}`,
1445
+ silentSeconds: 360,
1446
+ runningSeconds: 3600,
1447
+ waitingOnExternalProcess: false,
1448
+ finishedResultUnconsumed: true,
1449
+ },
1450
+ ]);
1451
+ });
1452
+
1453
+ it('discards a finished sub-agent when the running-subagents record cannot be resolved', async () => {
1454
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1455
+ const finishedAgentId = 'aaabbbbcccc30003';
1456
+ writeAgentTranscript(
1457
+ sessionName,
1458
+ finishedAgentId,
1459
+ finishedEntries('2026-06-27T10:30:00.000Z'),
1460
+ nowEpochSeconds - 5220,
1461
+ );
1462
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1463
+ createResolver(),
1464
+ emptyProcessLister(),
1465
+ now,
1466
+ livenessResolverWith(null),
1467
+ );
1468
+
1469
+ const result = await repository.listSubAgentActivitiesBySessionName(
1470
+ [sessionName],
1471
+ transcriptMapFor([sessionName]),
1472
+ );
1473
+
1474
+ expect(result.size).toBe(0);
1475
+ });
1476
+
1477
+ it('discards a finished sub-agent whose id has already been removed from the running-subagents record', async () => {
1478
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1479
+ const finishedAgentId = 'aaabbbbcccc30004';
1480
+ writeAgentTranscript(
1481
+ sessionName,
1482
+ finishedAgentId,
1483
+ finishedEntries('2026-06-27T10:30:00.000Z'),
1484
+ nowEpochSeconds - 5220,
1485
+ );
1486
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1487
+ createResolver(),
1488
+ emptyProcessLister(),
1489
+ now,
1490
+ livenessResolverWith(['aaabbbbcccc30005']),
1491
+ );
1492
+
1493
+ const result = await repository.listSubAgentActivitiesBySessionName(
1494
+ [sessionName],
1495
+ transcriptMapFor([sessionName]),
1496
+ );
1497
+
1498
+ expect(result.size).toBe(0);
1499
+ });
1500
+
1501
+ it('reports a still-working sub-agent listed in the running-subagents record as having no unconsumed result', async () => {
1502
+ const sessionName = 'https_//github_com/owner/repo/issues/9';
1503
+ const workingAgentId = 'aaabbbbcccc30006';
1504
+ writeAgentTranscript(
1505
+ sessionName,
1506
+ workingAgentId,
1507
+ pendingToolUseEntries('2026-06-27T11:45:00.000Z'),
1508
+ nowEpochSeconds - 600,
1509
+ );
1510
+ const repository = new TranscriptSessionSubAgentActivityRepository(
1511
+ createResolver(),
1512
+ emptyProcessLister(),
1513
+ now,
1514
+ livenessResolverWith([workingAgentId]),
1515
+ );
1516
+
1517
+ const result = await repository.listSubAgentActivitiesBySessionName(
1518
+ [sessionName],
1519
+ transcriptMapFor([sessionName]),
1520
+ );
1521
+
1522
+ expect(result.get(sessionName)).toEqual([
1523
+ {
1524
+ label: `agent-${workingAgentId}`,
1525
+ silentSeconds: 600,
1526
+ runningSeconds: 900,
1527
+ waitingOnExternalProcess: false,
1528
+ finishedResultUnconsumed: false,
1370
1529
  },
1371
1530
  ]);
1372
1531
  });
@@ -344,6 +344,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
344
344
  filePath,
345
345
  fileName,
346
346
  nowEpochSeconds,
347
+ liveSubAgentIds !== null,
347
348
  loadNormalizedProcessCommandLines,
348
349
  );
349
350
  if (activity !== null) {
@@ -357,6 +358,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
357
358
  filePath: string,
358
359
  fileName: string,
359
360
  nowEpochSeconds: number,
361
+ listedInRunningSubAgentsRecord: boolean,
360
362
  loadNormalizedProcessCommandLines: () => Promise<string[]>,
361
363
  ): Promise<SubAgentActivity | null> => {
362
364
  let content: string;
@@ -368,7 +370,10 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
368
370
  return null;
369
371
  }
370
372
  const transcript = parseTranscript(content);
371
- if (transcript.lastEntryIndicatesCompletion) {
373
+ if (
374
+ transcript.lastEntryIndicatesCompletion &&
375
+ !listedInRunningSubAgentsRecord
376
+ ) {
372
377
  return null;
373
378
  }
374
379
  const silentSeconds = clampToZero(
@@ -388,6 +393,7 @@ export class TranscriptSessionSubAgentActivityRepository implements SessionSubAg
388
393
  transcript.pendingToolCommands,
389
394
  loadNormalizedProcessCommandLines,
390
395
  )),
396
+ finishedResultUnconsumed: transcript.lastEntryIndicatesCompletion,
391
397
  };
392
398
  };
393
399
 
@@ -268,6 +268,114 @@ describe('ApiV3CheerioRestIssueRepository', () => {
268
268
  });
269
269
  });
270
270
 
271
+ describe('get', () => {
272
+ it('reads the single project item scoped to the given project without consulting the getAllIssues memo', async () => {
273
+ const {
274
+ repository,
275
+ graphqlProjectItemRepository,
276
+ localStorageCacheRepository,
277
+ projectRepository,
278
+ dateRepository,
279
+ } = createApiV3CheerioRestIssueRepository();
280
+ const project = buildTestProject('test-project-id');
281
+ dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:00:00Z'));
282
+ localStorageCacheRepository.getSingle.mockResolvedValue(null);
283
+ projectRepository.getProject.mockResolvedValue(project);
284
+ graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
285
+ localStorageCacheRepository.setSingle.mockResolvedValue();
286
+ graphqlProjectItemRepository.fetchProjectItemByUrl.mockResolvedValue(
287
+ buildProjectItem('https://github.com/o/r/issues/1', 'live title'),
288
+ );
289
+
290
+ await repository.getAllIssues('test-project-id');
291
+ const issue = await repository.get(
292
+ 'https://github.com/o/r/issues/1',
293
+ project,
294
+ );
295
+
296
+ expect(issue?.title).toBe('live title');
297
+ expect(
298
+ graphqlProjectItemRepository.fetchProjectItemByUrl.mock.calls,
299
+ ).toEqual([['https://github.com/o/r/issues/1', 'test-project-id']]);
300
+ });
301
+
302
+ const issueUrlOnTwoProjects = 'https://github.com/o/r/issues/1';
303
+
304
+ const buildProjectScopedItem = (
305
+ itemId: string,
306
+ status: string,
307
+ story: string,
308
+ nextActionHour: string,
309
+ ): ProjectItem => ({
310
+ ...buildProjectItem(issueUrlOnTwoProjects, itemId),
311
+ id: itemId,
312
+ customFields: [
313
+ { name: 'Status', value: status },
314
+ { name: 'story', value: story },
315
+ { name: 'nextActionHour', value: nextActionHour },
316
+ ],
317
+ });
318
+
319
+ const itemOnOtherProject = buildProjectScopedItem(
320
+ 'item-on-other-project',
321
+ 'Awaiting Workspace',
322
+ 'other story',
323
+ '9',
324
+ );
325
+ const itemOnRequestedProject = buildProjectScopedItem(
326
+ 'item-on-requested-project',
327
+ 'Preparation',
328
+ 'requested story',
329
+ '17',
330
+ );
331
+
332
+ const arrangeItemsOnTwoProjects = (
333
+ graphqlProjectItemRepository: ReturnType<
334
+ typeof createApiV3CheerioRestIssueRepository
335
+ >['graphqlProjectItemRepository'],
336
+ ): void => {
337
+ const itemsByProjectId = new Map<string, ProjectItem>([
338
+ ['other-project-id', itemOnOtherProject],
339
+ ['requested-project-id', itemOnRequestedProject],
340
+ ]);
341
+ graphqlProjectItemRepository.fetchProjectItemByUrl.mockImplementation(
342
+ async (_issueUrl: string, projectId?: string) =>
343
+ projectId === undefined
344
+ ? Array.from(itemsByProjectId.values())[0]
345
+ : (itemsByProjectId.get(projectId) ?? null),
346
+ );
347
+ };
348
+
349
+ it('returns the project item of the requested project when the issue is on two projects', async () => {
350
+ const { repository, graphqlProjectItemRepository } =
351
+ createApiV3CheerioRestIssueRepository();
352
+ arrangeItemsOnTwoProjects(graphqlProjectItemRepository);
353
+
354
+ const issue = await repository.get(
355
+ issueUrlOnTwoProjects,
356
+ buildTestProject('requested-project-id'),
357
+ );
358
+
359
+ expect(issue?.itemId).toBe('item-on-requested-project');
360
+ expect(issue?.status).toBe('Preparation');
361
+ expect(issue?.story).toBe('requested story');
362
+ expect(issue?.nextActionHour).toBe(17);
363
+ });
364
+
365
+ it('returns null when the issue has project items only on other projects', async () => {
366
+ const { repository, graphqlProjectItemRepository } =
367
+ createApiV3CheerioRestIssueRepository();
368
+ arrangeItemsOnTwoProjects(graphqlProjectItemRepository);
369
+
370
+ const issue = await repository.get(
371
+ issueUrlOnTwoProjects,
372
+ buildTestProject('project-without-any-item'),
373
+ );
374
+
375
+ expect(issue).toBeNull();
376
+ });
377
+ });
378
+
271
379
  describe('getAllIssues incremental fetch', () => {
272
380
  it('light-scans the lastFetchedAt UTC day with no previous-day overlap, detail-fetches changed items by id, and upserts by url', async () => {
273
381
  const {
@@ -911,8 +911,16 @@ export class ApiV3CheerioRestIssueRepository
911
911
  searchIssues = (query: string): Promise<SearchedIssue[]> => {
912
912
  return this.restIssueRepository.searchIssues(query);
913
913
  };
914
- get = async (_issueUrl: string, _project: Project): Promise<Issue | null> => {
915
- return this.getIssueByUrl(_issueUrl);
914
+ get = async (issueUrl: string, project: Project): Promise<Issue | null> => {
915
+ const projectItem =
916
+ await this.graphqlProjectItemRepository.fetchProjectItemByUrl(
917
+ issueUrl,
918
+ project.id,
919
+ );
920
+ if (!projectItem) {
921
+ return null;
922
+ }
923
+ return this.convertProjectItemToIssue(projectItem);
916
924
  };
917
925
  update = async (issue: Issue, _project: Project): Promise<void> => {
918
926
  await this.updateIssue(issue);
@@ -0,0 +1,4 @@
1
+ export type KevReportWatermark = {
2
+ lastReportedDateAdded: string;
3
+ reportedCveIdsOnLastReportedDateAdded: string[];
4
+ };
@@ -3,6 +3,7 @@ export type SubAgentActivity = {
3
3
  silentSeconds: number;
4
4
  runningSeconds: number;
5
5
  waitingOnExternalProcess: boolean;
6
+ finishedResultUnconsumed: boolean;
6
7
  };
7
8
 
8
9
  export type LiveSessionActivitySnapshot = {
@@ -2,6 +2,7 @@ import { DailySecurityScanUseCase } from './DailySecurityScanUseCase';
2
2
  import { LocalCommandRunner } from './adapter-interfaces/LocalCommandRunner';
3
3
  import { IssueRepository } from './adapter-interfaces/IssueRepository';
4
4
  import { HttpRepository } from './adapter-interfaces/HttpRepository';
5
+ import { KevReportWatermarkRepository } from './adapter-interfaces/KevReportWatermarkRepository';
5
6
  import { mock } from 'jest-mock-extended';
6
7
 
7
8
  const KEV_CATALOG_URL =
@@ -12,16 +13,21 @@ describe('DailySecurityScanUseCase', () => {
12
13
  const mockLocalCommandRunner = mock<LocalCommandRunner>();
13
14
  const mockIssueRepository = mock<IssueRepository>();
14
15
  const mockHttpRepository = mock<HttpRepository>();
16
+ const mockKevReportWatermarkRepository =
17
+ mock<KevReportWatermarkRepository>();
18
+ mockKevReportWatermarkRepository.load.mockResolvedValue({ type: 'absent' });
15
19
  const useCase = new DailySecurityScanUseCase(
16
20
  mockLocalCommandRunner,
17
21
  mockIssueRepository,
18
22
  mockHttpRepository,
23
+ mockKevReportWatermarkRepository,
19
24
  );
20
25
  return {
21
26
  useCase,
22
27
  mockLocalCommandRunner,
23
28
  mockIssueRepository,
24
29
  mockHttpRepository,
30
+ mockKevReportWatermarkRepository,
25
31
  };
26
32
  };
27
33
 
@@ -1238,5 +1244,209 @@ describe('DailySecurityScanUseCase', () => {
1238
1244
  );
1239
1245
  expect(grepCalls).toHaveLength(0);
1240
1246
  });
1247
+
1248
+ const scannedExampleLibrary = osvScanOutput([
1249
+ {
1250
+ name: 'example-library',
1251
+ version: '1.2.3',
1252
+ ecosystem: 'npm',
1253
+ id: 'GHSA-1111-2222-3333',
1254
+ aliases: ['CVE-2024-0001', 'CVE-2024-0002', 'CVE-2024-0003'],
1255
+ summary: 'Example Library Deserialization',
1256
+ },
1257
+ ]);
1258
+
1259
+ const kevCatalogOf = (
1260
+ vulnerabilities: { cveID: string; dateAdded: string }[],
1261
+ ) => ({
1262
+ vulnerabilities: vulnerabilities.map((vulnerability) => ({
1263
+ cveID: vulnerability.cveID,
1264
+ vendorProject: 'Example',
1265
+ product: 'ExampleLibrary',
1266
+ vulnerabilityName: `${vulnerability.cveID} Deserialization`,
1267
+ dateAdded: vulnerability.dateAdded,
1268
+ })),
1269
+ });
1270
+
1271
+ it('records every addition it considered in the watermark after the report issue is created', async () => {
1272
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1273
+ buildScanEnvironment(
1274
+ scannedExampleLibrary,
1275
+ kevCatalogOf([
1276
+ { cveID: 'CVE-2024-0001', dateAdded: '2024-01-01' },
1277
+ { cveID: 'CVE-2024-0002', dateAdded: '2024-01-02' },
1278
+ ]),
1279
+ );
1280
+
1281
+ await runWithKevReporting(useCase);
1282
+
1283
+ expect(
1284
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1285
+ ).toHaveLength(1);
1286
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1287
+ [
1288
+ {
1289
+ lastReportedDateAdded: '2024-01-02',
1290
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0002'],
1291
+ },
1292
+ ],
1293
+ ]);
1294
+ });
1295
+
1296
+ it('does not report an addition that the stored watermark already covers', async () => {
1297
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1298
+ buildScanEnvironment(
1299
+ scannedExampleLibrary,
1300
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1301
+ );
1302
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1303
+ type: 'stored',
1304
+ watermark: {
1305
+ lastReportedDateAdded: '2024-01-02',
1306
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0001'],
1307
+ },
1308
+ });
1309
+
1310
+ await runWithKevReporting(useCase);
1311
+
1312
+ expect(
1313
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1314
+ ).toHaveLength(0);
1315
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1316
+ });
1317
+
1318
+ it('reports an addition sharing the stored watermark date that was not reported before', async () => {
1319
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1320
+ buildScanEnvironment(
1321
+ scannedExampleLibrary,
1322
+ kevCatalogOf([
1323
+ { cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' },
1324
+ { cveID: 'CVE-2024-0002', dateAdded: '2024-01-02' },
1325
+ ]),
1326
+ );
1327
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1328
+ type: 'stored',
1329
+ watermark: {
1330
+ lastReportedDateAdded: '2024-01-02',
1331
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-0001'],
1332
+ },
1333
+ });
1334
+
1335
+ await runWithKevReporting(useCase);
1336
+
1337
+ const kevCalls = kevReportCalls(
1338
+ mockIssueRepository.createNewIssue.mock.calls,
1339
+ );
1340
+ expect(kevCalls).toHaveLength(1);
1341
+ expect(kevCalls[0][3]).toContain('CVE-2024-0002');
1342
+ expect(kevCalls[0][3]).not.toContain('CVE-2024-0001');
1343
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1344
+ [
1345
+ {
1346
+ lastReportedDateAdded: '2024-01-02',
1347
+ reportedCveIdsOnLastReportedDateAdded: [
1348
+ 'CVE-2024-0001',
1349
+ 'CVE-2024-0002',
1350
+ ],
1351
+ },
1352
+ ],
1353
+ ]);
1354
+ });
1355
+
1356
+ it('does not select an addition dated strictly before the stored watermark date', async () => {
1357
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1358
+ buildScanEnvironment(
1359
+ scannedExampleLibrary,
1360
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-01' }]),
1361
+ );
1362
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1363
+ type: 'stored',
1364
+ watermark: {
1365
+ lastReportedDateAdded: '2024-01-02',
1366
+ reportedCveIdsOnLastReportedDateAdded: [],
1367
+ },
1368
+ });
1369
+
1370
+ await runWithKevReporting(useCase);
1371
+
1372
+ expect(
1373
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1374
+ ).toHaveLength(0);
1375
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1376
+ });
1377
+
1378
+ it('advances the watermark over an addition that affects no scanned package so it is not weighed again', async () => {
1379
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1380
+ buildScanEnvironment(
1381
+ scannedExampleLibrary,
1382
+ kevCatalogOf([{ cveID: 'CVE-2024-9999', dateAdded: '2024-01-02' }]),
1383
+ );
1384
+
1385
+ await runWithKevReporting(useCase);
1386
+
1387
+ expect(
1388
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1389
+ ).toHaveLength(0);
1390
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toEqual([
1391
+ [
1392
+ {
1393
+ lastReportedDateAdded: '2024-01-02',
1394
+ reportedCveIdsOnLastReportedDateAdded: ['CVE-2024-9999'],
1395
+ },
1396
+ ],
1397
+ ]);
1398
+ });
1399
+
1400
+ it('skips the report and leaves the watermark untouched when the stored watermark is unreadable', async () => {
1401
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1402
+ buildScanEnvironment(
1403
+ scannedExampleLibrary,
1404
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1405
+ );
1406
+ mockKevReportWatermarkRepository.load.mockResolvedValue({
1407
+ type: 'unreadable',
1408
+ reason: 'the stored watermark file holds malformed JSON',
1409
+ });
1410
+ const errorSpy = jest
1411
+ .spyOn(console, 'error')
1412
+ .mockImplementation(() => {});
1413
+
1414
+ await runWithKevReporting(useCase);
1415
+
1416
+ expect(
1417
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1418
+ ).toHaveLength(0);
1419
+ expect(mockKevReportWatermarkRepository.save.mock.calls).toHaveLength(0);
1420
+ expect(errorSpy).toHaveBeenCalledWith(
1421
+ expect.stringContaining(
1422
+ 'the stored watermark file holds malformed JSON',
1423
+ ),
1424
+ );
1425
+ errorSpy.mockRestore();
1426
+ });
1427
+
1428
+ it('creates the report issue and logs when the watermark write fails, so the same additions are weighed again', async () => {
1429
+ const { useCase, mockIssueRepository, mockKevReportWatermarkRepository } =
1430
+ buildScanEnvironment(
1431
+ scannedExampleLibrary,
1432
+ kevCatalogOf([{ cveID: 'CVE-2024-0001', dateAdded: '2024-01-02' }]),
1433
+ );
1434
+ mockKevReportWatermarkRepository.save.mockRejectedValue(
1435
+ new Error('disk is read-only'),
1436
+ );
1437
+ const errorSpy = jest
1438
+ .spyOn(console, 'error')
1439
+ .mockImplementation(() => {});
1440
+
1441
+ await runWithKevReporting(useCase);
1442
+
1443
+ expect(
1444
+ kevReportCalls(mockIssueRepository.createNewIssue.mock.calls),
1445
+ ).toHaveLength(1);
1446
+ expect(errorSpy).toHaveBeenCalledWith(
1447
+ expect.stringContaining('disk is read-only'),
1448
+ );
1449
+ errorSpy.mockRestore();
1450
+ });
1241
1451
  });
1242
1452
  });