edsger 0.4.7 → 0.4.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.
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
* Verifies that all PR review comments have been addressed and resolves them
|
|
4
4
|
* Uses GitHub GraphQL API to accurately detect unresolved review threads
|
|
5
5
|
*/
|
|
6
|
+
import { EdsgerConfig } from '../../types/index.js';
|
|
6
7
|
export interface CodeRefineVerificationOptions {
|
|
7
8
|
featureId: string;
|
|
8
9
|
mcpServerUrl: string;
|
|
9
10
|
mcpToken: string;
|
|
10
11
|
githubToken: string;
|
|
12
|
+
config: EdsgerConfig;
|
|
11
13
|
verbose?: boolean;
|
|
12
14
|
}
|
|
13
15
|
export interface CodeRefineVerificationData {
|
|
@@ -42,7 +44,7 @@ export interface CodeRefineVerificationResult {
|
|
|
42
44
|
data: CodeRefineVerificationData;
|
|
43
45
|
}
|
|
44
46
|
/**
|
|
45
|
-
* Verify and resolve PR review comments using GraphQL API
|
|
47
|
+
* Verify and resolve PR review comments using GraphQL API and LLM analysis
|
|
46
48
|
*/
|
|
47
49
|
export declare function verifyAndResolveComments(options: CodeRefineVerificationOptions): Promise<CodeRefineVerificationResult>;
|
|
48
50
|
export declare function checkCodeRefineVerificationRequirements(): Promise<boolean>;
|
|
@@ -4,9 +4,42 @@
|
|
|
4
4
|
* Uses GitHub GraphQL API to accurately detect unresolved review threads
|
|
5
5
|
*/
|
|
6
6
|
import { Octokit } from '@octokit/rest';
|
|
7
|
+
import { query } from '@anthropic-ai/claude-code';
|
|
7
8
|
import { logInfo, logError } from '../../utils/logger.js';
|
|
8
9
|
import { parsePullRequestUrl, fetchPRReviews, } from '../code-refine/context-fetcher.js';
|
|
9
10
|
import { getFeature } from '../../api/features/get-feature.js';
|
|
11
|
+
/**
|
|
12
|
+
* Fetch PR file changes (diff information)
|
|
13
|
+
*/
|
|
14
|
+
async function fetchPRFileChanges(octokit, owner, repo, prNumber, verbose) {
|
|
15
|
+
try {
|
|
16
|
+
if (verbose) {
|
|
17
|
+
logInfo('📂 Fetching PR file changes...');
|
|
18
|
+
}
|
|
19
|
+
const { data: files } = await octokit.pulls.listFiles({
|
|
20
|
+
owner,
|
|
21
|
+
repo,
|
|
22
|
+
pull_number: prNumber,
|
|
23
|
+
per_page: 100,
|
|
24
|
+
});
|
|
25
|
+
const fileChanges = files.map((file) => ({
|
|
26
|
+
filename: file.filename,
|
|
27
|
+
status: file.status,
|
|
28
|
+
additions: file.additions,
|
|
29
|
+
deletions: file.deletions,
|
|
30
|
+
changes: file.changes,
|
|
31
|
+
patch: file.patch,
|
|
32
|
+
}));
|
|
33
|
+
if (verbose) {
|
|
34
|
+
logInfo(`✅ Found ${fileChanges.length} changed files`);
|
|
35
|
+
}
|
|
36
|
+
return fileChanges;
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
logError(`Failed to fetch PR file changes: ${error}`);
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
10
43
|
/**
|
|
11
44
|
* Fetch unresolved review threads using GitHub GraphQL API
|
|
12
45
|
* This provides accurate resolution status unlike REST API
|
|
@@ -78,22 +111,152 @@ async function fetchUnresolvedReviewThreads(octokit, owner, repo, prNumber, verb
|
|
|
78
111
|
}
|
|
79
112
|
}
|
|
80
113
|
/**
|
|
81
|
-
* Analyze
|
|
82
|
-
*
|
|
114
|
+
* Analyze whether a review thread has been addressed by examining code changes
|
|
115
|
+
* Uses LLM to intelligently determine if the feedback was addressed
|
|
83
116
|
*/
|
|
84
|
-
function
|
|
117
|
+
async function analyzeThreadWithLLM(thread, fileChange, config, verbose) {
|
|
85
118
|
const firstComment = thread.comments.nodes[0];
|
|
86
119
|
if (!firstComment) {
|
|
87
|
-
return
|
|
120
|
+
return {
|
|
121
|
+
isAddressed: false,
|
|
122
|
+
reason: 'Comment thread exists but has no comments',
|
|
123
|
+
};
|
|
88
124
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
// If file was not changed at all, feedback definitely not addressed
|
|
126
|
+
if (!fileChange || !fileChange.patch) {
|
|
127
|
+
return {
|
|
128
|
+
isAddressed: false,
|
|
129
|
+
reason: `File ${firstComment.path} has not been modified in this PR`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// If file was deleted, thread should be resolved as obsolete
|
|
133
|
+
if (fileChange.status === 'removed') {
|
|
134
|
+
return {
|
|
135
|
+
isAddressed: true,
|
|
136
|
+
reason: 'File has been removed',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
if (verbose) {
|
|
141
|
+
logInfo(`🤖 Using LLM to analyze if comment in ${firstComment.path}:${firstComment.line} has been addressed...`);
|
|
142
|
+
}
|
|
143
|
+
const analysisPrompt = createThreadAnalysisPrompt(thread, fileChange, firstComment);
|
|
144
|
+
let lastResponse = '';
|
|
145
|
+
let analysisResult = null;
|
|
146
|
+
function* userMessage() {
|
|
147
|
+
yield {
|
|
148
|
+
type: 'user',
|
|
149
|
+
message: { role: 'user', content: analysisPrompt },
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
for await (const message of query({
|
|
153
|
+
prompt: userMessage(),
|
|
154
|
+
options: {
|
|
155
|
+
model: config.claude.model || 'sonnet',
|
|
156
|
+
maxTurns: 10,
|
|
157
|
+
permissionMode: 'bypassPermissions',
|
|
158
|
+
},
|
|
159
|
+
})) {
|
|
160
|
+
if (message.type === 'assistant' && message.message?.content) {
|
|
161
|
+
for (const content of message.message.content) {
|
|
162
|
+
if (content.type === 'text') {
|
|
163
|
+
lastResponse += content.text + '\n';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (message.type === 'result') {
|
|
168
|
+
if (message.subtype === 'success') {
|
|
169
|
+
const responseText = message.result || lastResponse;
|
|
170
|
+
// Try to extract JSON from response
|
|
171
|
+
const jsonMatch = responseText.match(/```json\s*\n([\s\S]*?)\n\s*```/);
|
|
172
|
+
if (jsonMatch) {
|
|
173
|
+
analysisResult = JSON.parse(jsonMatch[1]);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
// Try to parse directly
|
|
177
|
+
try {
|
|
178
|
+
analysisResult = JSON.parse(responseText);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Fallback: look for isAddressed boolean in text
|
|
182
|
+
const isAddressedMatch = /isAddressed["']?\s*:\s*(true|false)/i.exec(responseText);
|
|
183
|
+
if (isAddressedMatch) {
|
|
184
|
+
analysisResult = {
|
|
185
|
+
isAddressed: isAddressedMatch[1].toLowerCase() === 'true',
|
|
186
|
+
reason: responseText.split('\n').find((line) => line.trim()) ||
|
|
187
|
+
'Analysis completed',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (analysisResult) {
|
|
196
|
+
if (verbose) {
|
|
197
|
+
logInfo(` ${analysisResult.isAddressed ? '✅' : '❌'} ${analysisResult.reason}`);
|
|
198
|
+
}
|
|
199
|
+
return analysisResult;
|
|
200
|
+
}
|
|
201
|
+
// Fallback if LLM analysis failed
|
|
202
|
+
return {
|
|
203
|
+
isAddressed: false,
|
|
204
|
+
reason: 'Unable to analyze - please review manually',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
if (verbose) {
|
|
209
|
+
logError(`LLM analysis failed: ${error}`);
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
isAddressed: false,
|
|
213
|
+
reason: 'Analysis failed - please review manually',
|
|
214
|
+
};
|
|
95
215
|
}
|
|
96
|
-
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Create prompt for LLM to analyze if a comment thread has been addressed
|
|
219
|
+
*/
|
|
220
|
+
function createThreadAnalysisPrompt(thread, fileChange, firstComment) {
|
|
221
|
+
const allComments = thread.comments.nodes
|
|
222
|
+
.map((c, idx) => `Comment ${idx + 1} by @${c.author.login}:\n${c.body}`)
|
|
223
|
+
.join('\n\n');
|
|
224
|
+
return `You are analyzing whether a code review comment has been addressed by subsequent code changes.
|
|
225
|
+
|
|
226
|
+
**Review Thread Information:**
|
|
227
|
+
- File: ${firstComment.path}
|
|
228
|
+
- Line: ${firstComment.line || 'N/A'}
|
|
229
|
+
- Thread has ${thread.comments.totalCount} comment(s)
|
|
230
|
+
|
|
231
|
+
**Review Comments:**
|
|
232
|
+
${allComments}
|
|
233
|
+
|
|
234
|
+
**Code Changes in This File:**
|
|
235
|
+
\`\`\`diff
|
|
236
|
+
${fileChange.patch || '(No patch available)'}
|
|
237
|
+
\`\`\`
|
|
238
|
+
|
|
239
|
+
**Your Task:**
|
|
240
|
+
Analyze whether the code changes adequately address the feedback in the review comments. Consider:
|
|
241
|
+
1. Does the feedback request a specific code change?
|
|
242
|
+
2. Have those changes (or equivalent changes) been made?
|
|
243
|
+
3. If the feedback points to a specific line, have related areas of code been modified to address the concern?
|
|
244
|
+
|
|
245
|
+
**Important:** The comment may point to a specific line, but the fix might be in nearby code. Focus on whether the **underlying issue** was addressed, not just whether that exact line was modified.
|
|
246
|
+
|
|
247
|
+
Return your analysis in this JSON format:
|
|
248
|
+
\`\`\`json
|
|
249
|
+
{
|
|
250
|
+
"isAddressed": true or false,
|
|
251
|
+
"reason": "Brief explanation of why you determined the feedback was or was not addressed"
|
|
252
|
+
}
|
|
253
|
+
\`\`\`
|
|
254
|
+
|
|
255
|
+
Example responses:
|
|
256
|
+
- If a comment says "add null check" and the diff shows a null check was added nearby: {"isAddressed": true, "reason": "Null check added in related code"}
|
|
257
|
+
- If a comment suggests a refactor but no relevant changes are visible: {"isAddressed": false, "reason": "No refactoring changes visible in diff"}
|
|
258
|
+
- If code was modified in related areas addressing the concern: {"isAddressed": true, "reason": "Related code modified to address the concern"}
|
|
259
|
+
`;
|
|
97
260
|
}
|
|
98
261
|
/**
|
|
99
262
|
* Mark review threads as resolved using GraphQL API
|
|
@@ -132,10 +295,10 @@ async function resolveReviewThreads(octokit, threads, verbose) {
|
|
|
132
295
|
return markedCount;
|
|
133
296
|
}
|
|
134
297
|
/**
|
|
135
|
-
* Verify and resolve PR review comments using GraphQL API
|
|
298
|
+
* Verify and resolve PR review comments using GraphQL API and LLM analysis
|
|
136
299
|
*/
|
|
137
300
|
export async function verifyAndResolveComments(options) {
|
|
138
|
-
const { featureId, mcpServerUrl, mcpToken, githubToken, verbose } = options;
|
|
301
|
+
const { featureId, mcpServerUrl, mcpToken, githubToken, config, verbose } = options;
|
|
139
302
|
if (verbose) {
|
|
140
303
|
logInfo(`Starting code refine verification for feature ID: ${featureId}`);
|
|
141
304
|
}
|
|
@@ -155,10 +318,11 @@ export async function verifyAndResolveComments(options) {
|
|
|
155
318
|
const octokit = new Octokit({
|
|
156
319
|
auth: githubToken,
|
|
157
320
|
});
|
|
158
|
-
// Fetch unresolved review threads
|
|
159
|
-
const [unresolvedThreads, reviews] = await Promise.all([
|
|
321
|
+
// Fetch unresolved review threads, reviews, and file changes
|
|
322
|
+
const [unresolvedThreads, reviews, fileChanges] = await Promise.all([
|
|
160
323
|
fetchUnresolvedReviewThreads(octokit, owner, repo, prNumber, verbose),
|
|
161
324
|
fetchPRReviews(octokit, owner, repo, prNumber, verbose),
|
|
325
|
+
fetchPRFileChanges(octokit, owner, repo, prNumber, verbose),
|
|
162
326
|
]);
|
|
163
327
|
// Check if there's anything to verify
|
|
164
328
|
if (reviews.length === 0 && unresolvedThreads.length === 0) {
|
|
@@ -178,16 +342,46 @@ export async function verifyAndResolveComments(options) {
|
|
|
178
342
|
},
|
|
179
343
|
};
|
|
180
344
|
}
|
|
345
|
+
// Use LLM to intelligently analyze unresolved threads
|
|
346
|
+
if (verbose && unresolvedThreads.length > 0) {
|
|
347
|
+
logInfo(`🔍 Analyzing ${unresolvedThreads.length} unresolved threads with LLM...`);
|
|
348
|
+
}
|
|
349
|
+
// Analyze each thread with LLM to determine if it's truly unresolved
|
|
350
|
+
const threadAnalysisResults = await Promise.all(unresolvedThreads.map(async (thread) => {
|
|
351
|
+
const firstComment = thread.comments.nodes[0];
|
|
352
|
+
const fileChange = fileChanges.find((fc) => fc.filename === firstComment?.path);
|
|
353
|
+
const analysis = await analyzeThreadWithLLM(thread, fileChange, config, verbose);
|
|
354
|
+
return {
|
|
355
|
+
thread,
|
|
356
|
+
analysis,
|
|
357
|
+
};
|
|
358
|
+
}));
|
|
359
|
+
// Separate threads that LLM determined are addressed vs not addressed
|
|
360
|
+
const addressedThreads = threadAnalysisResults.filter((result) => result.analysis.isAddressed);
|
|
361
|
+
const trulyUnresolvedThreads = threadAnalysisResults.filter((result) => !result.analysis.isAddressed);
|
|
362
|
+
if (verbose) {
|
|
363
|
+
logInfo(`📊 LLM Analysis: ${addressedThreads.length} threads addressed, ${trulyUnresolvedThreads.length} still need attention`);
|
|
364
|
+
}
|
|
365
|
+
// Auto-resolve threads that LLM determined are addressed
|
|
366
|
+
if (addressedThreads.length > 0) {
|
|
367
|
+
if (verbose) {
|
|
368
|
+
logInfo(`✅ Auto-resolving ${addressedThreads.length} threads that have been addressed...`);
|
|
369
|
+
}
|
|
370
|
+
const resolvedCount = await resolveReviewThreads(octokit, addressedThreads.map((r) => r.thread), verbose);
|
|
371
|
+
if (verbose) {
|
|
372
|
+
logInfo(`✅ Successfully resolved ${resolvedCount} threads`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
181
375
|
// Check reviews - they need to be dismissed or re-reviewed
|
|
182
376
|
const unresolvedReviews = reviews.filter((review) => review.state === 'CHANGES_REQUESTED');
|
|
183
377
|
if (verbose) {
|
|
184
|
-
logInfo(`📊 Review Threads: ${
|
|
378
|
+
logInfo(`📊 Review Threads: ${trulyUnresolvedThreads.length} still unresolved (after LLM analysis)`);
|
|
185
379
|
if (reviews.length > 0) {
|
|
186
380
|
logInfo(`📊 Reviews: ${reviews.length - unresolvedReviews.length} addressed, ${unresolvedReviews.length} still requesting changes`);
|
|
187
381
|
}
|
|
188
382
|
}
|
|
189
|
-
// If all threads are resolved AND no reviews requesting changes, success
|
|
190
|
-
if (
|
|
383
|
+
// If all threads are truly resolved (after LLM analysis) AND no reviews requesting changes, success
|
|
384
|
+
if (trulyUnresolvedThreads.length === 0 && unresolvedReviews.length === 0) {
|
|
191
385
|
if (verbose) {
|
|
192
386
|
logInfo('✅ All comments have been addressed! All review threads are resolved.');
|
|
193
387
|
}
|
|
@@ -202,13 +396,14 @@ export async function verifyAndResolveComments(options) {
|
|
|
202
396
|
totalReviews: reviews.length,
|
|
203
397
|
unresolvedReviews: 0,
|
|
204
398
|
totalComments: unresolvedThreads.length,
|
|
205
|
-
resolvedComments:
|
|
399
|
+
resolvedComments: addressedThreads.length,
|
|
206
400
|
unresolvedComments: 0,
|
|
401
|
+
commentsMarkedResolved: addressedThreads.length,
|
|
207
402
|
},
|
|
208
403
|
};
|
|
209
404
|
}
|
|
210
405
|
else {
|
|
211
|
-
// Verification failed - build detailed info with specific failure reasons
|
|
406
|
+
// Verification failed - build detailed info with specific failure reasons from LLM analysis
|
|
212
407
|
if (verbose) {
|
|
213
408
|
if (unresolvedReviews.length > 0) {
|
|
214
409
|
logInfo(`⚠️ ${unresolvedReviews.length} reviews still requesting changes`);
|
|
@@ -219,27 +414,26 @@ export async function verifyAndResolveComments(options) {
|
|
|
219
414
|
}
|
|
220
415
|
});
|
|
221
416
|
}
|
|
222
|
-
if (
|
|
223
|
-
logInfo(`⚠️ ${
|
|
224
|
-
|
|
225
|
-
const firstComment = thread.comments.nodes[0];
|
|
417
|
+
if (trulyUnresolvedThreads.length > 0) {
|
|
418
|
+
logInfo(`⚠️ ${trulyUnresolvedThreads.length} review threads still need to be addressed`);
|
|
419
|
+
trulyUnresolvedThreads.forEach((result) => {
|
|
420
|
+
const firstComment = result.thread.comments.nodes[0];
|
|
226
421
|
if (firstComment) {
|
|
227
422
|
logInfo(` - Comment by @${firstComment.author.login}`);
|
|
228
423
|
logInfo(` File: ${firstComment.path}:${firstComment.line || '?'}`);
|
|
229
424
|
logInfo(` ${firstComment.body.substring(0, 100)}...`);
|
|
230
|
-
logInfo(`
|
|
425
|
+
logInfo(` LLM Analysis: ${result.analysis.reason}`);
|
|
231
426
|
}
|
|
232
427
|
});
|
|
233
428
|
}
|
|
234
429
|
}
|
|
235
|
-
// Build targeted suggestions based on
|
|
430
|
+
// Build targeted suggestions based on LLM analysis
|
|
236
431
|
const suggestions = [];
|
|
237
|
-
// Create specific suggestions for each unresolved comment
|
|
238
|
-
|
|
239
|
-
const firstComment = thread.comments.nodes[0];
|
|
432
|
+
// Create specific suggestions for each truly unresolved comment (based on LLM analysis)
|
|
433
|
+
trulyUnresolvedThreads.forEach((result, index) => {
|
|
434
|
+
const firstComment = result.thread.comments.nodes[0];
|
|
240
435
|
if (firstComment) {
|
|
241
|
-
|
|
242
|
-
suggestions.push(`${index + 1}. [${firstComment.path}:${firstComment.line || '?'}] by @${firstComment.author.login}: ${reason}`);
|
|
436
|
+
suggestions.push(`${index + 1}. [${firstComment.path}:${firstComment.line || '?'}] by @${firstComment.author.login}: ${result.analysis.reason}`);
|
|
243
437
|
}
|
|
244
438
|
});
|
|
245
439
|
// Add review-specific suggestions if any
|
|
@@ -249,16 +443,16 @@ export async function verifyAndResolveComments(options) {
|
|
|
249
443
|
suggestions.push(` - @${review.user.login}: ${review.body ? review.body.substring(0, 150) : 'No details provided'}${review.body && review.body.length > 150 ? '...' : ''}`);
|
|
250
444
|
});
|
|
251
445
|
}
|
|
252
|
-
// Build detailed unresolved info with failure reasons
|
|
253
|
-
const unresolvedCommentDetails =
|
|
254
|
-
const firstComment = thread.comments.nodes[0];
|
|
446
|
+
// Build detailed unresolved info with LLM-analyzed failure reasons
|
|
447
|
+
const unresolvedCommentDetails = trulyUnresolvedThreads.map((result) => {
|
|
448
|
+
const firstComment = result.thread.comments.nodes[0];
|
|
255
449
|
return {
|
|
256
450
|
commentId: firstComment.id,
|
|
257
451
|
author: firstComment.author.login,
|
|
258
452
|
file: firstComment.path,
|
|
259
453
|
line: firstComment.line,
|
|
260
454
|
body: firstComment.body,
|
|
261
|
-
failureReason:
|
|
455
|
+
failureReason: result.analysis.reason, // Use LLM analysis result
|
|
262
456
|
url: firstComment.url,
|
|
263
457
|
};
|
|
264
458
|
});
|
|
@@ -270,14 +464,14 @@ export async function verifyAndResolveComments(options) {
|
|
|
270
464
|
submittedAt: review.submitted_at,
|
|
271
465
|
}));
|
|
272
466
|
let errorMessage = '';
|
|
273
|
-
if (unresolvedReviews.length > 0 &&
|
|
274
|
-
errorMessage = `${unresolvedReviews.length} reviews and ${
|
|
467
|
+
if (unresolvedReviews.length > 0 && trulyUnresolvedThreads.length > 0) {
|
|
468
|
+
errorMessage = `${unresolvedReviews.length} reviews and ${trulyUnresolvedThreads.length} review threads still need to be addressed (based on LLM analysis)`;
|
|
275
469
|
}
|
|
276
470
|
else if (unresolvedReviews.length > 0) {
|
|
277
471
|
errorMessage = `${unresolvedReviews.length} reviews still requesting changes`;
|
|
278
472
|
}
|
|
279
473
|
else {
|
|
280
|
-
errorMessage = `${
|
|
474
|
+
errorMessage = `${trulyUnresolvedThreads.length} review comments still need to be addressed (based on LLM analysis)`;
|
|
281
475
|
}
|
|
282
476
|
return {
|
|
283
477
|
status: 'error',
|
|
@@ -286,9 +480,10 @@ export async function verifyAndResolveComments(options) {
|
|
|
286
480
|
featureId,
|
|
287
481
|
totalReviews: reviews.length,
|
|
288
482
|
unresolvedReviews: unresolvedReviews.length,
|
|
289
|
-
totalComments: unresolvedThreads.length,
|
|
290
|
-
resolvedComments:
|
|
291
|
-
unresolvedComments:
|
|
483
|
+
totalComments: unresolvedThreads.length, // Original count before LLM analysis
|
|
484
|
+
resolvedComments: addressedThreads.length, // LLM determined these are addressed
|
|
485
|
+
unresolvedComments: trulyUnresolvedThreads.length, // LLM determined these still need work
|
|
486
|
+
commentsMarkedResolved: addressedThreads.length,
|
|
292
487
|
suggestions,
|
|
293
488
|
unresolvedReviewDetails,
|
|
294
489
|
unresolvedCommentDetails,
|