github-issue-tower-defence-management 1.179.0 → 1.180.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/CHANGELOG.md +15 -0
- package/bin/adapter/entry-points/console/consoleOperationApi.js +42 -1
- package/bin/adapter/entry-points/console/consoleOperationApi.js.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/entry-points/console/webServer.js +6 -0
- package/bin/adapter/entry-points/console/webServer.js.map +1 -1
- package/bin/adapter/repositories/SystemDateRepository.js +10 -10
- package/bin/adapter/repositories/SystemDateRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +23 -4
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js +1 -1
- package/bin/domain/usecases/AnalyzeProblemByIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js +1 -1
- package/bin/domain/usecases/AnalyzeStoriesUseCase.js.map +1 -1
- package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js +1 -1
- package/bin/domain/usecases/ClearPastNextActionDateHourUseCase.js.map +1 -1
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js +3 -3
- package/bin/domain/usecases/CreateEstimationIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +6 -6
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js +3 -1
- package/bin/domain/usecases/IssueRejectionEvaluator.js.map +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
- package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/issueReactivationTriggerIsPending.js +2 -2
- package/bin/domain/usecases/issueReactivationTriggerIsPending.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/consoleOperationApi.test.ts +230 -0
- package/src/adapter/entry-points/console/consoleOperationApi.ts +48 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleScenario.spec.ts +36 -0
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +25 -1
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.stories.tsx +30 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.test.tsx +111 -0
- package/src/adapter/entry-points/console/ui/src/features/console/components/list/ConsoleStoryReorderPanel.tsx +91 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/consoleApi.ts +21 -0
- package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.test.ts +24 -2
- package/src/adapter/entry-points/console/ui/src/features/console/logic/relativeTime.ts +3 -1
- package/src/adapter/entry-points/console/ui/src/features/console/pages/ConsolePage.tsx +60 -11
- package/src/adapter/entry-points/console/ui-dist/assets/index-I_2Yu8fM.js +86 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/entry-points/console/webServer.ts +11 -0
- package/src/adapter/repositories/SystemDateRepository.test.ts +60 -0
- package/src/adapter/repositories/SystemDateRepository.ts +10 -10
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +268 -2
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +33 -4
- package/src/domain/usecases/AnalyzeProblemByIssueUseCase.ts +1 -1
- package/src/domain/usecases/AnalyzeStoriesUseCase.test.ts +80 -0
- package/src/domain/usecases/AnalyzeStoriesUseCase.ts +1 -1
- package/src/domain/usecases/ClearPastNextActionDateHourUseCase.ts +6 -4
- package/src/domain/usecases/CreateEstimationIssueUseCase.test.ts +225 -0
- package/src/domain/usecases/CreateEstimationIssueUseCase.ts +3 -3
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +107 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +7 -7
- package/src/domain/usecases/IssueRejectionEvaluator.test.ts +83 -0
- package/src/domain/usecases/IssueRejectionEvaluator.ts +3 -1
- package/src/domain/usecases/StartPreparationUseCase.ts +5 -3
- package/src/domain/usecases/issueReactivationTriggerIsPending.test.ts +33 -33
- package/src/domain/usecases/issueReactivationTriggerIsPending.ts +6 -4
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts +3 -1
- package/types/adapter/entry-points/console/consoleOperationApi.d.ts.map +1 -1
- package/types/adapter/entry-points/console/webServer.d.ts +2 -0
- package/types/adapter/entry-points/console/webServer.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/ClearPastNextActionDateHourUseCase.d.ts.map +1 -1
- package/types/domain/usecases/IssueRejectionEvaluator.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/issueReactivationTriggerIsPending.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-KCmqh781.js +0 -86
- package/src/adapter/entry-points/console/ui-dist/assets/index-KCmqh781.js +0 -86
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-I_2Yu8fM.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-DeZcPNCr.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
import { IssueAttachmentRepository } from '../../../domain/usecases/adapter-interfaces/IssueAttachmentRepository';
|
|
5
5
|
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
6
|
+
import { ProjectRepository } from '../../../domain/usecases/adapter-interfaces/ProjectRepository';
|
|
6
7
|
import {
|
|
7
8
|
CONSOLE_LIST_TAB_NAMES,
|
|
8
9
|
buildConsoleDataResponse,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
handleComment,
|
|
29
30
|
handleCreateIssue,
|
|
30
31
|
handleIntmux,
|
|
32
|
+
handleReorderStory,
|
|
31
33
|
handleReview,
|
|
32
34
|
handleReviewComment,
|
|
33
35
|
handleTriage,
|
|
@@ -232,6 +234,7 @@ export type WebServerOptions = {
|
|
|
232
234
|
resolveProject?: ConsoleProjectResolver | null;
|
|
233
235
|
isPjcodeConfigured?: ConsolePjcodeValidator | null;
|
|
234
236
|
issueAttachmentRepository?: IssueAttachmentRepository | null;
|
|
237
|
+
projectRepository?: Pick<ProjectRepository, 'updateStoryList'> | null;
|
|
235
238
|
issueTitleStateCache?: IssueTitleStateCache | null;
|
|
236
239
|
pullRequestStatusCache?: PullRequestStatusCache | null;
|
|
237
240
|
};
|
|
@@ -529,6 +532,8 @@ const dispatchOperation = (
|
|
|
529
532
|
return handleAttachmentUpload(context, body);
|
|
530
533
|
case '/api/createissue':
|
|
531
534
|
return handleCreateIssue(context, body);
|
|
535
|
+
case '/api/reorderstory':
|
|
536
|
+
return handleReorderStory(context, body);
|
|
532
537
|
default:
|
|
533
538
|
return null;
|
|
534
539
|
}
|
|
@@ -551,12 +556,18 @@ const handleOperationApi = async (
|
|
|
551
556
|
}
|
|
552
557
|
const resolveIssueRepository =
|
|
553
558
|
options.resolveIssueRepository ?? ((): IssueRepository => issueRepository);
|
|
559
|
+
const projectRepository = options.projectRepository ?? null;
|
|
554
560
|
const context: ConsoleOperationContext = {
|
|
555
561
|
resolveIssueRepository,
|
|
556
562
|
resolveProject,
|
|
557
563
|
isPjcodeConfigured,
|
|
558
564
|
consoleDataOutputDir: options.consoleDataOutputDir,
|
|
559
565
|
issueAttachmentRepository: options.issueAttachmentRepository ?? null,
|
|
566
|
+
updateStoryList:
|
|
567
|
+
projectRepository !== null
|
|
568
|
+
? (project, stories) =>
|
|
569
|
+
projectRepository.updateStoryList(project, stories)
|
|
570
|
+
: null,
|
|
560
571
|
};
|
|
561
572
|
const dispatched = dispatchOperation(context, requestPath, body);
|
|
562
573
|
if (dispatched === null) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { SystemDateRepository } from './SystemDateRepository';
|
|
2
|
+
|
|
3
|
+
describe('SystemDateRepository', () => {
|
|
4
|
+
const repo = new SystemDateRepository();
|
|
5
|
+
|
|
6
|
+
const jan15At09h05 = new Date(Date.UTC(2026, 0, 15, 9, 5, 0));
|
|
7
|
+
const jan15At10h30 = new Date(Date.UTC(2026, 0, 15, 10, 30, 0));
|
|
8
|
+
const jan16At08h00 = new Date(Date.UTC(2026, 0, 16, 8, 0, 0));
|
|
9
|
+
|
|
10
|
+
describe('formatDurationToHHMM', () => {
|
|
11
|
+
it('formats whole hours', () => {
|
|
12
|
+
expect(repo.formatDurationToHHMM(120)).toBe('02:00');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('formats hours and minutes', () => {
|
|
16
|
+
expect(repo.formatDurationToHHMM(90)).toBe('01:30');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('formats minutes only', () => {
|
|
20
|
+
expect(repo.formatDurationToHHMM(5)).toBe('00:05');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('formatDateWithDayOfWeek', () => {
|
|
25
|
+
it('formats a UTC date with day-of-week in YYYY/MM/DD (Day) form', () => {
|
|
26
|
+
expect(repo.formatDateWithDayOfWeek(jan15At09h05)).toBe(
|
|
27
|
+
'2026/01/15 (Thu)',
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('formatDateTimeWithDayOfWeek', () => {
|
|
33
|
+
it('appends UTC HH:MM to the date-with-day-of-week', () => {
|
|
34
|
+
expect(repo.formatDateTimeWithDayOfWeek(jan15At09h05)).toBe(
|
|
35
|
+
'2026/01/15 (Thu) 09:05',
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('pads single-digit hour and minute', () => {
|
|
40
|
+
const date = new Date(Date.UTC(2026, 2, 3, 4, 7, 0));
|
|
41
|
+
expect(repo.formatDateTimeWithDayOfWeek(date)).toBe(
|
|
42
|
+
'2026/03/03 (Tue) 04:07',
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('formatStartEnd', () => {
|
|
48
|
+
it('omits the date for same-day end', () => {
|
|
49
|
+
expect(repo.formatStartEnd(jan15At09h05, jan15At10h30)).toBe(
|
|
50
|
+
'2026/01/15 (Thu) 09:05 - 10:30',
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('includes full date-time for end on a different day', () => {
|
|
55
|
+
expect(repo.formatStartEnd(jan15At09h05, jan16At08h00)).toBe(
|
|
56
|
+
'2026/01/15 (Thu) 09:05 - 2026/01/16 (Fri) 08:00',
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -9,25 +9,25 @@ export class SystemDateRepository implements DateRepository {
|
|
|
9
9
|
};
|
|
10
10
|
formatDateTimeWithDayOfWeek = (date: Date): string => {
|
|
11
11
|
const dateWithDayOfWeek = this.formatDateWithDayOfWeek(date);
|
|
12
|
-
const hours = String(date.
|
|
13
|
-
const minutes = String(date.
|
|
12
|
+
const hours = String(date.getUTCHours()).padStart(2, '0');
|
|
13
|
+
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
|
14
14
|
return `${dateWithDayOfWeek} ${hours}:${minutes}`;
|
|
15
15
|
};
|
|
16
16
|
|
|
17
17
|
formatDateWithDayOfWeek = (date: Date): string => {
|
|
18
18
|
const dayOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][
|
|
19
|
-
date.
|
|
19
|
+
date.getUTCDay()
|
|
20
20
|
];
|
|
21
|
-
const month = String(date.
|
|
22
|
-
const day = String(date.
|
|
23
|
-
return `${date.
|
|
21
|
+
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
|
22
|
+
const day = String(date.getUTCDate()).padStart(2, '0');
|
|
23
|
+
return `${date.getUTCFullYear()}/${month}/${day} (${dayOfWeek})`;
|
|
24
24
|
};
|
|
25
25
|
formatStartEnd = (start: Date, end: Date): string => {
|
|
26
26
|
const endDate =
|
|
27
|
-
start.
|
|
28
|
-
start.
|
|
29
|
-
start.
|
|
30
|
-
? `${String(end.
|
|
27
|
+
start.getUTCFullYear() === end.getUTCFullYear() &&
|
|
28
|
+
start.getUTCMonth() === end.getUTCMonth() &&
|
|
29
|
+
start.getUTCDate() === end.getUTCDate()
|
|
30
|
+
? `${String(end.getUTCHours()).padStart(2, '0')}:${String(end.getUTCMinutes()).padStart(2, '0')}`
|
|
31
31
|
: this.formatDateTimeWithDayOfWeek(end);
|
|
32
32
|
return `${this.formatDateTimeWithDayOfWeek(start)} - ${endDate}`;
|
|
33
33
|
};
|
|
@@ -394,6 +394,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
394
394
|
dateRepository,
|
|
395
395
|
} = createApiV3CheerioRestIssueRepository();
|
|
396
396
|
const cachedProject = buildTestProject('cached-project');
|
|
397
|
+
const freshProject = buildTestProject('cached-project');
|
|
397
398
|
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
398
399
|
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
399
400
|
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
@@ -406,6 +407,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
406
407
|
),
|
|
407
408
|
],
|
|
408
409
|
});
|
|
410
|
+
projectRepository.getProject.mockResolvedValue(freshProject);
|
|
409
411
|
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
410
412
|
buildLightItem(
|
|
411
413
|
'item-fresh',
|
|
@@ -427,8 +429,10 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
427
429
|
const result = await repository.getAllIssues('cached-project');
|
|
428
430
|
|
|
429
431
|
expect(result.cacheUsed).toBe(true);
|
|
430
|
-
expect(result.project).toBe(
|
|
431
|
-
expect(projectRepository.getProject).
|
|
432
|
+
expect(result.project).toBe(freshProject);
|
|
433
|
+
expect(projectRepository.getProject).toHaveBeenCalledWith(
|
|
434
|
+
'cached-project',
|
|
435
|
+
);
|
|
432
436
|
const lightCall =
|
|
433
437
|
graphqlProjectItemRepository.fetchProjectItemsLight.mock.calls[0];
|
|
434
438
|
expect(lightCall[0]).toBe('cached-project');
|
|
@@ -463,6 +467,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
463
467
|
repository,
|
|
464
468
|
graphqlProjectItemRepository,
|
|
465
469
|
localStorageCacheRepository,
|
|
470
|
+
projectRepository,
|
|
466
471
|
dateRepository,
|
|
467
472
|
} = createApiV3CheerioRestIssueRepository();
|
|
468
473
|
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
@@ -472,6 +477,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
472
477
|
project: buildTestProject('cached-project'),
|
|
473
478
|
issues: [],
|
|
474
479
|
});
|
|
480
|
+
projectRepository.getProject.mockResolvedValue(
|
|
481
|
+
buildTestProject('cached-project'),
|
|
482
|
+
);
|
|
475
483
|
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
476
484
|
buildLightItem(
|
|
477
485
|
'wellBefore',
|
|
@@ -509,6 +517,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
509
517
|
repository,
|
|
510
518
|
graphqlProjectItemRepository,
|
|
511
519
|
localStorageCacheRepository,
|
|
520
|
+
projectRepository,
|
|
512
521
|
dateRepository,
|
|
513
522
|
} = createApiV3CheerioRestIssueRepository();
|
|
514
523
|
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:30:00Z'));
|
|
@@ -518,6 +527,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
518
527
|
project: buildTestProject('cached-project'),
|
|
519
528
|
issues: [],
|
|
520
529
|
});
|
|
530
|
+
projectRepository.getProject.mockResolvedValue(
|
|
531
|
+
buildTestProject('cached-project'),
|
|
532
|
+
);
|
|
521
533
|
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
522
534
|
buildLightItem(
|
|
523
535
|
'previousDay',
|
|
@@ -548,6 +560,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
548
560
|
repository,
|
|
549
561
|
graphqlProjectItemRepository,
|
|
550
562
|
localStorageCacheRepository,
|
|
563
|
+
projectRepository,
|
|
551
564
|
dateRepository,
|
|
552
565
|
} = createApiV3CheerioRestIssueRepository();
|
|
553
566
|
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
@@ -562,6 +575,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
562
575
|
),
|
|
563
576
|
],
|
|
564
577
|
});
|
|
578
|
+
projectRepository.getProject.mockResolvedValue(
|
|
579
|
+
buildTestProject('cached-project'),
|
|
580
|
+
);
|
|
565
581
|
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([
|
|
566
582
|
buildLightItem(
|
|
567
583
|
'stale',
|
|
@@ -618,6 +634,252 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
618
634
|
});
|
|
619
635
|
});
|
|
620
636
|
|
|
637
|
+
describe('getAllIssues story option rename detection', () => {
|
|
638
|
+
const buildStoryOption = (
|
|
639
|
+
id: string,
|
|
640
|
+
name: string,
|
|
641
|
+
): {
|
|
642
|
+
id: string;
|
|
643
|
+
name: string;
|
|
644
|
+
color: 'GRAY';
|
|
645
|
+
description: string;
|
|
646
|
+
} => ({ id, name, color: 'GRAY', description: '' });
|
|
647
|
+
|
|
648
|
+
const buildProjectWithStories = (
|
|
649
|
+
id: string,
|
|
650
|
+
stories: { id: string; name: string }[],
|
|
651
|
+
): Project => ({
|
|
652
|
+
...buildTestProject(id),
|
|
653
|
+
story: {
|
|
654
|
+
name: 'Story',
|
|
655
|
+
fieldId: 'story-field-id',
|
|
656
|
+
databaseId: 1,
|
|
657
|
+
stories: stories.map((s) => buildStoryOption(s.id, s.name)),
|
|
658
|
+
workflowManagementStory: { id: 'wms-id', name: 'workflow management' },
|
|
659
|
+
},
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('calls projectRepository.getProject on every incremental fetch cycle', async () => {
|
|
663
|
+
const {
|
|
664
|
+
repository,
|
|
665
|
+
graphqlProjectItemRepository,
|
|
666
|
+
localStorageCacheRepository,
|
|
667
|
+
projectRepository,
|
|
668
|
+
dateRepository,
|
|
669
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
670
|
+
const cachedProject = buildTestProject('cached-project');
|
|
671
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
672
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
673
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
674
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
675
|
+
project: cachedProject,
|
|
676
|
+
issues: [],
|
|
677
|
+
});
|
|
678
|
+
projectRepository.getProject.mockResolvedValue(cachedProject);
|
|
679
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
|
|
680
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
681
|
+
|
|
682
|
+
await repository.getAllIssues('cached-project');
|
|
683
|
+
|
|
684
|
+
expect(projectRepository.getProject).toHaveBeenCalledWith(
|
|
685
|
+
'cached-project',
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it('escalates to full fetch when a story option name changes between cache and fresh project', async () => {
|
|
690
|
+
const {
|
|
691
|
+
repository,
|
|
692
|
+
graphqlProjectItemRepository,
|
|
693
|
+
localStorageCacheRepository,
|
|
694
|
+
projectRepository,
|
|
695
|
+
dateRepository,
|
|
696
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
697
|
+
const cachedProject = buildProjectWithStories('cached-project', [
|
|
698
|
+
{ id: 'opt-1', name: 'Old Story Name' },
|
|
699
|
+
]);
|
|
700
|
+
const freshProject = buildProjectWithStories('cached-project', [
|
|
701
|
+
{ id: 'opt-1', name: 'New Story Name' },
|
|
702
|
+
]);
|
|
703
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
704
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
705
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
706
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
707
|
+
project: cachedProject,
|
|
708
|
+
issues: [],
|
|
709
|
+
});
|
|
710
|
+
projectRepository.getProject.mockResolvedValue(freshProject);
|
|
711
|
+
graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
|
|
712
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
713
|
+
|
|
714
|
+
const result = await repository.getAllIssues('cached-project');
|
|
715
|
+
|
|
716
|
+
expect(
|
|
717
|
+
graphqlProjectItemRepository.fetchProjectItems,
|
|
718
|
+
).toHaveBeenCalledWith('cached-project');
|
|
719
|
+
expect(
|
|
720
|
+
graphqlProjectItemRepository.fetchProjectItemsLight,
|
|
721
|
+
).not.toHaveBeenCalled();
|
|
722
|
+
expect(result.cacheUsed).toBe(false);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('escalates to full fetch when a story option is removed in the fresh project', async () => {
|
|
726
|
+
const {
|
|
727
|
+
repository,
|
|
728
|
+
graphqlProjectItemRepository,
|
|
729
|
+
localStorageCacheRepository,
|
|
730
|
+
projectRepository,
|
|
731
|
+
dateRepository,
|
|
732
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
733
|
+
const cachedProject = buildProjectWithStories('cached-project', [
|
|
734
|
+
{ id: 'opt-1', name: 'Story A' },
|
|
735
|
+
{ id: 'opt-2', name: 'Story B' },
|
|
736
|
+
]);
|
|
737
|
+
const freshProject = buildProjectWithStories('cached-project', [
|
|
738
|
+
{ id: 'opt-1', name: 'Story A' },
|
|
739
|
+
]);
|
|
740
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
741
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
742
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
743
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
744
|
+
project: cachedProject,
|
|
745
|
+
issues: [],
|
|
746
|
+
});
|
|
747
|
+
projectRepository.getProject.mockResolvedValue(freshProject);
|
|
748
|
+
graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
|
|
749
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
750
|
+
|
|
751
|
+
const result = await repository.getAllIssues('cached-project');
|
|
752
|
+
|
|
753
|
+
expect(
|
|
754
|
+
graphqlProjectItemRepository.fetchProjectItems,
|
|
755
|
+
).toHaveBeenCalledWith('cached-project');
|
|
756
|
+
expect(result.cacheUsed).toBe(false);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('proceeds with incremental fetch when story options are unchanged', async () => {
|
|
760
|
+
const {
|
|
761
|
+
repository,
|
|
762
|
+
graphqlProjectItemRepository,
|
|
763
|
+
localStorageCacheRepository,
|
|
764
|
+
projectRepository,
|
|
765
|
+
dateRepository,
|
|
766
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
767
|
+
const cachedProject = buildProjectWithStories('cached-project', [
|
|
768
|
+
{ id: 'opt-1', name: 'Story A' },
|
|
769
|
+
]);
|
|
770
|
+
const freshProject = buildProjectWithStories('cached-project', [
|
|
771
|
+
{ id: 'opt-1', name: 'Story A' },
|
|
772
|
+
]);
|
|
773
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
774
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
775
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
776
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
777
|
+
project: cachedProject,
|
|
778
|
+
issues: [],
|
|
779
|
+
});
|
|
780
|
+
projectRepository.getProject.mockResolvedValue(freshProject);
|
|
781
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
|
|
782
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
783
|
+
|
|
784
|
+
const result = await repository.getAllIssues('cached-project');
|
|
785
|
+
|
|
786
|
+
expect(
|
|
787
|
+
graphqlProjectItemRepository.fetchProjectItems,
|
|
788
|
+
).not.toHaveBeenCalled();
|
|
789
|
+
expect(result.cacheUsed).toBe(true);
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
it('writes the fresh project (not cache.project) to cache during incremental fetch', async () => {
|
|
793
|
+
const {
|
|
794
|
+
repository,
|
|
795
|
+
graphqlProjectItemRepository,
|
|
796
|
+
localStorageCacheRepository,
|
|
797
|
+
projectRepository,
|
|
798
|
+
dateRepository,
|
|
799
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
800
|
+
const cachedProject = buildProjectWithStories('cached-project', [
|
|
801
|
+
{ id: 'opt-1', name: 'Story A' },
|
|
802
|
+
]);
|
|
803
|
+
const freshProject = buildProjectWithStories('cached-project', [
|
|
804
|
+
{ id: 'opt-1', name: 'Story A' },
|
|
805
|
+
{ id: 'opt-2', name: 'Story B (new)' },
|
|
806
|
+
]);
|
|
807
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
808
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
809
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
810
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
811
|
+
project: cachedProject,
|
|
812
|
+
issues: [],
|
|
813
|
+
});
|
|
814
|
+
projectRepository.getProject.mockResolvedValue(freshProject);
|
|
815
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
|
|
816
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
817
|
+
|
|
818
|
+
await repository.getAllIssues('cached-project');
|
|
819
|
+
|
|
820
|
+
const cacheWrite = localStorageCacheRepository.setSingle.mock.calls[0][1];
|
|
821
|
+
expect(cacheWrite).toEqual(
|
|
822
|
+
expect.objectContaining({ project: freshProject }),
|
|
823
|
+
);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
it('falls back to cache.project and logs a warning when getProject fails during incremental fetch', async () => {
|
|
827
|
+
const {
|
|
828
|
+
repository,
|
|
829
|
+
graphqlProjectItemRepository,
|
|
830
|
+
localStorageCacheRepository,
|
|
831
|
+
projectRepository,
|
|
832
|
+
dateRepository,
|
|
833
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
834
|
+
const cachedProject = buildTestProject('cached-project');
|
|
835
|
+
const warnSpy = jest
|
|
836
|
+
.spyOn(console, 'warn')
|
|
837
|
+
.mockImplementation(() => undefined);
|
|
838
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
839
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
840
|
+
lastFetchedAt: '2026-07-07T00:30:00.000Z',
|
|
841
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
842
|
+
project: cachedProject,
|
|
843
|
+
issues: [],
|
|
844
|
+
});
|
|
845
|
+
projectRepository.getProject.mockRejectedValue(
|
|
846
|
+
new Error('network error'),
|
|
847
|
+
);
|
|
848
|
+
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
|
|
849
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
850
|
+
|
|
851
|
+
const result = await repository.getAllIssues('cached-project');
|
|
852
|
+
|
|
853
|
+
expect(result.cacheUsed).toBe(true);
|
|
854
|
+
expect(result.project).toBe(cachedProject);
|
|
855
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
856
|
+
expect.stringContaining('Failed to refresh project metadata'),
|
|
857
|
+
);
|
|
858
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
859
|
+
expect.stringContaining('cached-project'),
|
|
860
|
+
);
|
|
861
|
+
warnSpy.mockRestore();
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('re-throws the error when getProject fails during a full fetch with no cache', async () => {
|
|
865
|
+
const {
|
|
866
|
+
repository,
|
|
867
|
+
localStorageCacheRepository,
|
|
868
|
+
projectRepository,
|
|
869
|
+
dateRepository,
|
|
870
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
871
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:00:00Z'));
|
|
872
|
+
localStorageCacheRepository.getSingle.mockResolvedValue(null);
|
|
873
|
+
projectRepository.getProject.mockRejectedValue(
|
|
874
|
+
new Error('project fetch failed'),
|
|
875
|
+
);
|
|
876
|
+
|
|
877
|
+
await expect(repository.getAllIssues('cached-project')).rejects.toThrow(
|
|
878
|
+
'project fetch failed',
|
|
879
|
+
);
|
|
880
|
+
});
|
|
881
|
+
});
|
|
882
|
+
|
|
621
883
|
describe('getLastIssuesFetchedAt', () => {
|
|
622
884
|
it('reports null before any fetch has happened for the project', () => {
|
|
623
885
|
const { repository } = createApiV3CheerioRestIssueRepository();
|
|
@@ -653,6 +915,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
653
915
|
repository,
|
|
654
916
|
graphqlProjectItemRepository,
|
|
655
917
|
localStorageCacheRepository,
|
|
918
|
+
projectRepository,
|
|
656
919
|
dateRepository,
|
|
657
920
|
} = createApiV3CheerioRestIssueRepository();
|
|
658
921
|
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:45:00Z'));
|
|
@@ -662,6 +925,9 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
662
925
|
project: buildTestProject('cached-project'),
|
|
663
926
|
issues: [],
|
|
664
927
|
});
|
|
928
|
+
projectRepository.getProject.mockResolvedValue(
|
|
929
|
+
buildTestProject('cached-project'),
|
|
930
|
+
);
|
|
665
931
|
graphqlProjectItemRepository.fetchProjectItemsLight.mockResolvedValue([]);
|
|
666
932
|
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
667
933
|
|
|
@@ -808,11 +808,41 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
808
808
|
now.getTime() - new Date(cache.lastFullFetchAt).getTime() >=
|
|
809
809
|
FULL_ISSUE_FETCH_INTERVAL_MS;
|
|
810
810
|
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
811
|
+
let project: Project;
|
|
812
|
+
try {
|
|
813
|
+
const freshProject = await this.projectRepository.getProject(projectId);
|
|
814
|
+
if (!freshProject) {
|
|
814
815
|
throw new Error(`Project not found. projectId: ${projectId}`);
|
|
815
816
|
}
|
|
817
|
+
project = freshProject;
|
|
818
|
+
} catch (error) {
|
|
819
|
+
if (!isFullFetch && cache !== null) {
|
|
820
|
+
console.warn(
|
|
821
|
+
`Failed to refresh project metadata, using cached. projectId: ${projectId}, error: ${String(error)}`,
|
|
822
|
+
);
|
|
823
|
+
project = cache.project;
|
|
824
|
+
} else {
|
|
825
|
+
throw error;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const cacheStoriesByOptionId = new Map(
|
|
830
|
+
cache?.project.story?.stories.map((s) => [s.id, s.name]) ?? [],
|
|
831
|
+
);
|
|
832
|
+
const freshStoryOptionIds = new Set(
|
|
833
|
+
project.story?.stories.map((s) => s.id) ?? [],
|
|
834
|
+
);
|
|
835
|
+
const storyOptionsChanged =
|
|
836
|
+
cache !== null &&
|
|
837
|
+
([...cacheStoriesByOptionId.entries()].some(
|
|
838
|
+
([id, name]) =>
|
|
839
|
+
!freshStoryOptionIds.has(id) ||
|
|
840
|
+
project.story?.stories.find((s) => s.id === id)?.name !== name,
|
|
841
|
+
) ??
|
|
842
|
+
false);
|
|
843
|
+
const effectiveIsFullFetch = isFullFetch || storyOptionsChanged;
|
|
844
|
+
|
|
845
|
+
if (effectiveIsFullFetch) {
|
|
816
846
|
const items =
|
|
817
847
|
await this.graphqlProjectItemRepository.fetchProjectItems(projectId);
|
|
818
848
|
const issues = items.map((item) => this.convertProjectItemToIssue(item));
|
|
@@ -827,7 +857,6 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
827
857
|
return { issues, project, cacheUsed: false };
|
|
828
858
|
}
|
|
829
859
|
|
|
830
|
-
const project = cache.project;
|
|
831
860
|
const lastFetchedAt = new Date(cache.lastFetchedAt);
|
|
832
861
|
const cutoff = new Date(
|
|
833
862
|
lastFetchedAt.getTime() - INCREMENTAL_FETCH_SKEW_BUFFER_MS,
|
|
@@ -26,7 +26,7 @@ export class AnalyzeProblemByIssueUseCase {
|
|
|
26
26
|
!story ||
|
|
27
27
|
!input.targetDates.find(
|
|
28
28
|
(targetDate) =>
|
|
29
|
-
targetDate.
|
|
29
|
+
targetDate.getUTCHours() === 0 && targetDate.getUTCMinutes() === 0,
|
|
30
30
|
)
|
|
31
31
|
) {
|
|
32
32
|
return;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { mock } from 'jest-mock-extended';
|
|
2
|
+
import { AnalyzeStoriesUseCase } from './AnalyzeStoriesUseCase';
|
|
3
|
+
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
4
|
+
import { DateRepository } from './adapter-interfaces/DateRepository';
|
|
5
|
+
import { Project } from '../entities/Project';
|
|
6
|
+
import { StoryObject } from '../entities/StoryObjectMap';
|
|
7
|
+
|
|
8
|
+
describe('AnalyzeStoriesUseCase', () => {
|
|
9
|
+
const mockIssueRepository = mock<IssueRepository>();
|
|
10
|
+
const mockDateRepository = mock<DateRepository>();
|
|
11
|
+
|
|
12
|
+
let useCase: AnalyzeStoriesUseCase;
|
|
13
|
+
|
|
14
|
+
const projectWithStory: Project = {
|
|
15
|
+
...mock<Project>(),
|
|
16
|
+
story: {
|
|
17
|
+
name: 'Story',
|
|
18
|
+
fieldId: 'story-field',
|
|
19
|
+
databaseId: 1,
|
|
20
|
+
stories: [],
|
|
21
|
+
workflowManagementStory: { id: 'wms-id', name: 'workflow management' },
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const commonInput = {
|
|
26
|
+
project: projectWithStory,
|
|
27
|
+
issues: [],
|
|
28
|
+
cacheUsed: false,
|
|
29
|
+
manager: 'manager-user',
|
|
30
|
+
org: 'org',
|
|
31
|
+
repo: 'repo',
|
|
32
|
+
urlOfStoryView: 'https://github.com/org/repo',
|
|
33
|
+
storyObjectMap: new Map<string, StoryObject>(),
|
|
34
|
+
members: [],
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
jest.clearAllMocks();
|
|
39
|
+
useCase = new AnalyzeStoriesUseCase(
|
|
40
|
+
mockIssueRepository,
|
|
41
|
+
mockDateRepository,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('run — UTC 05:00 guard', () => {
|
|
46
|
+
it('returns early when no targetDate is at UTC 05:00', async () => {
|
|
47
|
+
const nonMatchingDate = new Date(Date.UTC(2026, 0, 15, 4, 0, 0));
|
|
48
|
+
|
|
49
|
+
await useCase.run({ ...commonInput, targetDates: [nonMatchingDate] });
|
|
50
|
+
|
|
51
|
+
expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('proceeds when a targetDate is exactly UTC 05:00', async () => {
|
|
55
|
+
const matchingDate = new Date(Date.UTC(2026, 0, 15, 5, 0, 0));
|
|
56
|
+
|
|
57
|
+
await useCase.run({ ...commonInput, targetDates: [matchingDate] });
|
|
58
|
+
|
|
59
|
+
expect(mockIssueRepository.createNewIssue).toHaveBeenCalledTimes(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('returns early when project has no story field', async () => {
|
|
63
|
+
const projectWithoutStory: Project = { ...projectWithStory, story: null };
|
|
64
|
+
|
|
65
|
+
await useCase.run({
|
|
66
|
+
...commonInput,
|
|
67
|
+
project: projectWithoutStory,
|
|
68
|
+
targetDates: [new Date(Date.UTC(2026, 0, 15, 5, 0, 0))],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns early when targetDates is empty', async () => {
|
|
75
|
+
await useCase.run({ ...commonInput, targetDates: [] });
|
|
76
|
+
|
|
77
|
+
expect(mockIssueRepository.createNewIssue).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -28,10 +28,12 @@ export class ClearPastNextActionDateHourUseCase {
|
|
|
28
28
|
}
|
|
29
29
|
const scheduledDate = issue.nextActionDate ?? now;
|
|
30
30
|
const scheduledTime = new Date(
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
31
|
+
Date.UTC(
|
|
32
|
+
scheduledDate.getUTCFullYear(),
|
|
33
|
+
scheduledDate.getUTCMonth(),
|
|
34
|
+
scheduledDate.getUTCDate(),
|
|
35
|
+
issue.nextActionHour,
|
|
36
|
+
),
|
|
35
37
|
);
|
|
36
38
|
if (scheduledTime.getTime() > now.getTime()) {
|
|
37
39
|
continue;
|