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.
Files changed (117) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +7 -9
  3. package/bin/adapter/entry-points/cli/index.js +8 -14
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/cli/projectConfig.js +0 -6
  6. package/bin/adapter/entry-points/cli/projectConfig.js.map +1 -1
  7. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js +6 -12
  8. package/bin/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.js.map +1 -1
  9. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -10
  10. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  11. package/bin/adapter/entry-points/handlers/machineStatusWriter.js +23 -19
  12. package/bin/adapter/entry-points/handlers/machineStatusWriter.js.map +1 -1
  13. package/bin/adapter/entry-points/handlers/situationFileWriter.js.map +1 -1
  14. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js +1 -2
  15. package/bin/adapter/entry-points/handlers/staleTmuxSessionCleaner.js.map +1 -1
  16. package/bin/adapter/repositories/LocalStorageCacheRepository.js +25 -0
  17. package/bin/adapter/repositories/LocalStorageCacheRepository.js.map +1 -1
  18. package/bin/adapter/repositories/ProcHostMetricsRepository.js +10 -5
  19. package/bin/adapter/repositories/ProcHostMetricsRepository.js.map +1 -1
  20. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +119 -59
  21. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  22. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js +12 -3
  23. package/bin/adapter/repositories/issue/GraphqlProjectItemRepository.js.map +1 -1
  24. package/bin/domain/usecases/GetStoryObjectMapUseCase.js +1 -1
  25. package/bin/domain/usecases/GetStoryObjectMapUseCase.js.map +1 -1
  26. package/bin/domain/usecases/HandleScheduledEventUseCase.js +1 -8
  27. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  28. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -2
  29. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  30. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +1 -1
  31. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
  32. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +1 -1
  33. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  34. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js +1 -1
  35. package/bin/domain/usecases/SetupTowerDefenceProjectUseCase.js.map +1 -1
  36. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js +1 -1
  37. package/bin/domain/usecases/StaleTmuxSessionKillUseCase.js.map +1 -1
  38. package/bin/domain/usecases/StartPreparationUseCase.js +1 -1
  39. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -1
  40. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js +3 -1
  41. package/bin/domain/usecases/console/GenerateConsoleListsUseCase.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/adapter/entry-points/cli/index.test.ts +0 -5
  44. package/src/adapter/entry-points/cli/index.ts +16 -17
  45. package/src/adapter/entry-points/cli/projectConfig.ts +0 -8
  46. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.test.ts +4 -25
  47. package/src/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.ts +4 -7
  48. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +2 -25
  49. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +2 -4
  50. package/src/adapter/entry-points/handlers/machineStatusWriter.test.ts +29 -11
  51. package/src/adapter/entry-points/handlers/machineStatusWriter.ts +30 -19
  52. package/src/adapter/entry-points/handlers/situationFileWriter.test.ts +0 -5
  53. package/src/adapter/entry-points/handlers/situationFileWriter.ts +0 -1
  54. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.test.ts +0 -5
  55. package/src/adapter/entry-points/handlers/staleTmuxSessionCleaner.ts +1 -9
  56. package/src/adapter/repositories/LocalStorageCacheRepository.test.ts +75 -0
  57. package/src/adapter/repositories/LocalStorageCacheRepository.ts +24 -0
  58. package/src/adapter/repositories/ProcHostMetricsRepository.test.ts +29 -8
  59. package/src/adapter/repositories/ProcHostMetricsRepository.ts +10 -6
  60. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +198 -47
  61. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +160 -81
  62. package/src/adapter/repositories/issue/GraphqlProjectItemRepository.ts +23 -3
  63. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +7 -12
  64. package/src/domain/usecases/GetStoryObjectMapUseCase.ts +1 -5
  65. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -24
  66. package/src/domain/usecases/HandleScheduledEventUseCase.ts +6 -17
  67. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +4 -8
  68. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +35 -42
  69. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +1 -5
  70. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +32 -32
  71. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +1 -5
  72. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.test.ts +17 -4
  73. package/src/domain/usecases/SetupTowerDefenceProjectUseCase.ts +1 -4
  74. package/src/domain/usecases/StaleTmuxSessionKillUseCase.test.ts +1 -7
  75. package/src/domain/usecases/StaleTmuxSessionKillUseCase.ts +1 -5
  76. package/src/domain/usecases/StartPreparationUseCase.test.ts +1 -94
  77. package/src/domain/usecases/StartPreparationUseCase.ts +2 -5
  78. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +3 -10
  79. package/src/domain/usecases/console/GenerateConsoleListsUseCase.test.ts +10 -0
  80. package/src/domain/usecases/console/GenerateConsoleListsUseCase.ts +4 -1
  81. package/types/adapter/entry-points/cli/projectConfig.d.ts +0 -1
  82. package/types/adapter/entry-points/cli/projectConfig.d.ts.map +1 -1
  83. package/types/adapter/entry-points/function/getStoryObjectMap.d.ts +1 -1
  84. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts +1 -1
  85. package/types/adapter/entry-points/handlers/GetStoryObjectMapUseCaseHandler.d.ts.map +1 -1
  86. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  87. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts +1 -0
  88. package/types/adapter/entry-points/handlers/machineStatusWriter.d.ts.map +1 -1
  89. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts +0 -1
  90. package/types/adapter/entry-points/handlers/situationFileWriter.d.ts.map +1 -1
  91. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts +0 -1
  92. package/types/adapter/entry-points/handlers/staleTmuxSessionCleaner.d.ts.map +1 -1
  93. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts +2 -0
  94. package/types/adapter/repositories/LocalStorageCacheRepository.d.ts.map +1 -1
  95. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts +1 -1
  96. package/types/adapter/repositories/ProcHostMetricsRepository.d.ts.map +1 -1
  97. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +22 -7
  98. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  99. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts +2 -1
  100. package/types/adapter/repositories/issue/GraphqlProjectItemRepository.d.ts.map +1 -1
  101. package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts +0 -1
  102. package/types/domain/usecases/GetStoryObjectMapUseCase.d.ts.map +1 -1
  103. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +0 -1
  104. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  105. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  106. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +0 -1
  107. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
  108. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +0 -1
  109. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  110. package/types/domain/usecases/SetupTowerDefenceProjectUseCase.d.ts.map +1 -1
  111. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts +0 -1
  112. package/types/domain/usecases/StaleTmuxSessionKillUseCase.d.ts.map +1 -1
  113. package/types/domain/usecases/StartPreparationUseCase.d.ts +0 -1
  114. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -1
  115. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +4 -3
  116. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  117. 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('getAllIssuesFromCache', () => {
143
- const testCases: {
144
- name: string;
145
- params: Parameters<
146
- ApiV3CheerioRestIssueRepository['getAllIssuesFromCache']
147
- >;
148
- expected: Awaited<
149
- ReturnType<ApiV3CheerioRestIssueRepository['getAllIssuesFromCache']>
150
- >;
151
- }[] = [
152
- {
153
- name: 'normal case',
154
- params: ['test-key', 1],
155
- expected: null,
156
- },
157
- ];
158
- test.each(testCases)('%s', async (arg) => {
159
- const { repository, localStorageCacheRepository } =
160
- createApiV3CheerioRestIssueRepository();
161
- localStorageCacheRepository.getLatest.mockResolvedValue({
162
- timestamp: new Date('2000-01-01'),
163
- value: [],
164
- });
165
- const result = await repository.getAllIssuesFromCache(...arg.params);
166
- expect(result).toEqual(arg.expected);
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
- describe('getAllIssues', () => {
170
- const testCases: {
171
- name: string;
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.set.mockResolvedValue();
191
- const result = await repository.getAllIssues(...arg.params);
192
- expect(result).toEqual(arg.expected);
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
- repository.getAllIssues('test-project-id', 1),
212
- ).rejects.toThrow(fetchError);
213
- expect(localStorageCacheRepository.set).not.toHaveBeenCalled();
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
- 'getLatest' | 'set'
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
- getAllIssuesFromCache = async (
582
- cacheKey: string,
583
- allowCacheMinutes: number,
584
- ): Promise<Issue[] | null> => {
585
- const cache = await this.localStorageCacheRepository.getLatest(cacheKey);
586
- if (cache) {
587
- const now = new Date();
588
- const cacheTimestamp = cache.timestamp;
589
- const diff = now.getTime() - cacheTimestamp.getTime();
590
- if (diff < allowCacheMinutes * 60 * 1000) {
591
- if (!Array.isArray(cache.value)) {
592
- return null;
593
- }
594
- const issues = cache.value
595
- .filter(
596
- (issue: unknown): issue is object => typeof issue === 'object',
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
- .map((issue: object): object => {
599
- const nextActionDate =
600
- !('nextActionDate' in issue) ||
601
- typeof issue.nextActionDate !== 'string' ||
602
- issue.nextActionDate === null
603
- ? null
604
- : new Date(issue.nextActionDate);
605
- const completionDate50PercentConfidence =
606
- !('completionDate50PercentConfidence' in issue) ||
607
- typeof issue.completionDate50PercentConfidence !== 'string'
608
- ? null
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
- if (typia.is<Issue[]>(issues)) {
634
- return issues;
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 cacheKey = `allIssues-${projectId}`;
649
- const cachedIssues = await this.getAllIssuesFromCache(
650
- cacheKey,
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 issues = await this.getAllIssuesFromGitHub(projectId);
657
- await this.localStorageCacheRepository.set(cacheKey, issues);
658
- return { issues, cacheUsed: false };
694
+ const result = await this.refreshAllIssues(projectId);
695
+ this.getAllIssuesRefreshMemo.set(projectId, result);
696
+ return result;
659
697
  };
660
- getAllIssuesFromGitHub = async (
698
+
699
+ private refreshAllIssues = async (
661
700
  projectId: Project['id'],
662
- ): Promise<Issue[]> => {
663
- const items =
664
- await this.graphqlProjectItemRepository.fetchProjectItems(projectId);
665
- return items.map((item) => this.convertProjectItemToIssue(item));
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: 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: 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) {