github-issue-tower-defence-management 1.77.3 → 1.79.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 +20 -0
  2. package/README.md +5 -2
  3. package/bin/adapter/entry-points/cli/index.js +11 -9
  4. package/bin/adapter/entry-points/cli/index.js.map +1 -1
  5. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -3
  6. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  7. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +57 -9
  8. package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
  9. package/bin/domain/usecases/HandleScheduledEventUseCase.js +3 -3
  10. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  11. package/bin/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.js → RevertNotReadyReviewQueueIssueUseCase.js} +20 -4
  12. package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -0
  13. package/package.json +1 -1
  14. package/src/adapter/entry-points/cli/index.test.ts +7 -37
  15. package/src/adapter/entry-points/cli/index.ts +12 -15
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +4 -4
  17. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +168 -76
  18. package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +89 -15
  19. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +8 -8
  20. package/src/domain/usecases/HandleScheduledEventUseCase.ts +3 -3
  21. package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +1127 -0
  22. package/src/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.ts → RevertNotReadyReviewQueueIssueUseCase.ts} +40 -1
  23. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts +9 -5
  24. package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
  25. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +3 -3
  26. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  27. package/types/domain/usecases/{RevertNotReadyAwaitingQualityCheckUseCase.d.ts → RevertNotReadyReviewQueueIssueUseCase.d.ts} +3 -3
  28. package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -0
  29. package/bin/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.js.map +0 -1
  30. package/src/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.test.ts +0 -728
  31. package/types/domain/usecases/RevertNotReadyAwaitingQualityCheckUseCase.d.ts.map +0 -1
@@ -0,0 +1,1127 @@
1
+ import { RevertNotReadyReviewQueueIssueUseCase } from './RevertNotReadyReviewQueueIssueUseCase';
2
+ import { Issue } from '../entities/Issue';
3
+ import { Project } from '../entities/Project';
4
+
5
+ const createMockProject = (overrides: Partial<Project> = {}): Project => ({
6
+ id: 'project-1',
7
+ url: 'https://github.com/users/user/projects/1',
8
+ databaseId: 1,
9
+ name: 'Test Project',
10
+ status: {
11
+ name: 'Status',
12
+ fieldId: 'field-1',
13
+ statuses: [
14
+ {
15
+ id: 'awaiting-workspace-id',
16
+ name: 'Awaiting Workspace',
17
+ color: 'GRAY',
18
+ description: '',
19
+ },
20
+ {
21
+ id: 'awaiting-quality-check-id',
22
+ name: 'Awaiting Quality Check',
23
+ color: 'BLUE',
24
+ description: '',
25
+ },
26
+ {
27
+ id: 'unread-id',
28
+ name: 'Unread',
29
+ color: 'ORANGE',
30
+ description: '',
31
+ },
32
+ ],
33
+ },
34
+ nextActionDate: null,
35
+ nextActionHour: null,
36
+ story: {
37
+ name: 'Story',
38
+ fieldId: 'story-field-1',
39
+ databaseId: 2,
40
+ stories: [
41
+ {
42
+ id: 'workflow-management-story-id',
43
+ name: 'workflow management',
44
+ color: 'GRAY',
45
+ description: '',
46
+ },
47
+ ],
48
+ workflowManagementStory: {
49
+ id: 'workflow-management-story-id',
50
+ name: 'workflow management',
51
+ },
52
+ },
53
+ remainingEstimationMinutes: null,
54
+ dependedIssueUrlSeparatedByComma: null,
55
+ completionDate50PercentConfidence: null,
56
+ ...overrides,
57
+ });
58
+
59
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
60
+ nameWithOwner: 'user/repo',
61
+ number: 1,
62
+ title: 'Test Issue',
63
+ state: 'OPEN',
64
+ status: 'Awaiting Quality Check',
65
+ story: null,
66
+ nextActionDate: null,
67
+ nextActionHour: null,
68
+ estimationMinutes: null,
69
+ dependedIssueUrls: [],
70
+ completionDate50PercentConfidence: null,
71
+ url: 'https://github.com/user/repo/issues/1',
72
+ assignees: [],
73
+ labels: [],
74
+ org: 'user',
75
+ repo: 'repo',
76
+ body: '',
77
+ itemId: 'item-1',
78
+ isPr: false,
79
+ isInProgress: false,
80
+ isClosed: false,
81
+ createdAt: new Date(),
82
+ author: '',
83
+ ...overrides,
84
+ });
85
+
86
+ const createMockPullRequest = (overrides: Partial<Issue> = {}): Issue =>
87
+ createMockIssue({
88
+ title: 'Test PR',
89
+ status: 'Unread',
90
+ url: 'https://github.com/user/repo/pull/1',
91
+ isPr: true,
92
+ ...overrides,
93
+ });
94
+
95
+ const createReadyPr = (url = 'https://github.com/user/repo/pull/1') => ({
96
+ url,
97
+ isConflicted: false,
98
+ isPassedAllCiJob: true,
99
+ isCiStateSuccess: true,
100
+ isResolvedAllReviewComments: true,
101
+ isBranchOutOfDate: false,
102
+ missingRequiredCheckNames: [],
103
+ });
104
+
105
+ describe('RevertNotReadyReviewQueueIssueUseCase', () => {
106
+ let mockProjectRepository: {
107
+ findProjectIdByUrl: jest.Mock;
108
+ getProject: jest.Mock;
109
+ };
110
+ let mockIssueRepository: {
111
+ getAllIssues: jest.Mock;
112
+ updateStatus: jest.Mock;
113
+ updateStory: jest.Mock;
114
+ findRelatedOpenPRs: jest.Mock;
115
+ getOpenPullRequest: jest.Mock;
116
+ getPullRequestChangedFilePaths: jest.Mock;
117
+ approvePullRequest: jest.Mock;
118
+ requestChangesWithInlineComment: jest.Mock;
119
+ };
120
+ let mockIssueCommentRepository: {
121
+ createComment: jest.Mock;
122
+ };
123
+ let mockProject: Project;
124
+ let useCase: RevertNotReadyReviewQueueIssueUseCase;
125
+
126
+ beforeEach(() => {
127
+ jest.resetAllMocks();
128
+
129
+ mockProject = createMockProject();
130
+
131
+ mockProjectRepository = {
132
+ findProjectIdByUrl: jest.fn().mockResolvedValue('project-1'),
133
+ getProject: jest.fn().mockResolvedValue(mockProject),
134
+ };
135
+
136
+ mockIssueRepository = {
137
+ getAllIssues: jest
138
+ .fn()
139
+ .mockResolvedValue({ issues: [], cacheUsed: false }),
140
+ updateStatus: jest.fn().mockResolvedValue(undefined),
141
+ updateStory: jest.fn().mockResolvedValue(undefined),
142
+ findRelatedOpenPRs: jest.fn().mockResolvedValue([]),
143
+ getOpenPullRequest: jest.fn().mockResolvedValue(null),
144
+ getPullRequestChangedFilePaths: jest.fn().mockResolvedValue([]),
145
+ approvePullRequest: jest.fn().mockResolvedValue(undefined),
146
+ requestChangesWithInlineComment: jest.fn().mockResolvedValue(undefined),
147
+ };
148
+
149
+ mockIssueCommentRepository = {
150
+ createComment: jest.fn().mockResolvedValue(undefined),
151
+ };
152
+
153
+ useCase = new RevertNotReadyReviewQueueIssueUseCase(
154
+ mockProjectRepository,
155
+ mockIssueRepository,
156
+ mockIssueCommentRepository,
157
+ );
158
+ });
159
+
160
+ describe('Awaiting Quality Check processing', () => {
161
+ it('should do nothing when there are no Awaiting Quality Check issues', async () => {
162
+ mockIssueRepository.getAllIssues.mockResolvedValue({
163
+ issues: [
164
+ createMockIssue({ status: 'Awaiting Workspace' }),
165
+ createMockIssue({ status: 'Preparation' }),
166
+ ],
167
+ cacheUsed: false,
168
+ });
169
+
170
+ await useCase.run({
171
+ projectUrl: 'https://github.com/users/user/projects/1',
172
+ allowIssueCacheMinutes: 10,
173
+ });
174
+
175
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
176
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
177
+ });
178
+
179
+ it('should skip Awaiting Quality Check issue with llm-agent label', async () => {
180
+ const issue = createMockIssue({
181
+ status: 'Awaiting Quality Check',
182
+ labels: ['llm-agent'],
183
+ });
184
+ mockIssueRepository.getAllIssues.mockResolvedValue({
185
+ issues: [issue],
186
+ cacheUsed: false,
187
+ });
188
+
189
+ await useCase.run({
190
+ projectUrl: 'https://github.com/users/user/projects/1',
191
+ allowIssueCacheMinutes: 10,
192
+ });
193
+
194
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
195
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it('should revert issue when no linked PR is found', async () => {
199
+ const issue = createMockIssue({
200
+ status: 'Awaiting Quality Check',
201
+ });
202
+ mockIssueRepository.getAllIssues.mockResolvedValue({
203
+ issues: [issue],
204
+ cacheUsed: false,
205
+ });
206
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
207
+
208
+ await useCase.run({
209
+ projectUrl: 'https://github.com/users/user/projects/1',
210
+ allowIssueCacheMinutes: 10,
211
+ });
212
+
213
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
214
+ mockProject,
215
+ issue,
216
+ 'awaiting-workspace-id',
217
+ );
218
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
219
+ issue,
220
+ expect.stringContaining('Auto Status Check: REJECTED'),
221
+ );
222
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
223
+ issue,
224
+ expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
225
+ );
226
+ });
227
+
228
+ it('should not revert a story-labeled issue with no linked PR when story is in labelsAsLlmAgentName', async () => {
229
+ const issue = createMockIssue({
230
+ status: 'Awaiting Quality Check',
231
+ labels: ['story'],
232
+ });
233
+ mockIssueRepository.getAllIssues.mockResolvedValue({
234
+ issues: [issue],
235
+ cacheUsed: false,
236
+ });
237
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
238
+
239
+ await useCase.run({
240
+ projectUrl: 'https://github.com/users/user/projects/1',
241
+ allowIssueCacheMinutes: 10,
242
+ labelsAsLlmAgentName: ['story', 'chore', 'accounting'],
243
+ });
244
+
245
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
246
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
247
+ });
248
+
249
+ it('should not revert a chore-labeled issue with no linked PR when chore is in labelsAsLlmAgentName', async () => {
250
+ const issue = createMockIssue({
251
+ status: 'Awaiting Quality Check',
252
+ labels: ['chore'],
253
+ });
254
+ mockIssueRepository.getAllIssues.mockResolvedValue({
255
+ issues: [issue],
256
+ cacheUsed: false,
257
+ });
258
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
259
+
260
+ await useCase.run({
261
+ projectUrl: 'https://github.com/users/user/projects/1',
262
+ allowIssueCacheMinutes: 10,
263
+ labelsAsLlmAgentName: ['story', 'chore'],
264
+ });
265
+
266
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
267
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
268
+ });
269
+
270
+ it('should still revert a story-labeled issue with no linked PR when labelsAsLlmAgentName is not provided', async () => {
271
+ const issue = createMockIssue({
272
+ status: 'Awaiting Quality Check',
273
+ labels: ['story'],
274
+ });
275
+ mockIssueRepository.getAllIssues.mockResolvedValue({
276
+ issues: [issue],
277
+ cacheUsed: false,
278
+ });
279
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
280
+
281
+ await useCase.run({
282
+ projectUrl: 'https://github.com/users/user/projects/1',
283
+ allowIssueCacheMinutes: 10,
284
+ });
285
+
286
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
287
+ mockProject,
288
+ issue,
289
+ 'awaiting-workspace-id',
290
+ );
291
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
292
+ issue,
293
+ expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
294
+ );
295
+ });
296
+
297
+ it('should not revert a story-labeled issue with no linked PR when labelsAsLlmAgentName is null', async () => {
298
+ const issue = createMockIssue({
299
+ status: 'Awaiting Quality Check',
300
+ labels: ['story'],
301
+ });
302
+ mockIssueRepository.getAllIssues.mockResolvedValue({
303
+ issues: [issue],
304
+ cacheUsed: false,
305
+ });
306
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
307
+
308
+ await useCase.run({
309
+ projectUrl: 'https://github.com/users/user/projects/1',
310
+ allowIssueCacheMinutes: 10,
311
+ labelsAsLlmAgentName: null,
312
+ });
313
+
314
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
315
+ mockProject,
316
+ issue,
317
+ 'awaiting-workspace-id',
318
+ );
319
+ });
320
+
321
+ it('should not revert issue when PR is ready', async () => {
322
+ const issue = createMockIssue({
323
+ status: 'Awaiting Quality Check',
324
+ });
325
+ mockIssueRepository.getAllIssues.mockResolvedValue({
326
+ issues: [issue],
327
+ cacheUsed: false,
328
+ });
329
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
330
+ createReadyPr(),
331
+ ]);
332
+
333
+ await useCase.run({
334
+ projectUrl: 'https://github.com/users/user/projects/1',
335
+ allowIssueCacheMinutes: 10,
336
+ });
337
+
338
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
339
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
340
+ });
341
+
342
+ it('should revert issue when PR is conflicted', async () => {
343
+ const issue = createMockIssue({
344
+ status: 'Awaiting Quality Check',
345
+ });
346
+ mockIssueRepository.getAllIssues.mockResolvedValue({
347
+ issues: [issue],
348
+ cacheUsed: false,
349
+ });
350
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
351
+ { ...createReadyPr(), isConflicted: true },
352
+ ]);
353
+
354
+ await useCase.run({
355
+ projectUrl: 'https://github.com/users/user/projects/1',
356
+ allowIssueCacheMinutes: 10,
357
+ });
358
+
359
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
360
+ mockProject,
361
+ issue,
362
+ 'awaiting-workspace-id',
363
+ );
364
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
365
+ issue,
366
+ expect.stringContaining('Auto Status Check: REJECTED'),
367
+ );
368
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
369
+ issue,
370
+ expect.stringContaining('PULL_REQUEST_CONFLICTED'),
371
+ );
372
+ });
373
+
374
+ it('should revert issue when CI is failing', async () => {
375
+ const issue = createMockIssue({
376
+ status: 'Awaiting Quality Check',
377
+ });
378
+ mockIssueRepository.getAllIssues.mockResolvedValue({
379
+ issues: [issue],
380
+ cacheUsed: false,
381
+ });
382
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
383
+ {
384
+ ...createReadyPr(),
385
+ isPassedAllCiJob: false,
386
+ isCiStateSuccess: false,
387
+ },
388
+ ]);
389
+
390
+ await useCase.run({
391
+ projectUrl: 'https://github.com/users/user/projects/1',
392
+ allowIssueCacheMinutes: 10,
393
+ });
394
+
395
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
396
+ mockProject,
397
+ issue,
398
+ 'awaiting-workspace-id',
399
+ );
400
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
401
+ issue,
402
+ expect.stringContaining('Auto Status Check: REJECTED'),
403
+ );
404
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
405
+ issue,
406
+ expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
407
+ );
408
+ });
409
+
410
+ it('should revert issue when review comments are not resolved', async () => {
411
+ const issue = createMockIssue({
412
+ status: 'Awaiting Quality Check',
413
+ });
414
+ mockIssueRepository.getAllIssues.mockResolvedValue({
415
+ issues: [issue],
416
+ cacheUsed: false,
417
+ });
418
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
419
+ { ...createReadyPr(), isResolvedAllReviewComments: false },
420
+ ]);
421
+
422
+ await useCase.run({
423
+ projectUrl: 'https://github.com/users/user/projects/1',
424
+ allowIssueCacheMinutes: 10,
425
+ });
426
+
427
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
428
+ mockProject,
429
+ issue,
430
+ 'awaiting-workspace-id',
431
+ );
432
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
433
+ issue,
434
+ expect.stringContaining('Auto Status Check: REJECTED'),
435
+ );
436
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
437
+ issue,
438
+ expect.stringContaining('ANY_REVIEW_COMMENT_NOT_RESOLVED'),
439
+ );
440
+ });
441
+
442
+ it('should revert issue when linked PR is in draft state', async () => {
443
+ const issue = createMockIssue({
444
+ status: 'Awaiting Quality Check',
445
+ });
446
+ mockIssueRepository.getAllIssues.mockResolvedValue({
447
+ issues: [issue],
448
+ cacheUsed: false,
449
+ });
450
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
451
+ { ...createReadyPr(), isDraft: true },
452
+ ]);
453
+
454
+ await useCase.run({
455
+ projectUrl: 'https://github.com/users/user/projects/1',
456
+ allowIssueCacheMinutes: 10,
457
+ });
458
+
459
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
460
+ mockProject,
461
+ issue,
462
+ 'awaiting-workspace-id',
463
+ );
464
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
465
+ issue,
466
+ expect.stringContaining('Auto Status Check: REJECTED'),
467
+ );
468
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
469
+ issue,
470
+ expect.stringContaining('PULL_REQUEST_IS_DRAFT'),
471
+ );
472
+ });
473
+
474
+ it('should revert issue when multiple linked open PRs are found', async () => {
475
+ const issue = createMockIssue({
476
+ status: 'Awaiting Quality Check',
477
+ });
478
+ mockIssueRepository.getAllIssues.mockResolvedValue({
479
+ issues: [issue],
480
+ cacheUsed: false,
481
+ });
482
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
483
+ createReadyPr('https://github.com/user/repo/pull/1'),
484
+ createReadyPr('https://github.com/user/repo/pull/2'),
485
+ ]);
486
+
487
+ await useCase.run({
488
+ projectUrl: 'https://github.com/users/user/projects/1',
489
+ allowIssueCacheMinutes: 10,
490
+ });
491
+
492
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
493
+ mockProject,
494
+ issue,
495
+ 'awaiting-workspace-id',
496
+ );
497
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
498
+ issue,
499
+ expect.stringContaining('Auto Status Check: REJECTED'),
500
+ );
501
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
502
+ issue,
503
+ expect.stringContaining('MULTIPLE_PULL_REQUESTS_FOUND'),
504
+ );
505
+ });
506
+
507
+ it('should revert issue when CI is SUCCESS but required check never started', async () => {
508
+ const issue = createMockIssue({
509
+ status: 'Awaiting Quality Check',
510
+ });
511
+ mockIssueRepository.getAllIssues.mockResolvedValue({
512
+ issues: [issue],
513
+ cacheUsed: false,
514
+ });
515
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
516
+ {
517
+ ...createReadyPr(),
518
+ isPassedAllCiJob: false,
519
+ isCiStateSuccess: true,
520
+ missingRequiredCheckNames: ['E2E Tests'],
521
+ },
522
+ ]);
523
+
524
+ await useCase.run({
525
+ projectUrl: 'https://github.com/users/user/projects/1',
526
+ allowIssueCacheMinutes: 10,
527
+ });
528
+
529
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
530
+ mockProject,
531
+ issue,
532
+ 'awaiting-workspace-id',
533
+ );
534
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
535
+ issue,
536
+ expect.stringContaining('Auto Status Check: REJECTED'),
537
+ );
538
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
539
+ issue,
540
+ expect.stringContaining('REQUIRED_CI_JOB_NEVER_STARTED'),
541
+ );
542
+ });
543
+
544
+ describe('change-target label auto-approve', () => {
545
+ const setupReadyIssue = (labels: string[]) => {
546
+ const issue = createMockIssue({
547
+ status: 'Awaiting Quality Check',
548
+ labels,
549
+ });
550
+ mockIssueRepository.getAllIssues.mockResolvedValue({
551
+ issues: [issue],
552
+ cacheUsed: false,
553
+ });
554
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
555
+ createReadyPr(),
556
+ ]);
557
+ return issue;
558
+ };
559
+
560
+ const runCycle = () =>
561
+ useCase.run({
562
+ projectUrl: 'https://github.com/users/user/projects/1',
563
+ allowIssueCacheMinutes: 10,
564
+ });
565
+
566
+ it('should not approve PR when issue has no change-target label', async () => {
567
+ setupReadyIssue([]);
568
+
569
+ await runCycle();
570
+
571
+ expect(
572
+ mockIssueRepository.getPullRequestChangedFilePaths,
573
+ ).not.toHaveBeenCalled();
574
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
575
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
576
+ });
577
+
578
+ it('should approve PR when issue has change-target label and all files are confined', async () => {
579
+ setupReadyIssue(['change-target:src/domain']);
580
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
581
+ 'src/domain/entities/Foo.ts',
582
+ 'src/domain/usecases/Bar.ts',
583
+ ]);
584
+
585
+ await runCycle();
586
+
587
+ expect(
588
+ mockIssueRepository.getPullRequestChangedFilePaths,
589
+ ).toHaveBeenCalledWith('https://github.com/user/repo/pull/1');
590
+ expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
591
+ 'https://github.com/user/repo/pull/1',
592
+ );
593
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
594
+ });
595
+
596
+ it('should not approve PR when any changed file is outside the labeled path', async () => {
597
+ setupReadyIssue(['change-target:src/domain']);
598
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
599
+ 'src/domain/entities/Foo.ts',
600
+ 'src/adapter/repositories/Outside.ts',
601
+ ]);
602
+
603
+ await runCycle();
604
+
605
+ expect(
606
+ mockIssueRepository.getPullRequestChangedFilePaths,
607
+ ).toHaveBeenCalledWith('https://github.com/user/repo/pull/1');
608
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
609
+ });
610
+
611
+ it('should approve when files are confined under any of multiple change-target labels', async () => {
612
+ setupReadyIssue(['change-target:src/domain', 'change-target:docs']);
613
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
614
+ 'src/domain/entities/Foo.ts',
615
+ 'docs/intro.md',
616
+ ]);
617
+
618
+ await runCycle();
619
+
620
+ expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
621
+ 'https://github.com/user/repo/pull/1',
622
+ );
623
+ });
624
+
625
+ it('should not approve when PR has more than 100 changed files and one file beyond entry 100 is outside the labeled path', async () => {
626
+ setupReadyIssue(['change-target:src/domain']);
627
+ const filePaths: string[] = [];
628
+ for (let i = 0; i < 150; i += 1) {
629
+ filePaths.push(`src/domain/file${i}.ts`);
630
+ }
631
+ filePaths.push('src/adapter/Outside.ts');
632
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue(
633
+ filePaths,
634
+ );
635
+
636
+ await runCycle();
637
+
638
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
639
+ });
640
+
641
+ it('should match boundary-safely (change-target:foo matches foo/bar.ts but not foobar/baz.ts)', async () => {
642
+ setupReadyIssue(['change-target:foo']);
643
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
644
+ 'foo/bar.ts',
645
+ 'foobar/baz.ts',
646
+ ]);
647
+
648
+ await runCycle();
649
+
650
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
651
+ });
652
+
653
+ it('should approve when changed files match exact path or subpath of the labeled path', async () => {
654
+ setupReadyIssue(['change-target:foo']);
655
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
656
+ 'foo/bar.ts',
657
+ 'foo/nested/baz.ts',
658
+ ]);
659
+
660
+ await runCycle();
661
+
662
+ expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
663
+ 'https://github.com/user/repo/pull/1',
664
+ );
665
+ });
666
+
667
+ it('should not approve when PR has zero changed files', async () => {
668
+ setupReadyIssue(['change-target:src/domain']);
669
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue(
670
+ [],
671
+ );
672
+
673
+ await runCycle();
674
+
675
+ expect(
676
+ mockIssueRepository.getPullRequestChangedFilePaths,
677
+ ).toHaveBeenCalledWith('https://github.com/user/repo/pull/1');
678
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
679
+ });
680
+
681
+ it('should not approve when there is no ready PR even if change-target label is present', async () => {
682
+ const issue = createMockIssue({
683
+ status: 'Awaiting Quality Check',
684
+ labels: ['change-target:src/domain'],
685
+ });
686
+ mockIssueRepository.getAllIssues.mockResolvedValue({
687
+ issues: [issue],
688
+ cacheUsed: false,
689
+ });
690
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
691
+
692
+ await runCycle();
693
+
694
+ expect(
695
+ mockIssueRepository.getPullRequestChangedFilePaths,
696
+ ).not.toHaveBeenCalled();
697
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
698
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
699
+ mockProject,
700
+ issue,
701
+ 'awaiting-workspace-id',
702
+ );
703
+ });
704
+
705
+ it('should not approve when PR has unresolved rejections even with change-target label', async () => {
706
+ const issue = createMockIssue({
707
+ status: 'Awaiting Quality Check',
708
+ labels: ['change-target:src/domain'],
709
+ });
710
+ mockIssueRepository.getAllIssues.mockResolvedValue({
711
+ issues: [issue],
712
+ cacheUsed: false,
713
+ });
714
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
715
+ { ...createReadyPr(), isConflicted: true },
716
+ ]);
717
+
718
+ await runCycle();
719
+
720
+ expect(
721
+ mockIssueRepository.getPullRequestChangedFilePaths,
722
+ ).not.toHaveBeenCalled();
723
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
724
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
725
+ mockProject,
726
+ issue,
727
+ 'awaiting-workspace-id',
728
+ );
729
+ });
730
+
731
+ it('should skip change-target auto-approve for issue with llm-agent label', async () => {
732
+ const issue = createMockIssue({
733
+ status: 'Awaiting Quality Check',
734
+ labels: ['llm-agent', 'change-target:src/domain'],
735
+ });
736
+ mockIssueRepository.getAllIssues.mockResolvedValue({
737
+ issues: [issue],
738
+ cacheUsed: false,
739
+ });
740
+ mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
741
+ createReadyPr(),
742
+ ]);
743
+
744
+ await runCycle();
745
+
746
+ expect(
747
+ mockIssueRepository.getPullRequestChangedFilePaths,
748
+ ).not.toHaveBeenCalled();
749
+ expect(mockIssueRepository.approvePullRequest).not.toHaveBeenCalled();
750
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
751
+ });
752
+
753
+ it('should normalize trailing slashes in change-target label paths', async () => {
754
+ setupReadyIssue(['change-target:src/domain/']);
755
+ mockIssueRepository.getPullRequestChangedFilePaths.mockResolvedValue([
756
+ 'src/domain/entities/Foo.ts',
757
+ ]);
758
+
759
+ await runCycle();
760
+
761
+ expect(mockIssueRepository.approvePullRequest).toHaveBeenCalledWith(
762
+ 'https://github.com/user/repo/pull/1',
763
+ );
764
+ });
765
+ });
766
+ });
767
+
768
+ describe('Unread pull request processing', () => {
769
+ it('should do nothing when there are no Unread pull requests', async () => {
770
+ mockIssueRepository.getAllIssues.mockResolvedValue({
771
+ issues: [
772
+ createMockPullRequest({ status: 'Awaiting Workspace' }),
773
+ createMockPullRequest({ status: 'Preparation' }),
774
+ ],
775
+ cacheUsed: false,
776
+ });
777
+
778
+ await useCase.run({
779
+ projectUrl: 'https://github.com/users/user/projects/1',
780
+ allowIssueCacheMinutes: 10,
781
+ });
782
+
783
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
784
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
785
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
786
+ });
787
+
788
+ it('should skip Unread issue that is not a pull request', async () => {
789
+ const issue = createMockIssue({
790
+ status: 'Unread',
791
+ isPr: false,
792
+ url: 'https://github.com/user/repo/issues/1',
793
+ });
794
+ mockIssueRepository.getAllIssues.mockResolvedValue({
795
+ issues: [issue],
796
+ cacheUsed: false,
797
+ });
798
+
799
+ await useCase.run({
800
+ projectUrl: 'https://github.com/users/user/projects/1',
801
+ allowIssueCacheMinutes: 10,
802
+ });
803
+
804
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
805
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
806
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
807
+ });
808
+
809
+ it('should skip Unread pull request with llm-agent label', async () => {
810
+ const pullRequest = createMockPullRequest({
811
+ status: 'Unread',
812
+ labels: ['llm-agent'],
813
+ });
814
+ mockIssueRepository.getAllIssues.mockResolvedValue({
815
+ issues: [pullRequest],
816
+ cacheUsed: false,
817
+ });
818
+
819
+ await useCase.run({
820
+ projectUrl: 'https://github.com/users/user/projects/1',
821
+ allowIssueCacheMinutes: 10,
822
+ });
823
+
824
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
825
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
826
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
827
+ });
828
+
829
+ it('should not move Unread pull request when it is review-ready', async () => {
830
+ const pullRequest = createMockPullRequest({
831
+ status: 'Unread',
832
+ });
833
+ mockIssueRepository.getAllIssues.mockResolvedValue({
834
+ issues: [pullRequest],
835
+ cacheUsed: false,
836
+ });
837
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue(createReadyPr());
838
+
839
+ await useCase.run({
840
+ projectUrl: 'https://github.com/users/user/projects/1',
841
+ allowIssueCacheMinutes: 10,
842
+ });
843
+
844
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
845
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
846
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
847
+ });
848
+
849
+ it('should move Unread pull request to Awaiting Workspace and set Story to workflow management when PR is conflicted', async () => {
850
+ const pullRequest = createMockPullRequest({
851
+ status: 'Unread',
852
+ });
853
+ mockIssueRepository.getAllIssues.mockResolvedValue({
854
+ issues: [pullRequest],
855
+ cacheUsed: false,
856
+ });
857
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue({
858
+ ...createReadyPr(),
859
+ isConflicted: true,
860
+ });
861
+
862
+ await useCase.run({
863
+ projectUrl: 'https://github.com/users/user/projects/1',
864
+ allowIssueCacheMinutes: 10,
865
+ });
866
+
867
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
868
+ mockProject,
869
+ pullRequest,
870
+ 'awaiting-workspace-id',
871
+ );
872
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
873
+ expect.objectContaining({ id: 'project-1' }),
874
+ pullRequest,
875
+ 'workflow-management-story-id',
876
+ );
877
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
878
+ pullRequest,
879
+ expect.stringContaining('Auto Status Check: REJECTED'),
880
+ );
881
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
882
+ pullRequest,
883
+ expect.stringContaining('PULL_REQUEST_CONFLICTED'),
884
+ );
885
+ });
886
+
887
+ it('should move Unread pull request when CI is failing', async () => {
888
+ const pullRequest = createMockPullRequest({
889
+ status: 'Unread',
890
+ });
891
+ mockIssueRepository.getAllIssues.mockResolvedValue({
892
+ issues: [pullRequest],
893
+ cacheUsed: false,
894
+ });
895
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue({
896
+ ...createReadyPr(),
897
+ isPassedAllCiJob: false,
898
+ isCiStateSuccess: false,
899
+ });
900
+
901
+ await useCase.run({
902
+ projectUrl: 'https://github.com/users/user/projects/1',
903
+ allowIssueCacheMinutes: 10,
904
+ });
905
+
906
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
907
+ mockProject,
908
+ pullRequest,
909
+ 'awaiting-workspace-id',
910
+ );
911
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
912
+ expect.objectContaining({ id: 'project-1' }),
913
+ pullRequest,
914
+ 'workflow-management-story-id',
915
+ );
916
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
917
+ pullRequest,
918
+ expect.stringContaining('ANY_CI_JOB_FAILED_OR_IN_PROGRESS'),
919
+ );
920
+ });
921
+
922
+ it('should move Unread pull request when it is in draft state', async () => {
923
+ const pullRequest = createMockPullRequest({
924
+ status: 'Unread',
925
+ });
926
+ mockIssueRepository.getAllIssues.mockResolvedValue({
927
+ issues: [pullRequest],
928
+ cacheUsed: false,
929
+ });
930
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue({
931
+ ...createReadyPr(),
932
+ isDraft: true,
933
+ });
934
+
935
+ await useCase.run({
936
+ projectUrl: 'https://github.com/users/user/projects/1',
937
+ allowIssueCacheMinutes: 10,
938
+ });
939
+
940
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
941
+ mockProject,
942
+ pullRequest,
943
+ 'awaiting-workspace-id',
944
+ );
945
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
946
+ expect.objectContaining({ id: 'project-1' }),
947
+ pullRequest,
948
+ 'workflow-management-story-id',
949
+ );
950
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
951
+ pullRequest,
952
+ expect.stringContaining('PULL_REQUEST_IS_DRAFT'),
953
+ );
954
+ });
955
+
956
+ it('should move Unread pull request when CI is SUCCESS but required check never started', async () => {
957
+ const pullRequest = createMockPullRequest({
958
+ status: 'Unread',
959
+ });
960
+ mockIssueRepository.getAllIssues.mockResolvedValue({
961
+ issues: [pullRequest],
962
+ cacheUsed: false,
963
+ });
964
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue({
965
+ ...createReadyPr(),
966
+ isPassedAllCiJob: false,
967
+ isCiStateSuccess: true,
968
+ missingRequiredCheckNames: ['E2E Tests'],
969
+ });
970
+
971
+ await useCase.run({
972
+ projectUrl: 'https://github.com/users/user/projects/1',
973
+ allowIssueCacheMinutes: 10,
974
+ });
975
+
976
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
977
+ mockProject,
978
+ pullRequest,
979
+ 'awaiting-workspace-id',
980
+ );
981
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
982
+ expect.objectContaining({ id: 'project-1' }),
983
+ pullRequest,
984
+ 'workflow-management-story-id',
985
+ );
986
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
987
+ pullRequest,
988
+ expect.stringContaining('REQUIRED_CI_JOB_NEVER_STARTED'),
989
+ );
990
+ });
991
+
992
+ it('should move Unread pull request when review comments are not resolved', async () => {
993
+ const pullRequest = createMockPullRequest({
994
+ status: 'Unread',
995
+ });
996
+ mockIssueRepository.getAllIssues.mockResolvedValue({
997
+ issues: [pullRequest],
998
+ cacheUsed: false,
999
+ });
1000
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue({
1001
+ ...createReadyPr(),
1002
+ isResolvedAllReviewComments: false,
1003
+ });
1004
+
1005
+ await useCase.run({
1006
+ projectUrl: 'https://github.com/users/user/projects/1',
1007
+ allowIssueCacheMinutes: 10,
1008
+ });
1009
+
1010
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
1011
+ mockProject,
1012
+ pullRequest,
1013
+ 'awaiting-workspace-id',
1014
+ );
1015
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
1016
+ expect.objectContaining({ id: 'project-1' }),
1017
+ pullRequest,
1018
+ 'workflow-management-story-id',
1019
+ );
1020
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1021
+ pullRequest,
1022
+ expect.stringContaining('ANY_REVIEW_COMMENT_NOT_RESOLVED'),
1023
+ );
1024
+ });
1025
+
1026
+ it('should move Unread pull request when no open PR is found at the PR URL', async () => {
1027
+ const pullRequest = createMockPullRequest({
1028
+ status: 'Unread',
1029
+ });
1030
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1031
+ issues: [pullRequest],
1032
+ cacheUsed: false,
1033
+ });
1034
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue(null);
1035
+
1036
+ await useCase.run({
1037
+ projectUrl: 'https://github.com/users/user/projects/1',
1038
+ allowIssueCacheMinutes: 10,
1039
+ });
1040
+
1041
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
1042
+ mockProject,
1043
+ pullRequest,
1044
+ 'awaiting-workspace-id',
1045
+ );
1046
+ expect(mockIssueRepository.updateStory).toHaveBeenCalledWith(
1047
+ expect.objectContaining({ id: 'project-1' }),
1048
+ pullRequest,
1049
+ 'workflow-management-story-id',
1050
+ );
1051
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1052
+ pullRequest,
1053
+ expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
1054
+ );
1055
+ });
1056
+
1057
+ it('should skip updateStory when project has no story field configured', async () => {
1058
+ const projectWithoutStory = createMockProject({ story: null });
1059
+ mockProjectRepository.getProject.mockResolvedValue(projectWithoutStory);
1060
+
1061
+ const pullRequest = createMockPullRequest({
1062
+ status: 'Unread',
1063
+ });
1064
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1065
+ issues: [pullRequest],
1066
+ cacheUsed: false,
1067
+ });
1068
+ mockIssueRepository.getOpenPullRequest.mockResolvedValue({
1069
+ ...createReadyPr(),
1070
+ isConflicted: true,
1071
+ });
1072
+
1073
+ await useCase.run({
1074
+ projectUrl: 'https://github.com/users/user/projects/1',
1075
+ allowIssueCacheMinutes: 10,
1076
+ });
1077
+
1078
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
1079
+ projectWithoutStory,
1080
+ pullRequest,
1081
+ 'awaiting-workspace-id',
1082
+ );
1083
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
1084
+ expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
1085
+ pullRequest,
1086
+ expect.stringContaining('Auto Status Check: REJECTED'),
1087
+ );
1088
+ });
1089
+
1090
+ it('should return early when Awaiting Workspace status option does not exist', async () => {
1091
+ const projectWithoutAwaitingWorkspace = createMockProject({
1092
+ status: {
1093
+ name: 'Status',
1094
+ fieldId: 'field-1',
1095
+ statuses: [
1096
+ {
1097
+ id: 'unread-id',
1098
+ name: 'Unread',
1099
+ color: 'ORANGE',
1100
+ description: '',
1101
+ },
1102
+ ],
1103
+ },
1104
+ });
1105
+ mockProjectRepository.getProject.mockResolvedValue(
1106
+ projectWithoutAwaitingWorkspace,
1107
+ );
1108
+
1109
+ const pullRequest = createMockPullRequest({
1110
+ status: 'Unread',
1111
+ });
1112
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1113
+ issues: [pullRequest],
1114
+ cacheUsed: false,
1115
+ });
1116
+
1117
+ await useCase.run({
1118
+ projectUrl: 'https://github.com/users/user/projects/1',
1119
+ allowIssueCacheMinutes: 10,
1120
+ });
1121
+
1122
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
1123
+ expect(mockIssueRepository.updateStory).not.toHaveBeenCalled();
1124
+ expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
1125
+ });
1126
+ });
1127
+ });