github-issue-tower-defence-management 1.172.1 → 1.173.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 (35) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +2 -0
  3. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
  4. package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
  5. package/bin/domain/usecases/HandleScheduledEventUseCase.js +17 -3
  6. package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
  7. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -26
  8. package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
  9. package/bin/domain/usecases/QualityCheckAdvanceUseCase.js +50 -0
  10. package/bin/domain/usecases/QualityCheckAdvanceUseCase.js.map +1 -0
  11. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
  12. package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
  13. package/bin/domain/usecases/extractNextStepAgent.js +30 -0
  14. package/bin/domain/usecases/extractNextStepAgent.js.map +1 -0
  15. package/package.json +1 -1
  16. package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +6 -0
  17. package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +116 -0
  18. package/src/domain/usecases/HandleScheduledEventUseCase.ts +21 -0
  19. package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +2 -29
  20. package/src/domain/usecases/QualityCheckAdvanceUseCase.test.ts +387 -0
  21. package/src/domain/usecases/QualityCheckAdvanceUseCase.ts +75 -0
  22. package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +111 -1
  23. package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +50 -1
  24. package/src/domain/usecases/extractNextStepAgent.ts +27 -0
  25. package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
  26. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -2
  27. package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
  28. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +0 -1
  29. package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
  30. package/types/domain/usecases/QualityCheckAdvanceUseCase.d.ts +14 -0
  31. package/types/domain/usecases/QualityCheckAdvanceUseCase.d.ts.map +1 -0
  32. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +4 -3
  33. package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
  34. package/types/domain/usecases/extractNextStepAgent.d.ts +2 -0
  35. package/types/domain/usecases/extractNextStepAgent.d.ts.map +1 -0
@@ -74,6 +74,7 @@ import { GitHubIssueCommentRepository } from '../../repositories/GitHubIssueComm
74
74
  import { ProjectRequiredFieldCreateUseCase } from '../../../domain/usecases/ProjectRequiredFieldCreateUseCase';
75
75
  import { SetupTowerDefenceProjectUseCase } from '../../../domain/usecases/SetupTowerDefenceProjectUseCase';
76
76
  import { DailySecurityScanUseCase } from '../../../domain/usecases/DailySecurityScanUseCase';
77
+ import { QualityCheckAdvanceUseCase } from '../../../domain/usecases/QualityCheckAdvanceUseCase';
77
78
  import { KyHttpRepository } from '../../repositories/KyHttpRepository';
78
79
  import { FileSystemKevReportWatermarkRepository } from '../../repositories/FileSystemKevReportWatermarkRepository';
79
80
  import {
@@ -507,6 +508,10 @@ export class HandleScheduledEventUseCaseHandler {
507
508
  )
508
509
  : null;
509
510
 
511
+ const qualityCheckAdvanceUseCase = new QualityCheckAdvanceUseCase(
512
+ issueRepository,
513
+ );
514
+
510
515
  const handleScheduledEventUseCase = new HandleScheduledEventUseCase(
511
516
  projectRequiredFieldCreateUseCase,
512
517
  setupTowerDefenceProjectUseCase,
@@ -531,6 +536,7 @@ export class HandleScheduledEventUseCaseHandler {
531
536
  agentDesignationLabelAdoptUseCase,
532
537
  updateRateLimitCacheUseCase,
533
538
  dailySecurityScanUseCase,
539
+ qualityCheckAdvanceUseCase,
534
540
  systemDateRepository,
535
541
  googleSpreadsheetRepository,
536
542
  projectRepository,
@@ -29,6 +29,7 @@ import { ProjectRequiredFieldCreateUseCase } from './ProjectRequiredFieldCreateU
29
29
  import { SetupTowerDefenceProjectUseCase } from './SetupTowerDefenceProjectUseCase';
30
30
  import { UpdateRateLimitCacheUseCase } from './UpdateRateLimitCacheUseCase';
31
31
  import { DailySecurityScanUseCase } from './DailySecurityScanUseCase';
32
+ import { QualityCheckAdvanceUseCase } from './QualityCheckAdvanceUseCase';
32
33
 
33
34
  describe('HandleScheduledEventUseCase', () => {
34
35
  describe('createTargetDateTimes', () => {
@@ -128,6 +129,7 @@ describe('HandleScheduledEventUseCase', () => {
128
129
  mock<AgentDesignationLabelAdoptUseCase>();
129
130
  const mockUpdateRateLimitCacheUseCase = mock<UpdateRateLimitCacheUseCase>();
130
131
  const mockDailySecurityScanUseCase = mock<DailySecurityScanUseCase>();
132
+ const mockAdvanceQualityCheckUseCase = mock<QualityCheckAdvanceUseCase>();
131
133
  const mockDateRepository = mock<DateRepository>();
132
134
  const mockSpreadsheetRepository = mock<SpreadsheetRepository>();
133
135
  const mockProjectRepository = mock<ProjectRepository>();
@@ -157,6 +159,7 @@ describe('HandleScheduledEventUseCase', () => {
157
159
  mockAgentDesignationLabelAdoptUseCase,
158
160
  mockUpdateRateLimitCacheUseCase,
159
161
  mockDailySecurityScanUseCase,
162
+ mockAdvanceQualityCheckUseCase,
160
163
  mockDateRepository,
161
164
  mockSpreadsheetRepository,
162
165
  mockProjectRepository,
@@ -1473,5 +1476,118 @@ describe('HandleScheduledEventUseCase', () => {
1473
1476
  expect(lastExecutionWrite).toBeUndefined();
1474
1477
  });
1475
1478
  });
1479
+
1480
+ describe('autoAdvanceQualityCheckEnabled', () => {
1481
+ const baseInput = {
1482
+ projectName: 'test-project',
1483
+ org: 'test-org',
1484
+ projectUrl: 'https://github.com/test-org/test-project',
1485
+ manager: 'test-manager',
1486
+ workingReport: {
1487
+ repo: 'test-repo',
1488
+ members: ['member1'],
1489
+ spreadsheetUrl: 'https://docs.google.com/spreadsheets/test',
1490
+ },
1491
+ urlOfStoryView: 'https://github.com/test-org/test-project/issues',
1492
+ disabled: false,
1493
+ };
1494
+
1495
+ it('does not call advanceQualityCheckUseCase when autoAdvanceQualityCheckEnabled is absent', async () => {
1496
+ await useCase.run(baseInput);
1497
+
1498
+ expect(mockAdvanceQualityCheckUseCase.run).not.toHaveBeenCalled();
1499
+ });
1500
+
1501
+ it('does not call advanceQualityCheckUseCase when autoAdvanceQualityCheckEnabled is false', async () => {
1502
+ await useCase.run({
1503
+ ...baseInput,
1504
+ startPreparation: {
1505
+ defaultAgentName: 'agent1',
1506
+ configFilePath: '/path/to/config.yml',
1507
+ maximumPreparingIssuesCount: null,
1508
+ autoAdvanceQualityCheckEnabled: false,
1509
+ },
1510
+ });
1511
+
1512
+ expect(mockAdvanceQualityCheckUseCase.run).not.toHaveBeenCalled();
1513
+ });
1514
+
1515
+ it('calls advanceQualityCheckUseCase when autoAdvanceQualityCheckEnabled is true', async () => {
1516
+ const mockProject = mock<Project>();
1517
+ const mockIssues: Issue[] = [];
1518
+ const fixedNow = new Date('2024-06-15T12:00:00Z');
1519
+ mockDateRepository.now.mockResolvedValue(fixedNow);
1520
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1521
+ issues: mockIssues,
1522
+ project: mockProject,
1523
+ cacheUsed: false,
1524
+ });
1525
+
1526
+ await useCase.run({
1527
+ ...baseInput,
1528
+ startPreparation: {
1529
+ defaultAgentName: 'agent1',
1530
+ configFilePath: '/path/to/config.yml',
1531
+ maximumPreparingIssuesCount: null,
1532
+ autoAdvanceQualityCheckEnabled: true,
1533
+ },
1534
+ });
1535
+
1536
+ expect(mockAdvanceQualityCheckUseCase.run).toHaveBeenCalledWith(
1537
+ expect.objectContaining({
1538
+ project: mockProject,
1539
+ issues: mockIssues,
1540
+ evaluatedAt: fixedNow,
1541
+ }),
1542
+ );
1543
+ });
1544
+
1545
+ it('passes awaitingQualityCheckStatus to advanceQualityCheckUseCase when configured', async () => {
1546
+ const mockProject = mock<Project>();
1547
+ mockIssueRepository.getAllIssues.mockResolvedValue({
1548
+ issues: [],
1549
+ project: mockProject,
1550
+ cacheUsed: false,
1551
+ });
1552
+
1553
+ await useCase.run({
1554
+ ...baseInput,
1555
+ startPreparation: {
1556
+ defaultAgentName: 'agent1',
1557
+ configFilePath: '/path/to/config.yml',
1558
+ maximumPreparingIssuesCount: null,
1559
+ autoAdvanceQualityCheckEnabled: true,
1560
+ awaitingQualityCheckStatus: 'Custom Review Status',
1561
+ },
1562
+ });
1563
+
1564
+ expect(mockAdvanceQualityCheckUseCase.run).toHaveBeenCalledWith(
1565
+ expect.objectContaining({
1566
+ awaitingQualityCheckStatusName: 'Custom Review Status',
1567
+ }),
1568
+ );
1569
+ });
1570
+
1571
+ it('continues to startPreparationUseCase when qualityCheckAdvanceUseCase.run rejects', async () => {
1572
+ mockAdvanceQualityCheckUseCase.run.mockRejectedValue(
1573
+ new AggregateError(
1574
+ [new Error('GitHub API rate limit')],
1575
+ 'Failed to advance 1 issue(s) from Awaiting Quality Check to Done',
1576
+ ),
1577
+ );
1578
+
1579
+ await useCase.run({
1580
+ ...baseInput,
1581
+ startPreparation: {
1582
+ defaultAgentName: 'agent1',
1583
+ configFilePath: '/path/to/config.yml',
1584
+ maximumPreparingIssuesCount: null,
1585
+ autoAdvanceQualityCheckEnabled: true,
1586
+ },
1587
+ });
1588
+
1589
+ expect(mockStartPreparationUseCase.run).toHaveBeenCalled();
1590
+ });
1591
+ });
1476
1592
  });
1477
1593
  });
@@ -37,6 +37,7 @@ import {
37
37
  DailySecurityScanConfig,
38
38
  DailySecurityScanUseCase,
39
39
  } from './DailySecurityScanUseCase';
40
+ import { QualityCheckAdvanceUseCase } from './QualityCheckAdvanceUseCase';
40
41
 
41
42
  export class ProjectNotFoundError extends Error {
42
43
  constructor(message: string) {
@@ -139,6 +140,7 @@ export class HandleScheduledEventUseCase {
139
140
  readonly agentDesignationLabelAdoptUseCase: AgentDesignationLabelAdoptUseCase,
140
141
  readonly updateRateLimitCacheUseCase: UpdateRateLimitCacheUseCase | null,
141
142
  readonly dailySecurityScanUseCase: DailySecurityScanUseCase | null,
143
+ readonly qualityCheckAdvanceUseCase: QualityCheckAdvanceUseCase,
142
144
  readonly dateRepository: DateRepository,
143
145
  readonly spreadsheetRepository: SpreadsheetRepository,
144
146
  readonly projectRepository: ProjectRepository,
@@ -177,6 +179,7 @@ export class HandleScheduledEventUseCase {
177
179
  awLogDirectoryPath?: string;
178
180
  awLogStaleThresholdMinutes?: number;
179
181
  awaitingQualityCheckStatus?: string | null;
182
+ autoAdvanceQualityCheckEnabled?: boolean;
180
183
  labelsAsLlmAgentName?: string[] | null;
181
184
  } | null;
182
185
  thresholdForAutoReject?: number;
@@ -333,6 +336,7 @@ export class HandleScheduledEventUseCase {
333
336
  targetDateTimes,
334
337
  storyIssues,
335
338
  runSlowSweep,
339
+ now,
336
340
  );
337
341
  rotationOrder = useCaseResult.rotationOrder;
338
342
  } catch (e) {
@@ -392,6 +396,7 @@ ${JSON.stringify(e)}
392
396
  targetDateTimes: Date[],
393
397
  storyObjectMap: StoryObjectMap,
394
398
  runSlowSweep: boolean,
399
+ now: Date,
395
400
  ): Promise<{ rotationOrder: RotationOrderEntry[] | null }> => {
396
401
  if (runSlowSweep) {
397
402
  await this.runSlowSweepUseCases(
@@ -462,6 +467,22 @@ ${JSON.stringify(e)}
462
467
  allowedIssueAuthors,
463
468
  });
464
469
  }
470
+ if (input.startPreparation.autoAdvanceQualityCheckEnabled) {
471
+ try {
472
+ await this.qualityCheckAdvanceUseCase.run({
473
+ project,
474
+ issues,
475
+ awaitingQualityCheckStatusName:
476
+ input.startPreparation.awaitingQualityCheckStatus ?? undefined,
477
+ evaluatedAt: now,
478
+ });
479
+ } catch (advanceError) {
480
+ console.error(
481
+ `[HandleScheduledEvent] Failed to advance quality check items for project ${project.url}: ${advanceError instanceof Error ? advanceError.message : String(advanceError)}`,
482
+ advanceError,
483
+ );
484
+ }
485
+ }
465
486
  const preparationResult = await this.startPreparationUseCase.run({
466
487
  projectUrl: input.projectUrl,
467
488
  defaultAgentName: input.startPreparation.defaultAgentName,
@@ -27,6 +27,7 @@ import {
27
27
  import { Issue } from '../entities/Issue';
28
28
  import { Project } from '../entities/Project';
29
29
  import { ensureAgentOptionAndGetId } from './ensureAgentOptionAndGetId';
30
+ import { extractNextStepAgent } from './extractNextStepAgent';
30
31
  import { issueReactivationTriggerIsPending } from './issueReactivationTriggerIsPending';
31
32
 
32
33
  export class IssueNotFoundError extends Error {
@@ -228,7 +229,7 @@ export class NotifyFinishedIssuePreparationUseCase {
228
229
  isTrustedAuthor(c.author) && c.content.startsWith('From: :robot:'),
229
230
  );
230
231
  const nextStepAgent = lastTrustedBotComment
231
- ? this.extractNextStepAgent(lastTrustedBotComment.content)
232
+ ? extractNextStepAgent(lastTrustedBotComment.content)
232
233
  : null;
233
234
  if (nextStepAgent !== null) {
234
235
  const agentOptionId = await this.ensureAgentOptionAndGetId(
@@ -612,34 +613,6 @@ export class NotifyFinishedIssuePreparationUseCase {
612
613
  return null;
613
614
  };
614
615
 
615
- private extractNextStepAgent = (body: string): string | null => {
616
- const reportMatch = body.match(/```json\n([\s\S]*?)\n```/);
617
- if (!reportMatch || reportMatch.length < 2) {
618
- return null;
619
- }
620
- let reportJson: unknown;
621
- try {
622
- reportJson = JSON.parse(reportMatch[1]);
623
- } catch (error) {
624
- console.warn(
625
- 'Invalid JSON in report body while checking nextStepAgent:',
626
- error,
627
- );
628
- return null;
629
- }
630
- if (typeof reportJson !== 'object' || reportJson === null) {
631
- return null;
632
- }
633
- if (!('nextStepAgent' in reportJson)) {
634
- return null;
635
- }
636
- const value = Reflect.get(reportJson, 'nextStepAgent');
637
- if (typeof value !== 'string' || value.trim() === '') {
638
- return null;
639
- }
640
- return value.trim();
641
- };
642
-
643
616
  private ensureAgentOptionAndGetId = async (
644
617
  project: Project,
645
618
  agentName: string,
@@ -0,0 +1,387 @@
1
+ import { QualityCheckAdvanceUseCase } from './QualityCheckAdvanceUseCase';
2
+ import { IssueRepository } from './adapter-interfaces/IssueRepository';
3
+ import { Issue } from '../entities/Issue';
4
+ import { Project } from '../entities/Project';
5
+ import {
6
+ AWAITING_QUALITY_CHECK_STATUS_NAME,
7
+ DONE_STATUS_NAME,
8
+ } from '../entities/WorkflowStatus';
9
+
10
+ type Mocked<T> = jest.Mocked<T> & jest.MockedObject<T>;
11
+
12
+ const createMockIssue = (overrides: Partial<Issue> = {}): Issue => ({
13
+ nameWithOwner: 'user/repo',
14
+ number: 1,
15
+ title: 'Test Issue',
16
+ state: 'OPEN',
17
+ status: AWAITING_QUALITY_CHECK_STATUS_NAME,
18
+ story: 'Default Story',
19
+ nextActionDate: null,
20
+ nextActionHour: null,
21
+ estimationMinutes: null,
22
+ dependedIssueUrls: [],
23
+ completionDate50PercentConfidence: null,
24
+ url: 'https://github.com/user/repo/issues/1',
25
+ assignees: [],
26
+ labels: [],
27
+ org: 'user',
28
+ repo: 'repo',
29
+ body: '',
30
+ itemId: 'item-1',
31
+ isPr: false,
32
+ isInProgress: false,
33
+ isClosed: false,
34
+ createdAt: new Date(),
35
+ author: '',
36
+ closingIssueReferenceUrls: [],
37
+ agent: null,
38
+ ...overrides,
39
+ });
40
+
41
+ const createMergedPr = (
42
+ closingIssueUrl: string,
43
+ overrides: Partial<Issue> = {},
44
+ ): Issue => ({
45
+ nameWithOwner: 'user/repo',
46
+ number: 100,
47
+ title: 'Merged PR',
48
+ state: 'MERGED',
49
+ status: null,
50
+ story: null,
51
+ nextActionDate: null,
52
+ nextActionHour: null,
53
+ estimationMinutes: null,
54
+ dependedIssueUrls: [],
55
+ completionDate50PercentConfidence: null,
56
+ url: 'https://github.com/user/repo/pull/100',
57
+ assignees: [],
58
+ labels: [],
59
+ org: 'user',
60
+ repo: 'repo',
61
+ body: '',
62
+ itemId: 'pr-item-1',
63
+ isPr: true,
64
+ isInProgress: false,
65
+ isClosed: true,
66
+ createdAt: new Date(),
67
+ author: '',
68
+ closingIssueReferenceUrls: [closingIssueUrl],
69
+ agent: null,
70
+ ...overrides,
71
+ });
72
+
73
+ const createMockProject = (): Project => ({
74
+ id: 'project-1',
75
+ url: 'https://github.com/orgs/user/projects/1',
76
+ databaseId: 1,
77
+ name: 'Test Project',
78
+ status: {
79
+ name: 'Status',
80
+ fieldId: 'status-field-id',
81
+ statuses: [
82
+ {
83
+ id: 'awaiting-workspace-id',
84
+ name: 'Awaiting Workspace',
85
+ color: 'GRAY',
86
+ description: '',
87
+ },
88
+ {
89
+ id: 'preparation-id',
90
+ name: 'Preparation',
91
+ color: 'YELLOW',
92
+ description: '',
93
+ },
94
+ {
95
+ id: 'awaiting-quality-check-id',
96
+ name: AWAITING_QUALITY_CHECK_STATUS_NAME,
97
+ color: 'GREEN',
98
+ description: '',
99
+ },
100
+ {
101
+ id: 'done-id',
102
+ name: DONE_STATUS_NAME,
103
+ color: 'PURPLE',
104
+ description: '',
105
+ },
106
+ ],
107
+ },
108
+ nextActionDate: null,
109
+ nextActionHour: null,
110
+ story: null,
111
+ remainingEstimationMinutes: null,
112
+ dependedIssueUrlSeparatedByComma: null,
113
+ completionDate50PercentConfidence: null,
114
+ agent: null,
115
+ });
116
+
117
+ const FIXED_NOW = new Date('2026-01-15T10:00:00Z');
118
+
119
+ describe('QualityCheckAdvanceUseCase', () => {
120
+ let useCase: QualityCheckAdvanceUseCase;
121
+ let mockIssueRepository: Mocked<Pick<IssueRepository, 'updateStatus'>>;
122
+
123
+ beforeEach(() => {
124
+ mockIssueRepository = {
125
+ updateStatus: jest.fn(),
126
+ };
127
+ useCase = new QualityCheckAdvanceUseCase(mockIssueRepository);
128
+ });
129
+
130
+ it('moves an issue in Awaiting Quality Check status to Done when its linked PR is merged', async () => {
131
+ const issue = createMockIssue();
132
+ const mergedPr = createMergedPr(issue.url);
133
+ const project = createMockProject();
134
+
135
+ await useCase.run({
136
+ project,
137
+ issues: [issue, mergedPr],
138
+ evaluatedAt: FIXED_NOW,
139
+ });
140
+
141
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
142
+ project,
143
+ issue,
144
+ 'done-id',
145
+ );
146
+ });
147
+
148
+ it('moves multiple issues in Awaiting Quality Check status to Done when each has a merged linked PR', async () => {
149
+ const issue1 = createMockIssue({
150
+ number: 1,
151
+ url: 'https://github.com/user/repo/issues/1',
152
+ });
153
+ const issue2 = createMockIssue({
154
+ number: 2,
155
+ url: 'https://github.com/user/repo/issues/2',
156
+ });
157
+ const mergedPr1 = createMergedPr(issue1.url, {
158
+ number: 101,
159
+ url: 'https://github.com/user/repo/pull/101',
160
+ itemId: 'pr-item-101',
161
+ });
162
+ const mergedPr2 = createMergedPr(issue2.url, {
163
+ number: 102,
164
+ url: 'https://github.com/user/repo/pull/102',
165
+ itemId: 'pr-item-102',
166
+ });
167
+ const project = createMockProject();
168
+
169
+ await useCase.run({
170
+ project,
171
+ issues: [issue1, issue2, mergedPr1, mergedPr2],
172
+ evaluatedAt: FIXED_NOW,
173
+ });
174
+
175
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledTimes(2);
176
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
177
+ project,
178
+ issue1,
179
+ 'done-id',
180
+ );
181
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
182
+ project,
183
+ issue2,
184
+ 'done-id',
185
+ );
186
+ });
187
+
188
+ it('does not move issues that are not in Awaiting Quality Check status', async () => {
189
+ const awaitingWorkspaceIssue = createMockIssue({
190
+ status: 'Awaiting Workspace',
191
+ });
192
+ const mergedPr = createMergedPr(awaitingWorkspaceIssue.url);
193
+ const project = createMockProject();
194
+
195
+ await useCase.run({
196
+ project,
197
+ issues: [awaitingWorkspaceIssue, mergedPr],
198
+ evaluatedAt: FIXED_NOW,
199
+ });
200
+
201
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
202
+ });
203
+
204
+ it('does not move closed issues', async () => {
205
+ const closedIssue = createMockIssue({ isClosed: true });
206
+ const mergedPr = createMergedPr(closedIssue.url);
207
+ const project = createMockProject();
208
+
209
+ await useCase.run({
210
+ project,
211
+ issues: [closedIssue, mergedPr],
212
+ evaluatedAt: FIXED_NOW,
213
+ });
214
+
215
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
216
+ });
217
+
218
+ it('respects a custom awaiting quality check status name', async () => {
219
+ const customStatusName = 'Custom Review Status';
220
+ const issue = createMockIssue({ status: customStatusName });
221
+ const mergedPr = createMergedPr(issue.url);
222
+ const project = createMockProject();
223
+ project.status.statuses.push({
224
+ id: 'custom-review-id',
225
+ name: customStatusName,
226
+ color: 'BLUE',
227
+ description: '',
228
+ });
229
+
230
+ await useCase.run({
231
+ project,
232
+ issues: [issue, mergedPr],
233
+ awaitingQualityCheckStatusName: customStatusName,
234
+ evaluatedAt: FIXED_NOW,
235
+ });
236
+
237
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
238
+ project,
239
+ issue,
240
+ 'done-id',
241
+ );
242
+ });
243
+
244
+ it('does not call updateStatus when Done status is not found in project', async () => {
245
+ const issue = createMockIssue();
246
+ const mergedPr = createMergedPr(issue.url);
247
+ const project = createMockProject();
248
+ project.status.statuses = project.status.statuses.filter(
249
+ (s) => s.name !== DONE_STATUS_NAME,
250
+ );
251
+
252
+ await useCase.run({
253
+ project,
254
+ issues: [issue, mergedPr],
255
+ evaluatedAt: FIXED_NOW,
256
+ });
257
+
258
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
259
+ });
260
+
261
+ it('returns the count of advanced issues', async () => {
262
+ const issue1 = createMockIssue({ number: 1 });
263
+ const issue2 = createMockIssue({
264
+ number: 2,
265
+ url: 'https://github.com/user/repo/issues/2',
266
+ });
267
+ const notAdvancedIssue = createMockIssue({
268
+ number: 3,
269
+ url: 'https://github.com/user/repo/issues/3',
270
+ status: 'Awaiting Workspace',
271
+ });
272
+ const mergedPr1 = createMergedPr(issue1.url, {
273
+ number: 101,
274
+ url: 'https://github.com/user/repo/pull/101',
275
+ itemId: 'pr-item-101',
276
+ });
277
+ const mergedPr2 = createMergedPr(issue2.url, {
278
+ number: 102,
279
+ url: 'https://github.com/user/repo/pull/102',
280
+ itemId: 'pr-item-102',
281
+ });
282
+ const project = createMockProject();
283
+
284
+ const count = await useCase.run({
285
+ project,
286
+ issues: [issue1, issue2, notAdvancedIssue, mergedPr1, mergedPr2],
287
+ evaluatedAt: FIXED_NOW,
288
+ });
289
+
290
+ expect(count).toBe(2);
291
+ });
292
+
293
+ it('does not advance an issue when no merged PR is linked to it', async () => {
294
+ const issue = createMockIssue();
295
+ const openPr = createMockIssue({
296
+ number: 100,
297
+ url: 'https://github.com/user/repo/pull/100',
298
+ itemId: 'pr-item-1',
299
+ isPr: true,
300
+ state: 'OPEN',
301
+ status: null,
302
+ isClosed: false,
303
+ closingIssueReferenceUrls: [issue.url],
304
+ });
305
+ const project = createMockProject();
306
+
307
+ await useCase.run({
308
+ project,
309
+ issues: [issue, openPr],
310
+ evaluatedAt: FIXED_NOW,
311
+ });
312
+
313
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
314
+ });
315
+
316
+ it('does not advance an issue with a pending nextActionDate reactivation trigger', async () => {
317
+ const futureDate = new Date('2099-12-31T00:00:00Z');
318
+ const issue = createMockIssue({ nextActionDate: futureDate });
319
+ const mergedPr = createMergedPr(issue.url);
320
+ const project = createMockProject();
321
+
322
+ await useCase.run({
323
+ project,
324
+ issues: [issue, mergedPr],
325
+ evaluatedAt: FIXED_NOW,
326
+ });
327
+
328
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it('does not advance an issue with dependedIssueUrls set', async () => {
332
+ const issue = createMockIssue({
333
+ dependedIssueUrls: ['https://github.com/user/repo/issues/999'],
334
+ });
335
+ const mergedPr = createMergedPr(issue.url);
336
+ const project = createMockProject();
337
+
338
+ await useCase.run({
339
+ project,
340
+ issues: [issue, mergedPr],
341
+ evaluatedAt: FIXED_NOW,
342
+ });
343
+
344
+ expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
345
+ });
346
+
347
+ it('advances the remaining items when one updateStatus call fails', async () => {
348
+ const issue1 = createMockIssue({
349
+ number: 1,
350
+ url: 'https://github.com/user/repo/issues/1',
351
+ });
352
+ const issue2 = createMockIssue({
353
+ number: 2,
354
+ url: 'https://github.com/user/repo/issues/2',
355
+ });
356
+ const mergedPr1 = createMergedPr(issue1.url, {
357
+ number: 101,
358
+ url: 'https://github.com/user/repo/pull/101',
359
+ itemId: 'pr-item-101',
360
+ });
361
+ const mergedPr2 = createMergedPr(issue2.url, {
362
+ number: 102,
363
+ url: 'https://github.com/user/repo/pull/102',
364
+ itemId: 'pr-item-102',
365
+ });
366
+ const project = createMockProject();
367
+ const updateError = new Error('updateStatus failed for issue1');
368
+ mockIssueRepository.updateStatus
369
+ .mockRejectedValueOnce(updateError)
370
+ .mockResolvedValueOnce(undefined);
371
+
372
+ await expect(
373
+ useCase.run({
374
+ project,
375
+ issues: [issue1, issue2, mergedPr1, mergedPr2],
376
+ evaluatedAt: FIXED_NOW,
377
+ }),
378
+ ).rejects.toBeInstanceOf(AggregateError);
379
+
380
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledTimes(2);
381
+ expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
382
+ project,
383
+ issue2,
384
+ 'done-id',
385
+ );
386
+ });
387
+ });