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.
Files changed (43) hide show
  1. package/.github/workflows/umino-project.yml +12 -0
  2. package/CHANGELOG.md +14 -0
  3. package/README.md +4 -0
  4. package/bin/adapter/entry-points/console/ui-dist/assets/index-D7Yt5Ya6.js +82 -0
  5. package/bin/adapter/entry-points/console/ui-dist/index.html +1 -1
  6. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +3 -0
  7. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  8. package/bin/adapter/repositories/issue/RestIssueRepository.js +47 -0
  9. package/bin/adapter/repositories/issue/RestIssueRepository.js.map +1 -1
  10. package/bin/domain/entities/SearchedIssue.js +3 -0
  11. package/bin/domain/entities/SearchedIssue.js.map +1 -0
  12. package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js +68 -10
  13. package/bin/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.js.map +1 -1
  14. package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -0
  15. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  16. package/package.json +1 -1
  17. package/src/adapter/entry-points/console/ui/e2e/consoleTestHarness.ts +1 -0
  18. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.test.ts +48 -0
  19. package/src/adapter/entry-points/console/ui/src/features/console/lib/markdown.ts +55 -25
  20. package/src/adapter/entry-points/console/ui-dist/assets/index-D7Yt5Ya6.js +82 -0
  21. package/src/adapter/entry-points/console/ui-dist/index.html +1 -1
  22. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +6 -1
  23. package/src/adapter/repositories/issue/RestIssueRepository.test.ts +107 -0
  24. package/src/adapter/repositories/issue/RestIssueRepository.ts +69 -2
  25. package/src/domain/entities/SearchedIssue.ts +11 -0
  26. package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.test.ts +208 -0
  27. package/src/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.ts +104 -13
  28. package/src/domain/usecases/HandleScheduledEventUseCase.ts +5 -0
  29. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +3 -1
  30. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +5 -3
  31. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  32. package/types/adapter/repositories/issue/RestIssueRepository.d.ts +5 -2
  33. package/types/adapter/repositories/issue/RestIssueRepository.d.ts.map +1 -1
  34. package/types/domain/entities/SearchedIssue.d.ts +11 -0
  35. package/types/domain/entities/SearchedIssue.d.ts.map +1 -0
  36. package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts +10 -2
  37. package/types/domain/usecases/AssignNoAssigneeIssueToManagerUseCase.d.ts.map +1 -1
  38. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +2 -0
  39. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  40. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +3 -1
  41. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  42. package/bin/adapter/entry-points/console/ui-dist/assets/index-CtUryuGr.js +0 -82
  43. 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-CtUryuGr.js"></script>
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 Pick<IssueRepository, 'updateAssigneeList' | 'removeLabel'>
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
  }
@@ -0,0 +1,11 @@
1
+ import { Member } from './Member';
2
+
3
+ export type SearchedIssue = {
4
+ url: string;
5
+ org: string;
6
+ repo: string;
7
+ number: number;
8
+ state: 'OPEN' | 'CLOSED' | 'MERGED';
9
+ author: string;
10
+ assignees: Member['name'][];
11
+ };
@@ -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<IssueRepository, 'updateAssigneeList'>,
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.assignees.length > 0 || issue.state !== 'OPEN') {
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 (authorAllowList !== null && !authorAllowList.includes(issue.author)) {
64
+ if (!(await this.addToProject(project, searchedIssue))) {
29
65
  continue;
30
66
  }
31
- try {
32
- await this.issueRepository.updateAssigneeList(issue, [input.manager]);
33
- } catch (e) {
34
- if (!(e instanceof Error)) {
35
- throw e;
36
- }
37
- console.error(
38
- `Failed to update assignee for issue ${issue.url}: ${e.message}`,
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 new Promise((resolve) => setTimeout(resolve, 1000));
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[]>;