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.
- package/CHANGELOG.md +14 -0
- package/README.md +2 -0
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js +3 -1
- package/bin/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.js.map +1 -1
- package/bin/domain/usecases/HandleScheduledEventUseCase.js +17 -3
- package/bin/domain/usecases/HandleScheduledEventUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +2 -26
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/QualityCheckAdvanceUseCase.js +50 -0
- package/bin/domain/usecases/QualityCheckAdvanceUseCase.js.map +1 -0
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js +19 -0
- package/bin/domain/usecases/RevertOrphanedPreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/extractNextStepAgent.js +30 -0
- package/bin/domain/usecases/extractNextStepAgent.js.map +1 -0
- package/package.json +1 -1
- package/src/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.ts +6 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.test.ts +116 -0
- package/src/domain/usecases/HandleScheduledEventUseCase.ts +21 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +2 -29
- package/src/domain/usecases/QualityCheckAdvanceUseCase.test.ts +387 -0
- package/src/domain/usecases/QualityCheckAdvanceUseCase.ts +75 -0
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.test.ts +111 -1
- package/src/domain/usecases/RevertOrphanedPreparationUseCase.ts +50 -1
- package/src/domain/usecases/extractNextStepAgent.ts +27 -0
- package/types/adapter/entry-points/handlers/HandleScheduledEventUseCaseHandler.d.ts.map +1 -1
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts +5 -2
- package/types/domain/usecases/HandleScheduledEventUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts +0 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/QualityCheckAdvanceUseCase.d.ts +14 -0
- package/types/domain/usecases/QualityCheckAdvanceUseCase.d.ts.map +1 -0
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts +4 -3
- package/types/domain/usecases/RevertOrphanedPreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/extractNextStepAgent.d.ts +2 -0
- 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
|
-
?
|
|
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
|
+
});
|