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.
- package/CHANGELOG.md +14 -0
- package/bin/adapter/repositories/GraphqlProjectRepository.js +11 -2
- package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
- package/bin/adapter/repositories/ProjectIssuesCacheRepository.js +104 -0
- package/bin/adapter/repositories/ProjectIssuesCacheRepository.js.map +1 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +10 -46
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js +24 -2
- package/bin/domain/usecases/CreateNewStoryByLabelUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +8 -8
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/repositories/GraphqlProjectRepository.diskCache.test.ts +6 -1
- package/src/adapter/repositories/GraphqlProjectRepository.ts +24 -3
- package/src/adapter/repositories/ProjectIssuesCacheRepository.test.ts +217 -0
- package/src/adapter/repositories/ProjectIssuesCacheRepository.ts +141 -0
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +20 -59
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.test.ts +277 -0
- package/src/domain/usecases/CreateNewStoryByLabelUseCase.ts +50 -4
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +23 -1
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +8 -8
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts +2 -1
- package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
- package/types/adapter/repositories/ProjectIssuesCacheRepository.d.ts +23 -0
- package/types/adapter/repositories/ProjectIssuesCacheRepository.d.ts.map +1 -0
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +1 -6
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/domain/usecases/CreateNewStoryByLabelUseCase.d.ts.map +1 -1
- 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
|
-
|
|
595
|
+
projectId: Project['id'],
|
|
624
596
|
): Promise<CachedProjectIssues | null> => {
|
|
625
|
-
const raw = await this.
|
|
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
|
-
|
|
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(
|
|
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.
|
|
673
|
+
await this.projectIssuesCacheRepository.write(projectId, {
|
|
713
674
|
lastFetchedAt: nowIso,
|
|
714
675
|
lastFullFetchAt: nowIso,
|
|
715
676
|
project,
|
|
716
677
|
issues,
|
|
717
|
-
}
|
|
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.
|
|
711
|
+
await this.projectIssuesCacheRepository.write(projectId, {
|
|
751
712
|
lastFetchedAt: nowIso,
|
|
752
713
|
lastFullFetchAt: cache.lastFullFetchAt,
|
|
753
714
|
project,
|
|
754
715
|
issues,
|
|
755
|
-
}
|
|
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
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
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;
|