github-issue-tower-defence-management 1.24.0 → 1.25.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 (63) hide show
  1. package/.github/workflows/test.yml +2 -2
  2. package/CHANGELOG.md +8 -0
  3. package/bin/adapter/repositories/GraphqlProjectRepository.js +12 -0
  4. package/bin/adapter/repositories/GraphqlProjectRepository.js.map +1 -1
  5. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js +9 -0
  6. package/bin/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.js.map +1 -1
  7. package/bin/domain/entities/ClaudeWindowUsage.js +3 -0
  8. package/bin/domain/entities/ClaudeWindowUsage.js.map +1 -0
  9. package/bin/domain/entities/Comment.js +3 -0
  10. package/bin/domain/entities/Comment.js.map +1 -0
  11. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +81 -0
  12. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -0
  13. package/bin/domain/usecases/StartPreparationUseCase.js +104 -0
  14. package/bin/domain/usecases/StartPreparationUseCase.js.map +1 -0
  15. package/bin/domain/usecases/adapter-interfaces/ClaudeRepository.js +3 -0
  16. package/bin/domain/usecases/adapter-interfaces/ClaudeRepository.js.map +1 -0
  17. package/bin/domain/usecases/adapter-interfaces/IssueCommentRepository.js +3 -0
  18. package/bin/domain/usecases/adapter-interfaces/IssueCommentRepository.js.map +1 -0
  19. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js +3 -0
  20. package/bin/domain/usecases/adapter-interfaces/LocalCommandRunner.js.map +1 -0
  21. package/package.json +1 -1
  22. package/src/adapter/repositories/CheerioProjectRepository.test.ts +1 -0
  23. package/src/adapter/repositories/GraphqlProjectRepository.test.ts +1 -0
  24. package/src/adapter/repositories/GraphqlProjectRepository.ts +14 -1
  25. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.test.ts +1 -0
  26. package/src/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.ts +15 -1
  27. package/src/domain/entities/ClaudeWindowUsage.ts +5 -0
  28. package/src/domain/entities/Comment.ts +5 -0
  29. package/src/domain/entities/Project.ts +1 -0
  30. package/src/domain/usecases/GetStoryObjectMapUseCase.test.ts +1 -0
  31. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +669 -0
  32. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +132 -0
  33. package/src/domain/usecases/StartPreparationUseCase.test.ts +715 -0
  34. package/src/domain/usecases/StartPreparationUseCase.ts +191 -0
  35. package/src/domain/usecases/adapter-interfaces/ClaudeRepository.ts +5 -0
  36. package/src/domain/usecases/adapter-interfaces/IssueCommentRepository.ts +7 -0
  37. package/src/domain/usecases/adapter-interfaces/IssueRepository.ts +11 -0
  38. package/src/domain/usecases/adapter-interfaces/LocalCommandRunner.ts +7 -0
  39. package/src/domain/usecases/adapter-interfaces/ProjectRepository.ts +1 -0
  40. package/types/adapter/repositories/GraphqlProjectRepository.d.ts +2 -1
  41. package/types/adapter/repositories/GraphqlProjectRepository.d.ts.map +1 -1
  42. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts +4 -1
  43. package/types/adapter/repositories/issue/ApiV3CheerioRestIssueRepository.d.ts.map +1 -1
  44. package/types/domain/entities/ClaudeWindowUsage.d.ts +6 -0
  45. package/types/domain/entities/ClaudeWindowUsage.d.ts.map +1 -0
  46. package/types/domain/entities/Comment.d.ts +6 -0
  47. package/types/domain/entities/Comment.d.ts.map +1 -0
  48. package/types/domain/entities/Project.d.ts +1 -0
  49. package/types/domain/entities/Project.d.ts.map +1 -1
  50. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +24 -0
  51. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -0
  52. package/types/domain/usecases/StartPreparationUseCase.d.ts +37 -0
  53. package/types/domain/usecases/StartPreparationUseCase.d.ts.map +1 -0
  54. package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts +5 -0
  55. package/types/domain/usecases/adapter-interfaces/ClaudeRepository.d.ts.map +1 -0
  56. package/types/domain/usecases/adapter-interfaces/IssueCommentRepository.d.ts +7 -0
  57. package/types/domain/usecases/adapter-interfaces/IssueCommentRepository.d.ts.map +1 -0
  58. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts +10 -0
  59. package/types/domain/usecases/adapter-interfaces/IssueRepository.d.ts.map +1 -1
  60. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts +8 -0
  61. package/types/domain/usecases/adapter-interfaces/LocalCommandRunner.d.ts.map +1 -0
  62. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts +1 -0
  63. package/types/domain/usecases/adapter-interfaces/ProjectRepository.d.ts.map +1 -1
@@ -0,0 +1,669 @@
1
+ import { NotifyFinishedIssuePreparationUseCase } from './NotifyFinishedIssuePreparationUseCase';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { ProjectRepository } from './adapter-interfaces/ProjectRepository';
4
+ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentRepository';
5
+ import { Issue } from '../entities/Issue';
6
+ import { Project } from '../entities/Project';
7
+ import { Comment } from '../entities/Comment';
8
+
9
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
10
+
11
+ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
12
+ id: 'project-1',
13
+ url: 'https://github.com/users/user/projects/1',
14
+ databaseId: 1,
15
+ name: 'Test Project',
16
+ status: {
17
+ name: 'Status',
18
+ fieldId: 'field-1',
19
+ statuses: [],
20
+ },
21
+ nextActionDate: null,
22
+ nextActionHour: null,
23
+ story: null,
24
+ remainingEstimationMinutes: null,
25
+ dependedIssueUrlSeparatedByComma: null,
26
+ completionDate50PercentConfidence: null,
27
+ ...overrides,
28
+ });
29
+
30
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
31
+ nameWithOwner: 'user/repo',
32
+ number: 1,
33
+ title: 'Test Issue',
34
+ state: 'OPEN',
35
+ status: 'Preparation',
36
+ story: null,
37
+ nextActionDate: null,
38
+ nextActionHour: null,
39
+ estimationMinutes: null,
40
+ dependedIssueUrls: [],
41
+ completionDate50PercentConfidence: null,
42
+ url: 'https://github.com/user/repo/issues/1',
43
+ assignees: [],
44
+ labels: [],
45
+ org: 'user',
46
+ repo: 'repo',
47
+ body: '',
48
+ itemId: 'item-1',
49
+ isPr: false,
50
+ isInProgress: false,
51
+ isClosed: false,
52
+ createdAt: new Date(),
53
+ ...overrides,
54
+ });
55
+
56
+ const createMockComment = (overrides: Partial<Comment> = {}): Comment => ({
57
+ author: 'test-user',
58
+ content: 'From: Test comment',
59
+ createdAt: new Date(),
60
+ ...overrides,
61
+ });
62
+
63
+ describe('NotifyFinishedIssuePreparationUseCase', () => {
64
+ let useCase: NotifyFinishedIssuePreparationUseCase;
65
+ let mockProjectRepository: Mocked<Pick<ProjectRepository, 'getByUrl'>>;
66
+ let mockIssueRepository: Mocked<
67
+ Pick<IssueRepository, 'get' | 'update' | 'findRelatedOpenPRs'>
68
+ >;
69
+ let mockIssueCommentRepository: Mocked<
70
+ Pick<IssueCommentRepository, 'getCommentsFromIssue' | 'createComment'>
71
+ >;
72
+ let mockProject: Project;
73
+
74
+ beforeEach(() => {
75
+ jest.resetAllMocks();
76
+
77
+ mockProject = createMockProject();
78
+
79
+ mockProjectRepository = {
80
+ getByUrl: jest.fn(),
81
+ };
82
+
83
+ mockIssueRepository = {
84
+ get: jest.fn(),
85
+ update: jest.fn(),
86
+ findRelatedOpenPRs: jest.fn(),
87
+ };
88
+
89
+ mockIssueCommentRepository = {
90
+ getCommentsFromIssue: jest.fn(),
91
+ createComment: jest.fn(),
92
+ };
93
+
94
+ useCase = new NotifyFinishedIssuePreparationUseCase(
95
+ mockProjectRepository,
96
+ mockIssueRepository,
97
+ mockIssueCommentRepository,
98
+ );
99
+ });
100
+
101
+ it('should update issue status from Preparation to Awaiting Quality Check when last comment starts with From:', async () => {
102
+ const issue = createMockIssue({
103
+ url: 'https://github.com/user/repo/issues/1',
104
+ status: 'Preparation',
105
+ });
106
+
107
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
108
+ mockIssueRepository.get.mockResolvedValue(issue);
109
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
110
+ createMockComment({ content: 'From: Test report' }),
111
+ ]);
112
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
113
+ {
114
+ url: 'https://github.com/user/repo/pull/1',
115
+ isConflicted: false,
116
+ isPassedAllCiJob: true,
117
+ isResolvedAllReviewComments: true,
118
+ isBranchOutOfDate: false,
119
+ },
120
+ ]);
121
+
122
+ await useCase.run({
123
+ projectUrl: 'https://github.com/users/user/projects/1',
124
+ issueUrl: 'https://github.com/user/repo/issues/1',
125
+ preparationStatus: 'Preparation',
126
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
127
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
128
+ thresholdForAutoReject: 3,
129
+ });
130
+
131
+ expect(mockIssueRepository.update).toHaveBeenCalledTimes(1);
132
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
133
+ expect.objectContaining({
134
+ url: 'https://github.com/user/repo/issues/1',
135
+ status: 'Awaiting Quality Check',
136
+ }),
137
+ mockProject,
138
+ );
139
+ });
140
+
141
+ it('should throw IssueNotFoundError when issue does not exist', async () => {
142
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
143
+ mockIssueRepository.get.mockResolvedValue(null);
144
+
145
+ await expect(
146
+ useCase.run({
147
+ projectUrl: 'https://github.com/users/user/projects/1',
148
+ issueUrl: 'https://github.com/user/repo/issues/999',
149
+ preparationStatus: 'Preparation',
150
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
151
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
152
+ thresholdForAutoReject: 3,
153
+ }),
154
+ ).rejects.toThrow(
155
+ 'Issue not found: https://github.com/user/repo/issues/999',
156
+ );
157
+ });
158
+
159
+ it('should throw IllegalIssueStatusError when issue status is not Preparation', async () => {
160
+ const issue = createMockIssue({
161
+ url: 'https://github.com/user/repo/issues/1',
162
+ status: 'Done',
163
+ });
164
+
165
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
166
+ mockIssueRepository.get.mockResolvedValue(issue);
167
+
168
+ await expect(
169
+ useCase.run({
170
+ projectUrl: 'https://github.com/users/user/projects/1',
171
+ issueUrl: 'https://github.com/user/repo/issues/1',
172
+ preparationStatus: 'Preparation',
173
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
174
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
175
+ thresholdForAutoReject: 3,
176
+ }),
177
+ ).rejects.toThrow(
178
+ 'Illegal issue status for https://github.com/user/repo/issues/1: expected Preparation, but got Done',
179
+ );
180
+ });
181
+
182
+ it('should reject and set status to Awaiting Workspace when last comment starts with Auto Status Check:', async () => {
183
+ const issue = createMockIssue({
184
+ url: 'https://github.com/user/repo/issues/1',
185
+ status: 'Preparation',
186
+ });
187
+
188
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
189
+ mockIssueRepository.get.mockResolvedValue(issue);
190
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
191
+ createMockComment({
192
+ content: 'Auto Status Check: REJECTED\n["NO_REPORT"]',
193
+ }),
194
+ ]);
195
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
196
+ {
197
+ url: 'https://github.com/user/repo/pull/1',
198
+ isConflicted: false,
199
+ isPassedAllCiJob: true,
200
+ isResolvedAllReviewComments: true,
201
+ isBranchOutOfDate: false,
202
+ },
203
+ ]);
204
+
205
+ await useCase.run({
206
+ projectUrl: 'https://github.com/users/user/projects/1',
207
+ issueUrl: 'https://github.com/user/repo/issues/1',
208
+ preparationStatus: 'Preparation',
209
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
210
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
211
+ thresholdForAutoReject: 3,
212
+ });
213
+
214
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
215
+ expect.objectContaining({
216
+ status: 'Awaiting Workspace',
217
+ }),
218
+ mockProject,
219
+ );
220
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
221
+ expect.objectContaining({
222
+ url: 'https://github.com/user/repo/issues/1',
223
+ }),
224
+ expect.stringContaining('Auto Status Check: REJECTED'),
225
+ );
226
+ });
227
+
228
+ it('should pass when last comment does not start with Auto Status Check or From:', async () => {
229
+ const issue = createMockIssue({
230
+ url: 'https://github.com/user/repo/issues/1',
231
+ status: 'Preparation',
232
+ });
233
+
234
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
235
+ mockIssueRepository.get.mockResolvedValue(issue);
236
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
237
+ createMockComment({ content: 'Some other comment' }),
238
+ ]);
239
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
240
+ {
241
+ url: 'https://github.com/user/repo/pull/1',
242
+ isConflicted: false,
243
+ isPassedAllCiJob: true,
244
+ isResolvedAllReviewComments: true,
245
+ isBranchOutOfDate: false,
246
+ },
247
+ ]);
248
+
249
+ await useCase.run({
250
+ projectUrl: 'https://github.com/users/user/projects/1',
251
+ issueUrl: 'https://github.com/user/repo/issues/1',
252
+ preparationStatus: 'Preparation',
253
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
254
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
255
+ thresholdForAutoReject: 3,
256
+ });
257
+
258
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
259
+ expect.objectContaining({
260
+ status: 'Awaiting Quality Check',
261
+ }),
262
+ mockProject,
263
+ );
264
+ });
265
+
266
+ it('should reject and set status to Awaiting Workspace when no comments exist', async () => {
267
+ const issue = createMockIssue({
268
+ url: 'https://github.com/user/repo/issues/1',
269
+ status: 'Preparation',
270
+ });
271
+
272
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
273
+ mockIssueRepository.get.mockResolvedValue(issue);
274
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([]);
275
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
276
+ {
277
+ url: 'https://github.com/user/repo/pull/1',
278
+ isConflicted: false,
279
+ isPassedAllCiJob: true,
280
+ isResolvedAllReviewComments: true,
281
+ isBranchOutOfDate: false,
282
+ },
283
+ ]);
284
+
285
+ await useCase.run({
286
+ projectUrl: 'https://github.com/users/user/projects/1',
287
+ issueUrl: 'https://github.com/user/repo/issues/1',
288
+ preparationStatus: 'Preparation',
289
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
290
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
291
+ thresholdForAutoReject: 3,
292
+ });
293
+
294
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
295
+ expect.objectContaining({
296
+ status: 'Awaiting Workspace',
297
+ }),
298
+ mockProject,
299
+ );
300
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalled();
301
+ });
302
+
303
+ it('should auto-escalate to Awaiting Quality Check after threshold rejections', async () => {
304
+ const issue = createMockIssue({
305
+ url: 'https://github.com/user/repo/issues/1',
306
+ status: 'Preparation',
307
+ });
308
+
309
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
310
+ mockIssueRepository.get.mockResolvedValue(issue);
311
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
312
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
313
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
314
+ createMockComment({ content: 'Auto Status Check: REJECTED - third' }),
315
+ ]);
316
+
317
+ await useCase.run({
318
+ projectUrl: 'https://github.com/users/user/projects/1',
319
+ issueUrl: 'https://github.com/user/repo/issues/1',
320
+ preparationStatus: 'Preparation',
321
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
322
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
323
+ thresholdForAutoReject: 3,
324
+ });
325
+
326
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
327
+ expect.objectContaining({
328
+ status: 'Awaiting Quality Check',
329
+ }),
330
+ mockProject,
331
+ );
332
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
333
+ expect.objectContaining({
334
+ url: 'https://github.com/user/repo/issues/1',
335
+ }),
336
+ expect.stringContaining(
337
+ 'Failed to pass the check autimatically for 3 times',
338
+ ),
339
+ );
340
+ });
341
+
342
+ it('should not auto-escalate when rejections are below threshold', async () => {
343
+ const issue = createMockIssue({
344
+ url: 'https://github.com/user/repo/issues/1',
345
+ status: 'Preparation',
346
+ });
347
+
348
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
349
+ mockIssueRepository.get.mockResolvedValue(issue);
350
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
351
+ createMockComment({ content: 'Auto Status Check: REJECTED - first' }),
352
+ createMockComment({ content: 'Auto Status Check: REJECTED - second' }),
353
+ ]);
354
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
355
+ {
356
+ url: 'https://github.com/user/repo/pull/1',
357
+ isConflicted: false,
358
+ isPassedAllCiJob: true,
359
+ isResolvedAllReviewComments: true,
360
+ isBranchOutOfDate: false,
361
+ },
362
+ ]);
363
+
364
+ await useCase.run({
365
+ projectUrl: 'https://github.com/users/user/projects/1',
366
+ issueUrl: 'https://github.com/user/repo/issues/1',
367
+ preparationStatus: 'Preparation',
368
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
369
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
370
+ thresholdForAutoReject: 3,
371
+ });
372
+
373
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
374
+ expect.objectContaining({
375
+ status: 'Awaiting Workspace',
376
+ }),
377
+ mockProject,
378
+ );
379
+ });
380
+
381
+ it('should reject when PR is not found', async () => {
382
+ const issue = createMockIssue({
383
+ url: 'https://github.com/user/repo/issues/1',
384
+ status: 'Preparation',
385
+ });
386
+
387
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
388
+ mockIssueRepository.get.mockResolvedValue(issue);
389
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
390
+ createMockComment({ content: 'From: Test report' }),
391
+ ]);
392
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
393
+
394
+ await useCase.run({
395
+ projectUrl: 'https://github.com/users/user/projects/1',
396
+ issueUrl: 'https://github.com/user/repo/issues/1',
397
+ preparationStatus: 'Preparation',
398
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
399
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
400
+ thresholdForAutoReject: 3,
401
+ });
402
+
403
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
404
+ expect.objectContaining({
405
+ status: 'Awaiting Workspace',
406
+ }),
407
+ mockProject,
408
+ );
409
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
410
+ expect.objectContaining({
411
+ url: 'https://github.com/user/repo/issues/1',
412
+ }),
413
+ expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
414
+ );
415
+ });
416
+
417
+ it('should reject when multiple PRs are found', async () => {
418
+ const issue = createMockIssue({
419
+ url: 'https://github.com/user/repo/issues/1',
420
+ status: 'Preparation',
421
+ });
422
+
423
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
424
+ mockIssueRepository.get.mockResolvedValue(issue);
425
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
426
+ createMockComment({ content: 'From: Test report' }),
427
+ ]);
428
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
429
+ {
430
+ url: 'https://github.com/user/repo/pull/1',
431
+ isConflicted: false,
432
+ isPassedAllCiJob: true,
433
+ isResolvedAllReviewComments: true,
434
+ isBranchOutOfDate: false,
435
+ },
436
+ {
437
+ url: 'https://github.com/user/repo/pull/2',
438
+ isConflicted: false,
439
+ isPassedAllCiJob: true,
440
+ isResolvedAllReviewComments: true,
441
+ isBranchOutOfDate: false,
442
+ },
443
+ ]);
444
+
445
+ await useCase.run({
446
+ projectUrl: 'https://github.com/users/user/projects/1',
447
+ issueUrl: 'https://github.com/user/repo/issues/1',
448
+ preparationStatus: 'Preparation',
449
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
450
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
451
+ thresholdForAutoReject: 3,
452
+ });
453
+
454
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
455
+ expect.objectContaining({
456
+ status: 'Awaiting Workspace',
457
+ }),
458
+ mockProject,
459
+ );
460
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
461
+ expect.objectContaining({
462
+ url: 'https://github.com/user/repo/issues/1',
463
+ }),
464
+ expect.stringContaining('MULTIPLE_PULL_REQUESTS_FOUND'),
465
+ );
466
+ });
467
+
468
+ it('should reject when PR is conflicted', async () => {
469
+ const issue = createMockIssue({
470
+ url: 'https://github.com/user/repo/issues/1',
471
+ status: 'Preparation',
472
+ });
473
+
474
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
475
+ mockIssueRepository.get.mockResolvedValue(issue);
476
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
477
+ createMockComment({ content: 'From: Test report' }),
478
+ ]);
479
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
480
+ {
481
+ url: 'https://github.com/user/repo/pull/1',
482
+ isConflicted: true,
483
+ isPassedAllCiJob: true,
484
+ isResolvedAllReviewComments: true,
485
+ isBranchOutOfDate: false,
486
+ },
487
+ ]);
488
+
489
+ await useCase.run({
490
+ projectUrl: 'https://github.com/users/user/projects/1',
491
+ issueUrl: 'https://github.com/user/repo/issues/1',
492
+ preparationStatus: 'Preparation',
493
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
494
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
495
+ thresholdForAutoReject: 3,
496
+ });
497
+
498
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
499
+ expect.objectContaining({
500
+ status: 'Awaiting Workspace',
501
+ }),
502
+ mockProject,
503
+ );
504
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
505
+ expect.objectContaining({
506
+ url: 'https://github.com/user/repo/issues/1',
507
+ }),
508
+ expect.stringContaining('PULL_REQUEST_CONFLICTED'),
509
+ );
510
+ });
511
+
512
+ it('should reject when CI job failed', async () => {
513
+ const issue = createMockIssue({
514
+ url: 'https://github.com/user/repo/issues/1',
515
+ status: 'Preparation',
516
+ });
517
+
518
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
519
+ mockIssueRepository.get.mockResolvedValue(issue);
520
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
521
+ createMockComment({ content: 'From: Test report' }),
522
+ ]);
523
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
524
+ {
525
+ url: 'https://github.com/user/repo/pull/1',
526
+ isConflicted: false,
527
+ isPassedAllCiJob: false,
528
+ isResolvedAllReviewComments: true,
529
+ isBranchOutOfDate: false,
530
+ },
531
+ ]);
532
+
533
+ await useCase.run({
534
+ projectUrl: 'https://github.com/users/user/projects/1',
535
+ issueUrl: 'https://github.com/user/repo/issues/1',
536
+ preparationStatus: 'Preparation',
537
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
538
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
539
+ thresholdForAutoReject: 3,
540
+ });
541
+
542
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
543
+ expect.objectContaining({
544
+ status: 'Awaiting Workspace',
545
+ }),
546
+ mockProject,
547
+ );
548
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
549
+ expect.objectContaining({
550
+ url: 'https://github.com/user/repo/issues/1',
551
+ }),
552
+ expect.stringContaining('ANY_CI_JOB_FAILED'),
553
+ );
554
+ });
555
+
556
+ it('should reject when review comments are not resolved', async () => {
557
+ const issue = createMockIssue({
558
+ url: 'https://github.com/user/repo/issues/1',
559
+ status: 'Preparation',
560
+ });
561
+
562
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
563
+ mockIssueRepository.get.mockResolvedValue(issue);
564
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
565
+ createMockComment({ content: 'From: Test report' }),
566
+ ]);
567
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
568
+ {
569
+ url: 'https://github.com/user/repo/pull/1',
570
+ isConflicted: false,
571
+ isPassedAllCiJob: true,
572
+ isResolvedAllReviewComments: false,
573
+ isBranchOutOfDate: false,
574
+ },
575
+ ]);
576
+
577
+ await useCase.run({
578
+ projectUrl: 'https://github.com/users/user/projects/1',
579
+ issueUrl: 'https://github.com/user/repo/issues/1',
580
+ preparationStatus: 'Preparation',
581
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
582
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
583
+ thresholdForAutoReject: 3,
584
+ });
585
+
586
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
587
+ expect.objectContaining({
588
+ status: 'Awaiting Workspace',
589
+ }),
590
+ mockProject,
591
+ );
592
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
593
+ expect.objectContaining({
594
+ url: 'https://github.com/user/repo/issues/1',
595
+ }),
596
+ expect.stringContaining('ANY_REVIEW_COMMENT_NOT_RESOLVED'),
597
+ );
598
+ });
599
+
600
+ it('should skip PR checks and update to Awaiting Quality Check when issue has category label', async () => {
601
+ const issue = createMockIssue({
602
+ url: 'https://github.com/user/repo/issues/1',
603
+ status: 'Preparation',
604
+ labels: ['category:frontend'],
605
+ });
606
+
607
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
608
+ mockIssueRepository.get.mockResolvedValue(issue);
609
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
610
+ createMockComment({ content: 'From: Test report' }),
611
+ ]);
612
+
613
+ await useCase.run({
614
+ projectUrl: 'https://github.com/users/user/projects/1',
615
+ issueUrl: 'https://github.com/user/repo/issues/1',
616
+ preparationStatus: 'Preparation',
617
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
618
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
619
+ thresholdForAutoReject: 3,
620
+ });
621
+
622
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
623
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
624
+ expect.objectContaining({
625
+ status: 'Awaiting Quality Check',
626
+ }),
627
+ mockProject,
628
+ );
629
+ });
630
+
631
+ it('should still check for report comment even when issue has category label', async () => {
632
+ const issue = createMockIssue({
633
+ url: 'https://github.com/user/repo/issues/1',
634
+ status: 'Preparation',
635
+ labels: ['category:backend'],
636
+ });
637
+
638
+ mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
639
+ mockIssueRepository.get.mockResolvedValue(issue);
640
+ mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
641
+ createMockComment({
642
+ content: 'Auto Status Check: REJECTED\n["NO_REPORT"]',
643
+ }),
644
+ ]);
645
+
646
+ await useCase.run({
647
+ projectUrl: 'https://github.com/users/user/projects/1',
648
+ issueUrl: 'https://github.com/user/repo/issues/1',
649
+ preparationStatus: 'Preparation',
650
+ awaitingWorkspaceStatus: 'Awaiting Workspace',
651
+ awaitingQualityCheckStatus: 'Awaiting Quality Check',
652
+ thresholdForAutoReject: 3,
653
+ });
654
+
655
+ expect(mockIssueRepository.findRelatedOpenPRs).not.toHaveBeenCalled();
656
+ expect(mockIssueRepository.update).toHaveBeenCalledWith(
657
+ expect.objectContaining({
658
+ status: 'Awaiting Workspace',
659
+ }),
660
+ mockProject,
661
+ );
662
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
663
+ expect.objectContaining({
664
+ url: 'https://github.com/user/repo/issues/1',
665
+ }),
666
+ expect.stringContaining('NO_REPORT'),
667
+ );
668
+ });
669
+ });