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.
- package/CHANGELOG.md +14 -0
- package/README.md +12 -0
- package/bin/adapter/entry-points/cli/index.js +48 -0
- package/bin/adapter/entry-points/cli/index.js.map +1 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +26 -0
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -0
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +6 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.js +25 -0
- package/bin/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/cli/index.test.ts +189 -0
- package/src/adapter/entry-points/cli/index.ts +101 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.test.ts +8 -0
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +4 -0
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +196 -0
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +45 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +4 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +6 -0
- package/src/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.test.ts +214 -0
- package/src/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.ts +36 -0
- package/types/adapter/entry-points/cli/index.d.ts.map +1 -1
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts +21 -0
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -0
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/SetDependedIssueUrlForOpenTaskPRsUseCase.d.ts +12 -0
- 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
|
+
});
|