github-issue-tower-defence-management 1.148.6 → 1.148.8
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/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js +5 -1
- package/bin/domain/usecases/CheckIssueReviewReadinessUseCase.js.map +1 -1
- package/bin/domain/usecases/DailySecurityScanUseCase.js +65 -41
- package/bin/domain/usecases/DailySecurityScanUseCase.js.map +1 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js +8 -1
- package/bin/domain/usecases/NotifyFinishedIssuePreparationUseCase.js.map +1 -1
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js +6 -0
- package/bin/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.js.map +1 -1
- package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js +31 -0
- package/bin/domain/usecases/isPullRequestDeclaredUnnecessary.js.map +1 -0
- package/package.json +1 -1
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.test.ts +43 -0
- package/src/domain/usecases/CheckIssueReviewReadinessUseCase.ts +11 -1
- package/src/domain/usecases/DailySecurityScanUseCase.test.ts +299 -237
- package/src/domain/usecases/DailySecurityScanUseCase.ts +106 -60
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.test.ts +79 -0
- package/src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts +13 -1
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.test.ts +81 -0
- package/src/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.ts +13 -1
- package/src/domain/usecases/isPullRequestDeclaredUnnecessary.test.ts +114 -0
- package/src/domain/usecases/isPullRequestDeclaredUnnecessary.ts +34 -0
- package/types/domain/usecases/CheckIssueReviewReadinessUseCase.d.ts.map +1 -1
- package/types/domain/usecases/DailySecurityScanUseCase.d.ts +0 -1
- package/types/domain/usecases/DailySecurityScanUseCase.d.ts.map +1 -1
- package/types/domain/usecases/NotifyFinishedIssuePreparationUseCase.d.ts.map +1 -1
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts +1 -1
- package/types/domain/usecases/RevertNotReadyReviewQueueIssueUseCase.d.ts.map +1 -1
- package/types/domain/usecases/isPullRequestDeclaredUnnecessary.d.ts +5 -0
- package/types/domain/usecases/isPullRequestDeclaredUnnecessary.d.ts.map +1 -0
|
@@ -47,6 +47,78 @@ const isKevCatalog = (value: unknown): value is KevCatalog => {
|
|
|
47
47
|
);
|
|
48
48
|
};
|
|
49
49
|
|
|
50
|
+
type ScannedVulnerablePackage = {
|
|
51
|
+
repositoryName: string;
|
|
52
|
+
ecosystem: string;
|
|
53
|
+
packageName: string;
|
|
54
|
+
packageVersion: string;
|
|
55
|
+
vulnerabilityId: string;
|
|
56
|
+
vulnerabilityIdentifiers: string[];
|
|
57
|
+
summary: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const parseScannerVulnerabilities = (
|
|
61
|
+
repositoryName: string,
|
|
62
|
+
scannerOutput: string,
|
|
63
|
+
): ScannedVulnerablePackage[] => {
|
|
64
|
+
let parsed: unknown;
|
|
65
|
+
try {
|
|
66
|
+
parsed = JSON.parse(scannerOutput);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error(
|
|
69
|
+
`Unparsable osv-scanner output for ${repositoryName}: ${String(error)}`,
|
|
70
|
+
);
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
const toRecord = (value: unknown): Record<string, unknown> =>
|
|
74
|
+
typeof value === 'object' && value !== null ? { ...value } : {};
|
|
75
|
+
const readArray = (value: unknown, key: string): unknown[] => {
|
|
76
|
+
const entry = toRecord(value)[key];
|
|
77
|
+
return Array.isArray(entry) ? entry : [];
|
|
78
|
+
};
|
|
79
|
+
const readString = (value: unknown, key: string): string => {
|
|
80
|
+
const entry = toRecord(value)[key];
|
|
81
|
+
return typeof entry === 'string' ? entry : '';
|
|
82
|
+
};
|
|
83
|
+
return readArray(parsed, 'results').flatMap((result) =>
|
|
84
|
+
readArray(result, 'packages').flatMap((scannedPackage) => {
|
|
85
|
+
const packageDetail = toRecord(scannedPackage).package;
|
|
86
|
+
return readArray(scannedPackage, 'vulnerabilities').map(
|
|
87
|
+
(vulnerability) => {
|
|
88
|
+
const vulnerabilityId = readString(vulnerability, 'id');
|
|
89
|
+
const aliases = readArray(vulnerability, 'aliases').filter(
|
|
90
|
+
(alias): alias is string => typeof alias === 'string',
|
|
91
|
+
);
|
|
92
|
+
return {
|
|
93
|
+
repositoryName,
|
|
94
|
+
ecosystem: readString(packageDetail, 'ecosystem'),
|
|
95
|
+
packageName: readString(packageDetail, 'name'),
|
|
96
|
+
packageVersion: readString(packageDetail, 'version'),
|
|
97
|
+
vulnerabilityId,
|
|
98
|
+
vulnerabilityIdentifiers: [vulnerabilityId, ...aliases],
|
|
99
|
+
summary: readString(vulnerability, 'summary'),
|
|
100
|
+
};
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const renderScannerFindings = (
|
|
108
|
+
today: string,
|
|
109
|
+
vulnerablePackages: ScannedVulnerablePackage[],
|
|
110
|
+
): string =>
|
|
111
|
+
[
|
|
112
|
+
'## OSV-Scanner findings',
|
|
113
|
+
'',
|
|
114
|
+
`### ${today}`,
|
|
115
|
+
'',
|
|
116
|
+
...vulnerablePackages.map(
|
|
117
|
+
(vulnerablePackage) =>
|
|
118
|
+
`- ${vulnerablePackage.ecosystem} ${vulnerablePackage.packageName} ${vulnerablePackage.packageVersion} ${vulnerablePackage.vulnerabilityIdentifiers.join(' ')} ${vulnerablePackage.summary}`,
|
|
119
|
+
),
|
|
120
|
+
].join('\n');
|
|
121
|
+
|
|
50
122
|
const KEV_CATALOG_URL =
|
|
51
123
|
'https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json';
|
|
52
124
|
|
|
@@ -78,7 +150,7 @@ export class DailySecurityScanUseCase {
|
|
|
78
150
|
const lastTargetDate = input.targetDates[input.targetDates.length - 1];
|
|
79
151
|
const today = lastTargetDate.toISOString().slice(0, 10);
|
|
80
152
|
|
|
81
|
-
await this.scanRepositories(
|
|
153
|
+
const scannedVulnerablePackages = await this.scanRepositories(
|
|
82
154
|
input.org,
|
|
83
155
|
input.manager,
|
|
84
156
|
today,
|
|
@@ -90,6 +162,7 @@ export class DailySecurityScanUseCase {
|
|
|
90
162
|
input.manager,
|
|
91
163
|
lastTargetDate,
|
|
92
164
|
input.dailySecurityScan,
|
|
165
|
+
scannedVulnerablePackages,
|
|
93
166
|
);
|
|
94
167
|
};
|
|
95
168
|
|
|
@@ -98,7 +171,7 @@ export class DailySecurityScanUseCase {
|
|
|
98
171
|
manager: Member['name'],
|
|
99
172
|
today: string,
|
|
100
173
|
config: DailySecurityScanConfig,
|
|
101
|
-
): Promise<
|
|
174
|
+
): Promise<ScannedVulnerablePackage[]> => {
|
|
102
175
|
const { stdout: findOutput } = await this.localCommandRunner.runCommand(
|
|
103
176
|
'find',
|
|
104
177
|
[
|
|
@@ -121,9 +194,10 @@ export class DailySecurityScanUseCase {
|
|
|
121
194
|
console.error(
|
|
122
195
|
`No repositories found in scan base directory: ${config.scanBaseDirectory}`,
|
|
123
196
|
);
|
|
124
|
-
return;
|
|
197
|
+
return [];
|
|
125
198
|
}
|
|
126
199
|
|
|
200
|
+
const scannedVulnerablePackages: ScannedVulnerablePackage[] = [];
|
|
127
201
|
for (const repositoryDirectory of repositoryDirectories) {
|
|
128
202
|
const { stdout: remoteUrl, exitCode: remoteExitCode } =
|
|
129
203
|
await this.localCommandRunner.runCommand('git', [
|
|
@@ -155,6 +229,8 @@ export class DailySecurityScanUseCase {
|
|
|
155
229
|
'source',
|
|
156
230
|
'-r',
|
|
157
231
|
repositoryDirectory,
|
|
232
|
+
'--format',
|
|
233
|
+
'json',
|
|
158
234
|
]);
|
|
159
235
|
if (scanExitCode === 0) {
|
|
160
236
|
continue;
|
|
@@ -166,7 +242,13 @@ export class DailySecurityScanUseCase {
|
|
|
166
242
|
continue;
|
|
167
243
|
}
|
|
168
244
|
|
|
169
|
-
const
|
|
245
|
+
const vulnerablePackages = parseScannerVulnerabilities(
|
|
246
|
+
repositoryName,
|
|
247
|
+
scanOutput,
|
|
248
|
+
);
|
|
249
|
+
scannedVulnerablePackages.push(...vulnerablePackages);
|
|
250
|
+
|
|
251
|
+
const findingsBody = renderScannerFindings(today, vulnerablePackages);
|
|
170
252
|
const existingIssues = await this.issueRepository.searchIssue({
|
|
171
253
|
owner: repositoryOrg,
|
|
172
254
|
repositoryName,
|
|
@@ -193,6 +275,7 @@ export class DailySecurityScanUseCase {
|
|
|
193
275
|
);
|
|
194
276
|
}
|
|
195
277
|
}
|
|
278
|
+
return scannedVulnerablePackages;
|
|
196
279
|
};
|
|
197
280
|
|
|
198
281
|
private reportKevAdditions = async (
|
|
@@ -200,6 +283,7 @@ export class DailySecurityScanUseCase {
|
|
|
200
283
|
manager: Member['name'],
|
|
201
284
|
lastTargetDate: Date,
|
|
202
285
|
config: DailySecurityScanConfig,
|
|
286
|
+
scannedVulnerablePackages: ScannedVulnerablePackage[],
|
|
203
287
|
): Promise<void> => {
|
|
204
288
|
if (!config.enableKevNvdReport || !config.kevReportRepo) {
|
|
205
289
|
return;
|
|
@@ -220,18 +304,15 @@ export class DailySecurityScanUseCase {
|
|
|
220
304
|
const newKevEntries = parsedKev.vulnerabilities.filter(
|
|
221
305
|
(vulnerability) => vulnerability.dateAdded >= yesterdayYmd,
|
|
222
306
|
);
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
)
|
|
231
|
-
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
if (usedKevEntries.length === 0) {
|
|
307
|
+
const affectingKevEntries = newKevEntries
|
|
308
|
+
.map((vulnerability) => ({
|
|
309
|
+
vulnerability,
|
|
310
|
+
affectedPackages: scannedVulnerablePackages.filter((scannedPackage) =>
|
|
311
|
+
scannedPackage.vulnerabilityIdentifiers.includes(vulnerability.cveID),
|
|
312
|
+
),
|
|
313
|
+
}))
|
|
314
|
+
.filter((entry) => entry.affectedPackages.length > 0);
|
|
315
|
+
if (affectingKevEntries.length === 0) {
|
|
235
316
|
return;
|
|
236
317
|
}
|
|
237
318
|
|
|
@@ -239,54 +320,19 @@ export class DailySecurityScanUseCase {
|
|
|
239
320
|
org,
|
|
240
321
|
config.kevReportRepo,
|
|
241
322
|
`CISA KEV new additions since ${yesterdayYmd}`,
|
|
242
|
-
|
|
243
|
-
.map(
|
|
244
|
-
|
|
245
|
-
`- ${vulnerability.dateAdded} ${vulnerability.cveID} ${vulnerability.vulnerabilityName}`,
|
|
323
|
+
affectingKevEntries
|
|
324
|
+
.map((entry) =>
|
|
325
|
+
[
|
|
326
|
+
`- ${entry.vulnerability.dateAdded} ${entry.vulnerability.cveID} ${entry.vulnerability.vulnerabilityName}`,
|
|
327
|
+
...entry.affectedPackages.map(
|
|
328
|
+
(affectedPackage) =>
|
|
329
|
+
` - ${affectedPackage.repositoryName} ${affectedPackage.ecosystem} ${affectedPackage.packageName} ${affectedPackage.packageVersion}`,
|
|
330
|
+
),
|
|
331
|
+
].join('\n'),
|
|
246
332
|
)
|
|
247
333
|
.join('\n'),
|
|
248
334
|
[manager],
|
|
249
335
|
[],
|
|
250
336
|
);
|
|
251
337
|
};
|
|
252
|
-
|
|
253
|
-
private isProductPresentInScannedWorkspace = async (
|
|
254
|
-
scanBaseDirectory: string,
|
|
255
|
-
product: string,
|
|
256
|
-
): Promise<boolean> => {
|
|
257
|
-
const { stdout: findOutput } = await this.localCommandRunner.runCommand(
|
|
258
|
-
'find',
|
|
259
|
-
[scanBaseDirectory, '-maxdepth', '3', '-name', '.git', '-type', 'd'],
|
|
260
|
-
);
|
|
261
|
-
const repositoryDirectories = findOutput
|
|
262
|
-
.split('\n')
|
|
263
|
-
.filter((line) => line.length > 0)
|
|
264
|
-
.map((gitDirectory) => gitDirectory.replace(/\/\.git$/, ''));
|
|
265
|
-
|
|
266
|
-
for (const repositoryDirectory of repositoryDirectories) {
|
|
267
|
-
const { stderr, exitCode } = await this.localCommandRunner.runCommand(
|
|
268
|
-
'git',
|
|
269
|
-
[
|
|
270
|
-
'-C',
|
|
271
|
-
repositoryDirectory,
|
|
272
|
-
'grep',
|
|
273
|
-
'-I',
|
|
274
|
-
'-i',
|
|
275
|
-
'-q',
|
|
276
|
-
'-F',
|
|
277
|
-
'-e',
|
|
278
|
-
product,
|
|
279
|
-
],
|
|
280
|
-
);
|
|
281
|
-
if (exitCode === 0) {
|
|
282
|
-
return true;
|
|
283
|
-
}
|
|
284
|
-
if (exitCode !== 1) {
|
|
285
|
-
console.error(
|
|
286
|
-
`Failed to search ${repositoryDirectory} for ${product}: ${stderr}`,
|
|
287
|
-
);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return false;
|
|
291
|
-
};
|
|
292
338
|
}
|
|
@@ -1087,6 +1087,85 @@ describe('NotifyFinishedIssuePreparationUseCase', () => {
|
|
|
1087
1087
|
);
|
|
1088
1088
|
});
|
|
1089
1089
|
|
|
1090
|
+
it('should not reject a missing PR when the last report declares pullRequestRequired as false', async () => {
|
|
1091
|
+
const issue = createMockIssue({
|
|
1092
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1093
|
+
status: 'Preparation',
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1097
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
1098
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
1099
|
+
createMockComment({
|
|
1100
|
+
content:
|
|
1101
|
+
'From: :robot: Test report\n```json\n{"pullRequestRequired": false}\n```',
|
|
1102
|
+
}),
|
|
1103
|
+
]);
|
|
1104
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([]);
|
|
1105
|
+
|
|
1106
|
+
await useCase.run({
|
|
1107
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
1108
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
1109
|
+
thresholdForAutoReject: 3,
|
|
1110
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
1111
|
+
allowedIssueAuthors: null,
|
|
1112
|
+
});
|
|
1113
|
+
|
|
1114
|
+
expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalledWith(
|
|
1115
|
+
expect.anything(),
|
|
1116
|
+
expect.stringContaining('PULL_REQUEST_NOT_FOUND'),
|
|
1117
|
+
);
|
|
1118
|
+
expect(mockIssueRepository.update).toHaveBeenCalledWith(
|
|
1119
|
+
expect.objectContaining({
|
|
1120
|
+
status: 'Awaiting Quality Check',
|
|
1121
|
+
}),
|
|
1122
|
+
mockProject,
|
|
1123
|
+
);
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
it('should still reject a draft PR when the last report declares pullRequestRequired as false', async () => {
|
|
1127
|
+
const issue = createMockIssue({
|
|
1128
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1129
|
+
status: 'Preparation',
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
mockProjectRepository.getByUrl.mockResolvedValue(mockProject);
|
|
1133
|
+
mockIssueRepository.get.mockResolvedValue(issue);
|
|
1134
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
1135
|
+
createMockComment({
|
|
1136
|
+
content:
|
|
1137
|
+
'From: :robot: Test report\n```json\n{"pullRequestRequired": false}\n```',
|
|
1138
|
+
}),
|
|
1139
|
+
]);
|
|
1140
|
+
mockIssueRepository.findRelatedOpenPRs.mockResolvedValue([
|
|
1141
|
+
{
|
|
1142
|
+
url: 'https://github.com/user/repo/pull/1',
|
|
1143
|
+
isDraft: true,
|
|
1144
|
+
isConflicted: false,
|
|
1145
|
+
isPassedAllCiJob: true,
|
|
1146
|
+
isCiStateSuccess: true,
|
|
1147
|
+
isResolvedAllReviewComments: true,
|
|
1148
|
+
isBranchOutOfDate: false,
|
|
1149
|
+
missingRequiredCheckNames: [],
|
|
1150
|
+
},
|
|
1151
|
+
]);
|
|
1152
|
+
|
|
1153
|
+
await useCase.run({
|
|
1154
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
1155
|
+
issueUrl: 'https://github.com/user/repo/issues/1',
|
|
1156
|
+
thresholdForAutoReject: 3,
|
|
1157
|
+
workflowBlockerResolvedWebhookUrl: null,
|
|
1158
|
+
allowedIssueAuthors: null,
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
1162
|
+
expect.objectContaining({
|
|
1163
|
+
url: 'https://github.com/user/repo/issues/1',
|
|
1164
|
+
}),
|
|
1165
|
+
expect.stringContaining('PULL_REQUEST_IS_DRAFT'),
|
|
1166
|
+
);
|
|
1167
|
+
});
|
|
1168
|
+
|
|
1090
1169
|
it('should not reject a missing PR when the issue label is only in labelsNotRequiringPullRequest', async () => {
|
|
1091
1170
|
const issue = createMockIssue({
|
|
1092
1171
|
url: 'https://github.com/user/repo/issues/1',
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from './IssueRejectionEvaluator';
|
|
15
15
|
import { ChangeTargetPullRequestApprover } from './ChangeTargetPullRequestApprover';
|
|
16
16
|
import { resolveLabelsNotRequiringPullRequest } from './resolveLabelsNotRequiringPullRequest';
|
|
17
|
+
import { isPullRequestDeclaredUnnecessary } from './isPullRequestDeclaredUnnecessary';
|
|
17
18
|
|
|
18
19
|
export class IssueNotFoundError extends Error {
|
|
19
20
|
constructor(issueUrl: string) {
|
|
@@ -318,7 +319,18 @@ export class NotifyFinishedIssuePreparationUseCase {
|
|
|
318
319
|
issue,
|
|
319
320
|
labelsNotRequiringPullRequest,
|
|
320
321
|
);
|
|
321
|
-
|
|
322
|
+
const requiredPrRejections = isPullRequestDeclaredUnnecessary(
|
|
323
|
+
comments,
|
|
324
|
+
isTrustedAuthor,
|
|
325
|
+
)
|
|
326
|
+
? prRejections.filter(
|
|
327
|
+
(rejection) => rejection.type !== 'PULL_REQUEST_NOT_FOUND',
|
|
328
|
+
)
|
|
329
|
+
: prRejections;
|
|
330
|
+
return {
|
|
331
|
+
rejections: [...rejections, ...requiredPrRejections],
|
|
332
|
+
approvedPrUrl,
|
|
333
|
+
};
|
|
322
334
|
};
|
|
323
335
|
|
|
324
336
|
private reportBodyHasNextStep = (body: string): boolean => {
|
|
@@ -166,6 +166,7 @@ describe('RevertNotReadyReviewQueueIssueUseCase', () => {
|
|
|
166
166
|
};
|
|
167
167
|
let mockIssueCommentRepository: {
|
|
168
168
|
createComment: jest.Mock;
|
|
169
|
+
getCommentsFromIssue: jest.Mock;
|
|
169
170
|
};
|
|
170
171
|
let mockProject: Project;
|
|
171
172
|
let useCase: RevertNotReadyReviewQueueIssueUseCase;
|
|
@@ -197,6 +198,7 @@ describe('RevertNotReadyReviewQueueIssueUseCase', () => {
|
|
|
197
198
|
|
|
198
199
|
mockIssueCommentRepository = {
|
|
199
200
|
createComment: jest.fn().mockResolvedValue(undefined),
|
|
201
|
+
getCommentsFromIssue: jest.fn().mockResolvedValue([]),
|
|
200
202
|
};
|
|
201
203
|
|
|
202
204
|
useCase = new RevertNotReadyReviewQueueIssueUseCase(
|
|
@@ -279,6 +281,85 @@ describe('RevertNotReadyReviewQueueIssueUseCase', () => {
|
|
|
279
281
|
);
|
|
280
282
|
});
|
|
281
283
|
|
|
284
|
+
it('should not revert an issue whose last agent report declares pullRequestRequired as false', async () => {
|
|
285
|
+
const issue = createMockIssue({
|
|
286
|
+
status: 'Awaiting Quality Check',
|
|
287
|
+
});
|
|
288
|
+
mockIssueRepository.getAllIssues.mockResolvedValue({
|
|
289
|
+
project: mockProject,
|
|
290
|
+
issues: [issue],
|
|
291
|
+
cacheUsed: false,
|
|
292
|
+
});
|
|
293
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
294
|
+
{
|
|
295
|
+
author: 'owner',
|
|
296
|
+
content:
|
|
297
|
+
'From: :robot: Agent report\n```json\n{"pullRequestRequired": false}\n```',
|
|
298
|
+
createdAt: new Date(),
|
|
299
|
+
},
|
|
300
|
+
]);
|
|
301
|
+
|
|
302
|
+
await useCase.run({
|
|
303
|
+
manager: 'manager-user',
|
|
304
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
305
|
+
allowedIssueAuthors: ['owner'],
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
|
|
309
|
+
expect(mockIssueCommentRepository.createComment).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('should still revert an issue whose linked PR is conflicted even when the last agent report declares pullRequestRequired as false', async () => {
|
|
313
|
+
const issue = createMockIssue({
|
|
314
|
+
status: 'Awaiting Quality Check',
|
|
315
|
+
});
|
|
316
|
+
linkRelatedOpenPrsToIssue(mockIssueRepository, issue, [
|
|
317
|
+
{ ...createReadyPr(), isConflicted: true },
|
|
318
|
+
]);
|
|
319
|
+
mockIssueCommentRepository.getCommentsFromIssue.mockResolvedValue([
|
|
320
|
+
{
|
|
321
|
+
author: 'owner',
|
|
322
|
+
content:
|
|
323
|
+
'From: :robot: Agent report\n```json\n{"pullRequestRequired": false}\n```',
|
|
324
|
+
createdAt: new Date(),
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
await useCase.run({
|
|
329
|
+
manager: 'manager-user',
|
|
330
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
331
|
+
allowedIssueAuthors: ['owner'],
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(mockIssueRepository.updateStatus).toHaveBeenCalledWith(
|
|
335
|
+
mockProject,
|
|
336
|
+
issue,
|
|
337
|
+
'awaiting-workspace-id',
|
|
338
|
+
);
|
|
339
|
+
expect(mockIssueCommentRepository.createComment).toHaveBeenCalledWith(
|
|
340
|
+
issue,
|
|
341
|
+
expect.stringContaining('PULL_REQUEST_CONFLICTED'),
|
|
342
|
+
);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should not read the comments of an issue whose linked PR is ready', async () => {
|
|
346
|
+
const issue = createMockIssue({
|
|
347
|
+
status: 'Awaiting Quality Check',
|
|
348
|
+
});
|
|
349
|
+
linkRelatedOpenPrsToIssue(mockIssueRepository, issue, [createReadyPr()]);
|
|
350
|
+
|
|
351
|
+
await useCase.run({
|
|
352
|
+
manager: 'manager-user',
|
|
353
|
+
projectUrl: 'https://github.com/users/user/projects/1',
|
|
354
|
+
allowedIssueAuthors: ['owner'],
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
expect(
|
|
358
|
+
mockIssueCommentRepository.getCommentsFromIssue,
|
|
359
|
+
).not.toHaveBeenCalled();
|
|
360
|
+
expect(mockIssueRepository.updateStatus).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
|
|
282
363
|
it('should not revert a story-labeled issue with no linked PR when story is in labelsAsLlmAgentName', async () => {
|
|
283
364
|
const issue = createMockIssue({
|
|
284
365
|
status: 'Awaiting Quality Check',
|
|
@@ -5,6 +5,7 @@ import { IssueCommentRepository } from './adapter-interfaces/IssueCommentReposit
|
|
|
5
5
|
import { IssueRejectionEvaluator } from './IssueRejectionEvaluator';
|
|
6
6
|
import { ChangeTargetPullRequestApprover } from './ChangeTargetPullRequestApprover';
|
|
7
7
|
import { resolveLabelsNotRequiringPullRequest } from './resolveLabelsNotRequiringPullRequest';
|
|
8
|
+
import { isPullRequestDeclaredUnnecessary } from './isPullRequestDeclaredUnnecessary';
|
|
8
9
|
import {
|
|
9
10
|
AWAITING_QUALITY_CHECK_STATUS_NAME,
|
|
10
11
|
AWAITING_WORKSPACE_STATUS_NAME,
|
|
@@ -59,7 +60,7 @@ export class RevertNotReadyReviewQueueIssueUseCase {
|
|
|
59
60
|
>,
|
|
60
61
|
private readonly issueCommentRepository: Pick<
|
|
61
62
|
IssueCommentRepository,
|
|
62
|
-
'createComment'
|
|
63
|
+
'createComment' | 'getCommentsFromIssue'
|
|
63
64
|
>,
|
|
64
65
|
) {
|
|
65
66
|
this.issueRejectionEvaluator = new IssueRejectionEvaluator(issueRepository);
|
|
@@ -130,6 +131,17 @@ export class RevertNotReadyReviewQueueIssueUseCase {
|
|
|
130
131
|
relatedOpenPrUrlsByIssueUrl.get(issue.url) ?? null,
|
|
131
132
|
},
|
|
132
133
|
);
|
|
134
|
+
if (
|
|
135
|
+
rejections.length === 1 &&
|
|
136
|
+
rejections[0].type === 'PULL_REQUEST_NOT_FOUND' &&
|
|
137
|
+
isPullRequestDeclaredUnnecessary(
|
|
138
|
+
await this.issueCommentRepository.getCommentsFromIssue(issue),
|
|
139
|
+
(author) =>
|
|
140
|
+
isAuthorAuthorizedForAutoStatusCheck(author, allowedIssueAuthors),
|
|
141
|
+
)
|
|
142
|
+
) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
133
145
|
if (rejections.length > 0) {
|
|
134
146
|
if (!issue.assignees.includes(params.manager)) {
|
|
135
147
|
continue;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { isPullRequestDeclaredUnnecessary } from './isPullRequestDeclaredUnnecessary';
|
|
2
|
+
|
|
3
|
+
const trustEveryAuthor = (): boolean => true;
|
|
4
|
+
|
|
5
|
+
const reportComment = (
|
|
6
|
+
reportJson: string,
|
|
7
|
+
author = 'agent-bot',
|
|
8
|
+
): { author: string; content: string } => ({
|
|
9
|
+
author,
|
|
10
|
+
content: `From: :robot: agent report\n\`\`\`json\n${reportJson}\n\`\`\``,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('isPullRequestDeclaredUnnecessary', () => {
|
|
14
|
+
it('returns true when the last report declares pullRequestRequired as false', () => {
|
|
15
|
+
expect(
|
|
16
|
+
isPullRequestDeclaredUnnecessary(
|
|
17
|
+
[reportComment('{"pullRequestRequired": false}')],
|
|
18
|
+
trustEveryAuthor,
|
|
19
|
+
),
|
|
20
|
+
).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('returns false when the last report declares pullRequestRequired as true', () => {
|
|
24
|
+
expect(
|
|
25
|
+
isPullRequestDeclaredUnnecessary(
|
|
26
|
+
[reportComment('{"pullRequestRequired": true}')],
|
|
27
|
+
trustEveryAuthor,
|
|
28
|
+
),
|
|
29
|
+
).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('returns false when the report carries no pullRequestRequired field', () => {
|
|
33
|
+
expect(
|
|
34
|
+
isPullRequestDeclaredUnnecessary(
|
|
35
|
+
[reportComment('{"nextStep": null}')],
|
|
36
|
+
trustEveryAuthor,
|
|
37
|
+
),
|
|
38
|
+
).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns false when pullRequestRequired is the string false rather than the boolean', () => {
|
|
42
|
+
expect(
|
|
43
|
+
isPullRequestDeclaredUnnecessary(
|
|
44
|
+
[reportComment('{"pullRequestRequired": "false"}')],
|
|
45
|
+
trustEveryAuthor,
|
|
46
|
+
),
|
|
47
|
+
).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('returns false when the author of the last comment is not trusted', () => {
|
|
51
|
+
expect(
|
|
52
|
+
isPullRequestDeclaredUnnecessary(
|
|
53
|
+
[reportComment('{"pullRequestRequired": false}', 'stranger')],
|
|
54
|
+
(author) => author === 'agent-bot',
|
|
55
|
+
),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('returns false when the last comment is not an agent report', () => {
|
|
60
|
+
expect(
|
|
61
|
+
isPullRequestDeclaredUnnecessary(
|
|
62
|
+
[
|
|
63
|
+
reportComment('{"pullRequestRequired": false}'),
|
|
64
|
+
{
|
|
65
|
+
author: 'agent-bot',
|
|
66
|
+
content:
|
|
67
|
+
'A later human comment\n```json\n{"pullRequestRequired": false}\n```',
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
trustEveryAuthor,
|
|
71
|
+
),
|
|
72
|
+
).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reads only the last comment and ignores an earlier declaration', () => {
|
|
76
|
+
expect(
|
|
77
|
+
isPullRequestDeclaredUnnecessary(
|
|
78
|
+
[
|
|
79
|
+
reportComment('{"pullRequestRequired": false}'),
|
|
80
|
+
reportComment('{"pullRequestRequired": true}'),
|
|
81
|
+
],
|
|
82
|
+
trustEveryAuthor,
|
|
83
|
+
),
|
|
84
|
+
).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns false when there is no comment at all', () => {
|
|
88
|
+
expect(isPullRequestDeclaredUnnecessary([], trustEveryAuthor)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns false when the JSON block cannot be parsed', () => {
|
|
92
|
+
const consoleWarn = jest
|
|
93
|
+
.spyOn(console, 'warn')
|
|
94
|
+
.mockImplementation(() => {});
|
|
95
|
+
|
|
96
|
+
expect(
|
|
97
|
+
isPullRequestDeclaredUnnecessary(
|
|
98
|
+
[reportComment('{"pullRequestRequired": false')],
|
|
99
|
+
trustEveryAuthor,
|
|
100
|
+
),
|
|
101
|
+
).toBe(false);
|
|
102
|
+
|
|
103
|
+
consoleWarn.mockRestore();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('returns false when the report carries no JSON block', () => {
|
|
107
|
+
expect(
|
|
108
|
+
isPullRequestDeclaredUnnecessary(
|
|
109
|
+
[{ author: 'agent-bot', content: 'From: :robot: agent report' }],
|
|
110
|
+
trustEveryAuthor,
|
|
111
|
+
),
|
|
112
|
+
).toBe(false);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const AGENT_REPORT_PREFIX = 'From: :robot:';
|
|
2
|
+
|
|
3
|
+
export const isPullRequestDeclaredUnnecessary = (
|
|
4
|
+
comments: { author: string; content: string }[],
|
|
5
|
+
isTrustedAuthor: (author: string) => boolean,
|
|
6
|
+
): boolean => {
|
|
7
|
+
const lastComment = comments[comments.length - 1];
|
|
8
|
+
if (
|
|
9
|
+
!lastComment ||
|
|
10
|
+
!isTrustedAuthor(lastComment.author) ||
|
|
11
|
+
!lastComment.content.startsWith(AGENT_REPORT_PREFIX)
|
|
12
|
+
) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
const reportMatch = lastComment.content.match(/```json\n([\s\S]*?)\n```/);
|
|
16
|
+
if (!reportMatch || reportMatch.length < 2) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
let reportJson: unknown;
|
|
20
|
+
try {
|
|
21
|
+
reportJson = JSON.parse(reportMatch[1]);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
console.warn(
|
|
24
|
+
'Invalid JSON in report body while checking pullRequestRequired:',
|
|
25
|
+
error,
|
|
26
|
+
);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (typeof reportJson !== 'object' || reportJson === null) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const report: Record<string, unknown> = { ...reportJson };
|
|
33
|
+
return report.pullRequestRequired === false;
|
|
34
|
+
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"CheckIssueReviewReadinessUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/CheckIssueReviewReadinessUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EAEL,oBAAoB,EACrB,MAAM,2BAA2B,CAAC;
|
|
1
|
+
{"version":3,"file":"CheckIssueReviewReadinessUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/CheckIssueReviewReadinessUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EAEL,oBAAoB,EACrB,MAAM,2BAA2B,CAAC;AAInC,KAAK,kBAAkB,GACnB,iBAAiB,GACjB,0BAA0B,GAC1B,sBAAsB,GACtB,oBAAoB,CAAC;AAEzB,MAAM,MAAM,0BAA0B,GAAG;IACvC,WAAW,EAAE,OAAO,CAAC;IACrB,UAAU,EAAE;QAAE,IAAI,EAAE,kBAAkB,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC;CAC5D,CAAC;AAEF,qBAAa,gCAAgC;IAIzC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAQhC,OAAO,CAAC,QAAQ,CAAC,sBAAsB;IAXzC,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAA0B;gBAG/C,eAAe,EAAE,IAAI,CACpC,eAAe,EACb,eAAe,GACf,oBAAoB,GACpB,oBAAoB,GACpB,gCAAgC,GAChC,iCAAiC,CACpC,EACgB,sBAAsB,EAAE,IAAI,CAC3C,sBAAsB,EACtB,sBAAsB,CACvB;IAKH,GAAG,GAAU,QAAQ;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QACtC,oBAAoB,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QACvC,6BAA6B,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;KACjD,KAAG,OAAO,CAAC,0BAA0B,CAAC,CA6DrC;IAEF,OAAO,CAAC,eAAe,CAIgD;IAEvE,OAAO,CAAC,qBAAqB,CAuB3B;CACH"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DailySecurityScanUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/DailySecurityScanUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,qCAAqC,CAAC;AACrE,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,MAAM,MAAM,uBAAuB,GAAG;IACpC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;
|
|
1
|
+
{"version":3,"file":"DailySecurityScanUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/DailySecurityScanUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,yCAAyC,CAAC;AAC7E,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,cAAc,EAAE,MAAM,qCAAqC,CAAC;AACrE,OAAO,EAAE,MAAM,EAAE,MAAM,oBAAoB,CAAC;AAE5C,MAAM,MAAM,uBAAuB,GAAG;IACpC,iBAAiB,EAAE,MAAM,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB,CAAC;AAkHF,qBAAa,wBAAwB;IAEjC,QAAQ,CAAC,kBAAkB,EAAE,kBAAkB;IAC/C,QAAQ,CAAC,eAAe,EAAE,IAAI,CAC5B,eAAe,EACf,gBAAgB,GAAG,aAAa,GAAG,oBAAoB,CACxD;IACD,QAAQ,CAAC,cAAc,EAAE,cAAc;gBAL9B,kBAAkB,EAAE,kBAAkB,EACtC,eAAe,EAAE,IAAI,CAC5B,eAAe,EACf,gBAAgB,GAAG,aAAa,GAAG,oBAAoB,CACxD,EACQ,cAAc,EAAE,cAAc;IAGzC,GAAG,GAAU,OAAO;QAClB,WAAW,EAAE,IAAI,EAAE,CAAC;QACpB,GAAG,EAAE,MAAM,CAAC;QACZ,OAAO,EAAE,MAAM,CAAC,MAAM,CAAC,CAAC;QACxB,iBAAiB,EAAE,uBAAuB,CAAC;KAC5C,KAAG,OAAO,CAAC,IAAI,CAAC,CA2Bf;IAEF,OAAO,CAAC,gBAAgB,CA8GtB;IAEF,OAAO,CAAC,kBAAkB,CAwDxB;CACH"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"NotifyFinishedIssuePreparationUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAC;AAC3E,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAC;
|
|
1
|
+
{"version":3,"file":"NotifyFinishedIssuePreparationUseCase.d.ts","sourceRoot":"","sources":["../../../src/domain/usecases/NotifyFinishedIssuePreparationUseCase.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,sCAAsC,CAAC;AACvE,OAAO,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAC;AAC3E,OAAO,EAAE,sBAAsB,EAAE,MAAM,6CAA6C,CAAC;AACrF,OAAO,EAAE,iBAAiB,EAAE,MAAM,wCAAwC,CAAC;AAe3E,qBAAa,kBAAmB,SAAQ,KAAK;gBAC/B,QAAQ,EAAE,MAAM;CAI7B;AACD,qBAAa,uBAAwB,SAAQ,KAAK;gBAE9C,QAAQ,EAAE,MAAM,EAChB,aAAa,EAAE,MAAM,GAAG,IAAI,EAC5B,cAAc,EAAE,MAAM,GAAG,IAAI;CAOhC;AAID,qBAAa,qCAAqC;IAK9C,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAClC,OAAO,CAAC,QAAQ,CAAC,eAAe;IAahC,OAAO,CAAC,QAAQ,CAAC,sBAAsB;IAIvC,OAAO,CAAC,QAAQ,CAAC,iBAAiB;IAtBpC,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAA0B;IAClE,OAAO,CAAC,QAAQ,CAAC,+BAA+B,CAAkC;gBAG/D,iBAAiB,EAAE,IAAI,CAAC,iBAAiB,EAAE,UAAU,CAAC,EACtD,eAAe,EAAE,IAAI,CACpC,eAAe,EACb,KAAK,GACL,QAAQ,GACR,cAAc,GACd,oBAAoB,GACpB,mBAAmB,GACnB,oBAAoB,GACpB,gCAAgC,GAChC,oBAAoB,GACpB,iCAAiC,GACjC,qBAAqB,CACxB,EACgB,sBAAsB,EAAE,IAAI,CAC3C,sBAAsB,EACtB,sBAAsB,GAAG,eAAe,CACzC,EACgB,iBAAiB,EAAE,IAAI,CACtC,iBAAiB,EACjB,gBAAgB,CACjB;IAQH,GAAG,GAAU,QAAQ;QACnB,UAAU,EAAE,MAAM,CAAC;QACnB,QAAQ,EAAE,MAAM,CAAC;QACjB,sBAAsB,EAAE,MAAM,CAAC;QAC/B,iCAAiC,EAAE,MAAM,GAAG,IAAI,CAAC;QACjD,mBAAmB,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QACtC,oBAAoB,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QACvC,6BAA6B,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAC;QAChD,uBAAuB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,IAAI,CAAC;KACzD,KAAG,OAAO,CAAC,IAAI,CAAC,CAsMf;IAEF,OAAO,CAAC,eAAe,CAIgD;IAEvE,OAAO,CAAC,iBAAiB,CA6CvB;IAEF,OAAO,CAAC,qBAAqB,CAuB3B;IAEF,OAAO,CAAC,gCAAgC,CAoBtC;IAEF,OAAO,CAAC,uBAAuB,CAQ7B;IAEF,OAAO,CAAC,+BAA+B,CAgCrC;CACH"}
|