github-issue-tower-defence-management 1.69.13 → 1.71.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 (31) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +12 -0
  3. package/bin/adapter/entry-points/cli/index.js +48 -0
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +26 -0
  8. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -0
  9. package/bin/domain/usecases/HandleScheduledEventUseCase.js +6 -1
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  11. package/bin/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.js +25 -0
  12. package/bin/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/cli/index.test.ts +189 -0
  15. package/src/adapter/entry-points/cli/index.ts +101 -0
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +8 -0
  17. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +4 -0
  18. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +196 -0
  19. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +45 -0
  20. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
  21. package/src/domain/usecases/HandleScheduledEventUseCase.ts +6 -0
  22. package/src/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.test.ts +214 -0
  23. package/src/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.ts +36 -0
  24. package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
  25. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  26. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts +21 -0
  27. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -0
  28. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
  29. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  30. package/types/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.d.ts +12 -0
  31. package/types/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.d.ts.map +1 -0
@@ -19,6 +19,7 @@ import { StartPreparationUseCase } from '../../../domain/usecases/StartPreparati
19
19
  import { writeRotationOrderFile } from '../handlers/rotationOrderFileWriter';
20
20
  import { ProxyClaudeTokenUsageRepository } from '../../repositories/ProxyClaudeTokenUsageRepository';
21
21
  import { NotifyFinishedIssuePreparationUseCase } from '../../../domain/usecases/NotifyFinishedIssuePreparationUseCase';
22
+ import { CheckIssueReviewReadinessUseCase } from '../../../domain/usecases/CheckIssueReviewReadinessUseCase';
22
23
  import { LocalStorageRepository } from '../../repositories/LocalStorageRepository';
23
24
  import { GraphqlProjectRepository } from '../../repositories/GraphqlProjectRepository';
24
25
  import { ApiV3IssueRepository } from '../../repositories/issue/ApiV3IssueRepository';
@@ -53,6 +54,12 @@ type NotifyFinishedOptions = {
53
54
  configFilePath: string;
54
55
  };
55
56
 
57
+ type CheckIssueReviewReadinessOptions = {
58
+ issueUrl: string;
59
+ projectUrl?: string;
60
+ configFilePath: string;
61
+ };
62
+
56
63
  const buildGithubRepositoryParams = (
57
64
  localStorageRepository: LocalStorageRepository,
58
65
  token: string,
@@ -444,6 +451,100 @@ program
444
451
  });
445
452
  });
446
453
 
454
+ program
455
+ .command('checkIssueReviewReadiness')
456
+ .description(
457
+ 'Check whether an issue is in a review-ready state without mutating any field or posting any comment',
458
+ )
459
+ .requiredOption(
460
+ '--configFilePath <path>',
461
+ 'Path to config file for tower defence management',
462
+ )
463
+ .requiredOption('--issueUrl <url>', 'GitHub issue URL')
464
+ .option('--projectUrl <url>', 'GitHub project URL')
465
+ .action(async (options: CheckIssueReviewReadinessOptions) => {
466
+ const token = process.env.GH_TOKEN;
467
+ if (!token) {
468
+ console.error('GH_TOKEN environment variable is required');
469
+ process.exit(1);
470
+ }
471
+
472
+ const configFileValues = loadConfigFile(options.configFilePath);
473
+
474
+ const cliOverrides: ConfigFile = {
475
+ projectUrl: options.projectUrl,
476
+ };
477
+
478
+ const tempProjectUrl =
479
+ cliOverrides.projectUrl ?? configFileValues.projectUrl;
480
+
481
+ let readmeOverrides: ConfigFile = {};
482
+ if (tempProjectUrl) {
483
+ const readme = await fetchProjectReadme(tempProjectUrl, token);
484
+ if (readme) {
485
+ readmeOverrides = parseProjectReadmeConfig(readme, tempProjectUrl);
486
+ }
487
+ }
488
+
489
+ const config = mergeConfigs(
490
+ configFileValues,
491
+ cliOverrides,
492
+ readmeOverrides,
493
+ );
494
+
495
+ const projectUrl = config.projectUrl;
496
+
497
+ if (!projectUrl) {
498
+ console.error(
499
+ 'projectUrl is required. Provide via --projectUrl, config file, or project README.',
500
+ );
501
+ process.exit(1);
502
+ }
503
+
504
+ const projectName = config.projectName ?? 'default';
505
+ const localStorageRepository = new LocalStorageRepository();
506
+ const cachePath = `./tmp/cache/${projectName}`;
507
+ const localStorageCacheRepository = new LocalStorageCacheRepository(
508
+ localStorageRepository,
509
+ cachePath,
510
+ );
511
+ const githubRepositoryParams = buildGithubRepositoryParams(
512
+ localStorageRepository,
513
+ token,
514
+ );
515
+ const projectRepository = new GraphqlProjectRepository(
516
+ ...githubRepositoryParams,
517
+ );
518
+ const apiV3IssueRepository = new ApiV3IssueRepository(
519
+ ...githubRepositoryParams,
520
+ );
521
+ const restIssueRepository = new RestIssueRepository(
522
+ ...githubRepositoryParams,
523
+ );
524
+ const graphqlProjectItemRepository = new GraphqlProjectItemRepository(
525
+ ...githubRepositoryParams,
526
+ );
527
+ const issueRepository = new ApiV3CheerioRestIssueRepository(
528
+ apiV3IssueRepository,
529
+ restIssueRepository,
530
+ graphqlProjectItemRepository,
531
+ localStorageCacheRepository,
532
+ ...githubRepositoryParams,
533
+ );
534
+
535
+ const useCase = new CheckIssueReviewReadinessUseCase(
536
+ projectRepository,
537
+ issueRepository,
538
+ );
539
+
540
+ const result = await useCase.run({
541
+ projectUrl,
542
+ issueUrl: options.issueUrl,
543
+ });
544
+
545
+ process.stdout.write(`${JSON.stringify(result)}\n`);
546
+ });
547
+
447
548
  /* istanbul ignore next */
448
549
  if (process.argv && require.main === module) {
449
550
  program.parse(process.argv);
@@ -60,6 +60,14 @@ jest.mock('../../../domain/usecases/AnalyzeStoriesUseCase', () => ({
60
60
  jest.mock('../../../domain/usecases/ClearDependedIssueURLUseCase', () => ({
61
61
  ClearDependedIssueURLUseCase: jest.fn().mockImplementation(() => ({})),
62
62
  }));
63
+ jest.mock(
64
+ '../../../domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase',
65
+ () => ({
66
+ SetDependedIssueUrlForOpenTaskPRsUseCase: jest
67
+ .fn()
68
+ .mockImplementation(() => ({})),
69
+ }),
70
+ );
63
71
  jest.mock('../../../domain/usecases/CreateEstimationIssueUseCase', () => ({
64
72
  CreateEstimationIssueUseCase: jest.fn().mockImplementation(() => ({})),
65
73
  }));
@@ -26,6 +26,7 @@ import { Project } from '../../../domain/entities/Project';
26
26
  import { BaseGitHubRepository } from '../../repositories/BaseGitHubRepository';
27
27
  import { AnalyzeStoriesUseCase } from '../../../domain/usecases/AnalyzeStoriesUseCase';
28
28
  import { ClearDependedIssueURLUseCase } from '../../../domain/usecases/ClearDependedIssueURLUseCase';
29
+ import { SetDependedIssueUrlForOpenTaskPRsUseCase } from '../../../domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase';
29
30
  import { CreateEstimationIssueUseCase } from '../../../domain/usecases/CreateEstimationIssueUseCase';
30
31
  import { ConvertCheckboxToIssueInStoryIssueUseCase } from '../../../domain/usecases/ConvertCheckboxToIssueInStoryIssueUseCase';
31
32
  import { ChangeStatusByStoryColorUseCase } from '../../../domain/usecases/ChangeStatusByStoryColorUseCase';
@@ -239,6 +240,8 @@ export class HandleScheduledEventUseCaseHandler {
239
240
  const clearDependedIssueURLUseCase = new ClearDependedIssueURLUseCase(
240
241
  issueRepository,
241
242
  );
243
+ const setDependedIssueUrlForOpenTaskPRsUseCase =
244
+ new SetDependedIssueUrlForOpenTaskPRsUseCase(issueRepository);
242
245
  const createEstimationIssueUseCase = new CreateEstimationIssueUseCase(
243
246
  issueRepository,
244
247
  systemDateRepository,
@@ -303,6 +306,7 @@ export class HandleScheduledEventUseCaseHandler {
303
306
  analyzeProblemByIssueUseCase,
304
307
  analyzeStoriesUseCase,
305
308
  clearDependedIssueURLUseCase,
309
+ setDependedIssueUrlForOpenTaskPRsUseCase,
306
310
  createEstimationIssueUseCase,
307
311
  convertCheckboxToIssueInStoryIssueUseCase,
308
312
  changeStatusByStoryColorUseCase,
@@ -0,0 +1,196 @@
1
+ import { CheckIssueReviewReadinessUseCase } from './CheckIssueReviewReadinessUseCase';
2
+ import { Issue } from '../entities/Issue';
3
+ import { Project } from '../entities/Project';
4
+ import { RelatedPullRequest } from './adapter-interfaces/IssueRepository';
5
+
6
+ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
7
+ id: 'project-1',
8
+ url: 'https://github.com/users/user/projects/1',
9
+ databaseId: 1,
10
+ name: 'Test Project',
11
+ status: {
12
+ name: 'Status',
13
+ fieldId: 'field-1',
14
+ statuses: [
15
+ {
16
+ id: 'preparation-id',
17
+ name: 'Preparation',
18
+ color: 'YELLOW',
19
+ description: '',
20
+ },
21
+ ],
22
+ },
23
+ nextActionDate: null,
24
+ nextActionHour: null,
25
+ story: null,
26
+ remainingEstimationMinutes: null,
27
+ dependedIssueUrlSeparatedByComma: null,
28
+ completionDate50PercentConfidence: null,
29
+ ...overrides,
30
+ });
31
+
32
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
33
+ nameWithOwner: 'user/repo',
34
+ number: 1,
35
+ title: 'Test Issue',
36
+ state: 'OPEN',
37
+ status: 'Preparation',
38
+ story: null,
39
+ nextActionDate: null,
40
+ nextActionHour: null,
41
+ estimationMinutes: null,
42
+ dependedIssueUrls: [],
43
+ completionDate50PercentConfidence: null,
44
+ url: 'https://github.com/user/repo/issues/1',
45
+ assignees: [],
46
+ labels: [],
47
+ org: 'user',
48
+ repo: 'repo',
49
+ body: '',
50
+ itemId: 'item-1',
51
+ isPr: false,
52
+ isInProgress: false,
53
+ isClosed: false,
54
+ createdAt: new Date('2000-01-01T00:00:00Z'),
55
+ author: 'test-user',
56
+ ...overrides,
57
+ });
58
+
59
+ const createReadyPr = (
60
+ overrides: Partial<RelatedPullRequest> = {},
61
+ ): RelatedPullRequest => ({
62
+ url: 'https://github.com/user/repo/pull/1',
63
+ branchName: 'feature-branch',
64
+ createdAt: new Date('2000-01-01T00:00:00Z'),
65
+ isDraft: false,
66
+ isConflicted: false,
67
+ isPassedAllCiJob: true,
68
+ isCiStateSuccess: true,
69
+ isResolvedAllReviewComments: true,
70
+ isBranchOutOfDate: false,
71
+ missingRequiredCheckNames: [],
72
+ ...overrides,
73
+ });
74
+
75
+ describe('CheckIssueReviewReadinessUseCase', () => {
76
+ let mockProjectRepository: { getByUrl: jest.Mock };
77
+ let mockIssueRepository: {
78
+ get: jest.Mock;
79
+ findRelatedOpenPRs: jest.Mock;
80
+ getOpenPullRequest: jest.Mock;
81
+ };
82
+ let useCase: CheckIssueReviewReadinessUseCase;
83
+ let mockProject: Project;
84
+
85
+ beforeEach(() => {
86
+ jest.resetAllMocks();
87
+
88
+ mockProject = createMockProject();
89
+
90
+ mockProjectRepository = {
91
+ getByUrl: jest.fn(),
92
+ };
93
+
94
+ mockIssueRepository = {
95
+ get: jest.fn(),
96
+ findRelatedOpenPRs: jest.fn(),
97
+ getOpenPullRequest: jest.fn(),
98
+ };
99
+
100
+ useCase = new CheckIssueReviewReadinessUseCase(
101
+ mockProjectRepository,
102
+ mockIssueRepository,
103
+ );
104
+ });
105
+
106
+ describe('run', () => {
107
+ it('should return reviewReady=true with empty rejections when the linked PR is ready', async () => {
108
+ const issue = createMockIssue();
109
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
110
+ mockIssueRepository.get.mockResolvedValue(issue);
111
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
112
+ createReadyPr(),
113
+ ]);
114
+
115
+ const result = await useCase.run({
116
+ projectUrl: 'https://github.com/users/user/projects/1',
117
+ issueUrl: 'https://github.com/user/repo/issues/1',
118
+ });
119
+
120
+ expect(result.reviewReady).toBe(true);
121
+ expect(result.rejections).toEqual([]);
122
+ });
123
+
124
+ it('should return reviewReady=false with rejections when the linked PR has failing CI', async () => {
125
+ const issue = createMockIssue();
126
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
127
+ mockIssueRepository.get.mockResolvedValue(issue);
128
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
129
+ createReadyPr({
130
+ isPassedAllCiJob: false,
131
+ isCiStateSuccess: false,
132
+ }),
133
+ ]);
134
+
135
+ const result = await useCase.run({
136
+ projectUrl: 'https://github.com/users/user/projects/1',
137
+ issueUrl: 'https://github.com/user/repo/issues/1',
138
+ });
139
+
140
+ expect(result.reviewReady).toBe(false);
141
+ expect(result.rejections).toHaveLength(1);
142
+ expect(result.rejections[0].type).toBe(
143
+ 'ANY_CI_JOB_FAILED_OR_IN_PROGRESS',
144
+ );
145
+ expect(result.rejections[0].detail).toContain(
146
+ 'https://github.com/user/repo/pull/1',
147
+ );
148
+ });
149
+
150
+ it('should return reviewReady=false with PULL_REQUEST_NOT_FOUND when no related PR exists', async () => {
151
+ const issue = createMockIssue();
152
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
153
+ mockIssueRepository.get.mockResolvedValue(issue);
154
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
155
+
156
+ const result = await useCase.run({
157
+ projectUrl: 'https://github.com/users/user/projects/1',
158
+ issueUrl: 'https://github.com/user/repo/issues/1',
159
+ });
160
+
161
+ expect(result.reviewReady).toBe(false);
162
+ expect(result.rejections).toEqual([
163
+ { type: 'PULL_REQUEST_NOT_FOUND', detail: 'PULL_REQUEST_NOT_FOUND' },
164
+ ]);
165
+ });
166
+
167
+ it('should throw IssueNotFoundError when the issue does not exist', async () => {
168
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
169
+ mockIssueRepository.get.mockResolvedValue(null);
170
+
171
+ await expect(
172
+ useCase.run({
173
+ projectUrl: 'https://github.com/users/user/projects/1',
174
+ issueUrl: 'https://github.com/user/repo/issues/999',
175
+ }),
176
+ ).rejects.toThrow(
177
+ 'Issue not found: https://github.com/user/repo/issues/999',
178
+ );
179
+ });
180
+
181
+ it('should not call findRelatedOpenPRs nor mutate state when issue has a category label other than e2e', async () => {
182
+ const issue = createMockIssue({ labels: ['category:frontend'] });
183
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
184
+ mockIssueRepository.get.mockResolvedValue(issue);
185
+
186
+ const result = await useCase.run({
187
+ projectUrl: 'https://github.com/users/user/projects/1',
188
+ issueUrl: 'https://github.com/user/repo/issues/1',
189
+ });
190
+
191
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
192
+ expect(result.reviewReady).toBe(true);
193
+ expect(result.rejections).toEqual([]);
194
+ });
195
+ });
196
+ });
@@ -0,0 +1,45 @@
1
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
2
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
3
+ import {
4
+ IssueRejectionEvaluator,
5
+ PrRejectedReasonType,
6
+ } from './IssueRejectionEvaluator';
7
+ import { IssueNotFoundError } from './NotifyFinishedIssuePreparationUseCase';
8
+
9
+ export type IssueReviewReadinessResult = {
10
+ reviewReady: boolean;
11
+ rejections: { type: PrRejectedReasonType; detail: string }[];
12
+ };
13
+
14
+ export class CheckIssueReviewReadinessUseCase {
15
+ private readonly issueRejectionEvaluator: IssueRejectionEvaluator;
16
+
17
+ constructor(
18
+ private readonly projectRepository: Pick<ProjectRepository, 'getByUrl'>,
19
+ private readonly issueRepository: Pick<
20
+ IssueRepository,
21
+ 'get' | 'findRelatedOpenPRs' | 'getOpenPullRequest'
22
+ >,
23
+ ) {
24
+ this.issueRejectionEvaluator = new IssueRejectionEvaluator(issueRepository);
25
+ }
26
+
27
+ run = async (params: {
28
+ projectUrl: string;
29
+ issueUrl: string;
30
+ }): Promise<IssueReviewReadinessResult> => {
31
+ const project = await this.projectRepository.getByUrl(params.projectUrl);
32
+ const issue = await this.issueRepository.get(params.issueUrl, project);
33
+
34
+ if (!issue) {
35
+ throw new IssueNotFoundError(params.issueUrl);
36
+ }
37
+
38
+ const { rejections } = await this.issueRejectionEvaluator.evaluate(issue);
39
+
40
+ return {
41
+ reviewReady: rejections.length === 0,
42
+ rejections,
43
+ };
44
+ };
45
+ }
@@ -6,6 +6,7 @@ import { ClearPastNextActionDateHourUseCase } from './ClearPastNextActionDateHou
6
6
  import { AnalyzeProblemByIssueUseCase } from './AnalyzeProblemByIssueUseCase';
7
7
  import { AnalyzeStoriesUseCase } from './AnalyzeStoriesUseCase';
8
8
  import { ClearDependedIssueURLUseCase } from './ClearDependedIssueURLUseCase';
9
+ import { SetDependedIssueUrlForOpenTaskPRsUseCase } from './SetDependedIssueUrlForOpenTaskPRsUseCase';
9
10
  import { CreateEstimationIssueUseCase } from './CreateEstimationIssueUseCase';
10
11
  import { ConvertCheckboxToIssueInStoryIssueUseCase } from './ConvertCheckboxToIssueInStoryIssueUseCase';
11
12
  import { DateRepository } from './adapter-interfaces/DateRepository';
@@ -94,6 +95,8 @@ describe('HandleScheduledEventUseCase', () => {
94
95
  const mockAnalyzeStoriesUseCase = mock<AnalyzeStoriesUseCase>();
95
96
  const mockClearDependedIssueURLUseCase =
96
97
  mock<ClearDependedIssueURLUseCase>();
98
+ const mockSetDependedIssueUrlForOpenTaskPRsUseCase =
99
+ mock<SetDependedIssueUrlForOpenTaskPRsUseCase>();
97
100
  const mockCreateEstimationIssueUseCase =
98
101
  mock<CreateEstimationIssueUseCase>();
99
102
  const mockConvertCheckboxToIssueInStoryIssueUseCase =
@@ -127,6 +130,7 @@ describe('HandleScheduledEventUseCase', () => {
127
130
  mockAnalyzeProblemByIssueUseCase,
128
131
  mockAnalyzeStoriesUseCase,
129
132
  mockClearDependedIssueURLUseCase,
133
+ mockSetDependedIssueUrlForOpenTaskPRsUseCase,
130
134
  mockCreateEstimationIssueUseCase,
131
135
  mockConvertCheckboxToIssueInStoryIssueUseCase,
132
136
  mockChangeStatusByStoryColorUseCase,
@@ -12,6 +12,7 @@ import { ClearPastNextActionDateHourUseCase } from './ClearPastNextActionDateHou
12
12
  import { AnalyzeProblemByIssueUseCase } from './AnalyzeProblemByIssueUseCase';
13
13
  import { AnalyzeStoriesUseCase } from './AnalyzeStoriesUseCase';
14
14
  import { ClearDependedIssueURLUseCase } from './ClearDependedIssueURLUseCase';
15
+ import { SetDependedIssueUrlForOpenTaskPRsUseCase } from './SetDependedIssueUrlForOpenTaskPRsUseCase';
15
16
  import { CreateEstimationIssueUseCase } from './CreateEstimationIssueUseCase';
16
17
  import { ConvertCheckboxToIssueInStoryIssueUseCase } from './ConvertCheckboxToIssueInStoryIssueUseCase';
17
18
  import { ChangeStatusByStoryColorUseCase } from './ChangeStatusByStoryColorUseCase';
@@ -46,6 +47,7 @@ export class HandleScheduledEventUseCase {
46
47
  readonly analyzeProblemByIssueUseCase: AnalyzeProblemByIssueUseCase,
47
48
  readonly analyzeStoriesUseCase: AnalyzeStoriesUseCase,
48
49
  readonly clearDependedIssueURLUseCase: ClearDependedIssueURLUseCase,
50
+ readonly setDependedIssueUrlForOpenTaskPRsUseCase: SetDependedIssueUrlForOpenTaskPRsUseCase,
49
51
  readonly createEstimationIssueUseCase: CreateEstimationIssueUseCase,
50
52
  readonly convertCheckboxToIssueInStoryIssueUseCase: ConvertCheckboxToIssueInStoryIssueUseCase,
51
53
  readonly changeStatusByStoryColorUseCase: ChangeStatusByStoryColorUseCase,
@@ -377,6 +379,10 @@ ${JSON.stringify(e)}
377
379
  issues,
378
380
  cacheUsed,
379
381
  });
382
+ await this.setDependedIssueUrlForOpenTaskPRsUseCase.run({
383
+ project,
384
+ issues,
385
+ });
380
386
  await this.createEstimationIssueUseCase.run({
381
387
  targetDates: targetDateTimes,
382
388
  project,
@@ -0,0 +1,214 @@
1
+ import { mock } from 'jest-mock-extended';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { SetDependedIssueUrlForOpenTaskPRsUseCase } from './SetDependedIssueUrlForOpenTaskPRsUseCase';
4
+ import { Project } from '../entities/Project';
5
+ import { Issue } from '../entities/Issue';
6
+
7
+ describe('SetDependedIssueUrlForOpenTaskPRsUseCase', () => {
8
+ const mockIssueRepository = mock<IssueRepository>();
9
+ const useCase = new SetDependedIssueUrlForOpenTaskPRsUseCase(
10
+ mockIssueRepository,
11
+ );
12
+
13
+ const projectWithField: Project = {
14
+ ...mock<Project>(),
15
+ dependedIssueUrlSeparatedByComma: {
16
+ name: 'Depended Issue URL separated by comma',
17
+ fieldId: 'depended-field-id',
18
+ },
19
+ };
20
+ const projectWithoutField: Project = {
21
+ ...mock<Project>(),
22
+ dependedIssueUrlSeparatedByComma: null,
23
+ };
24
+
25
+ const openTaskIssue: Issue = {
26
+ ...mock<Issue>(),
27
+ url: 'https://github.com/owner/repo/issues/1',
28
+ isPr: false,
29
+ isClosed: false,
30
+ state: 'OPEN',
31
+ };
32
+ const closedTaskIssue: Issue = {
33
+ ...mock<Issue>(),
34
+ url: 'https://github.com/owner/repo/issues/2',
35
+ isPr: false,
36
+ isClosed: true,
37
+ state: 'CLOSED',
38
+ };
39
+ const prItem: Issue = {
40
+ ...mock<Issue>(),
41
+ url: 'https://github.com/owner/repo/pull/10',
42
+ isPr: true,
43
+ isClosed: false,
44
+ state: 'OPEN',
45
+ };
46
+
47
+ beforeEach(() => {
48
+ jest.clearAllMocks();
49
+ });
50
+
51
+ it('should call setDependedIssueUrl for each related open PR linked to an open task issue', async () => {
52
+ mockIssueRepository.findRelatedOpenPRs.mockImplementation(
53
+ async (issueUrl) => {
54
+ if (issueUrl === openTaskIssue.url) {
55
+ return [
56
+ {
57
+ url: 'https://github.com/owner/repo/pull/100',
58
+ branchName: null,
59
+ createdAt: new Date(0),
60
+ isDraft: false,
61
+ isConflicted: false,
62
+ isPassedAllCiJob: true,
63
+ isCiStateSuccess: true,
64
+ isResolvedAllReviewComments: true,
65
+ isBranchOutOfDate: false,
66
+ missingRequiredCheckNames: [],
67
+ },
68
+ ];
69
+ }
70
+ return [];
71
+ },
72
+ );
73
+
74
+ await useCase.run({
75
+ project: projectWithField,
76
+ issues: [openTaskIssue],
77
+ });
78
+
79
+ expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
80
+ openTaskIssue.url,
81
+ );
82
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledTimes(1);
83
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
84
+ 'https://github.com/owner/repo/pull/100',
85
+ projectWithField,
86
+ openTaskIssue.url,
87
+ );
88
+ });
89
+
90
+ it('should skip closed task issues so that PRs linked only to a closed task are not touched', async () => {
91
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
92
+
93
+ await useCase.run({
94
+ project: projectWithField,
95
+ issues: [closedTaskIssue],
96
+ });
97
+
98
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
99
+ expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it('should skip PR project items themselves and only iterate task issues', async () => {
103
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
104
+
105
+ await useCase.run({
106
+ project: projectWithField,
107
+ issues: [prItem],
108
+ });
109
+
110
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
111
+ expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
112
+ });
113
+
114
+ it('should not call setDependedIssueUrl when no related open PRs are found for an open task issue', async () => {
115
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
116
+
117
+ await useCase.run({
118
+ project: projectWithField,
119
+ issues: [openTaskIssue],
120
+ });
121
+
122
+ expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
123
+ openTaskIssue.url,
124
+ );
125
+ expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
126
+ });
127
+
128
+ it('should do nothing when the project does not have the depended-issue-url field configured', async () => {
129
+ const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
130
+
131
+ await useCase.run({
132
+ project: projectWithoutField,
133
+ issues: [openTaskIssue],
134
+ });
135
+
136
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
137
+ expect(mockIssueRepository.setDependedIssueUrl).not.toHaveBeenCalled();
138
+ expect(warnSpy).toHaveBeenCalled();
139
+
140
+ warnSpy.mockRestore();
141
+ });
142
+
143
+ it('should iterate over each open task issue and request its related open PRs independently', async () => {
144
+ const secondOpenTaskIssue: Issue = {
145
+ ...mock<Issue>(),
146
+ url: 'https://github.com/owner/repo/issues/3',
147
+ isPr: false,
148
+ isClosed: false,
149
+ state: 'OPEN',
150
+ };
151
+
152
+ mockIssueRepository.findRelatedOpenPRs.mockImplementation(
153
+ async (issueUrl) => {
154
+ if (issueUrl === openTaskIssue.url) {
155
+ return [
156
+ {
157
+ url: 'https://github.com/owner/repo/pull/100',
158
+ branchName: null,
159
+ createdAt: new Date(0),
160
+ isDraft: false,
161
+ isConflicted: false,
162
+ isPassedAllCiJob: true,
163
+ isCiStateSuccess: true,
164
+ isResolvedAllReviewComments: true,
165
+ isBranchOutOfDate: false,
166
+ missingRequiredCheckNames: [],
167
+ },
168
+ ];
169
+ }
170
+ if (issueUrl === secondOpenTaskIssue.url) {
171
+ return [
172
+ {
173
+ url: 'https://github.com/owner/repo/pull/200',
174
+ branchName: null,
175
+ createdAt: new Date(0),
176
+ isDraft: false,
177
+ isConflicted: false,
178
+ isPassedAllCiJob: true,
179
+ isCiStateSuccess: true,
180
+ isResolvedAllReviewComments: true,
181
+ isBranchOutOfDate: false,
182
+ missingRequiredCheckNames: [],
183
+ },
184
+ ];
185
+ }
186
+ return [];
187
+ },
188
+ );
189
+
190
+ await useCase.run({
191
+ project: projectWithField,
192
+ issues: [openTaskIssue, closedTaskIssue, prItem, secondOpenTaskIssue],
193
+ });
194
+
195
+ expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledTimes(2);
196
+ expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
197
+ openTaskIssue.url,
198
+ );
199
+ expect(mockIssueRepository.findRelatedOpenPRs).toHaveBeenCalledWith(
200
+ secondOpenTaskIssue.url,
201
+ );
202
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledTimes(2);
203
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
204
+ 'https://github.com/owner/repo/pull/100',
205
+ projectWithField,
206
+ openTaskIssue.url,
207
+ );
208
+ expect(mockIssueRepository.setDependedIssueUrl).toHaveBeenCalledWith(
209
+ 'https://github.com/owner/repo/pull/200',
210
+ projectWithField,
211
+ secondOpenTaskIssue.url,
212
+ );
213
+ });
214
+ });