github-issue-tower-defence-management 1.119.4 → 1.121.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 +14 -0
- package/README.md +7 -9
- package/bin/adapter/entry-points/cli/index.js +8 -14
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/cli/projectConfig.js +0 -6
- package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +6 -12
- package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -10
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js +23 -19
- package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
- package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +1 -2
- package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -1
- package/bin/adapter/repositories/LocalStorageCacheRepository.js +25 -0
- package/bin/adapter/repositories/LocalStorageCacheRepository.js.map +1 -1
- package/bin/adapter/repositories/ProcHostMetricsRepository.js +10 -5
- package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +119 -59
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +12 -3
- package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
- package/bin/domain/usecases/GetStoryObjectMapUseCase.js +1 -1
- package/bin/domain/usecases/GetStoryObjectMapUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -8
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -2
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +1 -1
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +1 -1
- package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +1 -1
- package/bin/domain/usecases/StaleTmuxSessionKillUseCase.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/console/GenerateConsoleListsUseCase.js +3 -1
- package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +0 -5
- package/src/adapter/entry-points/cli/index.ts +16 -17
- package/src/adapter/entry-points/cli/projectConfig.ts +0 -8
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +4 -25
- package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +4 -7
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +2 -25
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -4
- package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +29 -11
- package/src/adapter/entry-points/handlers/machineStatusWriter.ts +30 -19
- package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +0 -5
- package/src/adapter/entry-points/handlers/situationFileWriter.ts +0 -1
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +0 -5
- package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +1 -9
- package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +75 -0
- package/src/adapter/repositories/LocalStorageCacheRepository.ts +24 -0
- package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +29 -8
- package/src/adapter/repositories/ProcHostMetricsRepository.ts +10 -6
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +198 -47
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +160 -81
- package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +23 -3
- package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +7 -12
- package/src/domain/usecases/GetStoryObjectMapUseCase.ts +1 -5
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -24
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +6 -17
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -8
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +35 -42
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +1 -5
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +32 -32
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -5
- package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +17 -4
- package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +1 -4
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +1 -7
- package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +1 -5
- package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -94
- package/src/domain/usecases/StartPreparationUseCase.ts +2 -5
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +3 -10
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +10 -0
- package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +4 -1
- package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -1
- package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
- package/types/adapter/entry-points/function/getStoryObjectMap.d.ts +1 -1
- package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts +1 -1
- package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
- package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +0 -1
- package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +0 -1
- package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -1
- package/types/adapter/repositories/LocalStorageCacheRepository.d.ts +2 -0
- package/types/adapter/repositories/LocalStorageCacheRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +1 -1
- package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +22 -7
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -1
- package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
- package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts +0 -1
- package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +0 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +0 -1
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +0 -1
- package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts +0 -1
- package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +4 -3
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/console/GenerateConsoleListsUseCase.d.ts.map +1 -1
|
@@ -9,6 +9,66 @@ import {
|
|
|
9
9
|
import { LocalStorageCacheRepository } from '../LocalStorageCacheRepository';
|
|
10
10
|
import { LocalStorageRepository } from '../LocalStorageRepository';
|
|
11
11
|
import { Project } from '../../../domain/entities/Project';
|
|
12
|
+
import { ProjectRepository } from '../../../domain/usecases/adapter-interfaces/ProjectRepository';
|
|
13
|
+
import { DateRepository } from '../../../domain/usecases/adapter-interfaces/DateRepository';
|
|
14
|
+
|
|
15
|
+
const buildTestProject = (id: string): Project => ({
|
|
16
|
+
id,
|
|
17
|
+
url: 'https://github.com/orgs/o/projects/1',
|
|
18
|
+
databaseId: 1,
|
|
19
|
+
name: 'Test Project',
|
|
20
|
+
status: { name: 'Status', fieldId: 'f-status', statuses: [] },
|
|
21
|
+
nextActionDate: null,
|
|
22
|
+
nextActionHour: null,
|
|
23
|
+
story: null,
|
|
24
|
+
remainingEstimationMinutes: null,
|
|
25
|
+
dependedIssueUrlSeparatedByComma: null,
|
|
26
|
+
completionDate50PercentConfidence: null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const buildCachedIssueRecord = (url: string, title: string) => ({
|
|
30
|
+
nameWithOwner: 'o/r',
|
|
31
|
+
url,
|
|
32
|
+
title,
|
|
33
|
+
number: 1,
|
|
34
|
+
state: 'OPEN',
|
|
35
|
+
labels: [],
|
|
36
|
+
assignees: [],
|
|
37
|
+
nextActionDate: null,
|
|
38
|
+
nextActionHour: null,
|
|
39
|
+
estimationMinutes: null,
|
|
40
|
+
dependedIssueUrls: [],
|
|
41
|
+
completionDate50PercentConfidence: null,
|
|
42
|
+
status: null,
|
|
43
|
+
story: null,
|
|
44
|
+
org: 'o',
|
|
45
|
+
repo: 'r',
|
|
46
|
+
body: '',
|
|
47
|
+
itemId: 'item-cached',
|
|
48
|
+
isPr: false,
|
|
49
|
+
isInProgress: false,
|
|
50
|
+
isClosed: false,
|
|
51
|
+
createdAt: '2026-07-01T00:00:00.000Z',
|
|
52
|
+
author: '',
|
|
53
|
+
closingIssueReferenceUrls: [],
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const buildProjectItem = (url: string, title: string): ProjectItem => ({
|
|
57
|
+
id: `item-${title}`,
|
|
58
|
+
nameWithOwner: 'o/r',
|
|
59
|
+
number: 1,
|
|
60
|
+
title,
|
|
61
|
+
state: 'OPEN',
|
|
62
|
+
url,
|
|
63
|
+
body: null,
|
|
64
|
+
labels: [],
|
|
65
|
+
assignees: [],
|
|
66
|
+
createdAt: '2026-07-01T00:00:00.000Z',
|
|
67
|
+
updatedAt: '2026-07-06T00:00:00.000Z',
|
|
68
|
+
author: '',
|
|
69
|
+
closingIssueReferenceUrls: [],
|
|
70
|
+
customFields: [],
|
|
71
|
+
});
|
|
12
72
|
|
|
13
73
|
describe('ApiV3CheerioRestIssueRepository', () => {
|
|
14
74
|
describe('convertProjectItemToIssue', () => {
|
|
@@ -35,6 +95,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
35
95
|
labels: [],
|
|
36
96
|
assignees: [],
|
|
37
97
|
createdAt: '2024-01-01T00:00:00Z',
|
|
98
|
+
updatedAt: '2024-01-02T00:00:00Z',
|
|
38
99
|
author: 'test-author',
|
|
39
100
|
closingIssueReferenceUrls: [
|
|
40
101
|
'https://github.com/HiromiShikata/test-repository/issues/7',
|
|
@@ -91,6 +152,7 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
91
152
|
labels: [],
|
|
92
153
|
assignees: [],
|
|
93
154
|
createdAt: '2024-01-01T00:00:00Z',
|
|
155
|
+
updatedAt: '2024-01-02T00:00:00Z',
|
|
94
156
|
author: '',
|
|
95
157
|
closingIssueReferenceUrls: [],
|
|
96
158
|
customFields: [
|
|
@@ -139,57 +201,133 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
139
201
|
expect(result).toEqual(arg.expected);
|
|
140
202
|
});
|
|
141
203
|
});
|
|
142
|
-
describe('
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
expect(
|
|
204
|
+
describe('getAllIssues full fetch', () => {
|
|
205
|
+
it('fetches the project and all items when no cache exists', async () => {
|
|
206
|
+
const {
|
|
207
|
+
repository,
|
|
208
|
+
graphqlProjectItemRepository,
|
|
209
|
+
localStorageCacheRepository,
|
|
210
|
+
projectRepository,
|
|
211
|
+
dateRepository,
|
|
212
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
213
|
+
const project = buildTestProject('test-project-id');
|
|
214
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:00:00Z'));
|
|
215
|
+
localStorageCacheRepository.getSingle.mockResolvedValue(null);
|
|
216
|
+
projectRepository.getProject.mockResolvedValue(project);
|
|
217
|
+
graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
|
|
218
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
219
|
+
|
|
220
|
+
const result = await repository.getAllIssues('test-project-id');
|
|
221
|
+
|
|
222
|
+
expect(result.issues).toEqual([]);
|
|
223
|
+
expect(result.project).toBe(project);
|
|
224
|
+
expect(result.cacheUsed).toBe(false);
|
|
225
|
+
expect(
|
|
226
|
+
graphqlProjectItemRepository.fetchProjectItems,
|
|
227
|
+
).toHaveBeenCalledWith('test-project-id');
|
|
228
|
+
expect(localStorageCacheRepository.setSingle).toHaveBeenCalledTimes(1);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('memoizes the refresh so a second call does not fetch again', async () => {
|
|
232
|
+
const {
|
|
233
|
+
repository,
|
|
234
|
+
graphqlProjectItemRepository,
|
|
235
|
+
localStorageCacheRepository,
|
|
236
|
+
projectRepository,
|
|
237
|
+
dateRepository,
|
|
238
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
239
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:00:00Z'));
|
|
240
|
+
localStorageCacheRepository.getSingle.mockResolvedValue(null);
|
|
241
|
+
projectRepository.getProject.mockResolvedValue(
|
|
242
|
+
buildTestProject('test-project-id'),
|
|
243
|
+
);
|
|
244
|
+
graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
|
|
245
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
246
|
+
|
|
247
|
+
await repository.getAllIssues('test-project-id');
|
|
248
|
+
await repository.getAllIssues('test-project-id');
|
|
249
|
+
|
|
250
|
+
expect(
|
|
251
|
+
graphqlProjectItemRepository.fetchProjectItems,
|
|
252
|
+
).toHaveBeenCalledTimes(1);
|
|
167
253
|
});
|
|
168
254
|
});
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
params: Parameters<ApiV3CheerioRestIssueRepository['getAllIssues']>;
|
|
173
|
-
expected: Awaited<
|
|
174
|
-
ReturnType<ApiV3CheerioRestIssueRepository['getAllIssues']>
|
|
175
|
-
>;
|
|
176
|
-
}[] = [
|
|
177
|
-
{
|
|
178
|
-
name: 'normal case',
|
|
179
|
-
params: ['test-project-id', 1],
|
|
180
|
-
expected: { issues: [], cacheUsed: false },
|
|
181
|
-
},
|
|
182
|
-
];
|
|
183
|
-
test.each(testCases)('%s', async (arg) => {
|
|
255
|
+
|
|
256
|
+
describe('getAllIssues incremental fetch', () => {
|
|
257
|
+
it('reuses the cached project, upserts changed issues by url, and marks cacheUsed', async () => {
|
|
184
258
|
const {
|
|
185
259
|
repository,
|
|
186
260
|
graphqlProjectItemRepository,
|
|
187
261
|
localStorageCacheRepository,
|
|
262
|
+
projectRepository,
|
|
263
|
+
dateRepository,
|
|
188
264
|
} = createApiV3CheerioRestIssueRepository();
|
|
265
|
+
const cachedProject = buildTestProject('cached-project');
|
|
266
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:30:00Z'));
|
|
267
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
268
|
+
lastFetchedAt: '2026-07-07T00:00:00.000Z',
|
|
269
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
270
|
+
project: cachedProject,
|
|
271
|
+
issues: [
|
|
272
|
+
buildCachedIssueRecord(
|
|
273
|
+
'https://github.com/o/r/issues/1',
|
|
274
|
+
'stale title',
|
|
275
|
+
),
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([
|
|
279
|
+
buildProjectItem('https://github.com/o/r/issues/1', 'fresh title'),
|
|
280
|
+
buildProjectItem('https://github.com/o/r/issues/2', 'new issue'),
|
|
281
|
+
]);
|
|
282
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
283
|
+
|
|
284
|
+
const result = await repository.getAllIssues('cached-project');
|
|
285
|
+
|
|
286
|
+
expect(result.cacheUsed).toBe(true);
|
|
287
|
+
expect(result.project).toBe(cachedProject);
|
|
288
|
+
expect(projectRepository.getProject).not.toHaveBeenCalled();
|
|
289
|
+
const call = graphqlProjectItemRepository.fetchProjectItems.mock.calls[0];
|
|
290
|
+
expect(call[0]).toBe('cached-project');
|
|
291
|
+
expect(call[1]).toBe('updated:>=2026-07-06');
|
|
292
|
+
const titlesByUrl = new Map(
|
|
293
|
+
result.issues.map((issue) => [issue.url, issue.title]),
|
|
294
|
+
);
|
|
295
|
+
expect(titlesByUrl.get('https://github.com/o/r/issues/1')).toBe(
|
|
296
|
+
'fresh title',
|
|
297
|
+
);
|
|
298
|
+
expect(titlesByUrl.get('https://github.com/o/r/issues/2')).toBe(
|
|
299
|
+
'new issue',
|
|
300
|
+
);
|
|
301
|
+
expect(result.issues).toHaveLength(2);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('performs a full fetch when the hourly gate has elapsed', async () => {
|
|
305
|
+
const {
|
|
306
|
+
repository,
|
|
307
|
+
graphqlProjectItemRepository,
|
|
308
|
+
localStorageCacheRepository,
|
|
309
|
+
projectRepository,
|
|
310
|
+
dateRepository,
|
|
311
|
+
} = createApiV3CheerioRestIssueRepository();
|
|
312
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T02:00:00Z'));
|
|
313
|
+
localStorageCacheRepository.getSingle.mockResolvedValue({
|
|
314
|
+
lastFetchedAt: '2026-07-07T00:50:00.000Z',
|
|
315
|
+
lastFullFetchAt: '2026-07-07T00:00:00.000Z',
|
|
316
|
+
project: buildTestProject('cached-project'),
|
|
317
|
+
issues: [],
|
|
318
|
+
});
|
|
319
|
+
const freshProject = buildTestProject('fresh-project');
|
|
320
|
+
projectRepository.getProject.mockResolvedValue(freshProject);
|
|
189
321
|
graphqlProjectItemRepository.fetchProjectItems.mockResolvedValue([]);
|
|
190
|
-
localStorageCacheRepository.
|
|
191
|
-
|
|
192
|
-
|
|
322
|
+
localStorageCacheRepository.setSingle.mockResolvedValue();
|
|
323
|
+
|
|
324
|
+
const result = await repository.getAllIssues('cached-project');
|
|
325
|
+
|
|
326
|
+
expect(result.cacheUsed).toBe(false);
|
|
327
|
+
expect(result.project).toBe(freshProject);
|
|
328
|
+
expect(projectRepository.getProject).toHaveBeenCalledWith(
|
|
329
|
+
'cached-project',
|
|
330
|
+
);
|
|
193
331
|
});
|
|
194
332
|
});
|
|
195
333
|
|
|
@@ -199,7 +337,14 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
199
337
|
repository,
|
|
200
338
|
graphqlProjectItemRepository,
|
|
201
339
|
localStorageCacheRepository,
|
|
340
|
+
projectRepository,
|
|
341
|
+
dateRepository,
|
|
202
342
|
} = createApiV3CheerioRestIssueRepository();
|
|
343
|
+
dateRepository.now.mockResolvedValue(new Date('2026-07-07T00:00:00Z'));
|
|
344
|
+
localStorageCacheRepository.getSingle.mockResolvedValue(null);
|
|
345
|
+
projectRepository.getProject.mockResolvedValue(
|
|
346
|
+
buildTestProject('test-project-id'),
|
|
347
|
+
);
|
|
203
348
|
const fetchError = new Error(
|
|
204
349
|
'fetchProjectItems: expected 5 items but accumulated 1',
|
|
205
350
|
);
|
|
@@ -207,10 +352,10 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
207
352
|
fetchError,
|
|
208
353
|
);
|
|
209
354
|
|
|
210
|
-
await expect(
|
|
211
|
-
|
|
212
|
-
)
|
|
213
|
-
expect(localStorageCacheRepository.
|
|
355
|
+
await expect(repository.getAllIssues('test-project-id')).rejects.toThrow(
|
|
356
|
+
fetchError,
|
|
357
|
+
);
|
|
358
|
+
expect(localStorageCacheRepository.setSingle).not.toHaveBeenCalled();
|
|
214
359
|
});
|
|
215
360
|
});
|
|
216
361
|
|
|
@@ -1979,6 +2124,8 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
1979
2124
|
const restIssueRepository = mock<RestIssueRepository>();
|
|
1980
2125
|
const graphqlProjectItemRepository = mock<GraphqlProjectItemRepository>();
|
|
1981
2126
|
const localStorageCacheRepository = mock<LocalStorageCacheRepository>();
|
|
2127
|
+
const projectRepository = mock<ProjectRepository>();
|
|
2128
|
+
const dateRepository = mock<DateRepository>();
|
|
1982
2129
|
const localStorageRepository = mock<LocalStorageRepository>();
|
|
1983
2130
|
const sleep = jest.fn().mockResolvedValue(undefined);
|
|
1984
2131
|
|
|
@@ -1987,6 +2134,8 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
1987
2134
|
restIssueRepository,
|
|
1988
2135
|
graphqlProjectItemRepository,
|
|
1989
2136
|
localStorageCacheRepository,
|
|
2137
|
+
projectRepository,
|
|
2138
|
+
dateRepository,
|
|
1990
2139
|
localStorageRepository,
|
|
1991
2140
|
'dummy',
|
|
1992
2141
|
sleep,
|
|
@@ -2006,6 +2155,8 @@ describe('ApiV3CheerioRestIssueRepository', () => {
|
|
|
2006
2155
|
restIssueRepository,
|
|
2007
2156
|
graphqlProjectItemRepository,
|
|
2008
2157
|
localStorageCacheRepository,
|
|
2158
|
+
projectRepository,
|
|
2159
|
+
dateRepository,
|
|
2009
2160
|
sleep,
|
|
2010
2161
|
};
|
|
2011
2162
|
};
|
|
@@ -23,6 +23,8 @@ import { BaseGitHubRepository } from '../BaseGitHubRepository';
|
|
|
23
23
|
import { normalizeFieldName } from '../utils';
|
|
24
24
|
import { LocalStorageRepository } from '../LocalStorageRepository';
|
|
25
25
|
import { Member } from '../../../domain/entities/Member';
|
|
26
|
+
import { ProjectRepository } from '../../../domain/usecases/adapter-interfaces/ProjectRepository';
|
|
27
|
+
import { DateRepository } from '../../../domain/usecases/adapter-interfaces/DateRepository';
|
|
26
28
|
import {
|
|
27
29
|
Sleep,
|
|
28
30
|
realSleep,
|
|
@@ -31,6 +33,15 @@ import {
|
|
|
31
33
|
hasRateLimitSignals,
|
|
32
34
|
} from './githubRateLimitRetry';
|
|
33
35
|
|
|
36
|
+
export const FULL_ISSUE_FETCH_INTERVAL_MS = 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
export type CachedProjectIssues = {
|
|
39
|
+
lastFetchedAt: string;
|
|
40
|
+
lastFullFetchAt: string;
|
|
41
|
+
project: Project;
|
|
42
|
+
issues: Issue[];
|
|
43
|
+
};
|
|
44
|
+
|
|
34
45
|
type TimelineItem = {
|
|
35
46
|
__typename: string;
|
|
36
47
|
willCloseTarget?: boolean;
|
|
@@ -494,8 +505,10 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
494
505
|
>,
|
|
495
506
|
readonly localStorageCacheRepository: Pick<
|
|
496
507
|
LocalStorageCacheRepository,
|
|
497
|
-
'
|
|
508
|
+
'getSingle' | 'setSingle'
|
|
498
509
|
>,
|
|
510
|
+
readonly projectRepository: Pick<ProjectRepository, 'getProject'>,
|
|
511
|
+
readonly dateRepository: DateRepository,
|
|
499
512
|
readonly localStorageRepository: LocalStorageRepository,
|
|
500
513
|
readonly ghToken: string = process.env.GH_TOKEN || 'dummy',
|
|
501
514
|
readonly sleep: Sleep = realSleep,
|
|
@@ -503,6 +516,11 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
503
516
|
super(localStorageRepository, ghToken);
|
|
504
517
|
}
|
|
505
518
|
|
|
519
|
+
private readonly getAllIssuesRefreshMemo = new Map<
|
|
520
|
+
Project['id'],
|
|
521
|
+
{ issues: Issue[]; project: Project; cacheUsed: boolean }
|
|
522
|
+
>();
|
|
523
|
+
|
|
506
524
|
private fetchWithRateLimitRetry = (
|
|
507
525
|
request: () => Promise<Response>,
|
|
508
526
|
): Promise<Response> => fetchWithGitHubRateLimitRetry(request, this.sleep);
|
|
@@ -578,91 +596,158 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
578
596
|
closingIssueReferenceUrls: item.closingIssueReferenceUrls,
|
|
579
597
|
};
|
|
580
598
|
};
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
599
|
+
private restoreIssuesFromCache = (rawIssues: unknown): Issue[] | null => {
|
|
600
|
+
if (!Array.isArray(rawIssues)) {
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
const issues = rawIssues
|
|
604
|
+
.filter(
|
|
605
|
+
(issue: unknown): issue is object =>
|
|
606
|
+
typeof issue === 'object' && issue !== null,
|
|
607
|
+
)
|
|
608
|
+
.map((issue: object): object => {
|
|
609
|
+
const nextActionDate =
|
|
610
|
+
!('nextActionDate' in issue) ||
|
|
611
|
+
typeof issue.nextActionDate !== 'string' ||
|
|
612
|
+
issue.nextActionDate === null
|
|
613
|
+
? null
|
|
614
|
+
: new Date(issue.nextActionDate);
|
|
615
|
+
const completionDate50PercentConfidence =
|
|
616
|
+
!('completionDate50PercentConfidence' in issue) ||
|
|
617
|
+
typeof issue.completionDate50PercentConfidence !== 'string'
|
|
618
|
+
? null
|
|
619
|
+
: new Date(issue.completionDate50PercentConfidence);
|
|
620
|
+
const createdAt =
|
|
621
|
+
!('createdAt' in issue) || typeof issue.createdAt !== 'string'
|
|
622
|
+
? new Date()
|
|
623
|
+
: new Date(issue.createdAt);
|
|
624
|
+
const closingIssueReferenceUrls =
|
|
625
|
+
'closingIssueReferenceUrls' in issue &&
|
|
626
|
+
Array.isArray(issue.closingIssueReferenceUrls) &&
|
|
627
|
+
issue.closingIssueReferenceUrls.every(
|
|
628
|
+
(url): url is string => typeof url === 'string',
|
|
597
629
|
)
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
: new Date(issue.completionDate50PercentConfidence);
|
|
610
|
-
const createdAt =
|
|
611
|
-
!('createdAt' in issue) || typeof issue.createdAt !== 'string'
|
|
612
|
-
? new Date()
|
|
613
|
-
: new Date(issue.createdAt);
|
|
614
|
-
const closingIssueReferenceUrls =
|
|
615
|
-
'closingIssueReferenceUrls' in issue &&
|
|
616
|
-
Array.isArray(issue.closingIssueReferenceUrls) &&
|
|
617
|
-
issue.closingIssueReferenceUrls.every(
|
|
618
|
-
(url): url is string => typeof url === 'string',
|
|
619
|
-
)
|
|
620
|
-
? issue.closingIssueReferenceUrls
|
|
621
|
-
: [];
|
|
622
|
-
|
|
623
|
-
return {
|
|
624
|
-
...issue,
|
|
625
|
-
nextActionDate: nextActionDate,
|
|
626
|
-
completionDate50PercentConfidence:
|
|
627
|
-
completionDate50PercentConfidence,
|
|
628
|
-
createdAt: createdAt,
|
|
629
|
-
closingIssueReferenceUrls: closingIssueReferenceUrls,
|
|
630
|
-
};
|
|
631
|
-
});
|
|
630
|
+
? issue.closingIssueReferenceUrls
|
|
631
|
+
: [];
|
|
632
|
+
|
|
633
|
+
return {
|
|
634
|
+
...issue,
|
|
635
|
+
nextActionDate: nextActionDate,
|
|
636
|
+
completionDate50PercentConfidence: completionDate50PercentConfidence,
|
|
637
|
+
createdAt: createdAt,
|
|
638
|
+
closingIssueReferenceUrls: closingIssueReferenceUrls,
|
|
639
|
+
};
|
|
640
|
+
});
|
|
632
641
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
}
|
|
636
|
-
}
|
|
642
|
+
if (typia.is<Issue[]>(issues)) {
|
|
643
|
+
return issues;
|
|
637
644
|
}
|
|
638
645
|
return null;
|
|
639
646
|
};
|
|
640
647
|
|
|
648
|
+
private readCachedProjectIssues = async (
|
|
649
|
+
cacheKey: string,
|
|
650
|
+
): Promise<CachedProjectIssues | null> => {
|
|
651
|
+
const raw = await this.localStorageCacheRepository.getSingle(cacheKey);
|
|
652
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
if (
|
|
656
|
+
!('lastFetchedAt' in raw) ||
|
|
657
|
+
typeof raw.lastFetchedAt !== 'string' ||
|
|
658
|
+
!('lastFullFetchAt' in raw) ||
|
|
659
|
+
typeof raw.lastFullFetchAt !== 'string' ||
|
|
660
|
+
!('project' in raw) ||
|
|
661
|
+
!('issues' in raw)
|
|
662
|
+
) {
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
if (!typia.is<Project>(raw.project)) {
|
|
666
|
+
return null;
|
|
667
|
+
}
|
|
668
|
+
const issues = this.restoreIssuesFromCache(raw.issues);
|
|
669
|
+
if (!issues) {
|
|
670
|
+
return null;
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
lastFetchedAt: raw.lastFetchedAt,
|
|
674
|
+
lastFullFetchAt: raw.lastFullFetchAt,
|
|
675
|
+
project: raw.project,
|
|
676
|
+
issues,
|
|
677
|
+
};
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
private toDateString = (date: Date): string =>
|
|
681
|
+
`${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
|
|
682
|
+
|
|
641
683
|
getAllIssues = async (
|
|
642
684
|
projectId: Project['id'],
|
|
643
|
-
allowCacheMinutes: number,
|
|
644
685
|
): Promise<{
|
|
645
686
|
issues: Issue[];
|
|
687
|
+
project: Project;
|
|
646
688
|
cacheUsed: boolean;
|
|
647
689
|
}> => {
|
|
648
|
-
const
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
allowCacheMinutes,
|
|
652
|
-
);
|
|
653
|
-
if (cachedIssues) {
|
|
654
|
-
return { issues: cachedIssues, cacheUsed: true };
|
|
690
|
+
const memoized = this.getAllIssuesRefreshMemo.get(projectId);
|
|
691
|
+
if (memoized) {
|
|
692
|
+
return memoized;
|
|
655
693
|
}
|
|
656
|
-
const
|
|
657
|
-
|
|
658
|
-
return
|
|
694
|
+
const result = await this.refreshAllIssues(projectId);
|
|
695
|
+
this.getAllIssuesRefreshMemo.set(projectId, result);
|
|
696
|
+
return result;
|
|
659
697
|
};
|
|
660
|
-
|
|
698
|
+
|
|
699
|
+
private refreshAllIssues = async (
|
|
661
700
|
projectId: Project['id'],
|
|
662
|
-
): Promise<Issue[]> => {
|
|
663
|
-
const
|
|
664
|
-
|
|
665
|
-
|
|
701
|
+
): Promise<{ issues: Issue[]; project: Project; cacheUsed: boolean }> => {
|
|
702
|
+
const cacheKey = `allIssues-${projectId}`;
|
|
703
|
+
const now = await this.dateRepository.now();
|
|
704
|
+
const cache = await this.readCachedProjectIssues(cacheKey);
|
|
705
|
+
const isFullFetch =
|
|
706
|
+
cache === null ||
|
|
707
|
+
now.getTime() - new Date(cache.lastFullFetchAt).getTime() >=
|
|
708
|
+
FULL_ISSUE_FETCH_INTERVAL_MS;
|
|
709
|
+
|
|
710
|
+
if (isFullFetch) {
|
|
711
|
+
const project = await this.projectRepository.getProject(projectId);
|
|
712
|
+
if (!project) {
|
|
713
|
+
throw new Error(`Project not found. projectId: ${projectId}`);
|
|
714
|
+
}
|
|
715
|
+
const items =
|
|
716
|
+
await this.graphqlProjectItemRepository.fetchProjectItems(projectId);
|
|
717
|
+
const issues = items.map((item) => this.convertProjectItemToIssue(item));
|
|
718
|
+
const nowIso = now.toISOString();
|
|
719
|
+
await this.localStorageCacheRepository.setSingle(cacheKey, {
|
|
720
|
+
lastFetchedAt: nowIso,
|
|
721
|
+
lastFullFetchAt: nowIso,
|
|
722
|
+
project,
|
|
723
|
+
issues,
|
|
724
|
+
} satisfies CachedProjectIssues);
|
|
725
|
+
return { issues, project, cacheUsed: false };
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const project = cache.project;
|
|
729
|
+
const overlapStartDate = new Date(cache.lastFetchedAt);
|
|
730
|
+
overlapStartDate.setUTCDate(overlapStartDate.getUTCDate() - 1);
|
|
731
|
+
const changedItems =
|
|
732
|
+
await this.graphqlProjectItemRepository.fetchProjectItems(
|
|
733
|
+
projectId,
|
|
734
|
+
`updated:>=${this.toDateString(overlapStartDate)}`,
|
|
735
|
+
);
|
|
736
|
+
const issuesByUrl = new Map<string, Issue>(
|
|
737
|
+
cache.issues.map((issue) => [issue.url, issue]),
|
|
738
|
+
);
|
|
739
|
+
for (const item of changedItems) {
|
|
740
|
+
const issue = this.convertProjectItemToIssue(item);
|
|
741
|
+
issuesByUrl.set(issue.url, issue);
|
|
742
|
+
}
|
|
743
|
+
const issues = Array.from(issuesByUrl.values());
|
|
744
|
+
await this.localStorageCacheRepository.setSingle(cacheKey, {
|
|
745
|
+
lastFetchedAt: now.toISOString(),
|
|
746
|
+
lastFullFetchAt: cache.lastFullFetchAt,
|
|
747
|
+
project,
|
|
748
|
+
issues,
|
|
749
|
+
} satisfies CachedProjectIssues);
|
|
750
|
+
return { issues, project, cacheUsed: true };
|
|
666
751
|
};
|
|
667
752
|
createNewIssue = async (
|
|
668
753
|
org: string,
|
|
@@ -1307,19 +1392,13 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
1307
1392
|
return Array.from(relatedPRsMap.values());
|
|
1308
1393
|
};
|
|
1309
1394
|
|
|
1310
|
-
getAllOpened = async (
|
|
1311
|
-
project
|
|
1312
|
-
allowCacheMinutes: number,
|
|
1313
|
-
): Promise<Issue[]> => {
|
|
1314
|
-
const { issues } = await this.getAllIssues(project.id, allowCacheMinutes);
|
|
1395
|
+
getAllOpened = async (project: Project): Promise<Issue[]> => {
|
|
1396
|
+
const { issues } = await this.getAllIssues(project.id);
|
|
1315
1397
|
return issues.filter((issue) => !issue.isClosed);
|
|
1316
1398
|
};
|
|
1317
1399
|
|
|
1318
|
-
getStoryObjectMap = async (
|
|
1319
|
-
project
|
|
1320
|
-
allowCacheMinutes: number,
|
|
1321
|
-
): Promise<StoryObjectMap> => {
|
|
1322
|
-
const { issues } = await this.getAllIssues(project.id, allowCacheMinutes);
|
|
1400
|
+
getStoryObjectMap = async (project: Project): Promise<StoryObjectMap> => {
|
|
1401
|
+
const { issues } = await this.getAllIssues(project.id);
|
|
1323
1402
|
const storyObjectMap: StoryObjectMap = new Map();
|
|
1324
1403
|
const targetStories = project.story?.stories || [];
|
|
1325
1404
|
for (const story of targetStories) {
|