Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/review/node/test/githubReviewAgent.spec.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 assert from 'assert';
7
import { describe, suite, test } from 'vitest';
8
import type { TextDocument } from 'vscode';
9
import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';
10
import { CopilotToken, createTestExtendedTokenInfo } from '../../../../platform/authentication/common/copilotToken';
11
import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService';
12
import { ICAPIClientService } from '../../../../platform/endpoint/common/capiClient';
13
import { IDomainService } from '../../../../platform/endpoint/common/domainService';
14
import { IEnvService } from '../../../../platform/env/common/envService';
15
import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService';
16
import { NullGitExtensionService } from '../../../../platform/git/common/nullGitExtensionService';
17
import { IIgnoreService, NullIgnoreService } from '../../../../platform/ignore/common/ignoreService';
18
import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';
19
import { MockCAPIClientService } from '../../../../platform/ignore/node/test/mockCAPIClientService';
20
import { MockWorkspaceService } from '../../../../platform/ignore/node/test/mockWorkspaceService';
21
import { IFetcherService } from '../../../../platform/networking/common/fetcherService';
22
import { ReviewComment, ReviewRequest } from '../../../../platform/review/common/reviewService';
23
import { MockCustomInstructionsService } from '../../../../platform/test/common/testCustomInstructionsService';
24
import { createFakeStreamResponse } from '../../../../platform/test/node/fetcher';
25
import { TestLogService } from '../../../../platform/testing/common/testLogService';
26
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
27
import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';
28
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
29
import { Event } from '../../../../util/vs/base/common/event';
30
import { URI } from '../../../../util/vs/base/common/uri';
31
import {
32
createReviewComment,
33
ExcludedComment,
34
LineChange,
35
loadCustomInstructions,
36
normalizePath,
37
parseLine,
38
parsePatch,
39
removeSuggestion,
40
ResponseComment,
41
reverseParsedPatch,
42
reversePatch
43
} from '../githubReviewAgent';
44
45
suite('githubReviewAgent', () => {
46
47
describe('normalizePath', () => {
48
49
test('returns path unchanged when no backslashes', () => {
50
const result = normalizePath('src/components/Button.tsx');
51
assert.strictEqual(result, 'src/components/Button.tsx');
52
});
53
54
test('converts backslashes to forward slashes', () => {
55
// This test verifies the function works regardless of platform
56
// On Windows, backslashes would be converted; on other platforms, they're still converted
57
const input = 'src\\components\\Button.tsx';
58
const result = normalizePath(input);
59
// On Windows (win32): converts to forward slashes
60
// On other platforms: returns unchanged (no backslashes in typical paths)
61
if (process.platform === 'win32') {
62
assert.strictEqual(result, 'src/components/Button.tsx');
63
} else {
64
assert.strictEqual(result, input);
65
}
66
});
67
68
test('handles empty string', () => {
69
const result = normalizePath('');
70
assert.strictEqual(result, '');
71
});
72
73
test('handles path with mixed slashes on Windows', () => {
74
const input = 'src/components\\utils\\helper.ts';
75
const result = normalizePath(input);
76
if (process.platform === 'win32') {
77
assert.strictEqual(result, 'src/components/utils/helper.ts');
78
} else {
79
assert.strictEqual(result, input);
80
}
81
});
82
});
83
84
describe('parseLine', () => {
85
86
test('returns empty array for empty line', () => {
87
const result = parseLine('');
88
assert.deepStrictEqual(result, []);
89
});
90
91
test('returns empty array for DONE marker', () => {
92
const result = parseLine('data: [DONE]');
93
assert.deepStrictEqual(result, []);
94
});
95
96
test('returns empty array when no copilot_references', () => {
97
const result = parseLine('data: {"choices":[]}');
98
assert.deepStrictEqual(result, []);
99
});
100
101
test('returns empty array when copilot_references is empty', () => {
102
const result = parseLine('data: {"copilot_references":[]}');
103
assert.deepStrictEqual(result, []);
104
});
105
106
test('parses generated pull request comment', () => {
107
const data = {
108
copilot_references: [{
109
type: 'github.generated-pull-request-comment',
110
data: {
111
path: 'src/file.ts',
112
line: 10,
113
body: 'This is a bug'
114
}
115
}]
116
};
117
const result = parseLine(`data: ${JSON.stringify(data)}`);
118
119
assert.strictEqual(result.length, 1);
120
assert.strictEqual(result[0].type, 'github.generated-pull-request-comment');
121
if (result[0].type === 'github.generated-pull-request-comment') {
122
assert.strictEqual(result[0].data.path, 'src/file.ts');
123
assert.strictEqual(result[0].data.line, 10);
124
assert.strictEqual(result[0].data.body, 'This is a bug');
125
}
126
});
127
128
test('parses excluded pull request comment', () => {
129
const data = {
130
copilot_references: [{
131
type: 'github.excluded-pull-request-comment',
132
data: {
133
path: 'src/file.ts',
134
line: 5,
135
body: 'Low confidence comment',
136
exclusion_reason: 'denylisted_type'
137
}
138
}]
139
};
140
const result = parseLine(`data: ${JSON.stringify(data)}`);
141
142
assert.strictEqual(result.length, 1);
143
assert.strictEqual(result[0].type, 'github.excluded-pull-request-comment');
144
});
145
146
test('parses excluded file reference', () => {
147
const data = {
148
copilot_references: [{
149
type: 'github.excluded-file',
150
data: {
151
file_path: 'src/file.txt',
152
language: 'plaintext',
153
reason: 'file_type_not_supported'
154
}
155
}]
156
};
157
const result = parseLine(`data: ${JSON.stringify(data)}`);
158
159
assert.strictEqual(result.length, 1);
160
assert.strictEqual(result[0].type, 'github.excluded-file');
161
});
162
163
test('parses multiple references in single line', () => {
164
const data = {
165
copilot_references: [
166
{
167
type: 'github.generated-pull-request-comment',
168
data: { path: 'a.ts', line: 1, body: 'Comment 1' }
169
},
170
{
171
type: 'github.generated-pull-request-comment',
172
data: { path: 'b.ts', line: 2, body: 'Comment 2' }
173
}
174
]
175
};
176
const result = parseLine(`data: ${JSON.stringify(data)}`);
177
178
assert.strictEqual(result.length, 2);
179
});
180
181
test('filters out references without type', () => {
182
const data = {
183
copilot_references: [
184
{ type: 'github.generated-pull-request-comment', data: { path: 'a.ts', line: 1, body: 'Valid' } },
185
{ data: { path: 'b.ts', line: 2, body: 'No type field' } }
186
]
187
};
188
const result = parseLine(`data: ${JSON.stringify(data)}`);
189
190
assert.strictEqual(result.length, 1);
191
});
192
});
193
194
describe('removeSuggestion', () => {
195
196
test('returns original content when no suggestion block', () => {
197
const body = 'This is a regular comment without suggestions.';
198
const result = removeSuggestion(body);
199
200
assert.strictEqual(result.content, body);
201
assert.deepStrictEqual(result.suggestions, []);
202
});
203
204
test('extracts single suggestion and removes block', () => {
205
const body = 'Fix the typo.\n```suggestion\nconst fixed = true;\n```';
206
const result = removeSuggestion(body);
207
208
assert.strictEqual(result.content, 'Fix the typo.\n');
209
// The regex captures content including the trailing newline before ```
210
assert.deepStrictEqual(result.suggestions, ['const fixed = true;\n']);
211
});
212
213
test('extracts multiple suggestions', () => {
214
const body = 'First issue.\n```suggestion\nfix1\n```\nSecond issue.\n```suggestion\nfix2\n```';
215
const result = removeSuggestion(body);
216
217
assert.strictEqual(result.suggestions.length, 2);
218
// The regex captures content including the trailing newline before ```
219
assert.strictEqual(result.suggestions[0], 'fix1\n');
220
assert.strictEqual(result.suggestions[1], 'fix2\n');
221
});
222
223
test('handles suggestion with CRLF line endings', () => {
224
const body = 'Fix.\r\n```suggestion\r\nconst x = 1;\r\n```';
225
const result = removeSuggestion(body);
226
227
// The regex captures content including the trailing CRLF before ```
228
assert.deepStrictEqual(result.suggestions, ['const x = 1;\r\n']);
229
});
230
231
test('handles empty suggestion block', () => {
232
const body = 'Remove this line.\n```suggestion\n```';
233
const result = removeSuggestion(body);
234
235
assert.strictEqual(result.content, 'Remove this line.\n');
236
assert.deepStrictEqual(result.suggestions, []);
237
});
238
239
test('handles suggestion with trailing spaces after keyword', () => {
240
const body = 'Fix.\n```suggestion \ncode here\n```';
241
const result = removeSuggestion(body);
242
243
// The regex captures content including the trailing newline before ```
244
assert.deepStrictEqual(result.suggestions, ['code here\n']);
245
});
246
247
test('preserves non-suggestion code blocks', () => {
248
const body = 'Example:\n```typescript\nconst x = 1;\n```\nDone.';
249
const result = removeSuggestion(body);
250
251
assert.strictEqual(result.content, body);
252
assert.deepStrictEqual(result.suggestions, []);
253
});
254
});
255
256
describe('parsePatch', () => {
257
258
test('returns empty array for empty input', () => {
259
const result = parsePatch([]);
260
assert.deepStrictEqual(result, []);
261
});
262
263
test('parses single addition', () => {
264
const patchLines = [
265
'@@ -1,3 +1,4 @@',
266
' line1',
267
'+added line',
268
' line2',
269
' line3'
270
];
271
const result = parsePatch(patchLines);
272
273
assert.strictEqual(result.length, 1);
274
assert.strictEqual(result[0].type, 'add');
275
assert.strictEqual(result[0].content, 'added line');
276
assert.strictEqual(result[0].beforeLineNumber, 2);
277
});
278
279
test('parses single deletion', () => {
280
const patchLines = [
281
'@@ -1,4 +1,3 @@',
282
' line1',
283
'-deleted line',
284
' line2',
285
' line3'
286
];
287
const result = parsePatch(patchLines);
288
289
assert.strictEqual(result.length, 1);
290
assert.strictEqual(result[0].type, 'remove');
291
assert.strictEqual(result[0].content, 'deleted line');
292
assert.strictEqual(result[0].beforeLineNumber, 2);
293
});
294
295
test('parses mixed additions and deletions', () => {
296
const patchLines = [
297
'@@ -1,3 +1,3 @@',
298
' line1',
299
'-old line',
300
'+new line',
301
' line3'
302
];
303
const result = parsePatch(patchLines);
304
305
assert.strictEqual(result.length, 2);
306
assert.strictEqual(result[0].type, 'remove');
307
assert.strictEqual(result[0].content, 'old line');
308
assert.strictEqual(result[1].type, 'add');
309
assert.strictEqual(result[1].content, 'new line');
310
});
311
312
test('parses multiple hunks', () => {
313
const patchLines = [
314
'@@ -1,2 +1,3 @@',
315
' line1',
316
'+added1',
317
'@@ -10,2 +11,3 @@',
318
' line10',
319
'+added2'
320
];
321
const result = parsePatch(patchLines);
322
323
assert.strictEqual(result.length, 2);
324
assert.strictEqual(result[0].beforeLineNumber, 2);
325
assert.strictEqual(result[1].beforeLineNumber, 11);
326
});
327
328
test('ignores lines before first hunk header', () => {
329
const patchLines = [
330
'diff --git a/file.ts b/file.ts',
331
'index abc..def 100644',
332
'--- a/file.ts',
333
'+++ b/file.ts',
334
'@@ -1,2 +1,3 @@',
335
' context',
336
'+added'
337
];
338
const result = parsePatch(patchLines);
339
340
assert.strictEqual(result.length, 1);
341
assert.strictEqual(result[0].content, 'added');
342
});
343
344
test('handles malformed hunk header gracefully', () => {
345
const patchLines = [
346
'@@ invalid header @@',
347
'+should be ignored',
348
'@@ -5,2 +5,3 @@',
349
' context',
350
'+added after valid header'
351
];
352
const result = parsePatch(patchLines);
353
354
// Only the change after the valid header should be parsed
355
assert.strictEqual(result.length, 1);
356
assert.strictEqual(result[0].content, 'added after valid header');
357
assert.strictEqual(result[0].beforeLineNumber, 6);
358
});
359
360
test('returns empty array for patch with no hunk headers', () => {
361
const patchLines = [
362
'diff --git a/file.ts b/file.ts',
363
'index abc..def 100644',
364
'--- a/file.ts',
365
'+++ b/file.ts',
366
// No @@ header
367
];
368
const result = parsePatch(patchLines);
369
370
assert.deepStrictEqual(result, []);
371
});
372
373
test('handles hunk with only context lines', () => {
374
const patchLines = [
375
'@@ -1,3 +1,3 @@',
376
' line1',
377
' line2',
378
' line3'
379
];
380
const result = parsePatch(patchLines);
381
382
assert.deepStrictEqual(result, []);
383
});
384
385
test('handles consecutive additions', () => {
386
const patchLines = [
387
'@@ -1,2 +1,5 @@',
388
' line1',
389
'+added1',
390
'+added2',
391
'+added3',
392
' line2'
393
];
394
const result = parsePatch(patchLines);
395
396
assert.strictEqual(result.length, 3);
397
assert.strictEqual(result[0].beforeLineNumber, 2);
398
assert.strictEqual(result[1].beforeLineNumber, 2);
399
assert.strictEqual(result[2].beforeLineNumber, 2);
400
});
401
402
test('handles consecutive deletions', () => {
403
const patchLines = [
404
'@@ -1,5 +1,2 @@',
405
' line1',
406
'-deleted1',
407
'-deleted2',
408
'-deleted3',
409
' line5'
410
];
411
const result = parsePatch(patchLines);
412
413
assert.strictEqual(result.length, 3);
414
// Each deletion increments beforeLineNumber
415
assert.strictEqual(result[0].beforeLineNumber, 2);
416
assert.strictEqual(result[1].beforeLineNumber, 3);
417
assert.strictEqual(result[2].beforeLineNumber, 4);
418
});
419
});
420
421
describe('reverseParsedPatch', () => {
422
423
test('returns original lines when patch is empty', () => {
424
const lines = ['line1', 'line2', 'line3'];
425
const result = reverseParsedPatch([...lines], []);
426
427
assert.deepStrictEqual(result, lines);
428
});
429
430
test('reverses an addition by removing the line', () => {
431
const afterLines = ['line1', 'added', 'line2'];
432
const patch: LineChange[] = [
433
{ beforeLineNumber: 2, content: 'added', type: 'add' }
434
];
435
const result = reverseParsedPatch([...afterLines], patch);
436
437
assert.deepStrictEqual(result, ['line1', 'line2']);
438
});
439
440
test('reverses a deletion by re-adding the line', () => {
441
const afterLines = ['line1', 'line3'];
442
const patch: LineChange[] = [
443
{ beforeLineNumber: 2, content: 'line2', type: 'remove' }
444
];
445
const result = reverseParsedPatch([...afterLines], patch);
446
447
assert.deepStrictEqual(result, ['line1', 'line2', 'line3']);
448
});
449
450
// TODO(bug): This test documents buggy behavior in reverseParsedPatch - the patch is NOT actually reversed.
451
// When given a replacement (delete 'old' and add 'new' at the same line), the function returns input unchanged:
452
// 1. Processing 'remove' inserts 'old' at index 1: ['line1', 'old', 'new', 'line3']
453
// 2. Processing 'add' removes at index 1: ['line1', 'new', 'line3']
454
// The result equals the input, meaning no reversal occurred.
455
// Expected correct behavior: ['line1', 'old', 'line3'] (the original state before the replacement).
456
// This test validates incorrect behavior and should be fixed when reverseParsedPatch is corrected.
457
test('reverses a replacement (delete then add)', () => {
458
const afterLines = ['line1', 'new', 'line3'];
459
const patch: LineChange[] = [
460
{ beforeLineNumber: 2, content: 'old', type: 'remove' },
461
{ beforeLineNumber: 2, content: 'new', type: 'add' }
462
];
463
const result = reverseParsedPatch([...afterLines], patch);
464
465
// BUG: Returns input unchanged instead of properly reversed result ['line1', 'old', 'line3']
466
assert.deepStrictEqual(result, ['line1', 'new', 'line3']);
467
});
468
469
test('handles multiple additions at different positions', () => {
470
// After: line1, added1, line2, added2, line3
471
// Patch adds at positions 2 and 4 (in after state)
472
const afterLines = ['line1', 'added1', 'line2', 'added2', 'line3'];
473
const patch: LineChange[] = [
474
{ beforeLineNumber: 2, content: 'added1', type: 'add' },
475
{ beforeLineNumber: 3, content: 'added2', type: 'add' }
476
];
477
const result = reverseParsedPatch([...afterLines], patch);
478
479
// After first removal: ['line1', 'line2', 'added2', 'line3']
480
// After second removal at position 2: ['line1', 'line2', 'line3']
481
assert.deepStrictEqual(result, ['line1', 'line2', 'line3']);
482
});
483
484
test('handles multiple deletions at different positions', () => {
485
// After: line1, line3, line5
486
// Before had line2 at position 2 and line4 at position 4
487
const afterLines = ['line1', 'line3', 'line5'];
488
const patch: LineChange[] = [
489
{ beforeLineNumber: 2, content: 'line2', type: 'remove' },
490
{ beforeLineNumber: 4, content: 'line4', type: 'remove' }
491
];
492
const result = reverseParsedPatch([...afterLines], patch);
493
494
// After first insert at 1: ['line1', 'line2', 'line3', 'line5']
495
// After second insert at 3: ['line1', 'line2', 'line3', 'line4', 'line5']
496
assert.deepStrictEqual(result, ['line1', 'line2', 'line3', 'line4', 'line5']);
497
});
498
499
test('handles empty file lines array', () => {
500
const afterLines: string[] = [];
501
const patch: LineChange[] = [
502
{ beforeLineNumber: 1, content: 'was here', type: 'remove' }
503
];
504
const result = reverseParsedPatch([...afterLines], patch);
505
506
assert.deepStrictEqual(result, ['was here']);
507
});
508
509
test('handles addition at end of file', () => {
510
const afterLines = ['line1', 'line2', 'added at end'];
511
const patch: LineChange[] = [
512
{ beforeLineNumber: 3, content: 'added at end', type: 'add' }
513
];
514
const result = reverseParsedPatch([...afterLines], patch);
515
516
assert.deepStrictEqual(result, ['line1', 'line2']);
517
});
518
});
519
520
describe('reversePatch', () => {
521
522
test('reverses simple addition', () => {
523
const after = 'line1\nadded\nline2';
524
const diff = '@@ -1,2 +1,3 @@\n line1\n+added\n line2';
525
526
const result = reversePatch(after, diff);
527
528
assert.strictEqual(result, 'line1\nline2');
529
});
530
531
test('reverses simple deletion', () => {
532
const after = 'line1\nline3';
533
const diff = '@@ -1,3 +1,2 @@\n line1\n-line2\n line3';
534
535
const result = reversePatch(after, diff);
536
537
assert.strictEqual(result, 'line1\nline2\nline3');
538
});
539
540
test('reverses replacement', () => {
541
const after = 'line1\nnew\nline3';
542
const diff = '@@ -1,3 +1,3 @@\n line1\n-old\n+new\n line3';
543
544
const result = reversePatch(after, diff);
545
546
assert.strictEqual(result, 'line1\nold\nline3');
547
});
548
549
test('handles CRLF in after content', () => {
550
const after = 'line1\r\nadded\r\nline2';
551
const diff = '@@ -1,2 +1,3 @@\n line1\n+added\n line2';
552
553
const result = reversePatch(after, diff);
554
555
assert.strictEqual(result, 'line1\nline2');
556
});
557
558
test('handles empty diff', () => {
559
const after = 'line1\nline2';
560
const diff = '';
561
562
const result = reversePatch(after, diff);
563
564
assert.strictEqual(result, 'line1\nline2');
565
});
566
});
567
568
describe('createReviewComment', () => {
569
570
function createTestRequest(overrides?: Partial<ReviewRequest>): ReviewRequest {
571
return {
572
source: 'githubReviewAgent',
573
promptCount: 1,
574
messageId: 'test-message-id',
575
inputType: 'change',
576
inputRanges: [],
577
...overrides,
578
};
579
}
580
581
test('creates comment with correct range from line number', () => {
582
const docData = createTextDocumentData(
583
URI.file('/test/file.ts'),
584
'line1\n indented line\nline3',
585
'typescript'
586
);
587
const ghComment: ResponseComment = {
588
type: 'github.generated-pull-request-comment',
589
data: {
590
path: 'file.ts',
591
line: 2,
592
body: 'This line has an issue.'
593
}
594
};
595
const request = createTestRequest();
596
597
const comment = createReviewComment(ghComment, request, docData.document, 0);
598
599
assert.strictEqual(comment.range.start.line, 1); // 0-indexed
600
assert.strictEqual(comment.range.start.character, 4); // firstNonWhitespaceCharacterIndex
601
assert.strictEqual(comment.range.end.line, 1);
602
assert.strictEqual(comment.languageId, 'typescript');
603
assert.strictEqual(comment.originalIndex, 0);
604
assert.strictEqual(comment.kind, 'bug');
605
assert.strictEqual(comment.severity, 'medium');
606
});
607
608
test('extracts suggestion from body and creates edit', () => {
609
const docData = createTextDocumentData(
610
URI.file('/test/file.ts'),
611
'const x = 1;\nconst y = 2;\nconst z = 3;',
612
'typescript'
613
);
614
const ghComment: ResponseComment = {
615
type: 'github.generated-pull-request-comment',
616
data: {
617
path: 'file.ts',
618
line: 2,
619
body: 'Fix the variable name.\n```suggestion\nconst fixedY = 2;\n```'
620
}
621
};
622
const request = createTestRequest();
623
624
const comment = createReviewComment(ghComment, request, docData.document, 0);
625
626
// Body should have suggestion removed - body is MarkdownString in this case
627
const bodyValue = typeof comment.body === 'string' ? comment.body : comment.body.value;
628
assert.strictEqual(bodyValue, 'Fix the variable name.\n');
629
// Should have one edit suggestion
630
assert.ok(comment.suggestion);
631
assert.ok(!('then' in comment.suggestion)); // Not a promise
632
const suggestion = comment.suggestion as { edits: { newText: string }[] };
633
assert.strictEqual(suggestion.edits.length, 1);
634
assert.strictEqual(suggestion.edits[0].newText, 'const fixedY = 2;\n');
635
});
636
637
test('handles comment with start_line for multi-line range', () => {
638
const docData = createTextDocumentData(
639
URI.file('/test/file.ts'),
640
'line1\nline2\nline3\nline4',
641
'typescript'
642
);
643
const ghComment: ResponseComment = {
644
type: 'github.generated-pull-request-comment',
645
data: {
646
path: 'file.ts',
647
line: 3,
648
start_line: 2,
649
body: 'Multi-line issue.\n```suggestion\nreplacement\n```'
650
}
651
};
652
const request = createTestRequest();
653
654
const comment = createReviewComment(ghComment, request, docData.document, 1);
655
656
// Suggestion range should span from start_line to line
657
assert.ok(comment.suggestion);
658
assert.ok(!('then' in comment.suggestion)); // Not a promise
659
const suggestion = comment.suggestion as { edits: { range: { start: { line: number }; end: { line: number } } }[] };
660
assert.strictEqual(suggestion.edits[0].range.start.line, 1); // start_line - 1
661
assert.strictEqual(suggestion.edits[0].range.end.line, 3); // line
662
assert.strictEqual(comment.originalIndex, 1);
663
});
664
665
test('handles excluded comment', () => {
666
const docData = createTextDocumentData(
667
URI.file('/test/file.ts'),
668
'line1\nline2\nline3',
669
'typescript'
670
);
671
const ghComment: ExcludedComment = {
672
type: 'github.excluded-pull-request-comment',
673
data: {
674
path: 'file.ts',
675
line: 2,
676
body: 'Low confidence comment.',
677
exclusion_reason: 'denylisted_type'
678
}
679
};
680
const request = createTestRequest();
681
682
const comment = createReviewComment(ghComment, request, docData.document, 0);
683
684
const bodyValue = typeof comment.body === 'string' ? comment.body : comment.body.value;
685
assert.strictEqual(bodyValue, 'Low confidence comment.');
686
assert.strictEqual(comment.range.start.line, 1);
687
});
688
689
test('handles comment without suggestion', () => {
690
const docData = createTextDocumentData(
691
URI.file('/test/file.ts'),
692
'const x = 1;',
693
'typescript'
694
);
695
const ghComment: ResponseComment = {
696
type: 'github.generated-pull-request-comment',
697
data: {
698
path: 'file.ts',
699
line: 1,
700
body: 'Consider renaming this variable.'
701
}
702
};
703
const request = createTestRequest();
704
705
const comment = createReviewComment(ghComment, request, docData.document, 0);
706
707
const bodyValue = typeof comment.body === 'string' ? comment.body : comment.body.value;
708
assert.strictEqual(bodyValue, 'Consider renaming this variable.');
709
assert.ok(comment.suggestion);
710
assert.ok(!('then' in comment.suggestion)); // Not a promise
711
const suggestion = comment.suggestion as { edits: unknown[] };
712
assert.strictEqual(suggestion.edits.length, 0);
713
});
714
});
715
716
describe('loadCustomInstructions', () => {
717
718
function createMockWorkspaceService(): IWorkspaceService {
719
return {
720
asRelativePath: (uri: URI) => uri.path.split('/').pop() || uri.path
721
} as IWorkspaceService;
722
}
723
724
test('returns empty array when no instructions configured', async () => {
725
const customInstructionsService = new MockCustomInstructionsService();
726
const workspaceService = createMockWorkspaceService();
727
const languageIdToFilePatterns = new Map<string, Set<string>>();
728
729
const result = await loadCustomInstructions(
730
customInstructionsService,
731
workspaceService,
732
'diff',
733
languageIdToFilePatterns,
734
1
735
);
736
737
assert.deepStrictEqual(result, []);
738
});
739
740
test('loads instructions from agent instruction files', async () => {
741
// Create a custom service that returns agent instructions
742
const testUri = URI.file('/test/instructions.md');
743
const customInstructionsService = {
744
...new MockCustomInstructionsService(),
745
getAgentInstructions: () => Promise.resolve([testUri]),
746
fetchInstructionsFromFile: (uri: typeof testUri) => Promise.resolve({
747
content: [{ instruction: 'Test instruction', languageId: undefined }]
748
}),
749
fetchInstructionsFromSetting: () => Promise.resolve([])
750
};
751
const workspaceService = createMockWorkspaceService();
752
const languageIdToFilePatterns = new Map<string, Set<string>>();
753
754
const result = await loadCustomInstructions(
755
customInstructionsService as unknown as ICustomInstructionsService,
756
workspaceService,
757
'selection',
758
languageIdToFilePatterns,
759
1
760
);
761
762
assert.strictEqual(result.length, 1);
763
assert.strictEqual(result[0].type, 'github.coding_guideline');
764
assert.strictEqual(result[0].data.description, 'Test instruction');
765
assert.deepStrictEqual(result[0].data.filePatterns, ['*']);
766
});
767
768
test('loads instructions from settings', async () => {
769
// Create a custom service that returns settings instructions
770
const customInstructionsService = {
771
...new MockCustomInstructionsService(),
772
getAgentInstructions: () => Promise.resolve([]),
773
fetchInstructionsFromFile: () => Promise.resolve(undefined),
774
fetchInstructionsFromSetting: () => Promise.resolve([{
775
content: [{ instruction: 'Settings instruction', languageId: undefined }]
776
}])
777
};
778
const workspaceService = createMockWorkspaceService();
779
const languageIdToFilePatterns = new Map<string, Set<string>>();
780
781
const result = await loadCustomInstructions(
782
customInstructionsService as unknown as ICustomInstructionsService,
783
workspaceService,
784
'selection',
785
languageIdToFilePatterns,
786
1
787
);
788
789
// CodeGenerationInstructions + CodeFeedbackInstructions for 'selection' kind
790
// Each setting config will be called, and each returns 1 instruction
791
assert.ok(result.length >= 1);
792
assert.strictEqual(result[0].type, 'github.coding_guideline');
793
});
794
795
test('filters instructions by languageId when specified', async () => {
796
const testUri = URI.file('/test/instructions.md');
797
const customInstructionsService = {
798
...new MockCustomInstructionsService(),
799
getAgentInstructions: () => Promise.resolve([testUri]),
800
fetchInstructionsFromFile: () => Promise.resolve({
801
content: [
802
{ instruction: 'TypeScript only', languageId: 'typescript' },
803
{ instruction: 'Python only', languageId: 'python' },
804
{ instruction: 'All languages', languageId: undefined }
805
]
806
}),
807
fetchInstructionsFromSetting: () => Promise.resolve([])
808
};
809
const workspaceService = createMockWorkspaceService();
810
// Only TypeScript is in the map, so Python instruction should be skipped
811
const languageIdToFilePatterns = new Map<string, Set<string>>([
812
['typescript', new Set(['*.ts', '*.tsx'])]
813
]);
814
815
const result = await loadCustomInstructions(
816
customInstructionsService as unknown as ICustomInstructionsService,
817
workspaceService,
818
'selection',
819
languageIdToFilePatterns,
820
1
821
);
822
823
// Should have 2 instructions: TypeScript + All languages (Python skipped)
824
assert.strictEqual(result.length, 2);
825
const descriptions = result.map(r => r.data.description);
826
assert.ok(descriptions.includes('TypeScript only'));
827
assert.ok(descriptions.includes('All languages'));
828
assert.ok(!descriptions.includes('Python only'));
829
830
// TypeScript instruction should have specific file patterns
831
const tsInstruction = result.find(r => r.data.description === 'TypeScript only');
832
assert.ok(tsInstruction);
833
assert.deepStrictEqual(tsInstruction.data.filePatterns.sort(), ['*.ts', '*.tsx']);
834
});
835
836
test('filters settings instructions by languageId', async () => {
837
// Create a custom service that returns settings instructions with languageId
838
const customInstructionsService = {
839
...new MockCustomInstructionsService(),
840
getAgentInstructions: () => Promise.resolve([]),
841
fetchInstructionsFromFile: () => Promise.resolve(undefined),
842
fetchInstructionsFromSetting: () => Promise.resolve([{
843
content: [
844
{ instruction: 'JavaScript rule', languageId: 'javascript' },
845
{ instruction: 'Ruby rule', languageId: 'ruby' },
846
{ instruction: 'General rule', languageId: undefined }
847
]
848
}])
849
};
850
const workspaceService = createMockWorkspaceService();
851
// Only JavaScript is in the map, Ruby should be filtered out
852
const languageIdToFilePatterns = new Map<string, Set<string>>([
853
['javascript', new Set(['*.js'])]
854
]);
855
856
const result = await loadCustomInstructions(
857
customInstructionsService as unknown as ICustomInstructionsService,
858
workspaceService,
859
'selection',
860
languageIdToFilePatterns,
861
1
862
);
863
864
// JavaScript + General should be included, Ruby filtered out
865
const descriptions = result.map(r => r.data.description);
866
assert.ok(descriptions.includes('JavaScript rule'));
867
assert.ok(descriptions.includes('General rule'));
868
assert.ok(!descriptions.includes('Ruby rule'));
869
});
870
});
871
872
describe('githubReview', () => {
873
// These tests verify the integration of githubReview with mocked services
874
// Following the pattern from chatMLFetcherRetry.spec.ts for extending mocks
875
876
// Common mock services shared across tests
877
const createMockFetcherService = (): IFetcherService => ({
878
makeAbortController: () => ({ abort: () => { }, signal: {} }),
879
isAbortError: () => false,
880
} as unknown as IFetcherService);
881
882
const createBaseMocks = () => ({
883
domainService: { _serviceBrand: undefined, onDidChangeDomains: Event.None } as IDomainService,
884
fetcherService: createMockFetcherService(),
885
envService: { sessionId: 'test' } as IEnvService,
886
});
887
888
const createMockGitExtensionService = (): IGitExtensionService => {
889
const mockGitApi = {
890
getRepository: () => ({ rootUri: URI.file('/test') }),
891
repositories: [],
892
};
893
return {
894
getExtensionApi: () => mockGitApi,
895
extensionAvailable: true,
896
} as unknown as IGitExtensionService;
897
};
898
899
test('returns success with empty comments when git extension is not available', async () => {
900
const { githubReview } = await import('../githubReviewAgent');
901
const { domainService, fetcherService, envService } = createBaseMocks();
902
903
const result = await githubReview(
904
new TestLogService(),
905
new NullGitExtensionService(),
906
new MockAuthenticationService() as unknown as IAuthenticationService,
907
new MockCAPIClientService() as unknown as ICAPIClientService,
908
domainService,
909
fetcherService,
910
envService,
911
new NullIgnoreService(),
912
new MockWorkspaceService(),
913
new MockCustomInstructionsService(),
914
{ repositoryRoot: '/test', commitMessages: [], patches: [] },
915
undefined,
916
{ report: () => { } },
917
CancellationToken.None
918
);
919
920
assert.strictEqual(result.type, 'success');
921
if (result.type === 'success') {
922
assert.deepStrictEqual(result.comments, []);
923
}
924
});
925
926
test('returns success with empty comments when no patches provided', async () => {
927
const { githubReview } = await import('../githubReviewAgent');
928
const { domainService, fetcherService, envService } = createBaseMocks();
929
930
const result = await githubReview(
931
new TestLogService(),
932
createMockGitExtensionService(),
933
new MockAuthenticationService() as unknown as IAuthenticationService,
934
new MockCAPIClientService() as unknown as ICAPIClientService,
935
domainService,
936
fetcherService,
937
envService,
938
new NullIgnoreService(),
939
new MockWorkspaceService(),
940
new MockCustomInstructionsService(),
941
{ repositoryRoot: '/test', commitMessages: [], patches: [] },
942
undefined,
943
{ report: () => { } },
944
CancellationToken.None
945
);
946
947
assert.strictEqual(result.type, 'success');
948
if (result.type === 'success') {
949
assert.deepStrictEqual(result.comments, []);
950
}
951
});
952
953
test('processes patches and returns review comments from API response', async () => {
954
const { githubReview } = await import('../githubReviewAgent');
955
const { domainService, fetcherService, envService } = createBaseMocks();
956
957
// Extend MockAuthenticationService to return a valid token (following chatMLFetcherRetry.spec.ts pattern)
958
class TestAuthenticationService extends MockAuthenticationService {
959
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
960
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));
961
}
962
}
963
964
// Set up CAPI client to return a streaming response with a comment
965
const sseResponse = [
966
`data: ${JSON.stringify({
967
copilot_references: [{
968
type: 'github.generated-pull-request-comment',
969
data: {
970
path: 'file.ts',
971
line: 1,
972
body: 'Consider using const instead of let.'
973
}
974
}]
975
})}\n`,
976
'data: [DONE]\n'
977
];
978
class TestCAPIClientService extends MockCAPIClientService {
979
override makeRequest<T>(): Promise<T> {
980
return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);
981
}
982
}
983
984
// Set up workspace service with a document (inline extension pattern)
985
const fileUri = URI.file('/test/file.ts');
986
const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');
987
class TestWorkspaceService extends MockWorkspaceService {
988
override openTextDocument(uri: URI): Promise<TextDocument> {
989
if (uri.toString() === fileUri.toString()) {
990
return Promise.resolve(docData.document);
991
}
992
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
993
}
994
}
995
996
const reportedComments: ReviewComment[] = [];
997
const progress = {
998
report: (comments: ReviewComment[]) => reportedComments.push(...comments)
999
};
1000
1001
const result = await githubReview(
1002
new TestLogService(),
1003
createMockGitExtensionService(),
1004
new TestAuthenticationService() as unknown as IAuthenticationService,
1005
new TestCAPIClientService() as unknown as ICAPIClientService,
1006
domainService,
1007
fetcherService,
1008
envService,
1009
new NullIgnoreService(),
1010
new TestWorkspaceService(),
1011
new MockCustomInstructionsService(),
1012
{
1013
repositoryRoot: '/test',
1014
commitMessages: ['test commit'],
1015
patches: [{
1016
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1017
fileUri: fileUri.toString(),
1018
}]
1019
},
1020
undefined,
1021
progress,
1022
CancellationToken.None
1023
);
1024
1025
assert.strictEqual(result.type, 'success');
1026
if (result.type === 'success') {
1027
assert.strictEqual(result.comments.length, 1);
1028
assert.strictEqual(reportedComments.length, 1);
1029
}
1030
});
1031
1032
test('returns info error when all files are ignored', async () => {
1033
const { githubReview } = await import('../githubReviewAgent');
1034
const { domainService, fetcherService, envService } = createBaseMocks();
1035
1036
// Create an ignore service that ignores all files
1037
const ignoreService = {
1038
isCopilotIgnored: () => Promise.resolve(true),
1039
};
1040
1041
// Set up workspace service with a document (inline extension pattern)
1042
const fileUri = URI.file('/test/file.ts');
1043
const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');
1044
class TestWorkspaceService extends MockWorkspaceService {
1045
override openTextDocument(uri: URI): Promise<TextDocument> {
1046
if (uri.toString() === fileUri.toString()) {
1047
return Promise.resolve(docData.document);
1048
}
1049
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1050
}
1051
}
1052
1053
const result = await githubReview(
1054
new TestLogService(),
1055
createMockGitExtensionService(),
1056
new MockAuthenticationService() as unknown as IAuthenticationService,
1057
new MockCAPIClientService() as unknown as ICAPIClientService,
1058
domainService,
1059
fetcherService,
1060
envService,
1061
ignoreService as unknown as IIgnoreService,
1062
new TestWorkspaceService(),
1063
new MockCustomInstructionsService(),
1064
{
1065
repositoryRoot: '/test',
1066
commitMessages: [],
1067
patches: [{
1068
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1069
fileUri: fileUri.toString(),
1070
}]
1071
},
1072
undefined,
1073
{ report: () => { } },
1074
CancellationToken.None
1075
);
1076
1077
assert.strictEqual(result.type, 'error');
1078
if (result.type === 'error') {
1079
assert.strictEqual(result.severity, 'info');
1080
assert.ok(result.reason.includes('ignored'));
1081
}
1082
});
1083
1084
test('handles cancelled request via abort signal', async () => {
1085
const { githubReview } = await import('../githubReviewAgent');
1086
const { domainService, envService } = createBaseMocks();
1087
1088
// Create auth service with token
1089
class TestAuthenticationService extends MockAuthenticationService {
1090
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1091
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));
1092
}
1093
}
1094
1095
const fileUri = URI.file('/test/file.ts');
1096
const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');
1097
1098
class TestWorkspaceService extends MockWorkspaceService {
1099
override openTextDocument(uri: URI): Promise<TextDocument> {
1100
if (uri.toString() === fileUri.toString()) {
1101
return Promise.resolve(docData.document);
1102
}
1103
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1104
}
1105
override asRelativePath(uri: URI): string {
1106
return uri.path.replace(/^\/test\//, '');
1107
}
1108
}
1109
1110
// Mock fetcher with abort support
1111
const abortError = new Error('Aborted');
1112
const fetcherService: IFetcherService = {
1113
makeAbortController: () => ({ abort: () => { }, signal: {} }),
1114
isAbortError: (err: unknown) => err === abortError,
1115
} as unknown as IFetcherService;
1116
1117
// Create CAPI client that throws abort error
1118
class TestCAPIClientService extends MockCAPIClientService {
1119
buildUrl(_ep: unknown, path: string): URL {
1120
return new URL('https://api.github.com' + path);
1121
}
1122
override makeRequest<T>(): Promise<T> {
1123
return Promise.reject(abortError);
1124
}
1125
}
1126
1127
const result = await githubReview(
1128
new TestLogService(),
1129
createMockGitExtensionService(),
1130
new TestAuthenticationService() as unknown as IAuthenticationService,
1131
new TestCAPIClientService() as unknown as ICAPIClientService,
1132
domainService,
1133
fetcherService,
1134
envService,
1135
new NullIgnoreService(),
1136
new TestWorkspaceService(),
1137
new MockCustomInstructionsService(),
1138
{
1139
repositoryRoot: '/test',
1140
commitMessages: ['test commit'],
1141
patches: [{
1142
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1143
fileUri: fileUri.toString(),
1144
}]
1145
},
1146
undefined,
1147
{ report: () => { } },
1148
CancellationToken.None
1149
);
1150
1151
// When aborted, should return cancelled
1152
assert.strictEqual(result.type, 'cancelled');
1153
});
1154
1155
test('handles HTTP 402 quota exceeded error', async () => {
1156
const { githubReview } = await import('../githubReviewAgent');
1157
const { domainService, fetcherService, envService } = createBaseMocks();
1158
1159
// Create auth service with token
1160
class TestAuthenticationService extends MockAuthenticationService {
1161
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1162
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));
1163
}
1164
}
1165
1166
const fileUri = URI.file('/test/file.ts');
1167
const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');
1168
1169
class TestWorkspaceService extends MockWorkspaceService {
1170
override openTextDocument(uri: URI): Promise<TextDocument> {
1171
if (uri.toString() === fileUri.toString()) {
1172
return Promise.resolve(docData.document);
1173
}
1174
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1175
}
1176
override asRelativePath(uri: URI): string {
1177
return uri.path.replace(/^\/test\//, '');
1178
}
1179
}
1180
1181
// Create CAPI client that returns 402
1182
class TestCAPIClientService extends MockCAPIClientService {
1183
buildUrl(_ep: unknown, path: string): URL {
1184
return new URL('https://api.github.com' + path);
1185
}
1186
override makeRequest<T>(): Promise<T> {
1187
return Promise.resolve({
1188
ok: false,
1189
status: 402,
1190
headers: { get: (name: string) => name === 'x-github-request-id' ? 'test-req-id' : null },
1191
} as unknown as T);
1192
}
1193
}
1194
1195
try {
1196
await githubReview(
1197
new TestLogService(),
1198
createMockGitExtensionService(),
1199
new TestAuthenticationService() as unknown as IAuthenticationService,
1200
new TestCAPIClientService() as unknown as ICAPIClientService,
1201
domainService,
1202
fetcherService,
1203
envService,
1204
new NullIgnoreService(),
1205
new TestWorkspaceService(),
1206
new MockCustomInstructionsService(),
1207
{
1208
repositoryRoot: '/test',
1209
commitMessages: ['test commit'],
1210
patches: [{
1211
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1212
fileUri: fileUri.toString(),
1213
}]
1214
},
1215
undefined,
1216
{ report: () => { } },
1217
CancellationToken.None
1218
);
1219
assert.fail('Should have thrown an error');
1220
} catch (err: unknown) {
1221
const error = err as Error & { severity?: string };
1222
assert.ok(error.message.includes('quota'));
1223
assert.strictEqual(error.severity, 'info');
1224
}
1225
});
1226
1227
test('handles HTTP error response', async () => {
1228
const { githubReview } = await import('../githubReviewAgent');
1229
const { domainService, fetcherService, envService } = createBaseMocks();
1230
1231
// Create auth service with token
1232
class TestAuthenticationService extends MockAuthenticationService {
1233
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1234
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));
1235
}
1236
}
1237
1238
const fileUri = URI.file('/test/file.ts');
1239
const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');
1240
1241
class TestWorkspaceService extends MockWorkspaceService {
1242
override openTextDocument(uri: URI): Promise<TextDocument> {
1243
if (uri.toString() === fileUri.toString()) {
1244
return Promise.resolve(docData.document);
1245
}
1246
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1247
}
1248
override asRelativePath(uri: URI): string {
1249
return uri.path.replace(/^\/test\//, '');
1250
}
1251
}
1252
1253
// Create CAPI client that returns 500
1254
class TestCAPIClientService extends MockCAPIClientService {
1255
buildUrl(_ep: unknown, path: string): URL {
1256
return new URL('https://api.github.com' + path);
1257
}
1258
override makeRequest<T>(): Promise<T> {
1259
return Promise.resolve({
1260
ok: false,
1261
status: 500,
1262
headers: { get: (name: string) => name === 'x-github-request-id' ? 'test-req-id' : null },
1263
} as unknown as T);
1264
}
1265
}
1266
1267
try {
1268
await githubReview(
1269
new TestLogService(),
1270
createMockGitExtensionService(),
1271
new TestAuthenticationService() as unknown as IAuthenticationService,
1272
new TestCAPIClientService() as unknown as ICAPIClientService,
1273
domainService,
1274
fetcherService,
1275
envService,
1276
new NullIgnoreService(),
1277
new TestWorkspaceService(),
1278
new MockCustomInstructionsService(),
1279
{
1280
repositoryRoot: '/test',
1281
commitMessages: ['test commit'],
1282
patches: [{
1283
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1284
fileUri: fileUri.toString(),
1285
}]
1286
},
1287
undefined,
1288
{ report: () => { } },
1289
CancellationToken.None
1290
);
1291
assert.fail('Should have thrown an error');
1292
} catch (err: unknown) {
1293
const error = err as Error;
1294
assert.ok(error.message.includes('500'));
1295
assert.ok(error.message.includes('test-req-id'));
1296
}
1297
});
1298
1299
test('propagates non-abort fetch errors', async () => {
1300
const { githubReview } = await import('../githubReviewAgent');
1301
const { domainService, envService } = createBaseMocks();
1302
1303
// Create auth service with token
1304
class TestAuthenticationService extends MockAuthenticationService {
1305
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1306
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));
1307
}
1308
}
1309
1310
const fileUri = URI.file('/test/file.ts');
1311
const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');
1312
1313
class TestWorkspaceService extends MockWorkspaceService {
1314
override openTextDocument(uri: URI): Promise<TextDocument> {
1315
if (uri.toString() === fileUri.toString()) {
1316
return Promise.resolve(docData.document);
1317
}
1318
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1319
}
1320
override asRelativePath(uri: URI): string {
1321
return uri.path.replace(/^\/test\//, '');
1322
}
1323
}
1324
1325
// Mock fetcher that does NOT recognize this error as abort
1326
const networkError = new Error('Network failure');
1327
const fetcherService: IFetcherService = {
1328
makeAbortController: () => ({ abort: () => { }, signal: {} }),
1329
isAbortError: () => false, // Not an abort error
1330
} as unknown as IFetcherService;
1331
1332
// Create CAPI client that throws a network error
1333
class TestCAPIClientService extends MockCAPIClientService {
1334
buildUrl(_ep: unknown, path: string): URL {
1335
return new URL('https://api.github.com' + path);
1336
}
1337
override makeRequest<T>(): Promise<T> {
1338
return Promise.reject(networkError);
1339
}
1340
}
1341
1342
try {
1343
await githubReview(
1344
new TestLogService(),
1345
createMockGitExtensionService(),
1346
new TestAuthenticationService() as unknown as IAuthenticationService,
1347
new TestCAPIClientService() as unknown as ICAPIClientService,
1348
domainService,
1349
fetcherService,
1350
envService,
1351
new NullIgnoreService(),
1352
new TestWorkspaceService(),
1353
new MockCustomInstructionsService(),
1354
{
1355
repositoryRoot: '/test',
1356
commitMessages: ['test commit'],
1357
patches: [{
1358
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1359
fileUri: fileUri.toString(),
1360
}]
1361
},
1362
undefined,
1363
{ report: () => { } },
1364
CancellationToken.None
1365
);
1366
assert.fail('Should have thrown an error');
1367
} catch (err: unknown) {
1368
const error = err as Error;
1369
assert.strictEqual(error.message, 'Network failure');
1370
}
1371
});
1372
1373
test('ignores comments with paths not matching any change', async () => {
1374
const { githubReview } = await import('../githubReviewAgent');
1375
const { domainService, fetcherService, envService } = createBaseMocks();
1376
1377
// Extend MockAuthenticationService to return a valid token
1378
class TestAuthenticationService extends MockAuthenticationService {
1379
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1380
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));
1381
}
1382
}
1383
1384
// Set up workspace service with a document
1385
const fileUri = URI.file('/test/file.ts');
1386
const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');
1387
1388
class TestWorkspaceService extends MockWorkspaceService {
1389
override openTextDocument(uri: URI): Promise<TextDocument> {
1390
if (uri.toString() === fileUri.toString()) {
1391
return Promise.resolve(docData.document);
1392
}
1393
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1394
}
1395
override asRelativePath(uri: URI): string {
1396
return uri.path.replace(/^\/test\//, '');
1397
}
1398
}
1399
1400
// Response contains a comment for a different file - use proper SSE format
1401
const sseResponse = [
1402
`data: ${JSON.stringify({
1403
copilot_references: [{
1404
type: 'github.generated-pull-request-comment',
1405
data: {
1406
path: 'other-file.ts', // Different from file.ts
1407
line: 1,
1408
body: 'Comment on non-existent file'
1409
}
1410
}]
1411
})}\n`,
1412
'data: [DONE]\n'
1413
];
1414
class TestCAPIClientService extends MockCAPIClientService {
1415
override makeRequest<T>(): Promise<T> {
1416
return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);
1417
}
1418
}
1419
1420
const result = await githubReview(
1421
new TestLogService(),
1422
createMockGitExtensionService(),
1423
new TestAuthenticationService() as unknown as IAuthenticationService,
1424
new TestCAPIClientService() as unknown as ICAPIClientService,
1425
domainService,
1426
fetcherService,
1427
envService,
1428
new NullIgnoreService(),
1429
new TestWorkspaceService(),
1430
new MockCustomInstructionsService(),
1431
{
1432
repositoryRoot: '/test',
1433
commitMessages: ['test commit'],
1434
patches: [{
1435
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1436
fileUri: fileUri.toString(),
1437
}]
1438
},
1439
undefined,
1440
{ report: () => { } },
1441
CancellationToken.None
1442
);
1443
1444
// Should succeed but with no comments (the mismatched path comment is skipped)
1445
assert.strictEqual(result.type, 'success');
1446
if (result.type === 'success') {
1447
assert.strictEqual(result.comments.length, 0);
1448
}
1449
});
1450
1451
test('returns excluded comments in result', async () => {
1452
const { githubReview } = await import('../githubReviewAgent');
1453
const { domainService, fetcherService, envService } = createBaseMocks();
1454
1455
class TestAuthenticationService extends MockAuthenticationService {
1456
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1457
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));
1458
}
1459
}
1460
1461
const fileUri = URI.file('/test/file.ts');
1462
const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');
1463
1464
class TestWorkspaceService extends MockWorkspaceService {
1465
override openTextDocument(uri: URI): Promise<TextDocument> {
1466
if (uri.toString() === fileUri.toString()) {
1467
return Promise.resolve(docData.document);
1468
}
1469
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1470
}
1471
}
1472
1473
// Response with excluded comment
1474
const sseResponse = [
1475
`data: ${JSON.stringify({
1476
copilot_references: [{
1477
type: 'github.excluded-pull-request-comment',
1478
data: {
1479
path: 'file.ts',
1480
line: 1,
1481
body: 'Low confidence comment',
1482
exclusion_reason: 'denylisted_type'
1483
}
1484
}]
1485
})}\n`,
1486
'data: [DONE]\n'
1487
];
1488
class TestCAPIClientService extends MockCAPIClientService {
1489
override makeRequest<T>(): Promise<T> {
1490
return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);
1491
}
1492
}
1493
1494
const result = await githubReview(
1495
new TestLogService(),
1496
createMockGitExtensionService(),
1497
new TestAuthenticationService() as unknown as IAuthenticationService,
1498
new TestCAPIClientService() as unknown as ICAPIClientService,
1499
domainService,
1500
fetcherService,
1501
envService,
1502
new NullIgnoreService(),
1503
new TestWorkspaceService(),
1504
new MockCustomInstructionsService(),
1505
{
1506
repositoryRoot: '/test',
1507
commitMessages: ['test commit'],
1508
patches: [{
1509
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1510
fileUri: fileUri.toString(),
1511
}]
1512
},
1513
undefined,
1514
{ report: () => { } },
1515
CancellationToken.None
1516
);
1517
1518
assert.strictEqual(result.type, 'success');
1519
if (result.type === 'success') {
1520
assert.strictEqual(result.comments.length, 0);
1521
assert.strictEqual(result.excludedComments?.length, 1);
1522
const bodyValue = typeof result.excludedComments![0].body === 'string' ? result.excludedComments![0].body : result.excludedComments![0].body.value;
1523
assert.ok(bodyValue.includes('Low confidence'));
1524
}
1525
});
1526
1527
test('returns unsupported language reason when no comments and excluded files exist', async () => {
1528
const { githubReview } = await import('../githubReviewAgent');
1529
const { domainService, fetcherService, envService } = createBaseMocks();
1530
1531
class TestAuthenticationService extends MockAuthenticationService {
1532
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1533
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));
1534
}
1535
}
1536
1537
const fileUri = URI.file('/test/file.ts');
1538
const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');
1539
1540
class TestWorkspaceService extends MockWorkspaceService {
1541
override openTextDocument(uri: URI): Promise<TextDocument> {
1542
if (uri.toString() === fileUri.toString()) {
1543
return Promise.resolve(docData.document);
1544
}
1545
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1546
}
1547
}
1548
1549
// Response with excluded file due to unsupported language
1550
const sseResponse = [
1551
`data: ${JSON.stringify({
1552
copilot_references: [{
1553
type: 'github.excluded-file',
1554
data: {
1555
file_path: 'file.ts',
1556
language: 'cobol',
1557
reason: 'file_type_not_supported'
1558
}
1559
}]
1560
})}\n`,
1561
'data: [DONE]\n'
1562
];
1563
class TestCAPIClientService extends MockCAPIClientService {
1564
override makeRequest<T>(): Promise<T> {
1565
return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);
1566
}
1567
}
1568
1569
const result = await githubReview(
1570
new TestLogService(),
1571
createMockGitExtensionService(),
1572
new TestAuthenticationService() as unknown as IAuthenticationService,
1573
new TestCAPIClientService() as unknown as ICAPIClientService,
1574
domainService,
1575
fetcherService,
1576
envService,
1577
new NullIgnoreService(),
1578
new TestWorkspaceService(),
1579
new MockCustomInstructionsService(),
1580
{
1581
repositoryRoot: '/test',
1582
commitMessages: ['test commit'],
1583
patches: [{
1584
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1585
fileUri: fileUri.toString(),
1586
}]
1587
},
1588
undefined,
1589
{ report: () => { } },
1590
CancellationToken.None
1591
);
1592
1593
assert.strictEqual(result.type, 'success');
1594
if (result.type === 'success') {
1595
assert.strictEqual(result.comments.length, 0);
1596
assert.ok(result.reason);
1597
assert.ok(result.reason!.includes('cobol'));
1598
}
1599
});
1600
1601
test('does not report unsupported languages when comments exist', async () => {
1602
const { githubReview } = await import('../githubReviewAgent');
1603
const { domainService, fetcherService, envService } = createBaseMocks();
1604
1605
class TestAuthenticationService extends MockAuthenticationService {
1606
override getCopilotToken(_force?: boolean): Promise<CopilotToken> {
1607
return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));
1608
}
1609
}
1610
1611
const fileUri = URI.file('/test/file.ts');
1612
const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');
1613
1614
class TestWorkspaceService extends MockWorkspaceService {
1615
override openTextDocument(uri: URI): Promise<TextDocument> {
1616
if (uri.toString() === fileUri.toString()) {
1617
return Promise.resolve(docData.document);
1618
}
1619
return Promise.reject(new Error(`Document not found: ${uri.toString()}`));
1620
}
1621
}
1622
1623
// Response with both a comment and an excluded file
1624
const sseResponse = [
1625
`data: ${JSON.stringify({
1626
copilot_references: [
1627
{
1628
type: 'github.generated-pull-request-comment',
1629
data: {
1630
path: 'file.ts',
1631
line: 1,
1632
body: 'Use const instead of let'
1633
}
1634
},
1635
{
1636
type: 'github.excluded-file',
1637
data: {
1638
file_path: 'other.cobol',
1639
language: 'cobol',
1640
reason: 'file_type_not_supported'
1641
}
1642
}
1643
]
1644
})}\n`,
1645
'data: [DONE]\n'
1646
];
1647
class TestCAPIClientService extends MockCAPIClientService {
1648
override makeRequest<T>(): Promise<T> {
1649
return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);
1650
}
1651
}
1652
1653
const result = await githubReview(
1654
new TestLogService(),
1655
createMockGitExtensionService(),
1656
new TestAuthenticationService() as unknown as IAuthenticationService,
1657
new TestCAPIClientService() as unknown as ICAPIClientService,
1658
domainService,
1659
fetcherService,
1660
envService,
1661
new NullIgnoreService(),
1662
new TestWorkspaceService(),
1663
new MockCustomInstructionsService(),
1664
{
1665
repositoryRoot: '/test',
1666
commitMessages: ['test commit'],
1667
patches: [{
1668
patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',
1669
fileUri: fileUri.toString(),
1670
}]
1671
},
1672
undefined,
1673
{ report: () => { } },
1674
CancellationToken.None
1675
);
1676
1677
assert.strictEqual(result.type, 'success');
1678
if (result.type === 'success') {
1679
assert.strictEqual(result.comments.length, 1);
1680
// When comments exist, unsupported languages are not reported
1681
assert.strictEqual(result.reason, undefined);
1682
}
1683
});
1684
});
1685
});
1686
1687