Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineEdits/vscode-node/components/nesFeedbackSubmitter.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as l10n from '@vscode/l10n';
7
import { env, Uri, window, workspace } from 'vscode';
8
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
9
import { ILogger, ILogService } from '../../../../platform/log/common/logService';
10
import { IFetcherService } from '../../../../platform/networking/common/fetcherService';
11
import { LogEntry } from '../../../../platform/workspaceRecorder/common/workspaceLog';
12
import { encodeBase64, VSBuffer } from '../../../../util/vs/base/common/buffer';
13
14
/**
15
* Represents a feedback file with its name and content.
16
*/
17
export interface FeedbackFile {
18
name: string;
19
content: string;
20
}
21
22
/**
23
* Configuration for the feedback repository.
24
*/
25
interface FeedbackRepoConfig {
26
readonly owner: string;
27
readonly name: string;
28
readonly apiUrl: string;
29
}
30
31
/**
32
* Handles submission of NES feedback captures to a private GitHub repository.
33
* Responsible for file collection, user confirmation, filtering, and upload.
34
*/
35
export class NesFeedbackSubmitter {
36
37
private static readonly DEFAULT_REPO_CONFIG: FeedbackRepoConfig = {
38
owner: 'microsoft',
39
name: 'copilot-nes-feedback',
40
apiUrl: 'https://api.github.com'
41
};
42
43
private readonly _logger: ILogger;
44
45
constructor(
46
logService: ILogService,
47
private readonly _authenticationService: IAuthenticationService,
48
private readonly _fetcherService: IFetcherService,
49
private readonly _repoConfig: FeedbackRepoConfig = NesFeedbackSubmitter.DEFAULT_REPO_CONFIG
50
) {
51
this._logger = logService.createSubLogger(['NES', 'FeedbackSubmitter']);
52
}
53
54
/**
55
* Submit feedback files from the given folder to the private GitHub repository.
56
* Shows a preview dialog allowing users to select which files to include.
57
*/
58
public async submitFromFolder(feedbackFolderUri: Uri): Promise<void> {
59
try {
60
// Check if feedback folder exists and has files
61
const files = await this._collectFeedbackFiles(feedbackFolderUri);
62
if (files.length === 0) {
63
window.showInformationMessage('No NES feedback captures found to submit. Use "Copilot: Record Expected Edit (NES)" to capture feedback first.');
64
return;
65
}
66
67
// Read file contents
68
const fileContents = await this._readFeedbackFiles(files, feedbackFolderUri);
69
if (fileContents.length === 0) {
70
window.showErrorMessage('Failed to read feedback files.');
71
return;
72
}
73
74
// Extract unique document paths from the recordings to show the user
75
const documentPaths = this._extractDocumentPathsFromRecordings(fileContents);
76
77
// Extract nextUserEdit paths to calculate accurate recording counts
78
const nextUserEditPaths = this._extractNextUserEditPaths(fileContents);
79
80
// Show confirmation with file preview and allow filtering
81
// Returns excluded paths for efficiency (empty in the default case when all files are selected)
82
const excludedPaths = await this._showFilePreviewAndConfirm(documentPaths, nextUserEditPaths);
83
if (!excludedPaths) {
84
return;
85
}
86
87
// Filter recordings to remove excluded documents
88
const filteredContents = this._filterRecordingsByExcludedPaths(fileContents, excludedPaths, nextUserEditPaths);
89
if (filteredContents.length === 0) {
90
window.showInformationMessage('No files to submit after filtering.');
91
return;
92
}
93
94
// Get GitHub auth token - need permissive session for repo access
95
const session = await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub to submit feedback.') } });
96
if (!session) {
97
window.showErrorMessage('GitHub authentication required with repo access. Please sign in to GitHub.');
98
return;
99
}
100
101
// Upload files to the private repo
102
const folderUrl = await this._uploadToPrivateRepo(filteredContents, session.accessToken);
103
104
if (folderUrl) {
105
await this._showSuccessDialog(folderUrl);
106
this._logger.info(`Uploaded feedback to private repo: ${folderUrl}`);
107
}
108
} catch (error) {
109
this._logger.error(error instanceof Error ? error : String(error), 'Error submitting feedback');
110
window.showErrorMessage(`Failed to submit NES feedback: ${error instanceof Error ? error.message : 'Unknown error'}`);
111
}
112
}
113
114
/**
115
* Show success dialog with options to open the PR in GitHub or copy the link.
116
*/
117
private async _showSuccessDialog(prUrl: string): Promise<void> {
118
const result = await window.showInformationMessage(
119
'Feedback submitted! A pull request has been created.',
120
'Open Pull Request',
121
'Copy Link'
122
);
123
124
if (result === 'Open Pull Request') {
125
await env.openExternal(Uri.parse(prUrl));
126
} else if (result === 'Copy Link') {
127
await env.clipboard.writeText(prUrl);
128
window.showInformationMessage('Pull request URL copied to clipboard!');
129
}
130
}
131
132
/**
133
* Collect all feedback files from the capture folder.
134
*/
135
private async _collectFeedbackFiles(folderUri: Uri): Promise<Uri[]> {
136
try {
137
const entries = await workspace.fs.readDirectory(folderUri);
138
return entries
139
.filter(([name, type]) => type === 1 && name.endsWith('.json')) // FileType.File = 1
140
.map(([name]) => Uri.joinPath(folderUri, name));
141
} catch {
142
return [];
143
}
144
}
145
146
/**
147
* Read contents of feedback files.
148
*/
149
private async _readFeedbackFiles(fileUris: Uri[], folderUri: Uri): Promise<FeedbackFile[]> {
150
const results: FeedbackFile[] = [];
151
152
for (const fileUri of fileUris) {
153
try {
154
const content = await workspace.fs.readFile(fileUri);
155
const textContent = new TextDecoder().decode(content);
156
const relativeName = fileUri.path.replace(folderUri.path + '/', '');
157
results.push({
158
name: relativeName,
159
content: textContent
160
});
161
} catch (e) {
162
this._logger.warn(`Failed to read file: ${fileUri.fsPath}: ${e}`);
163
}
164
}
165
166
return results;
167
}
168
169
/**
170
* Extract unique document paths from recording files.
171
* Parses the log entries to find all documentEncountered events.
172
*/
173
private _extractDocumentPathsFromRecordings(files: FeedbackFile[]): string[] {
174
const paths = new Set<string>();
175
176
for (const file of files) {
177
// Only process recording files, not metadata
178
if (!file.name.endsWith('.recording.w.json')) {
179
continue;
180
}
181
182
try {
183
const recording = JSON.parse(file.content) as { log?: LogEntry[] };
184
if (recording.log) {
185
for (const entry of recording.log) {
186
if (entry.kind === 'documentEncountered') {
187
paths.add(entry.relativePath);
188
}
189
}
190
}
191
} catch {
192
// Ignore parse errors
193
}
194
}
195
196
return Array.from(paths).sort();
197
}
198
199
/**
200
* Extract the nextUserEdit path for each recording.
201
* Returns a map from recording name to its nextUserEdit relativePath (or undefined if none).
202
*/
203
private _extractNextUserEditPaths(files: FeedbackFile[]): Map<string, string | undefined> {
204
const result = new Map<string, string | undefined>();
205
206
for (const file of files) {
207
if (!file.name.endsWith('.recording.w.json')) {
208
continue;
209
}
210
211
try {
212
const recording = JSON.parse(file.content) as {
213
nextUserEdit?: { relativePath: string };
214
};
215
result.set(file.name, recording.nextUserEdit?.relativePath);
216
} catch {
217
// If parsing fails, assume it has no nextUserEdit
218
result.set(file.name, undefined);
219
}
220
}
221
222
return result;
223
}
224
225
/**
226
* Count how many recordings will be included after excluding certain paths.
227
* A recording is included only if its nextUserEdit path is not excluded.
228
*/
229
private _countIncludedRecordings(nextUserEditPaths: Map<string, string | undefined>, excludedPaths: Set<string>): number {
230
let count = 0;
231
for (const [, nextUserEditPath] of nextUserEditPaths) {
232
if (nextUserEditPath !== undefined && !excludedPaths.has(nextUserEditPath)) {
233
count++;
234
}
235
}
236
return count;
237
}
238
239
/**
240
* Create a summary string for a list of file paths.
241
* Shows up to maxFiles paths inline, with "and N more..." for the rest.
242
*/
243
private _createFilesSummary(paths: string[], maxFiles: number = 5): string {
244
const sortedPaths = [...paths].sort();
245
if (sortedPaths.length <= maxFiles) {
246
return sortedPaths.join(', ');
247
}
248
const shownFiles = sortedPaths.slice(0, maxFiles).join(', ');
249
return `${shownFiles}, and ${sortedPaths.length - maxFiles} more...`;
250
}
251
252
/**
253
* Show a preview of files that will be uploaded and ask for confirmation.
254
* Uses a QuickPick to allow users to select which files to include.
255
* @returns The excluded file paths (empty array if all selected), or undefined if cancelled.
256
*/
257
private async _showFilePreviewAndConfirm(
258
documentPaths: string[],
259
nextUserEditPaths: Map<string, string | undefined>
260
): Promise<string[] | undefined> {
261
const totalRecordingCount = this._countIncludedRecordings(nextUserEditPaths, new Set());
262
263
if (documentPaths.length === 0) {
264
// No document paths found, just show basic confirmation
265
const result = await window.showInformationMessage(
266
`Found ${totalRecordingCount} feedback recording(s). This will upload your NES feedback to the internal feedback repository.\n\n` +
267
`Only team members with access to the private repo can view this data.`,
268
{ modal: true },
269
'Submit Feedback'
270
);
271
return result === 'Submit Feedback' ? [] : undefined; // Empty array = no exclusions
272
}
273
274
// Create a summary of files
275
const filesSummary = this._createFilesSummary(documentPaths);
276
277
const result = await window.showInformationMessage(
278
`Found ${totalRecordingCount} recording(s) containing ${documentPaths.length} file(s):\n${filesSummary}\n\n` +
279
`This will upload your NES feedback to the internal feedback repository.`,
280
{ modal: true },
281
'Submit Feedback',
282
'Select Files to Include'
283
);
284
285
if (result === 'Submit Feedback') {
286
return []; // No exclusions - all files selected
287
}
288
289
if (result === 'Select Files to Include') {
290
return this._showFileSelectionQuickPick(documentPaths, nextUserEditPaths);
291
}
292
293
return undefined;
294
}
295
296
/**
297
* Show a multi-select QuickPick for file selection.
298
* Loops until user confirms or cancels, allowing them to edit their selection.
299
* @returns The excluded file paths, or undefined if cancelled.
300
*/
301
private async _showFileSelectionQuickPick(
302
documentPaths: string[],
303
nextUserEditPaths: Map<string, string | undefined>
304
): Promise<string[] | undefined> {
305
let currentSelection = new Set(documentPaths); // Start with all selected
306
307
while (true) {
308
const items = documentPaths.map(path => ({
309
label: path,
310
description: '',
311
picked: currentSelection.has(path)
312
}));
313
314
const selected = await window.showQuickPick(items, {
315
title: 'Select files to include in the upload',
316
placeHolder: 'Deselect files you want to exclude, then press Enter to confirm',
317
canPickMany: true,
318
ignoreFocusOut: true
319
});
320
321
if (!selected) {
322
// User cancelled QuickPick
323
return undefined;
324
}
325
326
const selectedPaths = new Set(selected.map(item => item.label));
327
const excludedPaths = documentPaths.filter(path => !selectedPaths.has(path));
328
329
if (selectedPaths.size === 0) {
330
window.showInformationMessage('No files selected. Upload cancelled.');
331
return undefined;
332
}
333
334
// Calculate how many recordings will actually be included
335
const excludedPathSet = new Set(excludedPaths);
336
const includedRecordingCount = this._countIncludedRecordings(nextUserEditPaths, excludedPathSet);
337
338
if (includedRecordingCount === 0) {
339
const tryAgain = await window.showInformationMessage(
340
'No recordings would be included with this selection (all nextUserEdit files are excluded).',
341
{ modal: true },
342
'Edit Selection'
343
);
344
if (tryAgain === 'Edit Selection') {
345
currentSelection = selectedPaths;
346
continue;
347
}
348
return undefined;
349
}
350
351
// Show final confirmation with accurate recording count and file summary
352
const selectedPathsArray = Array.from(selectedPaths);
353
const filesSummary = this._createFilesSummary(selectedPathsArray);
354
355
const confirmMessage = excludedPaths.length > 0
356
? `Submit ${includedRecordingCount} recording(s) with ${selectedPaths.size} file(s)? (${excludedPaths.length} excluded)\n\nIncluded: ${filesSummary}`
357
: `Submit ${includedRecordingCount} recording(s) containing ${selectedPaths.size} file(s)?\n\n${filesSummary}`;
358
359
const finalResult = await window.showInformationMessage(
360
confirmMessage,
361
{ modal: true },
362
'Submit Feedback',
363
'Edit Selection'
364
);
365
366
if (finalResult === 'Submit Feedback') {
367
return excludedPaths;
368
}
369
370
if (finalResult === 'Edit Selection') {
371
// Update current selection and loop back to QuickPick
372
currentSelection = selectedPaths;
373
continue;
374
}
375
376
// User clicked Cancel or dismissed the dialog
377
return undefined;
378
}
379
}
380
381
/**
382
* Filter recording files to remove excluded document paths.
383
* Removes documentEncountered entries and all related events for excluded documents.
384
* Recordings whose nextUserEdit is excluded are skipped entirely,
385
* along with their associated metadata files.
386
* Optimized for the common case where excludedPaths is empty (all files selected).
387
*/
388
private _filterRecordingsByExcludedPaths(
389
files: FeedbackFile[],
390
excludedPaths: string[],
391
nextUserEditPaths: Map<string, string | undefined>
392
): FeedbackFile[] {
393
// Fast path: no exclusions, return files as-is
394
if (excludedPaths.length === 0) {
395
return files;
396
}
397
398
const excludedPathSet = new Set(excludedPaths);
399
const filteredRecordings: FeedbackFile[] = [];
400
const skippedRecordingPrefixes = new Set<string>();
401
402
// First pass: filter recordings and track which ones to skip
403
for (const file of files) {
404
if (!file.name.endsWith('.recording.w.json')) {
405
continue;
406
}
407
408
// Use precomputed nextUserEditPaths to quickly skip recordings
409
const nextUserEditPath = nextUserEditPaths.get(file.name);
410
if (nextUserEditPath === undefined || excludedPathSet.has(nextUserEditPath)) {
411
// Skip this recording - no nextUserEdit or it's excluded
412
const prefix = file.name.replace('.recording.w.json', '');
413
skippedRecordingPrefixes.add(prefix);
414
this._logger.debug(`Skipping recording ${file.name}: nextUserEdit excluded or missing`);
415
continue;
416
}
417
418
try {
419
const filteredFile = this._filterSingleRecording(file, excludedPathSet);
420
filteredRecordings.push(filteredFile);
421
} catch {
422
// If parsing fails, include the file as-is
423
filteredRecordings.push(file);
424
}
425
}
426
427
// Second pass: include metadata files only if their recording wasn't skipped
428
const result: FeedbackFile[] = [...filteredRecordings];
429
for (const file of files) {
430
if (file.name.endsWith('.metadata.json')) {
431
const prefix = file.name.replace('.metadata.json', '');
432
if (!skippedRecordingPrefixes.has(prefix)) {
433
result.push(file);
434
} else {
435
this._logger.debug(`Skipping metadata ${file.name}: associated recording was skipped`);
436
}
437
}
438
}
439
440
return result;
441
}
442
443
/**
444
* Filter a single recording file based on excluded document paths.
445
* Assumes the recording will be included (nextUserEdit already checked).
446
*/
447
private _filterSingleRecording(file: FeedbackFile, excludedPathSet: Set<string>): FeedbackFile {
448
const recording = JSON.parse(file.content) as {
449
log?: LogEntry[];
450
nextUserEdit?: { relativePath: string; edit: unknown };
451
};
452
453
if (!recording.log) {
454
return file;
455
}
456
457
// Find document IDs that should be excluded
458
const excludedDocIds = new Set<number>();
459
for (const entry of recording.log) {
460
if (entry.kind === 'documentEncountered' && excludedPathSet.has(entry.relativePath)) {
461
excludedDocIds.add(entry.id);
462
}
463
}
464
465
// Filter log entries to remove excluded documents
466
const filteredLog = recording.log.filter(entry => {
467
if (entry.kind === 'header') {
468
return true;
469
}
470
if ('id' in entry && typeof entry.id === 'number') {
471
return !excludedDocIds.has(entry.id);
472
}
473
return true;
474
});
475
476
// Create filtered recording (nextUserEdit is preserved - we already checked it's not excluded)
477
const filteredRecording = {
478
...recording,
479
log: filteredLog
480
};
481
482
return {
483
name: file.name,
484
content: JSON.stringify(filteredRecording, null, 2)
485
};
486
}
487
488
/**
489
* Upload feedback files to the private GitHub repository via a pull request.
490
* Creates a new branch, uploads files to a timestamped folder, and opens a PR.
491
* @returns The URL to the pull request, or undefined on failure.
492
*/
493
private async _uploadToPrivateRepo(files: FeedbackFile[], token: string): Promise<string | undefined> {
494
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
495
const folderPath = `feedback/${timestamp}`;
496
497
// Get the current user for commit attribution
498
const user = await this._getCurrentUser(token);
499
const username = user?.login ?? 'anonymous';
500
501
// Create a unique branch name for this feedback submission
502
const branchName = `feedback/${username}/${timestamp}`;
503
504
// Get the SHA of the main branch to create our branch from
505
const mainBranchSha = await this._getBranchSha(token, 'main');
506
if (!mainBranchSha) {
507
throw new Error('Failed to get main branch SHA');
508
}
509
510
// Create the new branch
511
await this._createBranch(token, branchName, mainBranchSha);
512
513
// Upload each file to the new branch
514
for (const file of files) {
515
const filePath = `${folderPath}/${file.name}`;
516
await this._createFileInRepo(filePath, file.content, token, username, timestamp, branchName);
517
}
518
519
// Create the pull request
520
const prUrl = await this._createPullRequest(token, branchName, username, timestamp, files.length);
521
522
return prUrl;
523
}
524
525
/**
526
* Get the SHA of a branch.
527
*/
528
private async _getBranchSha(token: string, branch: string): Promise<string | undefined> {
529
try {
530
const response = await this._fetcherService.fetch(
531
`${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/git/ref/heads/${branch}`,
532
{
533
method: 'GET',
534
callSite: 'nes-feedback-branch-sha',
535
headers: {
536
'Authorization': `Bearer ${token}`,
537
'Accept': 'application/vnd.github+json',
538
'X-GitHub-Api-Version': '2022-11-28',
539
'User-Agent': this._fetcherService.getUserAgentLibrary()
540
}
541
}
542
);
543
544
if (response.ok) {
545
const data = await response.json() as { object: { sha: string } };
546
return data.object.sha;
547
}
548
} catch (e) {
549
this._logger.error(e instanceof Error ? e : String(e), 'Failed to get branch SHA');
550
}
551
return undefined;
552
}
553
554
/**
555
* Create a new branch in the repository.
556
*/
557
private async _createBranch(token: string, branchName: string, sha: string): Promise<void> {
558
const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/git/refs`;
559
560
const payload = {
561
ref: `refs/heads/${branchName}`,
562
sha: sha
563
};
564
565
const response = await fetch(url, {
566
method: 'POST',
567
headers: {
568
'Authorization': `Bearer ${token}`,
569
'Accept': 'application/vnd.github+json',
570
'Content-Type': 'application/json',
571
'X-GitHub-Api-Version': '2022-11-28',
572
'User-Agent': this._fetcherService.getUserAgentLibrary()
573
},
574
body: JSON.stringify(payload)
575
});
576
577
if (!response.ok) {
578
const errorText = await response.text();
579
this._logger.error(`Failed to create branch ${branchName}: ${response.status} ${response.statusText} - ${errorText}`);
580
throw new Error(`Failed to create branch: ${response.statusText}`);
581
}
582
}
583
584
/**
585
* Create a pull request from the feedback branch to main.
586
* @returns The URL to the created pull request.
587
*/
588
private async _createPullRequest(
589
token: string,
590
branchName: string,
591
username: string,
592
timestamp: string,
593
fileCount: number
594
): Promise<string | undefined> {
595
const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/pulls`;
596
597
const payload = {
598
title: `NES Feedback from ${username} (${timestamp})`,
599
head: branchName,
600
base: 'main',
601
body: `## NES Feedback Submission\n\n` +
602
`- **Submitted by:** ${username}\n` +
603
`- **Timestamp:** ${timestamp}\n` +
604
`- **Files:** ${fileCount} file(s)\n\n` +
605
`This feedback was automatically submitted via the "Copilot: Submit NES Feedback" command.`
606
};
607
608
const response = await fetch(url, {
609
method: 'POST',
610
headers: {
611
'Authorization': `Bearer ${token}`,
612
'Accept': 'application/vnd.github+json',
613
'Content-Type': 'application/json',
614
'X-GitHub-Api-Version': '2022-11-28',
615
'User-Agent': this._fetcherService.getUserAgentLibrary()
616
},
617
body: JSON.stringify(payload)
618
});
619
620
if (!response.ok) {
621
const errorText = await response.text();
622
this._logger.error(`Failed to create pull request: ${response.status} ${response.statusText} - ${errorText}`);
623
throw new Error(`Failed to create pull request: ${response.statusText}`);
624
}
625
626
const prData = await response.json() as { html_url: string };
627
return prData.html_url;
628
}
629
630
/**
631
* Create a file in the private feedback repository on a specific branch.
632
* Uses native fetch API since IFetcherService only supports GET/POST,
633
* but GitHub Contents API requires PUT for file creation.
634
*/
635
private async _createFileInRepo(
636
path: string,
637
content: string,
638
token: string,
639
username: string,
640
timestamp: string,
641
branch: string
642
): Promise<void> {
643
const url = `${this._repoConfig.apiUrl}/repos/${this._repoConfig.owner}/${this._repoConfig.name}/contents/${path}`;
644
645
const payload = {
646
message: `NES feedback from ${username} at ${timestamp}`,
647
content: encodeBase64(VSBuffer.fromString(content)),
648
branch: branch
649
};
650
651
// Use native fetch for PUT request (IFetcherService only supports GET/POST)
652
const response = await fetch(url, {
653
method: 'PUT',
654
headers: {
655
'Authorization': `Bearer ${token}`,
656
'Accept': 'application/vnd.github+json',
657
'Content-Type': 'application/json',
658
'X-GitHub-Api-Version': '2022-11-28',
659
'User-Agent': this._fetcherService.getUserAgentLibrary()
660
},
661
body: JSON.stringify(payload)
662
});
663
664
if (!response.ok) {
665
const errorText = await response.text();
666
this._logger.error(`Failed to create file ${path}: ${response.status} ${response.statusText} - ${errorText}`);
667
throw new Error(`Failed to upload file: ${response.statusText}`);
668
}
669
}
670
671
/**
672
* Get the current authenticated GitHub user.
673
*/
674
private async _getCurrentUser(token: string): Promise<{ login: string } | undefined> {
675
try {
676
const response = await this._fetcherService.fetch(
677
`${this._repoConfig.apiUrl}/user`,
678
{
679
method: 'GET',
680
callSite: 'nes-feedback-current-user',
681
headers: {
682
'Authorization': `Bearer ${token}`,
683
'Accept': 'application/vnd.github+json',
684
'X-GitHub-Api-Version': '2022-11-28',
685
'User-Agent': this._fetcherService.getUserAgentLibrary()
686
}
687
}
688
);
689
690
if (response.ok) {
691
return await response.json();
692
}
693
} catch (e) {
694
this._logger.warn(`Failed to get current user: ${e}`);
695
}
696
return undefined;
697
}
698
}
699
700