github-issue-tower-defence-management 1.148.10 → 1.148.12

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 (29) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/bin/adapter/repositories/GraphqlProjectRepository.js +11 -2
  3. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  4. package/bin/adapter/repositories/ProjectIssuesCacheRepository.js +104 -0
  5. package/bin/adapter/repositories/ProjectIssuesCacheRepository.js.map +1 -0
  6. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +10 -46
  7. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  8. package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js +24 -2
  9. package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js.map +1 -1
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js +8 -8
  11. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  12. package/package.json +1 -1
  13. package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +6 -1
  14. package/src/adapter/repositories/GraphqlProjectRepository.ts +24 -3
  15. package/src/adapter/repositories/ProjectIssuesCacheRepository.test.ts +217 -0
  16. package/src/adapter/repositories/ProjectIssuesCacheRepository.ts +141 -0
  17. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +20 -59
  18. package/src/domain/usecases/CreateNewStoryByLabelUseCase.test.ts +277 -0
  19. package/src/domain/usecases/CreateNewStoryByLabelUseCase.ts +50 -4
  20. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +23 -1
  21. package/src/domain/usecases/HandleScheduledEventUseCase.ts +8 -8
  22. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +2 -1
  23. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  24. package/types/adapter/repositories/ProjectIssuesCacheRepository.d.ts +23 -0
  25. package/types/adapter/repositories/ProjectIssuesCacheRepository.d.ts.map +1 -0
  26. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -6
  27. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  28. package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts.map +1 -1
  29. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
@@ -20,6 +20,12 @@ import {
20
20
  ProjectItem,
21
21
  } from './GraphqlProjectItemRepository';
22
22
  import { LocalStorageCacheRepository } from '../LocalStorageCacheRepository';
23
+ import {
24
+ CachedProjectIssues,
25
+ isIssueArray,
26
+ isProject,
27
+ ProjectIssuesCacheRepository,
28
+ } from '../ProjectIssuesCacheRepository';
23
29
  import { BaseGitHubRepository } from '../BaseGitHubRepository';
24
30
  import { fetchGithubGraphql } from '../githubGraphqlClient';
25
31
  import { normalizeFieldName } from '../utils';
@@ -42,45 +48,6 @@ export const REQUIRED_CHECKS_CACHE_TTL_MS = 10 * 60 * 1000;
42
48
  const SELF_AUTHORED_REVIEW_REFUSAL =
43
49
  'Can not request changes on your own pull request';
44
50
 
45
- const isIssueArray = (value: unknown): value is Issue[] =>
46
- Array.isArray(value) &&
47
- value.every(
48
- (item: unknown) =>
49
- typeof item === 'object' &&
50
- item !== null &&
51
- 'nameWithOwner' in item &&
52
- typeof item.nameWithOwner === 'string' &&
53
- 'number' in item &&
54
- typeof item.number === 'number' &&
55
- 'title' in item &&
56
- typeof item.title === 'string' &&
57
- 'url' in item &&
58
- typeof item.url === 'string',
59
- );
60
-
61
- const isProject = (value: unknown): value is Project => {
62
- if (typeof value !== 'object' || value === null) return false;
63
- if (!('id' in value) || typeof value.id !== 'string') return false;
64
- if (!('url' in value) || typeof value.url !== 'string') return false;
65
- if (!('databaseId' in value) || typeof value.databaseId !== 'number')
66
- return false;
67
- if (!('name' in value) || typeof value.name !== 'string') return false;
68
- if (
69
- !('status' in value) ||
70
- typeof value.status !== 'object' ||
71
- value.status === null
72
- )
73
- return false;
74
- return true;
75
- };
76
-
77
- export type CachedProjectIssues = {
78
- lastFetchedAt: string;
79
- lastFullFetchAt: string;
80
- project: Project;
81
- issues: Issue[];
82
- };
83
-
84
51
  type TimelineItem = {
85
52
  __typename: string;
86
53
  willCloseTarget?: boolean;
@@ -480,8 +447,13 @@ export class ApiV3CheerioRestIssueRepository
480
447
  readonly sleep: Sleep = realSleep,
481
448
  ) {
482
449
  super(localStorageRepository, ghToken);
450
+ this.projectIssuesCacheRepository = new ProjectIssuesCacheRepository(
451
+ localStorageCacheRepository,
452
+ );
483
453
  }
484
454
 
455
+ private readonly projectIssuesCacheRepository: ProjectIssuesCacheRepository;
456
+
485
457
  private readonly getAllIssuesRefreshMemo = new Map<
486
458
  Project['id'],
487
459
  { issues: Issue[]; project: Project; cacheUsed: boolean }
@@ -620,9 +592,9 @@ export class ApiV3CheerioRestIssueRepository
620
592
  };
621
593
 
622
594
  private readCachedProjectIssues = async (
623
- cacheKey: string,
595
+ projectId: Project['id'],
624
596
  ): Promise<CachedProjectIssues | null> => {
625
- const raw = await this.localStorageCacheRepository.getSingle(cacheKey);
597
+ const raw = await this.projectIssuesCacheRepository.readRaw(projectId);
626
598
  if (typeof raw !== 'object' || raw === null) {
627
599
  return null;
628
600
  }
@@ -657,18 +629,8 @@ export class ApiV3CheerioRestIssueRepository
657
629
  // GraphQL project load only when the daemon has not populated the cache yet.
658
630
  getCachedProject = async (
659
631
  projectId: Project['id'],
660
- ): Promise<Project | null> => {
661
- const raw = await this.localStorageCacheRepository.getSingle(
662
- `allIssues-${projectId}`,
663
- );
664
- if (typeof raw !== 'object' || raw === null || !('project' in raw)) {
665
- return null;
666
- }
667
- if (!isProject(raw.project)) {
668
- return null;
669
- }
670
- return raw.project;
671
- };
632
+ ): Promise<Project | null> =>
633
+ this.projectIssuesCacheRepository.readProject(projectId);
672
634
 
673
635
  private toDateString = (date: Date): string =>
674
636
  `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')}`;
@@ -692,9 +654,8 @@ export class ApiV3CheerioRestIssueRepository
692
654
  private refreshAllIssues = async (
693
655
  projectId: Project['id'],
694
656
  ): Promise<{ issues: Issue[]; project: Project; cacheUsed: boolean }> => {
695
- const cacheKey = `allIssues-${projectId}`;
696
657
  const now = await this.dateRepository.now();
697
- const cache = await this.readCachedProjectIssues(cacheKey);
658
+ const cache = await this.readCachedProjectIssues(projectId);
698
659
  const isFullFetch =
699
660
  cache === null ||
700
661
  now.getTime() - new Date(cache.lastFullFetchAt).getTime() >=
@@ -709,12 +670,12 @@ export class ApiV3CheerioRestIssueRepository
709
670
  await this.graphqlProjectItemRepository.fetchProjectItems(projectId);
710
671
  const issues = items.map((item) => this.convertProjectItemToIssue(item));
711
672
  const nowIso = now.toISOString();
712
- await this.localStorageCacheRepository.setSingle(cacheKey, {
673
+ await this.projectIssuesCacheRepository.write(projectId, {
713
674
  lastFetchedAt: nowIso,
714
675
  lastFullFetchAt: nowIso,
715
676
  project,
716
677
  issues,
717
- } satisfies CachedProjectIssues);
678
+ });
718
679
  this.lastIssuesFetchedAtByProjectId.set(projectId, nowIso);
719
680
  return { issues, project, cacheUsed: false };
720
681
  }
@@ -747,12 +708,12 @@ export class ApiV3CheerioRestIssueRepository
747
708
  }
748
709
  const issues = Array.from(issuesByUrl.values());
749
710
  const nowIso = now.toISOString();
750
- await this.localStorageCacheRepository.setSingle(cacheKey, {
711
+ await this.projectIssuesCacheRepository.write(projectId, {
751
712
  lastFetchedAt: nowIso,
752
713
  lastFullFetchAt: cache.lastFullFetchAt,
753
714
  project,
754
715
  issues,
755
- } satisfies CachedProjectIssues);
716
+ });
756
717
  this.lastIssuesFetchedAtByProjectId.set(projectId, nowIso);
757
718
  return { issues, project, cacheUsed: true };
758
719
  };
@@ -96,6 +96,222 @@ describe('CreateNewStoryByLabelUseCase', () => {
96
96
  );
97
97
  });
98
98
 
99
+ describe('logging', () => {
100
+ let logSpy: jest.SpyInstance;
101
+ const lines: string[] = [];
102
+
103
+ beforeEach(() => {
104
+ lines.length = 0;
105
+ logSpy = jest
106
+ .spyOn(console, 'log')
107
+ .mockImplementation((...args: unknown[]) => {
108
+ lines.push(args.map((arg) => String(arg)).join(' '));
109
+ });
110
+ });
111
+
112
+ afterEach(() => {
113
+ logSpy.mockRestore();
114
+ });
115
+
116
+ const loggedLines = (): string[] => lines;
117
+
118
+ it('should record that the option list was left unchanged because every labelled issue title already names a story option', async () => {
119
+ const projectWithExistingOption: Project = {
120
+ ...basicProject,
121
+ story: {
122
+ name: 'Story Field',
123
+ fieldId: 'storyFieldId',
124
+ databaseId: 123,
125
+ stories: [
126
+ {
127
+ id: 'existingNewStoryId',
128
+ name: 'New Feature Request',
129
+ color: 'RED',
130
+ description: '',
131
+ },
132
+ ],
133
+ workflowManagementStory: { id: 'workflow1', name: 'Workflow Story' },
134
+ },
135
+ };
136
+
137
+ await useCase.run({
138
+ project: projectWithExistingOption,
139
+ cacheUsed: false,
140
+ org: 'testOrg',
141
+ repo: 'testRepo',
142
+ storyObjectMap: new Map([
143
+ [
144
+ 'Story 1',
145
+ {
146
+ story: mock<StoryOption>(),
147
+ storyIssue: mock<Issue>(),
148
+ issues: [issueWithNewStoryLabel],
149
+ },
150
+ ],
151
+ ]),
152
+ issues: [],
153
+ });
154
+
155
+ expect(mockProjectRepository.updateStoryList).not.toHaveBeenCalled();
156
+ expect(
157
+ loggedLines().some((line) =>
158
+ line.includes(
159
+ 'every labelled issue title already names a story option',
160
+ ),
161
+ ),
162
+ ).toBe(true);
163
+ });
164
+
165
+ it('should record that the run found no labelled issue so that the absence of work is distinguishable from the step never running', async () => {
166
+ const storyObjectWithoutNewStoryIssues: StoryObject = {
167
+ story: mock<StoryOption>(),
168
+ storyIssue: mock<Issue>(),
169
+ issues: [regularIssue],
170
+ };
171
+
172
+ await useCase.run({
173
+ project: basicProject,
174
+ cacheUsed: false,
175
+ org: 'testOrg',
176
+ repo: 'testRepo',
177
+ storyObjectMap: new Map([
178
+ ['Story 1', storyObjectWithoutNewStoryIssues],
179
+ ]),
180
+ issues: [regularIssue],
181
+ });
182
+
183
+ expect(
184
+ loggedLines().some((line) =>
185
+ line.includes('found 0 issues carrying the new story label'),
186
+ ),
187
+ ).toBe(true);
188
+ });
189
+
190
+ it('should record the project having no story field', async () => {
191
+ await useCase.run({
192
+ project: { ...basicProject, story: null },
193
+ cacheUsed: false,
194
+ org: 'testOrg',
195
+ repo: 'testRepo',
196
+ storyObjectMap: new Map([['Story 1', storyObjectWithNewStoryIssues]]),
197
+ issues: [],
198
+ });
199
+
200
+ expect(
201
+ loggedLines().some((line) =>
202
+ line.includes('the project has no story field'),
203
+ ),
204
+ ).toBe(true);
205
+ });
206
+
207
+ it('should record the labelled issues, the submitted options, the story link and the label removal', async () => {
208
+ mockProjectRepository.updateStoryList.mockResolvedValue([
209
+ { id: 'story1', name: 'First Story', color: 'BLUE', description: '' },
210
+ {
211
+ id: 'newStoryId1',
212
+ name: 'New Feature Request',
213
+ color: 'RED',
214
+ description: '',
215
+ },
216
+ ]);
217
+ const storyObject: StoryObject = {
218
+ story: {
219
+ ...mock<StoryOption>(),
220
+ id: 'story1',
221
+ name: 'Existing Story',
222
+ color: 'BLUE',
223
+ },
224
+ storyIssue: mock<Issue>(),
225
+ issues: [issueWithNewStoryLabel],
226
+ };
227
+
228
+ await useCase.run({
229
+ project: basicProject,
230
+ cacheUsed: false,
231
+ org: 'testOrg',
232
+ repo: 'testRepo',
233
+ storyObjectMap: new Map([['Story 1', storyObject]]),
234
+ issues: [],
235
+ });
236
+
237
+ const lines = loggedLines();
238
+ expect(
239
+ lines.some((line) =>
240
+ line.includes('found 1 issues carrying the new story label'),
241
+ ),
242
+ ).toBe(true);
243
+ expect(
244
+ lines.some(
245
+ (line) =>
246
+ line.includes('labelled issues') &&
247
+ line.includes(issueWithNewStoryLabel.url),
248
+ ),
249
+ ).toBe(true);
250
+ expect(
251
+ lines.some(
252
+ (line) =>
253
+ line.includes('submitting 1 new story options') &&
254
+ line.includes('New Feature Request'),
255
+ ),
256
+ ).toBe(true);
257
+ expect(
258
+ lines.some(
259
+ (line) =>
260
+ line.includes('linked') &&
261
+ line.includes(issueWithNewStoryLabel.url) &&
262
+ line.includes('newStoryId1'),
263
+ ),
264
+ ).toBe(true);
265
+ expect(
266
+ lines.some(
267
+ (line) =>
268
+ line.includes('removed the new story label') &&
269
+ line.includes(issueWithNewStoryLabel.url),
270
+ ),
271
+ ).toBe(true);
272
+ });
273
+
274
+ it('should record that an issue kept its label because no story option matches its title', async () => {
275
+ const issueWithUnmatchedTitle: Issue = {
276
+ ...mock<Issue>(),
277
+ url: 'https://github.com/org/repo/issues/1401',
278
+ title: 'Unmatched Title',
279
+ number: 1401,
280
+ story: 'Existing Story',
281
+ labels: ['new-story'],
282
+ };
283
+ mockProjectRepository.updateStoryList.mockResolvedValue([
284
+ { id: 'story1', name: 'First Story', color: 'BLUE', description: '' },
285
+ ]);
286
+
287
+ await useCase.run({
288
+ project: basicProject,
289
+ cacheUsed: false,
290
+ org: 'testOrg',
291
+ repo: 'testRepo',
292
+ storyObjectMap: new Map([
293
+ [
294
+ 'Story 1',
295
+ {
296
+ story: mock<StoryOption>(),
297
+ storyIssue: mock<Issue>(),
298
+ issues: [issueWithUnmatchedTitle],
299
+ },
300
+ ],
301
+ ]),
302
+ issues: [],
303
+ });
304
+
305
+ expect(
306
+ loggedLines().some(
307
+ (line) =>
308
+ line.includes('no story option matches the title') &&
309
+ line.includes(issueWithUnmatchedTitle.url),
310
+ ),
311
+ ).toBe(true);
312
+ });
313
+ });
314
+
99
315
  describe('run', () => {
100
316
  it('should not process anything when project has no story field', async () => {
101
317
  const projectWithoutStory: Project = {
@@ -344,6 +560,67 @@ describe('CreateNewStoryByLabelUseCase', () => {
344
560
  expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
345
561
  expect(mockIssueRepository.updateLabels).not.toHaveBeenCalled();
346
562
  });
563
+
564
+ it('should link the issue to the existing option and remove the label without rewriting the option list when an option already carries the issue title', async () => {
565
+ const projectWithExistingOption: Project = {
566
+ ...basicProject,
567
+ story: {
568
+ name: 'Story Field',
569
+ fieldId: 'storyFieldId',
570
+ databaseId: 123,
571
+ stories: [
572
+ {
573
+ id: 'story1',
574
+ name: 'First Story',
575
+ color: 'BLUE',
576
+ description: '',
577
+ },
578
+ {
579
+ id: 'existingNewStoryId',
580
+ name: 'New Feature Request',
581
+ color: 'RED',
582
+ description: '',
583
+ },
584
+ ],
585
+ workflowManagementStory: { id: 'workflow1', name: 'Workflow Story' },
586
+ },
587
+ };
588
+
589
+ const storyObject: StoryObject = {
590
+ story: {
591
+ ...mock<StoryOption>(),
592
+ id: 'story1',
593
+ name: 'Existing Story',
594
+ color: 'BLUE',
595
+ },
596
+ storyIssue: mock<Issue>(),
597
+ issues: [issueWithNewStoryLabel],
598
+ };
599
+
600
+ await useCase.run({
601
+ project: projectWithExistingOption,
602
+ cacheUsed: false,
603
+ org: 'testOrg',
604
+ repo: 'testRepo',
605
+ storyObjectMap: new Map([['Story 1', storyObject]]),
606
+ issues: [],
607
+ });
608
+
609
+ expect(mockProjectRepository.updateStoryList).not.toHaveBeenCalled();
610
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledTimes(1);
611
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
612
+ {
613
+ ...projectWithExistingOption,
614
+ story: projectWithExistingOption.story,
615
+ },
616
+ issueWithNewStoryLabel,
617
+ 'existingNewStoryId',
618
+ );
619
+ expect(mockIssueRepository.updateLabels).toHaveBeenCalledWith(
620
+ issueWithNewStoryLabel,
621
+ ['bug', 'priority-high'],
622
+ );
623
+ });
347
624
  });
348
625
 
349
626
  describe('findNewStoryIssues', () => {
@@ -4,6 +4,8 @@ import { StoryObjectMap } from '../entities/StoryObjectMap';
4
4
  import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
5
5
  import { Issue } from '../entities/Issue';
6
6
 
7
+ const LOG_PREFIX = '[CreateNewStoryByLabel]';
8
+
7
9
  export class CreateNewStoryByLabelUseCase {
8
10
  constructor(
9
11
  readonly projectRepository: Pick<ProjectRepository, 'updateStoryList'>,
@@ -23,28 +25,60 @@ export class CreateNewStoryByLabelUseCase {
23
25
  }): Promise<void> => {
24
26
  const projectStory = input.project.story;
25
27
  if (!projectStory) {
28
+ console.log(
29
+ `${LOG_PREFIX} the project has no story field, so no labelled issue is evaluated. project=${input.project.url}`,
30
+ );
26
31
  return;
27
32
  }
28
33
  const newStoryIssues = this.findNewStoryIssues(
29
34
  input.storyObjectMap,
30
35
  input.issues,
31
36
  );
37
+ console.log(
38
+ `${LOG_PREFIX} found ${newStoryIssues.length} issues carrying the new story label. project=${input.project.url}`,
39
+ );
32
40
  if (newStoryIssues.length === 0) {
33
41
  return;
34
42
  }
43
+ console.log(
44
+ `${LOG_PREFIX} labelled issues: ${newStoryIssues
45
+ .map((issue) => issue.url)
46
+ .join(', ')}`,
47
+ );
35
48
  const newStoryList = this.createNewStoryList(
36
49
  projectStory,
37
50
  input.storyObjectMap,
38
51
  input.issues,
39
52
  );
40
- const savedNewStoryList = await this.projectRepository.updateStoryList(
41
- input.project,
42
- newStoryList,
53
+ const addedStories = newStoryList.filter((story) => story.id === null);
54
+ if (addedStories.length === 0) {
55
+ console.log(
56
+ `${LOG_PREFIX} every labelled issue title already names a story option, so the option list is left unchanged`,
57
+ );
58
+ } else {
59
+ console.log(
60
+ `${LOG_PREFIX} submitting ${addedStories.length} new story options: ${addedStories
61
+ .map((story) => story.name)
62
+ .join(', ')}`,
63
+ );
64
+ }
65
+ const savedNewStoryList =
66
+ addedStories.length === 0
67
+ ? projectStory.stories
68
+ : await this.projectRepository.updateStoryList(
69
+ input.project,
70
+ newStoryList,
71
+ );
72
+ console.log(
73
+ `${LOG_PREFIX} the story option list holds ${savedNewStoryList.length} options`,
43
74
  );
44
75
 
45
76
  for (const issue of newStoryIssues) {
46
77
  const linkedStory = savedNewStoryList.find((s) => s.name === issue.title);
47
78
  if (!linkedStory) {
79
+ console.log(
80
+ `${LOG_PREFIX} no story option matches the title of ${issue.url}, so it keeps the new story label`,
81
+ );
48
82
  continue;
49
83
  }
50
84
  await this.issueRepository.updateStory(
@@ -52,12 +86,18 @@ export class CreateNewStoryByLabelUseCase {
52
86
  issue,
53
87
  linkedStory.id,
54
88
  );
89
+ console.log(
90
+ `${LOG_PREFIX} linked ${issue.url} to the story option ${linkedStory.id}`,
91
+ );
55
92
  await this.issueRepository.updateLabels(
56
93
  issue,
57
94
  issue.labels.filter(
58
95
  (label) => label.toLowerCase().replace('-', '') !== 'newstory',
59
96
  ),
60
97
  );
98
+ console.log(
99
+ `${LOG_PREFIX} removed the new story label from ${issue.url}`,
100
+ );
61
101
  }
62
102
  };
63
103
 
@@ -91,7 +131,13 @@ export class CreateNewStoryByLabelUseCase {
91
131
  storyObjectMap: StoryObjectMap,
92
132
  issues: Issue[],
93
133
  ): (Omit<FieldOption, 'id'> & { id: FieldOption['id'] | null })[] => {
94
- const newStoryIssues = this.findNewStoryIssues(storyObjectMap, issues);
134
+ const existingStoryNames = new Set(
135
+ projectStory.stories.map((story) => story.name),
136
+ );
137
+ const newStoryIssues = this.findNewStoryIssues(
138
+ storyObjectMap,
139
+ issues,
140
+ ).filter((issue) => !existingStoryNames.has(issue.title));
95
141
  const newStoryList: (Omit<FieldOption, 'id'> & {
96
142
  id: FieldOption['id'] | null;
97
143
  })[] = [];
@@ -847,7 +847,29 @@ describe('HandleScheduledEventUseCase', () => {
847
847
  expect(mockAnalyzeStoriesUseCase.run).not.toHaveBeenCalled();
848
848
  expect(mockUpdateIssueStatusByLabelUseCase.run).not.toHaveBeenCalled();
849
849
  expect(mockChangeStatusByStoryColorUseCase.run).not.toHaveBeenCalled();
850
- expect(mockCreateNewStoryByLabelUseCase.run).not.toHaveBeenCalled();
850
+ });
851
+
852
+ it('should run the new story label use case on a loop where slow sweep is skipped', async () => {
853
+ const now = new Date('2024-01-01T00:10:00Z');
854
+ const recentSlowSweep = new Date(
855
+ now.getTime() - 300 * 1000,
856
+ ).toISOString();
857
+ mockSpreadsheetRepository.getSheet.mockResolvedValue([
858
+ ['LastExecutionDateTime'],
859
+ [
860
+ '2024-01-01T00:00:00Z',
861
+ '',
862
+ '',
863
+ 'LastSlowSweepDateTime',
864
+ recentSlowSweep,
865
+ ],
866
+ ]);
867
+ mockDateRepository.now.mockResolvedValue(now);
868
+
869
+ await useCase.run(baseInput);
870
+
871
+ expect(mockCreateNewStoryByLabelUseCase.run).toHaveBeenCalledTimes(1);
872
+ expect(mockAnalyzeStoriesUseCase.run).not.toHaveBeenCalled();
851
873
  });
852
874
 
853
875
  it('should still run preparation use cases even when slow sweep is skipped', async () => {
@@ -399,6 +399,14 @@ ${JSON.stringify(e)}
399
399
  storyObjectMap,
400
400
  );
401
401
  }
402
+ await this.createNewStoryByLabelUseCase.run({
403
+ project,
404
+ cacheUsed,
405
+ org: input.org,
406
+ repo: input.workingReport.repo,
407
+ storyObjectMap,
408
+ issues,
409
+ });
402
410
  const labelsAsLlmAgentName = resolveLabelsAsLlmAgentName({
403
411
  topLevel: input.labelsAsLlmAgentName,
404
412
  startPreparation: input.startPreparation?.labelsAsLlmAgentName,
@@ -557,14 +565,6 @@ ${JSON.stringify(e)}
557
565
  repo: input.workingReport.repo,
558
566
  storyObjectMap: storyObjectMap,
559
567
  });
560
- await this.createNewStoryByLabelUseCase.run({
561
- project,
562
- cacheUsed,
563
- org: input.org,
564
- repo: input.workingReport.repo,
565
- storyObjectMap: storyObjectMap,
566
- issues: issues,
567
- });
568
568
  await this.assignNoAssigneeIssueToManagerUseCase.run({
569
569
  issues,
570
570
  manager: input.manager,
@@ -9,7 +9,8 @@ export declare class GraphqlProjectRepository extends BaseGitHubRepository imple
9
9
  private readonly projectIdCache;
10
10
  private readonly fetchProjectIdFailedAt;
11
11
  private readonly projectCache?;
12
- constructor(localStorageRepository: LocalStorageRepository, ghToken?: string, projectCache?: Pick<LocalStorageCacheRepository, 'getLatest' | 'set'>);
12
+ private readonly projectIssuesCacheRepository;
13
+ constructor(localStorageRepository: LocalStorageRepository, ghToken?: string, projectCache?: Pick<LocalStorageCacheRepository, 'getLatest' | 'set' | 'getSingle' | 'setSingle'>);
13
14
  private readProjectIdFromDiskCache;
14
15
  private writeProjectIdToDiskCache;
15
16
  extractProjectFromUrl: (projectUrl: string) => {
@@ -1 +1 @@
1
- {"version":3,"file":"GraphqlProjectRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/GraphqlProjectRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAE9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,+BAA+B,CAAC;AAC5E,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,EAAE,iBAAiB,EAAE,MAAM,4DAA4D,CAAC;AAC/F,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAIL,8BAA8B,EAE/B,MAAM,4CAA4C,CAAC;AAOpD,eAAO,MAAM,yBAAyB,GACpC,OAAO,MAAM,KACZ,WAAW,CAAC,OAAO,CAcrB,CAAC;AAEF,qBAAa,wBACX,SAAQ,oBACR,YACE,IAAI,CACF,iBAAiB,EACf,YAAY,GACZ,oBAAoB,GACpB,UAAU,GACV,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,GAChB,aAAa,CAChB;IAEH,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAC5D,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAA6B;IACpE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAG5B;gBAGA,sBAAsB,EAAE,sBAAsB,EAC9C,OAAO,GAAE,MAAwC,EACjD,YAAY,CAAC,EAAE,IAAI,CAAC,2BAA2B,EAAE,WAAW,GAAG,KAAK,CAAC;IAMvE,OAAO,CAAC,0BAA0B,CAwBhC;IAEF,OAAO,CAAC,yBAAyB,CAe/B;IAEF,qBAAqB,GACnB,YAAY,MAAM,KACjB;QACD,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,MAAM,CAAC;KACvB,CAMC;IACF,cAAc,GACZ,OAAO,MAAM,EACb,eAAe,MAAM,KACpB,OAAO,CAAC,MAAM,CAAC,CA0GhB;IACF,kBAAkB,GAChB,YAAY,MAAM,KACjB,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAG9B;IACF,UAAU,GAAU,WAAW,OAAO,CAAC,IAAI,CAAC,KAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAgNpE;IACF,QAAQ,GAAU,KAAK,MAAM,KAAG,OAAO,CAAC,OAAO,CAAC,CAU9C;IACF,cAAc,GAAU,SAAS,OAAO,KAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAuC1D;IACF,WAAW,GACT,SAAS,OAAO,EAChB,OAAO,8BAA8B,KACpC,OAAO,CAAC,IAAI,CAAC,CAgDd;IACF,eAAe,GACb,SAAS,OAAO,EAChB,cAAc,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG;QACvC,EAAE,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KAC9B,CAAC,EAAE,KACH,OAAO,CAAC,WAAW,EAAE,CAAC,CA4CvB;IACF,gBAAgB,GACd,SAAS,OAAO,EAChB,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG;QACxC,EAAE,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KAC9B,CAAC,EAAE,KACH,OAAO,CAAC,WAAW,EAAE,CAAC,CAyCvB;CACH"}
1
+ {"version":3,"file":"GraphqlProjectRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/GraphqlProjectRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC;AAE9D,OAAO,EAAE,2BAA2B,EAAE,MAAM,+BAA+B,CAAC;AAE5E,OAAO,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,EAAE,iBAAiB,EAAE,MAAM,4DAA4D,CAAC;AAC/F,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAIL,8BAA8B,EAE/B,MAAM,4CAA4C,CAAC;AAOpD,eAAO,MAAM,yBAAyB,GACpC,OAAO,MAAM,KACZ,WAAW,CAAC,OAAO,CAcrB,CAAC;AAEF,qBAAa,wBACX,SAAQ,oBACR,YACE,IAAI,CACF,iBAAiB,EACf,YAAY,GACZ,oBAAoB,GACpB,UAAU,GACV,iBAAiB,GACjB,kBAAkB,GAClB,gBAAgB,GAChB,aAAa,CAChB;IAEH,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA6B;IAC5D,OAAO,CAAC,QAAQ,CAAC,sBAAsB,CAA6B;IACpE,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAC,CAG5B;IACF,OAAO,CAAC,QAAQ,CAAC,4BAA4B,CAAsC;gBAGjF,sBAAsB,EAAE,sBAAsB,EAC9C,OAAO,GAAE,MAAwC,EACjD,YAAY,CAAC,EAAE,IAAI,CACjB,2BAA2B,EAC3B,WAAW,GAAG,KAAK,GAAG,WAAW,GAAG,WAAW,CAChD;IAUH,OAAO,CAAC,0BAA0B,CAwBhC;IAEF,OAAO,CAAC,yBAAyB,CAe/B;IAEF,qBAAqB,GACnB,YAAY,MAAM,KACjB;QACD,KAAK,EAAE,MAAM,CAAC;QACd,aAAa,EAAE,MAAM,CAAC;KACvB,CAMC;IACF,cAAc,GACZ,OAAO,MAAM,EACb,eAAe,MAAM,KACpB,OAAO,CAAC,MAAM,CAAC,CA0GhB;IACF,kBAAkB,GAChB,YAAY,MAAM,KACjB,OAAO,CAAC,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAG9B;IACF,UAAU,GAAU,WAAW,OAAO,CAAC,IAAI,CAAC,KAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAgNpE;IACF,QAAQ,GAAU,KAAK,MAAM,KAAG,OAAO,CAAC,OAAO,CAAC,CAU9C;IACF,cAAc,GAAU,SAAS,OAAO,KAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CAuC1D;IACF,WAAW,GACT,SAAS,OAAO,EAChB,OAAO,8BAA8B,KACpC,OAAO,CAAC,IAAI,CAAC,CAgDd;IACF,eAAe,GACb,SAAS,OAAO,EAChB,cAAc,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG;QACvC,EAAE,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KAC9B,CAAC,EAAE,KACH,OAAO,CAAC,WAAW,EAAE,CAAC,CAkDvB;IACF,gBAAgB,GACd,SAAS,OAAO,EAChB,eAAe,CAAC,IAAI,CAAC,WAAW,EAAE,IAAI,CAAC,GAAG;QACxC,EAAE,EAAE,WAAW,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;KAC9B,CAAC,EAAE,KACH,OAAO,CAAC,WAAW,EAAE,CAAC,CA+CvB;CACH"}
@@ -0,0 +1,23 @@
1
+ import { Issue } from '../../domain/entities/Issue';
2
+ import { FieldOption, Project } from '../../domain/entities/Project';
3
+ import { LocalStorageCacheRepository } from './LocalStorageCacheRepository';
4
+ export type CachedProjectIssues = {
5
+ lastFetchedAt: string;
6
+ lastFullFetchAt: string;
7
+ project: Project;
8
+ issues: Issue[];
9
+ };
10
+ export declare const isProject: (value: unknown) => value is Project;
11
+ export declare const isIssueArray: (value: unknown) => value is Issue[];
12
+ export declare class ProjectIssuesCacheRepository {
13
+ readonly localStorageCacheRepository: Pick<LocalStorageCacheRepository, 'getSingle' | 'setSingle'>;
14
+ constructor(localStorageCacheRepository: Pick<LocalStorageCacheRepository, 'getSingle' | 'setSingle'>);
15
+ cacheKey: (projectId: Project["id"]) => string;
16
+ readRaw: (projectId: Project["id"]) => Promise<unknown>;
17
+ read: (projectId: Project["id"]) => Promise<CachedProjectIssues | null>;
18
+ readProject: (projectId: Project["id"]) => Promise<Project | null>;
19
+ write: (projectId: Project["id"], cached: CachedProjectIssues) => Promise<void>;
20
+ updateFieldOptions: (projectId: Project["id"], fieldId: string, options: FieldOption[]) => Promise<void>;
21
+ private projectWithFieldOptions;
22
+ }
23
+ //# sourceMappingURL=ProjectIssuesCacheRepository.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ProjectIssuesCacheRepository.d.ts","sourceRoot":"","sources":["../../../src/adapter/repositories/ProjectIssuesCacheRepository.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,6BAA6B,CAAC;AACpD,OAAO,EAAE,WAAW,EAAE,OAAO,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,2BAA2B,EAAE,MAAM,+BAA+B,CAAC;AAE5E,MAAM,MAAM,mBAAmB,GAAG;IAChC,aAAa,EAAE,MAAM,CAAC;IACtB,eAAe,EAAE,MAAM,CAAC;IACxB,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,KAAK,EAAE,CAAC;CACjB,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,OAcnD,CAAC;AAEF,eAAO,MAAM,YAAY,GAAI,OAAO,OAAO,KAAG,KAAK,IAAI,KAAK,EAczD,CAAC;AAEJ,qBAAa,4BAA4B;IAErC,QAAQ,CAAC,2BAA2B,EAAE,IAAI,CACxC,2BAA2B,EAC3B,WAAW,GAAG,WAAW,CAC1B;gBAHQ,2BAA2B,EAAE,IAAI,CACxC,2BAA2B,EAC3B,WAAW,GAAG,WAAW,CAC1B;IAGH,QAAQ,GAAI,WAAW,OAAO,CAAC,IAAI,CAAC,KAAG,MAAM,CAA6B;IAE1E,OAAO,GAAU,WAAW,OAAO,CAAC,IAAI,CAAC,KAAG,OAAO,CAAC,OAAO,CAAC,CACW;IAEvE,IAAI,GACF,WAAW,OAAO,CAAC,IAAI,CAAC,KACvB,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAwBpC;IAEF,WAAW,GAAU,WAAW,OAAO,CAAC,IAAI,CAAC,KAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CASrE;IAEF,KAAK,GACH,WAAW,OAAO,CAAC,IAAI,CAAC,EACxB,QAAQ,mBAAmB,KAC1B,OAAO,CAAC,IAAI,CAAC,CAKd;IAEF,kBAAkB,GAChB,WAAW,OAAO,CAAC,IAAI,CAAC,EACxB,SAAS,MAAM,EACf,SAAS,WAAW,EAAE,KACrB,OAAO,CAAC,IAAI,CAAC,CAgBd;IAEF,OAAO,CAAC,uBAAuB,CAY7B;CACH"}
@@ -16,12 +16,6 @@ import { Sleep } from './githubRateLimitRetry';
16
16
  export declare const FULL_ISSUE_FETCH_INTERVAL_MS: number;
17
17
  export declare const INCREMENTAL_FETCH_SKEW_BUFFER_MS: number;
18
18
  export declare const REQUIRED_CHECKS_CACHE_TTL_MS: number;
19
- export type CachedProjectIssues = {
20
- lastFetchedAt: string;
21
- lastFullFetchAt: string;
22
- project: Project;
23
- issues: Issue[];
24
- };
25
19
  export declare class ApiV3CheerioRestIssueRepository extends BaseGitHubRepository implements IssueRepository {
26
20
  readonly apiV3IssueRepository: Pick<ApiV3IssueRepository, 'searchIssue'>;
27
21
  readonly restIssueRepository: Pick<RestIssueRepository, 'createNewIssue' | 'updateIssue' | 'createComment' | 'getIssue' | 'updateLabels' | 'removeLabel' | 'updateAssigneeList' | 'searchIssues'>;
@@ -33,6 +27,7 @@ export declare class ApiV3CheerioRestIssueRepository extends BaseGitHubRepositor
33
27
  readonly ghToken: string;
34
28
  readonly sleep: Sleep;
35
29
  constructor(apiV3IssueRepository: Pick<ApiV3IssueRepository, 'searchIssue'>, restIssueRepository: Pick<RestIssueRepository, 'createNewIssue' | 'updateIssue' | 'createComment' | 'getIssue' | 'updateLabels' | 'removeLabel' | 'updateAssigneeList' | 'searchIssues'>, graphqlProjectItemRepository: Pick<GraphqlProjectItemRepository, 'fetchProjectItems' | 'fetchProjectItemsLight' | 'fetchProjectItemsByIds' | 'fetchProjectItemByUrl' | 'updateProjectField' | 'clearProjectField' | 'updateProjectTextField' | 'addIssueToProject'>, localStorageCacheRepository: Pick<LocalStorageCacheRepository, 'getSingle' | 'setSingle'>, projectRepository: Pick<ProjectRepository, 'getProject'>, dateRepository: DateRepository, localStorageRepository: LocalStorageRepository, ghToken?: string, sleep?: Sleep);
30
+ private readonly projectIssuesCacheRepository;
36
31
  private readonly getAllIssuesRefreshMemo;
37
32
  private readonly lastIssuesFetchedAtByProjectId;
38
33
  getLastIssuesFetchedAt: (projectId: Project["id"]) => string | null;