github-issue-tower-defence-management 1.147.4 → 1.148.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/umino-project.yml +12 -0
- package/CHANGELOG.md +14 -0
- package/README.md +4 -0
- package/bin/adapter/entry-points/console/ui-dist/assets/index-D7Yt5Ya6.js +82 -0
- package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +3 -0
- package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
- package/bin/adapter/repositories/issue/RestIssueRepository.js +47 -0
- package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
- package/bin/domain/entities/SearchedIssue.js +3 -0
- package/bin/domain/entities/SearchedIssue.js.map +1 -0
- package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js +68 -10
- package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/package.json +1 -1
- package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +48 -0
- package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +55 -25
- package/src/adapter/entry-points/console/ui-dist/assets/index-D7Yt5Ya6.js +82 -0
- package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
- package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +6 -1
- package/src/adapter/repositories/issue/RestIssueRepository.test.ts +107 -0
- package/src/adapter/repositories/issue/RestIssueRepository.ts +69 -2
- package/src/domain/entities/SearchedIssue.ts +11 -0
- package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.test.ts +208 -0
- package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.ts +104 -13
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +5 -0
- package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +3 -1
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +5 -3
- package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
- package/types/adapter/repositories/issue/RestIssueRepository.d.ts +5 -2
- package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
- package/types/domain/entities/SearchedIssue.d.ts +11 -0
- package/types/domain/entities/SearchedIssue.d.ts.map +1 -0
- package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts +10 -2
- package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +2 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -1
- package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
- package/bin/adapter/entry-points/console/ui-dist/assets/index-CtUryuGr.js +0 -82
- package/src/adapter/entry-points/console/ui-dist/assets/index-CtUryuGr.js +0 -82
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>TDPM Console</title>
|
|
7
|
-
<script type="module" crossorigin src="/assets/index-
|
|
7
|
+
<script type="module" crossorigin src="/assets/index-D7Yt5Ya6.js"></script>
|
|
8
8
|
<link rel="stylesheet" crossorigin href="/assets/index-BihdmZb-.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body>
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
11
11
|
import { Project } from '../../../domain/entities/Project';
|
|
12
12
|
import { Issue } from '../../../domain/entities/Issue';
|
|
13
|
+
import { SearchedIssue } from '../../../domain/entities/SearchedIssue';
|
|
13
14
|
import { StoryObjectMap } from '../../../domain/entities/StoryObjectMap';
|
|
14
15
|
import { ApiV3IssueRepository } from './ApiV3IssueRepository';
|
|
15
16
|
import { normalizeGitHubRawUrl } from './gitHubRawUrl';
|
|
@@ -455,6 +456,7 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
455
456
|
| 'updateLabels'
|
|
456
457
|
| 'removeLabel'
|
|
457
458
|
| 'updateAssigneeList'
|
|
459
|
+
| 'searchIssues'
|
|
458
460
|
>,
|
|
459
461
|
readonly graphqlProjectItemRepository: Pick<
|
|
460
462
|
GraphqlProjectItemRepository,
|
|
@@ -935,11 +937,14 @@ export class ApiV3CheerioRestIssueRepository
|
|
|
935
937
|
return this.restIssueRepository.removeLabel(issue, label);
|
|
936
938
|
};
|
|
937
939
|
updateAssigneeList = (
|
|
938
|
-
issue: Issue,
|
|
940
|
+
issue: Pick<Issue, 'org' | 'repo' | 'number'>,
|
|
939
941
|
assigneeList: Member['name'][],
|
|
940
942
|
): Promise<void> => {
|
|
941
943
|
return this.restIssueRepository.updateAssigneeList(issue, assigneeList);
|
|
942
944
|
};
|
|
945
|
+
searchIssues = (query: string): Promise<SearchedIssue[]> => {
|
|
946
|
+
return this.restIssueRepository.searchIssues(query);
|
|
947
|
+
};
|
|
943
948
|
get = async (_issueUrl: string, _project: Project): Promise<Issue | null> => {
|
|
944
949
|
return this.getIssueByUrl(_issueUrl);
|
|
945
950
|
};
|
|
@@ -262,4 +262,111 @@ describe('RestIssueRepository', () => {
|
|
|
262
262
|
);
|
|
263
263
|
});
|
|
264
264
|
});
|
|
265
|
+
|
|
266
|
+
describe('searchIssues', () => {
|
|
267
|
+
const buildSearchItem = (
|
|
268
|
+
overrides: Partial<{
|
|
269
|
+
html_url: string;
|
|
270
|
+
state: string;
|
|
271
|
+
user: { login: string } | null;
|
|
272
|
+
assignees: { login: string }[];
|
|
273
|
+
pull_request: { merged_at: string | null } | null;
|
|
274
|
+
}> = {},
|
|
275
|
+
) => ({
|
|
276
|
+
html_url: 'https://github.com/HiromiShikata/test-repository/pull/12',
|
|
277
|
+
state: 'open',
|
|
278
|
+
user: { login: 'dependabot[bot]' },
|
|
279
|
+
assignees: [],
|
|
280
|
+
pull_request: { merged_at: null },
|
|
281
|
+
...overrides,
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('sends the query to the search endpoint and maps the response', async () => {
|
|
285
|
+
mockGet.mockReturnValueOnce(
|
|
286
|
+
mockJsonResponse({ items: [buildSearchItem()] }),
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
const searchedIssues = await restIssueRepository.searchIssues(
|
|
290
|
+
'repo:HiromiShikata/test-repository is:open no:project',
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(mockGet).toHaveBeenCalledWith(
|
|
294
|
+
'https://api.github.com/search/issues',
|
|
295
|
+
{
|
|
296
|
+
searchParams: {
|
|
297
|
+
q: 'repo:HiromiShikata/test-repository is:open no:project',
|
|
298
|
+
per_page: 100,
|
|
299
|
+
page: 1,
|
|
300
|
+
advanced_search: 'true',
|
|
301
|
+
},
|
|
302
|
+
headers: { Authorization: 'token dummy-token' },
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
expect(searchedIssues).toEqual([
|
|
306
|
+
{
|
|
307
|
+
url: 'https://github.com/HiromiShikata/test-repository/pull/12',
|
|
308
|
+
org: 'HiromiShikata',
|
|
309
|
+
repo: 'test-repository',
|
|
310
|
+
number: 12,
|
|
311
|
+
state: 'OPEN',
|
|
312
|
+
author: 'dependabot',
|
|
313
|
+
assignees: [],
|
|
314
|
+
},
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('keeps the author login of a human account unchanged', async () => {
|
|
319
|
+
mockGet.mockReturnValueOnce(
|
|
320
|
+
mockJsonResponse({
|
|
321
|
+
items: [buildSearchItem({ user: { login: 'HiromiShikata' } })],
|
|
322
|
+
}),
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const searchedIssues = await restIssueRepository.searchIssues('anything');
|
|
326
|
+
|
|
327
|
+
expect(searchedIssues[0].author).toBe('HiromiShikata');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('maps a merged pull request and a closed issue to their states', async () => {
|
|
331
|
+
mockGet.mockReturnValueOnce(
|
|
332
|
+
mockJsonResponse({
|
|
333
|
+
items: [
|
|
334
|
+
buildSearchItem({
|
|
335
|
+
state: 'closed',
|
|
336
|
+
pull_request: { merged_at: '2026-08-09T00:00:00Z' },
|
|
337
|
+
}),
|
|
338
|
+
buildSearchItem({
|
|
339
|
+
html_url:
|
|
340
|
+
'https://github.com/HiromiShikata/test-repository/issues/13',
|
|
341
|
+
state: 'closed',
|
|
342
|
+
pull_request: null,
|
|
343
|
+
}),
|
|
344
|
+
],
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const searchedIssues = await restIssueRepository.searchIssues('anything');
|
|
349
|
+
|
|
350
|
+
expect(searchedIssues.map((issue) => issue.state)).toEqual([
|
|
351
|
+
'MERGED',
|
|
352
|
+
'CLOSED',
|
|
353
|
+
]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it('requests the next page while a full page is returned', async () => {
|
|
357
|
+
const fullPage = Array.from({ length: 100 }, (_unused, index) =>
|
|
358
|
+
buildSearchItem({
|
|
359
|
+
html_url: `https://github.com/HiromiShikata/test-repository/pull/${index + 1}`,
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
mockGet
|
|
363
|
+
.mockReturnValueOnce(mockJsonResponse({ items: fullPage }))
|
|
364
|
+
.mockReturnValueOnce(mockJsonResponse({ items: [buildSearchItem()] }));
|
|
365
|
+
|
|
366
|
+
const searchedIssues = await restIssueRepository.searchIssues('anything');
|
|
367
|
+
|
|
368
|
+
expect(mockGet).toHaveBeenCalledTimes(2);
|
|
369
|
+
expect(searchedIssues).toHaveLength(101);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
265
372
|
});
|
|
@@ -3,10 +3,24 @@ import { BaseGitHubRepository } from '../BaseGitHubRepository';
|
|
|
3
3
|
import { Issue } from '../../../domain/entities/Issue';
|
|
4
4
|
import { IssueRepository } from '../../../domain/usecases/adapter-interfaces/IssueRepository';
|
|
5
5
|
import { Member } from '../../../domain/entities/Member';
|
|
6
|
+
import { SearchedIssue } from '../../../domain/entities/SearchedIssue';
|
|
7
|
+
|
|
8
|
+
type SearchIssuesResponseItem = {
|
|
9
|
+
html_url: string;
|
|
10
|
+
state: string;
|
|
11
|
+
user: { login: string } | null;
|
|
12
|
+
assignees: { login: string }[];
|
|
13
|
+
pull_request?: { merged_at: string | null } | null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type SearchIssuesResponse = {
|
|
17
|
+
items: SearchIssuesResponseItem[];
|
|
18
|
+
};
|
|
6
19
|
|
|
7
20
|
export class RestIssueRepository
|
|
8
21
|
extends BaseGitHubRepository
|
|
9
|
-
implements
|
|
22
|
+
implements
|
|
23
|
+
Pick<IssueRepository, 'updateAssigneeList' | 'removeLabel' | 'searchIssues'>
|
|
10
24
|
{
|
|
11
25
|
createComment = async (issueUrl: string, comment: string) => {
|
|
12
26
|
const { owner, repo, issueNumber } = this.extractIssueFromUrl(issueUrl);
|
|
@@ -128,7 +142,7 @@ export class RestIssueRepository
|
|
|
128
142
|
};
|
|
129
143
|
|
|
130
144
|
updateAssigneeList = async (
|
|
131
|
-
issue: Issue,
|
|
145
|
+
issue: Pick<Issue, 'org' | 'repo' | 'number'>,
|
|
132
146
|
assigneeList: Member['name'][],
|
|
133
147
|
): Promise<void> => {
|
|
134
148
|
await ky.patch(
|
|
@@ -139,4 +153,57 @@ export class RestIssueRepository
|
|
|
139
153
|
},
|
|
140
154
|
);
|
|
141
155
|
};
|
|
156
|
+
|
|
157
|
+
searchIssues = async (query: string): Promise<SearchedIssue[]> => {
|
|
158
|
+
const perPage = 100;
|
|
159
|
+
const maxPageCount = 10;
|
|
160
|
+
const searchedIssues: SearchedIssue[] = [];
|
|
161
|
+
for (let page = 1; page <= maxPageCount; page++) {
|
|
162
|
+
const response = await ky
|
|
163
|
+
.get('https://api.github.com/search/issues', {
|
|
164
|
+
searchParams: {
|
|
165
|
+
q: query,
|
|
166
|
+
per_page: perPage,
|
|
167
|
+
page,
|
|
168
|
+
advanced_search: 'true',
|
|
169
|
+
},
|
|
170
|
+
headers: { Authorization: `token ${this.ghToken}` },
|
|
171
|
+
})
|
|
172
|
+
.json<SearchIssuesResponse>();
|
|
173
|
+
for (const item of response.items) {
|
|
174
|
+
const parsed = this.parseSearchedIssue(item);
|
|
175
|
+
if (parsed) {
|
|
176
|
+
searchedIssues.push(parsed);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (response.items.length < perPage) {
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return searchedIssues;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
private parseSearchedIssue = (
|
|
187
|
+
item: SearchIssuesResponseItem,
|
|
188
|
+
): SearchedIssue | null => {
|
|
189
|
+
const matched = item.html_url.match(
|
|
190
|
+
/^https:\/\/github\.com\/([^/]+)\/([^/]+)\/(?:issues|pull)\/(\d+)$/,
|
|
191
|
+
);
|
|
192
|
+
if (!matched) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
url: item.html_url,
|
|
197
|
+
org: matched[1],
|
|
198
|
+
repo: matched[2],
|
|
199
|
+
number: Number(matched[3]),
|
|
200
|
+
state: item.pull_request?.merged_at
|
|
201
|
+
? 'MERGED'
|
|
202
|
+
: item.state === 'open'
|
|
203
|
+
? 'OPEN'
|
|
204
|
+
: 'CLOSED',
|
|
205
|
+
author: (item.user?.login ?? '').replace(/\[bot\]$/, ''),
|
|
206
|
+
assignees: item.assignees.map((assignee) => assignee.login),
|
|
207
|
+
};
|
|
208
|
+
};
|
|
142
209
|
}
|
|
@@ -2,6 +2,8 @@ import { mock } from 'jest-mock-extended';
|
|
|
2
2
|
import { AssignNoAssigneeIssueToManagerUseCase } from './AssignNoAssigneeIssueToManagerUseCase';
|
|
3
3
|
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
4
4
|
import { Issue } from '../entities/Issue';
|
|
5
|
+
import { Project } from '../entities/Project';
|
|
6
|
+
import { SearchedIssue } from '../entities/SearchedIssue';
|
|
5
7
|
|
|
6
8
|
describe('AssignNoAssigneeIssueToManagerUseCase', () => {
|
|
7
9
|
const mockIssueRepository = mock<IssueRepository>();
|
|
@@ -275,6 +277,212 @@ describe('AssignNoAssigneeIssueToManagerUseCase', () => {
|
|
|
275
277
|
]);
|
|
276
278
|
});
|
|
277
279
|
|
|
280
|
+
describe('issues matched by queryToAddProject', () => {
|
|
281
|
+
const project = { ...mock<Project>(), id: 'project-1' };
|
|
282
|
+
const searchedIssue: SearchedIssue = {
|
|
283
|
+
url: 'https://github.com/testOrg/testRepo/pull/7',
|
|
284
|
+
org: 'testOrg',
|
|
285
|
+
repo: 'testRepo',
|
|
286
|
+
number: 7,
|
|
287
|
+
state: 'OPEN',
|
|
288
|
+
author: 'dependabot',
|
|
289
|
+
assignees: [],
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
it('adds a matched issue that is not a project item to the project and assigns the manager', async () => {
|
|
293
|
+
mockIssueRepository.searchIssues.mockResolvedValueOnce([searchedIssue]);
|
|
294
|
+
|
|
295
|
+
await useCase.run({
|
|
296
|
+
issues: [],
|
|
297
|
+
manager: 'manager1',
|
|
298
|
+
cacheUsed: false,
|
|
299
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
300
|
+
projectToAddSearchedIssues: project,
|
|
301
|
+
queryToAddProjectEnabled: true,
|
|
302
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(mockIssueRepository.searchIssues.mock.calls).toEqual([
|
|
306
|
+
['repo:testOrg/testRepo is:open no:project'],
|
|
307
|
+
]);
|
|
308
|
+
expect(mockIssueRepository.addIssueToProject.mock.calls).toEqual([
|
|
309
|
+
[project, searchedIssue.url],
|
|
310
|
+
]);
|
|
311
|
+
expect(mockIssueRepository.updateAssigneeList.mock.calls).toEqual([
|
|
312
|
+
[{ org: 'testOrg', repo: 'testRepo', number: 7 }, ['manager1']],
|
|
313
|
+
]);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('does not add or assign a matched issue whose author is outside autoAssignManagerAuthors', async () => {
|
|
317
|
+
mockIssueRepository.searchIssues.mockResolvedValueOnce([
|
|
318
|
+
{ ...searchedIssue, author: 'human-author' },
|
|
319
|
+
]);
|
|
320
|
+
|
|
321
|
+
await useCase.run({
|
|
322
|
+
issues: [],
|
|
323
|
+
manager: 'manager1',
|
|
324
|
+
cacheUsed: false,
|
|
325
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
326
|
+
projectToAddSearchedIssues: project,
|
|
327
|
+
queryToAddProjectEnabled: true,
|
|
328
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
expect(mockIssueRepository.addIssueToProject.mock.calls).toEqual([]);
|
|
332
|
+
expect(mockIssueRepository.updateAssigneeList.mock.calls).toEqual([]);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('does not add or assign a matched issue that already has an assignee or is not open', async () => {
|
|
336
|
+
mockIssueRepository.searchIssues.mockResolvedValueOnce([
|
|
337
|
+
{ ...searchedIssue, assignees: ['someone'] },
|
|
338
|
+
{ ...searchedIssue, number: 8, state: 'CLOSED' },
|
|
339
|
+
]);
|
|
340
|
+
|
|
341
|
+
await useCase.run({
|
|
342
|
+
issues: [],
|
|
343
|
+
manager: 'manager1',
|
|
344
|
+
cacheUsed: false,
|
|
345
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
346
|
+
projectToAddSearchedIssues: project,
|
|
347
|
+
queryToAddProjectEnabled: true,
|
|
348
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(mockIssueRepository.addIssueToProject.mock.calls).toEqual([]);
|
|
352
|
+
expect(mockIssueRepository.updateAssigneeList.mock.calls).toEqual([]);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('assigns a matched issue that is already a project item only once and does not re-add it', async () => {
|
|
356
|
+
const projectItem = {
|
|
357
|
+
...basicIssue,
|
|
358
|
+
url: searchedIssue.url,
|
|
359
|
+
author: 'dependabot',
|
|
360
|
+
};
|
|
361
|
+
mockIssueRepository.searchIssues.mockResolvedValueOnce([searchedIssue]);
|
|
362
|
+
|
|
363
|
+
await useCase.run({
|
|
364
|
+
issues: [projectItem],
|
|
365
|
+
manager: 'manager1',
|
|
366
|
+
cacheUsed: false,
|
|
367
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
368
|
+
projectToAddSearchedIssues: project,
|
|
369
|
+
queryToAddProjectEnabled: true,
|
|
370
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
expect(mockIssueRepository.addIssueToProject.mock.calls).toEqual([]);
|
|
374
|
+
expect(mockIssueRepository.updateAssigneeList.mock.calls).toEqual([
|
|
375
|
+
[projectItem, ['manager1']],
|
|
376
|
+
]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('does not search when no query is configured', async () => {
|
|
380
|
+
await useCase.run({
|
|
381
|
+
issues: [],
|
|
382
|
+
manager: 'manager1',
|
|
383
|
+
cacheUsed: false,
|
|
384
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
385
|
+
projectToAddSearchedIssues: project,
|
|
386
|
+
queryToAddProjectEnabled: true,
|
|
387
|
+
queryToAddProject: null,
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(mockIssueRepository.searchIssues.mock.calls).toEqual([]);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it('does not search when queryToAddProjectEnabled is false', async () => {
|
|
394
|
+
await useCase.run({
|
|
395
|
+
issues: [],
|
|
396
|
+
manager: 'manager1',
|
|
397
|
+
cacheUsed: false,
|
|
398
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
399
|
+
projectToAddSearchedIssues: project,
|
|
400
|
+
queryToAddProjectEnabled: false,
|
|
401
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
expect(mockIssueRepository.searchIssues.mock.calls).toEqual([]);
|
|
405
|
+
expect(mockIssueRepository.addIssueToProject.mock.calls).toEqual([]);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('does not search when queryToAddProjectEnabled is not configured', async () => {
|
|
409
|
+
await useCase.run({
|
|
410
|
+
issues: [],
|
|
411
|
+
manager: 'manager1',
|
|
412
|
+
cacheUsed: false,
|
|
413
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
414
|
+
projectToAddSearchedIssues: project,
|
|
415
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
expect(mockIssueRepository.searchIssues.mock.calls).toEqual([]);
|
|
419
|
+
expect(mockIssueRepository.addIssueToProject.mock.calls).toEqual([]);
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('logs and returns without throwing when the search fails', async () => {
|
|
423
|
+
mockIssueRepository.searchIssues.mockRejectedValueOnce(
|
|
424
|
+
new Error('Validation Failed'),
|
|
425
|
+
);
|
|
426
|
+
const consoleErrorSpy = jest
|
|
427
|
+
.spyOn(console, 'error')
|
|
428
|
+
.mockImplementation(() => undefined);
|
|
429
|
+
|
|
430
|
+
await useCase.run({
|
|
431
|
+
issues: [],
|
|
432
|
+
manager: 'manager1',
|
|
433
|
+
cacheUsed: false,
|
|
434
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
435
|
+
projectToAddSearchedIssues: project,
|
|
436
|
+
queryToAddProjectEnabled: true,
|
|
437
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
441
|
+
'Failed to search issues by repo:testOrg/testRepo is:open no:project: Validation Failed',
|
|
442
|
+
);
|
|
443
|
+
expect(mockIssueRepository.addIssueToProject.mock.calls).toEqual([]);
|
|
444
|
+
expect(mockIssueRepository.updateAssigneeList.mock.calls).toEqual([]);
|
|
445
|
+
|
|
446
|
+
consoleErrorSpy.mockRestore();
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('logs and continues when adding a matched issue to the project fails', async () => {
|
|
450
|
+
mockIssueRepository.searchIssues.mockResolvedValueOnce([
|
|
451
|
+
searchedIssue,
|
|
452
|
+
{
|
|
453
|
+
...searchedIssue,
|
|
454
|
+
url: 'https://github.com/testOrg/testRepo/pull/9',
|
|
455
|
+
number: 9,
|
|
456
|
+
},
|
|
457
|
+
]);
|
|
458
|
+
mockIssueRepository.addIssueToProject.mockRejectedValueOnce(
|
|
459
|
+
new Error('Content not found'),
|
|
460
|
+
);
|
|
461
|
+
const consoleErrorSpy = jest
|
|
462
|
+
.spyOn(console, 'error')
|
|
463
|
+
.mockImplementation(() => undefined);
|
|
464
|
+
|
|
465
|
+
await useCase.run({
|
|
466
|
+
issues: [],
|
|
467
|
+
manager: 'manager1',
|
|
468
|
+
cacheUsed: false,
|
|
469
|
+
autoAssignManagerAuthors: ['dependabot'],
|
|
470
|
+
projectToAddSearchedIssues: project,
|
|
471
|
+
queryToAddProjectEnabled: true,
|
|
472
|
+
queryToAddProject: 'repo:testOrg/testRepo is:open no:project',
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
476
|
+
'Failed to add issue https://github.com/testOrg/testRepo/pull/7 to project: Content not found',
|
|
477
|
+
);
|
|
478
|
+
expect(mockIssueRepository.updateAssigneeList.mock.calls).toEqual([
|
|
479
|
+
[{ org: 'testOrg', repo: 'testRepo', number: 9 }, ['manager1']],
|
|
480
|
+
]);
|
|
481
|
+
|
|
482
|
+
consoleErrorSpy.mockRestore();
|
|
483
|
+
});
|
|
484
|
+
});
|
|
485
|
+
|
|
278
486
|
it('should rethrow non-Error thrown values without logging', async () => {
|
|
279
487
|
const failingIssue = {
|
|
280
488
|
...basicIssue,
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { Issue } from '../entities/Issue';
|
|
2
|
+
import { Project } from '../entities/Project';
|
|
3
|
+
import { SearchedIssue } from '../entities/SearchedIssue';
|
|
2
4
|
import { IssueRepository } from './adapter-interfaces/IssueRepository';
|
|
3
5
|
import { Member } from '../entities/Member';
|
|
4
6
|
|
|
5
7
|
export class AssignNoAssigneeIssueToManagerUseCase {
|
|
6
8
|
constructor(
|
|
7
|
-
readonly issueRepository: Pick<
|
|
9
|
+
readonly issueRepository: Pick<
|
|
10
|
+
IssueRepository,
|
|
11
|
+
'updateAssigneeList' | 'searchIssues' | 'addIssueToProject'
|
|
12
|
+
>,
|
|
8
13
|
) {}
|
|
9
14
|
|
|
10
15
|
run = async (input: {
|
|
@@ -12,6 +17,9 @@ export class AssignNoAssigneeIssueToManagerUseCase {
|
|
|
12
17
|
manager: Member['name'];
|
|
13
18
|
cacheUsed: boolean;
|
|
14
19
|
autoAssignManagerAuthors?: string[] | null;
|
|
20
|
+
projectToAddSearchedIssues?: Project | null;
|
|
21
|
+
queryToAddProjectEnabled?: boolean | null;
|
|
22
|
+
queryToAddProject?: string | null;
|
|
15
23
|
}): Promise<void> => {
|
|
16
24
|
if (input.cacheUsed) {
|
|
17
25
|
return;
|
|
@@ -21,25 +29,108 @@ export class AssignNoAssigneeIssueToManagerUseCase {
|
|
|
21
29
|
input.autoAssignManagerAuthors.length > 0
|
|
22
30
|
? input.autoAssignManagerAuthors
|
|
23
31
|
: null;
|
|
32
|
+
const isAssignable = (target: {
|
|
33
|
+
assignees: Member['name'][];
|
|
34
|
+
state: Issue['state'];
|
|
35
|
+
author: string;
|
|
36
|
+
}): boolean => {
|
|
37
|
+
if (target.assignees.length > 0 || target.state !== 'OPEN') {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
return (
|
|
41
|
+
authorAllowList === null || authorAllowList.includes(target.author)
|
|
42
|
+
);
|
|
43
|
+
};
|
|
24
44
|
for (const issue of input.issues) {
|
|
25
|
-
if (issue
|
|
45
|
+
if (!isAssignable(issue)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
await this.assignManager(issue, issue.url, input.manager);
|
|
49
|
+
}
|
|
50
|
+
const project = input.projectToAddSearchedIssues;
|
|
51
|
+
const query = input.queryToAddProject;
|
|
52
|
+
if (!input.queryToAddProjectEnabled || !project || !query) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const projectItemUrls = new Set(input.issues.map((issue) => issue.url));
|
|
56
|
+
const searchedIssues = await this.searchIssues(query);
|
|
57
|
+
for (const searchedIssue of searchedIssues) {
|
|
58
|
+
if (
|
|
59
|
+
projectItemUrls.has(searchedIssue.url) ||
|
|
60
|
+
!isAssignable(searchedIssue)
|
|
61
|
+
) {
|
|
26
62
|
continue;
|
|
27
63
|
}
|
|
28
|
-
if (
|
|
64
|
+
if (!(await this.addToProject(project, searchedIssue))) {
|
|
29
65
|
continue;
|
|
30
66
|
}
|
|
31
|
-
|
|
32
|
-
await this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
67
|
+
if (
|
|
68
|
+
!(await this.assignManager(
|
|
69
|
+
{
|
|
70
|
+
org: searchedIssue.org,
|
|
71
|
+
repo: searchedIssue.repo,
|
|
72
|
+
number: searchedIssue.number,
|
|
73
|
+
},
|
|
74
|
+
searchedIssue.url,
|
|
75
|
+
input.manager,
|
|
76
|
+
))
|
|
77
|
+
) {
|
|
40
78
|
continue;
|
|
41
79
|
}
|
|
42
|
-
await
|
|
80
|
+
await this.waitBeforeNextRequest();
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
private searchIssues = async (query: string): Promise<SearchedIssue[]> => {
|
|
85
|
+
try {
|
|
86
|
+
return await this.issueRepository.searchIssues(query);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
if (!(e instanceof Error)) {
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
console.error(`Failed to search issues by ${query}: ${e.message}`);
|
|
92
|
+
return [];
|
|
43
93
|
}
|
|
44
94
|
};
|
|
95
|
+
|
|
96
|
+
private addToProject = async (
|
|
97
|
+
project: Project,
|
|
98
|
+
searchedIssue: SearchedIssue,
|
|
99
|
+
): Promise<boolean> => {
|
|
100
|
+
try {
|
|
101
|
+
await this.issueRepository.addIssueToProject(project, searchedIssue.url);
|
|
102
|
+
} catch (e) {
|
|
103
|
+
if (!(e instanceof Error)) {
|
|
104
|
+
throw e;
|
|
105
|
+
}
|
|
106
|
+
console.error(
|
|
107
|
+
`Failed to add issue ${searchedIssue.url} to project: ${e.message}`,
|
|
108
|
+
);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
return true;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
private assignManager = async (
|
|
115
|
+
issue: Pick<Issue, 'org' | 'repo' | 'number'>,
|
|
116
|
+
issueUrl: string,
|
|
117
|
+
manager: Member['name'],
|
|
118
|
+
): Promise<boolean> => {
|
|
119
|
+
try {
|
|
120
|
+
await this.issueRepository.updateAssigneeList(issue, [manager]);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (!(e instanceof Error)) {
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
console.error(
|
|
126
|
+
`Failed to update assignee for issue ${issueUrl}: ${e.message}`,
|
|
127
|
+
);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
return true;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
private waitBeforeNextRequest = async (): Promise<void> => {
|
|
134
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
135
|
+
};
|
|
45
136
|
}
|
|
@@ -178,6 +178,8 @@ export class HandleScheduledEventUseCase {
|
|
|
178
178
|
} | null;
|
|
179
179
|
thresholdForAutoReject?: number;
|
|
180
180
|
createTaskFromStoryBodyCheckboxEnabled?: boolean;
|
|
181
|
+
queryToAddProjectEnabled?: boolean;
|
|
182
|
+
queryToAddProject?: string | null;
|
|
181
183
|
storyProgressCommentEnabled?: boolean;
|
|
182
184
|
dailySecurityScan?: DailySecurityScanConfig | null;
|
|
183
185
|
}): Promise<{
|
|
@@ -568,6 +570,9 @@ ${JSON.stringify(e)}
|
|
|
568
570
|
manager: input.manager,
|
|
569
571
|
cacheUsed,
|
|
570
572
|
autoAssignManagerAuthors: input.autoAssignManagerAuthors ?? null,
|
|
573
|
+
projectToAddSearchedIssues: project,
|
|
574
|
+
queryToAddProjectEnabled: input.queryToAddProjectEnabled ?? false,
|
|
575
|
+
queryToAddProject: input.queryToAddProject ?? null,
|
|
571
576
|
});
|
|
572
577
|
await this.updateIssueStatusByLabelUseCase.run({
|
|
573
578
|
project,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Issue, Label } from '../../entities/Issue';
|
|
2
2
|
import { FieldOption, Project } from '../../entities/Project';
|
|
3
3
|
import { Member } from '../../entities/Member';
|
|
4
|
+
import { SearchedIssue } from '../../entities/SearchedIssue';
|
|
4
5
|
import { StoryObjectMap } from '../../entities/StoryObjectMap';
|
|
5
6
|
|
|
6
7
|
export type RelatedPullRequest = {
|
|
@@ -127,9 +128,10 @@ export interface IssueRepository {
|
|
|
127
128
|
updateLabels: (issue: Issue, labels: Issue['labels']) => Promise<void>;
|
|
128
129
|
removeLabel: (issue: Issue, label: Label) => Promise<void>;
|
|
129
130
|
updateAssigneeList: (
|
|
130
|
-
issue: Issue,
|
|
131
|
+
issue: Pick<Issue, 'org' | 'repo' | 'number'>,
|
|
131
132
|
assigneeList: Member['name'][],
|
|
132
133
|
) => Promise<void>;
|
|
134
|
+
searchIssues: (query: string) => Promise<SearchedIssue[]>;
|
|
133
135
|
get: (issueUrl: string, project: Project) => Promise<Issue | null>;
|
|
134
136
|
update: (issue: Issue, project: Project) => Promise<void>;
|
|
135
137
|
findRelatedOpenPRs: (issueUrl: string) => Promise<RelatedPullRequest[]>;
|