Path: blob/main/extensions/copilot/src/extension/review/node/test/githubReviewAgent.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { describe, suite, test } from 'vitest';7import type { TextDocument } from 'vscode';8import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';9import { CopilotToken, createTestExtendedTokenInfo } from '../../../../platform/authentication/common/copilotToken';10import { ICustomInstructionsService } from '../../../../platform/customInstructions/common/customInstructionsService';11import { ICAPIClientService } from '../../../../platform/endpoint/common/capiClient';12import { IDomainService } from '../../../../platform/endpoint/common/domainService';13import { IEnvService } from '../../../../platform/env/common/envService';14import { IGitExtensionService } from '../../../../platform/git/common/gitExtensionService';15import { NullGitExtensionService } from '../../../../platform/git/common/nullGitExtensionService';16import { IIgnoreService, NullIgnoreService } from '../../../../platform/ignore/common/ignoreService';17import { MockAuthenticationService } from '../../../../platform/ignore/node/test/mockAuthenticationService';18import { MockCAPIClientService } from '../../../../platform/ignore/node/test/mockCAPIClientService';19import { MockWorkspaceService } from '../../../../platform/ignore/node/test/mockWorkspaceService';20import { IFetcherService } from '../../../../platform/networking/common/fetcherService';21import { ReviewComment, ReviewRequest } from '../../../../platform/review/common/reviewService';22import { MockCustomInstructionsService } from '../../../../platform/test/common/testCustomInstructionsService';23import { createFakeStreamResponse } from '../../../../platform/test/node/fetcher';24import { TestLogService } from '../../../../platform/testing/common/testLogService';25import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';26import { createTextDocumentData } from '../../../../util/common/test/shims/textDocument';27import { CancellationToken } from '../../../../util/vs/base/common/cancellation';28import { Event } from '../../../../util/vs/base/common/event';29import { URI } from '../../../../util/vs/base/common/uri';30import {31createReviewComment,32ExcludedComment,33LineChange,34loadCustomInstructions,35normalizePath,36parseLine,37parsePatch,38removeSuggestion,39ResponseComment,40reverseParsedPatch,41reversePatch42} from '../githubReviewAgent';4344suite('githubReviewAgent', () => {4546describe('normalizePath', () => {4748test('returns path unchanged when no backslashes', () => {49const result = normalizePath('src/components/Button.tsx');50assert.strictEqual(result, 'src/components/Button.tsx');51});5253test('converts backslashes to forward slashes', () => {54// This test verifies the function works regardless of platform55// On Windows, backslashes would be converted; on other platforms, they're still converted56const input = 'src\\components\\Button.tsx';57const result = normalizePath(input);58// On Windows (win32): converts to forward slashes59// On other platforms: returns unchanged (no backslashes in typical paths)60if (process.platform === 'win32') {61assert.strictEqual(result, 'src/components/Button.tsx');62} else {63assert.strictEqual(result, input);64}65});6667test('handles empty string', () => {68const result = normalizePath('');69assert.strictEqual(result, '');70});7172test('handles path with mixed slashes on Windows', () => {73const input = 'src/components\\utils\\helper.ts';74const result = normalizePath(input);75if (process.platform === 'win32') {76assert.strictEqual(result, 'src/components/utils/helper.ts');77} else {78assert.strictEqual(result, input);79}80});81});8283describe('parseLine', () => {8485test('returns empty array for empty line', () => {86const result = parseLine('');87assert.deepStrictEqual(result, []);88});8990test('returns empty array for DONE marker', () => {91const result = parseLine('data: [DONE]');92assert.deepStrictEqual(result, []);93});9495test('returns empty array when no copilot_references', () => {96const result = parseLine('data: {"choices":[]}');97assert.deepStrictEqual(result, []);98});99100test('returns empty array when copilot_references is empty', () => {101const result = parseLine('data: {"copilot_references":[]}');102assert.deepStrictEqual(result, []);103});104105test('parses generated pull request comment', () => {106const data = {107copilot_references: [{108type: 'github.generated-pull-request-comment',109data: {110path: 'src/file.ts',111line: 10,112body: 'This is a bug'113}114}]115};116const result = parseLine(`data: ${JSON.stringify(data)}`);117118assert.strictEqual(result.length, 1);119assert.strictEqual(result[0].type, 'github.generated-pull-request-comment');120if (result[0].type === 'github.generated-pull-request-comment') {121assert.strictEqual(result[0].data.path, 'src/file.ts');122assert.strictEqual(result[0].data.line, 10);123assert.strictEqual(result[0].data.body, 'This is a bug');124}125});126127test('parses excluded pull request comment', () => {128const data = {129copilot_references: [{130type: 'github.excluded-pull-request-comment',131data: {132path: 'src/file.ts',133line: 5,134body: 'Low confidence comment',135exclusion_reason: 'denylisted_type'136}137}]138};139const result = parseLine(`data: ${JSON.stringify(data)}`);140141assert.strictEqual(result.length, 1);142assert.strictEqual(result[0].type, 'github.excluded-pull-request-comment');143});144145test('parses excluded file reference', () => {146const data = {147copilot_references: [{148type: 'github.excluded-file',149data: {150file_path: 'src/file.txt',151language: 'plaintext',152reason: 'file_type_not_supported'153}154}]155};156const result = parseLine(`data: ${JSON.stringify(data)}`);157158assert.strictEqual(result.length, 1);159assert.strictEqual(result[0].type, 'github.excluded-file');160});161162test('parses multiple references in single line', () => {163const data = {164copilot_references: [165{166type: 'github.generated-pull-request-comment',167data: { path: 'a.ts', line: 1, body: 'Comment 1' }168},169{170type: 'github.generated-pull-request-comment',171data: { path: 'b.ts', line: 2, body: 'Comment 2' }172}173]174};175const result = parseLine(`data: ${JSON.stringify(data)}`);176177assert.strictEqual(result.length, 2);178});179180test('filters out references without type', () => {181const data = {182copilot_references: [183{ type: 'github.generated-pull-request-comment', data: { path: 'a.ts', line: 1, body: 'Valid' } },184{ data: { path: 'b.ts', line: 2, body: 'No type field' } }185]186};187const result = parseLine(`data: ${JSON.stringify(data)}`);188189assert.strictEqual(result.length, 1);190});191});192193describe('removeSuggestion', () => {194195test('returns original content when no suggestion block', () => {196const body = 'This is a regular comment without suggestions.';197const result = removeSuggestion(body);198199assert.strictEqual(result.content, body);200assert.deepStrictEqual(result.suggestions, []);201});202203test('extracts single suggestion and removes block', () => {204const body = 'Fix the typo.\n```suggestion\nconst fixed = true;\n```';205const result = removeSuggestion(body);206207assert.strictEqual(result.content, 'Fix the typo.\n');208// The regex captures content including the trailing newline before ```209assert.deepStrictEqual(result.suggestions, ['const fixed = true;\n']);210});211212test('extracts multiple suggestions', () => {213const body = 'First issue.\n```suggestion\nfix1\n```\nSecond issue.\n```suggestion\nfix2\n```';214const result = removeSuggestion(body);215216assert.strictEqual(result.suggestions.length, 2);217// The regex captures content including the trailing newline before ```218assert.strictEqual(result.suggestions[0], 'fix1\n');219assert.strictEqual(result.suggestions[1], 'fix2\n');220});221222test('handles suggestion with CRLF line endings', () => {223const body = 'Fix.\r\n```suggestion\r\nconst x = 1;\r\n```';224const result = removeSuggestion(body);225226// The regex captures content including the trailing CRLF before ```227assert.deepStrictEqual(result.suggestions, ['const x = 1;\r\n']);228});229230test('handles empty suggestion block', () => {231const body = 'Remove this line.\n```suggestion\n```';232const result = removeSuggestion(body);233234assert.strictEqual(result.content, 'Remove this line.\n');235assert.deepStrictEqual(result.suggestions, []);236});237238test('handles suggestion with trailing spaces after keyword', () => {239const body = 'Fix.\n```suggestion \ncode here\n```';240const result = removeSuggestion(body);241242// The regex captures content including the trailing newline before ```243assert.deepStrictEqual(result.suggestions, ['code here\n']);244});245246test('preserves non-suggestion code blocks', () => {247const body = 'Example:\n```typescript\nconst x = 1;\n```\nDone.';248const result = removeSuggestion(body);249250assert.strictEqual(result.content, body);251assert.deepStrictEqual(result.suggestions, []);252});253});254255describe('parsePatch', () => {256257test('returns empty array for empty input', () => {258const result = parsePatch([]);259assert.deepStrictEqual(result, []);260});261262test('parses single addition', () => {263const patchLines = [264'@@ -1,3 +1,4 @@',265' line1',266'+added line',267' line2',268' line3'269];270const result = parsePatch(patchLines);271272assert.strictEqual(result.length, 1);273assert.strictEqual(result[0].type, 'add');274assert.strictEqual(result[0].content, 'added line');275assert.strictEqual(result[0].beforeLineNumber, 2);276});277278test('parses single deletion', () => {279const patchLines = [280'@@ -1,4 +1,3 @@',281' line1',282'-deleted line',283' line2',284' line3'285];286const result = parsePatch(patchLines);287288assert.strictEqual(result.length, 1);289assert.strictEqual(result[0].type, 'remove');290assert.strictEqual(result[0].content, 'deleted line');291assert.strictEqual(result[0].beforeLineNumber, 2);292});293294test('parses mixed additions and deletions', () => {295const patchLines = [296'@@ -1,3 +1,3 @@',297' line1',298'-old line',299'+new line',300' line3'301];302const result = parsePatch(patchLines);303304assert.strictEqual(result.length, 2);305assert.strictEqual(result[0].type, 'remove');306assert.strictEqual(result[0].content, 'old line');307assert.strictEqual(result[1].type, 'add');308assert.strictEqual(result[1].content, 'new line');309});310311test('parses multiple hunks', () => {312const patchLines = [313'@@ -1,2 +1,3 @@',314' line1',315'+added1',316'@@ -10,2 +11,3 @@',317' line10',318'+added2'319];320const result = parsePatch(patchLines);321322assert.strictEqual(result.length, 2);323assert.strictEqual(result[0].beforeLineNumber, 2);324assert.strictEqual(result[1].beforeLineNumber, 11);325});326327test('ignores lines before first hunk header', () => {328const patchLines = [329'diff --git a/file.ts b/file.ts',330'index abc..def 100644',331'--- a/file.ts',332'+++ b/file.ts',333'@@ -1,2 +1,3 @@',334' context',335'+added'336];337const result = parsePatch(patchLines);338339assert.strictEqual(result.length, 1);340assert.strictEqual(result[0].content, 'added');341});342343test('handles malformed hunk header gracefully', () => {344const patchLines = [345'@@ invalid header @@',346'+should be ignored',347'@@ -5,2 +5,3 @@',348' context',349'+added after valid header'350];351const result = parsePatch(patchLines);352353// Only the change after the valid header should be parsed354assert.strictEqual(result.length, 1);355assert.strictEqual(result[0].content, 'added after valid header');356assert.strictEqual(result[0].beforeLineNumber, 6);357});358359test('returns empty array for patch with no hunk headers', () => {360const patchLines = [361'diff --git a/file.ts b/file.ts',362'index abc..def 100644',363'--- a/file.ts',364'+++ b/file.ts',365// No @@ header366];367const result = parsePatch(patchLines);368369assert.deepStrictEqual(result, []);370});371372test('handles hunk with only context lines', () => {373const patchLines = [374'@@ -1,3 +1,3 @@',375' line1',376' line2',377' line3'378];379const result = parsePatch(patchLines);380381assert.deepStrictEqual(result, []);382});383384test('handles consecutive additions', () => {385const patchLines = [386'@@ -1,2 +1,5 @@',387' line1',388'+added1',389'+added2',390'+added3',391' line2'392];393const result = parsePatch(patchLines);394395assert.strictEqual(result.length, 3);396assert.strictEqual(result[0].beforeLineNumber, 2);397assert.strictEqual(result[1].beforeLineNumber, 2);398assert.strictEqual(result[2].beforeLineNumber, 2);399});400401test('handles consecutive deletions', () => {402const patchLines = [403'@@ -1,5 +1,2 @@',404' line1',405'-deleted1',406'-deleted2',407'-deleted3',408' line5'409];410const result = parsePatch(patchLines);411412assert.strictEqual(result.length, 3);413// Each deletion increments beforeLineNumber414assert.strictEqual(result[0].beforeLineNumber, 2);415assert.strictEqual(result[1].beforeLineNumber, 3);416assert.strictEqual(result[2].beforeLineNumber, 4);417});418});419420describe('reverseParsedPatch', () => {421422test('returns original lines when patch is empty', () => {423const lines = ['line1', 'line2', 'line3'];424const result = reverseParsedPatch([...lines], []);425426assert.deepStrictEqual(result, lines);427});428429test('reverses an addition by removing the line', () => {430const afterLines = ['line1', 'added', 'line2'];431const patch: LineChange[] = [432{ beforeLineNumber: 2, content: 'added', type: 'add' }433];434const result = reverseParsedPatch([...afterLines], patch);435436assert.deepStrictEqual(result, ['line1', 'line2']);437});438439test('reverses a deletion by re-adding the line', () => {440const afterLines = ['line1', 'line3'];441const patch: LineChange[] = [442{ beforeLineNumber: 2, content: 'line2', type: 'remove' }443];444const result = reverseParsedPatch([...afterLines], patch);445446assert.deepStrictEqual(result, ['line1', 'line2', 'line3']);447});448449// TODO(bug): This test documents buggy behavior in reverseParsedPatch - the patch is NOT actually reversed.450// When given a replacement (delete 'old' and add 'new' at the same line), the function returns input unchanged:451// 1. Processing 'remove' inserts 'old' at index 1: ['line1', 'old', 'new', 'line3']452// 2. Processing 'add' removes at index 1: ['line1', 'new', 'line3']453// The result equals the input, meaning no reversal occurred.454// Expected correct behavior: ['line1', 'old', 'line3'] (the original state before the replacement).455// This test validates incorrect behavior and should be fixed when reverseParsedPatch is corrected.456test('reverses a replacement (delete then add)', () => {457const afterLines = ['line1', 'new', 'line3'];458const patch: LineChange[] = [459{ beforeLineNumber: 2, content: 'old', type: 'remove' },460{ beforeLineNumber: 2, content: 'new', type: 'add' }461];462const result = reverseParsedPatch([...afterLines], patch);463464// BUG: Returns input unchanged instead of properly reversed result ['line1', 'old', 'line3']465assert.deepStrictEqual(result, ['line1', 'new', 'line3']);466});467468test('handles multiple additions at different positions', () => {469// After: line1, added1, line2, added2, line3470// Patch adds at positions 2 and 4 (in after state)471const afterLines = ['line1', 'added1', 'line2', 'added2', 'line3'];472const patch: LineChange[] = [473{ beforeLineNumber: 2, content: 'added1', type: 'add' },474{ beforeLineNumber: 3, content: 'added2', type: 'add' }475];476const result = reverseParsedPatch([...afterLines], patch);477478// After first removal: ['line1', 'line2', 'added2', 'line3']479// After second removal at position 2: ['line1', 'line2', 'line3']480assert.deepStrictEqual(result, ['line1', 'line2', 'line3']);481});482483test('handles multiple deletions at different positions', () => {484// After: line1, line3, line5485// Before had line2 at position 2 and line4 at position 4486const afterLines = ['line1', 'line3', 'line5'];487const patch: LineChange[] = [488{ beforeLineNumber: 2, content: 'line2', type: 'remove' },489{ beforeLineNumber: 4, content: 'line4', type: 'remove' }490];491const result = reverseParsedPatch([...afterLines], patch);492493// After first insert at 1: ['line1', 'line2', 'line3', 'line5']494// After second insert at 3: ['line1', 'line2', 'line3', 'line4', 'line5']495assert.deepStrictEqual(result, ['line1', 'line2', 'line3', 'line4', 'line5']);496});497498test('handles empty file lines array', () => {499const afterLines: string[] = [];500const patch: LineChange[] = [501{ beforeLineNumber: 1, content: 'was here', type: 'remove' }502];503const result = reverseParsedPatch([...afterLines], patch);504505assert.deepStrictEqual(result, ['was here']);506});507508test('handles addition at end of file', () => {509const afterLines = ['line1', 'line2', 'added at end'];510const patch: LineChange[] = [511{ beforeLineNumber: 3, content: 'added at end', type: 'add' }512];513const result = reverseParsedPatch([...afterLines], patch);514515assert.deepStrictEqual(result, ['line1', 'line2']);516});517});518519describe('reversePatch', () => {520521test('reverses simple addition', () => {522const after = 'line1\nadded\nline2';523const diff = '@@ -1,2 +1,3 @@\n line1\n+added\n line2';524525const result = reversePatch(after, diff);526527assert.strictEqual(result, 'line1\nline2');528});529530test('reverses simple deletion', () => {531const after = 'line1\nline3';532const diff = '@@ -1,3 +1,2 @@\n line1\n-line2\n line3';533534const result = reversePatch(after, diff);535536assert.strictEqual(result, 'line1\nline2\nline3');537});538539test('reverses replacement', () => {540const after = 'line1\nnew\nline3';541const diff = '@@ -1,3 +1,3 @@\n line1\n-old\n+new\n line3';542543const result = reversePatch(after, diff);544545assert.strictEqual(result, 'line1\nold\nline3');546});547548test('handles CRLF in after content', () => {549const after = 'line1\r\nadded\r\nline2';550const diff = '@@ -1,2 +1,3 @@\n line1\n+added\n line2';551552const result = reversePatch(after, diff);553554assert.strictEqual(result, 'line1\nline2');555});556557test('handles empty diff', () => {558const after = 'line1\nline2';559const diff = '';560561const result = reversePatch(after, diff);562563assert.strictEqual(result, 'line1\nline2');564});565});566567describe('createReviewComment', () => {568569function createTestRequest(overrides?: Partial<ReviewRequest>): ReviewRequest {570return {571source: 'githubReviewAgent',572promptCount: 1,573messageId: 'test-message-id',574inputType: 'change',575inputRanges: [],576...overrides,577};578}579580test('creates comment with correct range from line number', () => {581const docData = createTextDocumentData(582URI.file('/test/file.ts'),583'line1\n indented line\nline3',584'typescript'585);586const ghComment: ResponseComment = {587type: 'github.generated-pull-request-comment',588data: {589path: 'file.ts',590line: 2,591body: 'This line has an issue.'592}593};594const request = createTestRequest();595596const comment = createReviewComment(ghComment, request, docData.document, 0);597598assert.strictEqual(comment.range.start.line, 1); // 0-indexed599assert.strictEqual(comment.range.start.character, 4); // firstNonWhitespaceCharacterIndex600assert.strictEqual(comment.range.end.line, 1);601assert.strictEqual(comment.languageId, 'typescript');602assert.strictEqual(comment.originalIndex, 0);603assert.strictEqual(comment.kind, 'bug');604assert.strictEqual(comment.severity, 'medium');605});606607test('extracts suggestion from body and creates edit', () => {608const docData = createTextDocumentData(609URI.file('/test/file.ts'),610'const x = 1;\nconst y = 2;\nconst z = 3;',611'typescript'612);613const ghComment: ResponseComment = {614type: 'github.generated-pull-request-comment',615data: {616path: 'file.ts',617line: 2,618body: 'Fix the variable name.\n```suggestion\nconst fixedY = 2;\n```'619}620};621const request = createTestRequest();622623const comment = createReviewComment(ghComment, request, docData.document, 0);624625// Body should have suggestion removed - body is MarkdownString in this case626const bodyValue = typeof comment.body === 'string' ? comment.body : comment.body.value;627assert.strictEqual(bodyValue, 'Fix the variable name.\n');628// Should have one edit suggestion629assert.ok(comment.suggestion);630assert.ok(!('then' in comment.suggestion)); // Not a promise631const suggestion = comment.suggestion as { edits: { newText: string }[] };632assert.strictEqual(suggestion.edits.length, 1);633assert.strictEqual(suggestion.edits[0].newText, 'const fixedY = 2;\n');634});635636test('handles comment with start_line for multi-line range', () => {637const docData = createTextDocumentData(638URI.file('/test/file.ts'),639'line1\nline2\nline3\nline4',640'typescript'641);642const ghComment: ResponseComment = {643type: 'github.generated-pull-request-comment',644data: {645path: 'file.ts',646line: 3,647start_line: 2,648body: 'Multi-line issue.\n```suggestion\nreplacement\n```'649}650};651const request = createTestRequest();652653const comment = createReviewComment(ghComment, request, docData.document, 1);654655// Suggestion range should span from start_line to line656assert.ok(comment.suggestion);657assert.ok(!('then' in comment.suggestion)); // Not a promise658const suggestion = comment.suggestion as { edits: { range: { start: { line: number }; end: { line: number } } }[] };659assert.strictEqual(suggestion.edits[0].range.start.line, 1); // start_line - 1660assert.strictEqual(suggestion.edits[0].range.end.line, 3); // line661assert.strictEqual(comment.originalIndex, 1);662});663664test('handles excluded comment', () => {665const docData = createTextDocumentData(666URI.file('/test/file.ts'),667'line1\nline2\nline3',668'typescript'669);670const ghComment: ExcludedComment = {671type: 'github.excluded-pull-request-comment',672data: {673path: 'file.ts',674line: 2,675body: 'Low confidence comment.',676exclusion_reason: 'denylisted_type'677}678};679const request = createTestRequest();680681const comment = createReviewComment(ghComment, request, docData.document, 0);682683const bodyValue = typeof comment.body === 'string' ? comment.body : comment.body.value;684assert.strictEqual(bodyValue, 'Low confidence comment.');685assert.strictEqual(comment.range.start.line, 1);686});687688test('handles comment without suggestion', () => {689const docData = createTextDocumentData(690URI.file('/test/file.ts'),691'const x = 1;',692'typescript'693);694const ghComment: ResponseComment = {695type: 'github.generated-pull-request-comment',696data: {697path: 'file.ts',698line: 1,699body: 'Consider renaming this variable.'700}701};702const request = createTestRequest();703704const comment = createReviewComment(ghComment, request, docData.document, 0);705706const bodyValue = typeof comment.body === 'string' ? comment.body : comment.body.value;707assert.strictEqual(bodyValue, 'Consider renaming this variable.');708assert.ok(comment.suggestion);709assert.ok(!('then' in comment.suggestion)); // Not a promise710const suggestion = comment.suggestion as { edits: unknown[] };711assert.strictEqual(suggestion.edits.length, 0);712});713});714715describe('loadCustomInstructions', () => {716717function createMockWorkspaceService(): IWorkspaceService {718return {719asRelativePath: (uri: URI) => uri.path.split('/').pop() || uri.path720} as IWorkspaceService;721}722723test('returns empty array when no instructions configured', async () => {724const customInstructionsService = new MockCustomInstructionsService();725const workspaceService = createMockWorkspaceService();726const languageIdToFilePatterns = new Map<string, Set<string>>();727728const result = await loadCustomInstructions(729customInstructionsService,730workspaceService,731'diff',732languageIdToFilePatterns,7331734);735736assert.deepStrictEqual(result, []);737});738739test('loads instructions from agent instruction files', async () => {740// Create a custom service that returns agent instructions741const testUri = URI.file('/test/instructions.md');742const customInstructionsService = {743...new MockCustomInstructionsService(),744getAgentInstructions: () => Promise.resolve([testUri]),745fetchInstructionsFromFile: (uri: typeof testUri) => Promise.resolve({746content: [{ instruction: 'Test instruction', languageId: undefined }]747}),748fetchInstructionsFromSetting: () => Promise.resolve([])749};750const workspaceService = createMockWorkspaceService();751const languageIdToFilePatterns = new Map<string, Set<string>>();752753const result = await loadCustomInstructions(754customInstructionsService as unknown as ICustomInstructionsService,755workspaceService,756'selection',757languageIdToFilePatterns,7581759);760761assert.strictEqual(result.length, 1);762assert.strictEqual(result[0].type, 'github.coding_guideline');763assert.strictEqual(result[0].data.description, 'Test instruction');764assert.deepStrictEqual(result[0].data.filePatterns, ['*']);765});766767test('loads instructions from settings', async () => {768// Create a custom service that returns settings instructions769const customInstructionsService = {770...new MockCustomInstructionsService(),771getAgentInstructions: () => Promise.resolve([]),772fetchInstructionsFromFile: () => Promise.resolve(undefined),773fetchInstructionsFromSetting: () => Promise.resolve([{774content: [{ instruction: 'Settings instruction', languageId: undefined }]775}])776};777const workspaceService = createMockWorkspaceService();778const languageIdToFilePatterns = new Map<string, Set<string>>();779780const result = await loadCustomInstructions(781customInstructionsService as unknown as ICustomInstructionsService,782workspaceService,783'selection',784languageIdToFilePatterns,7851786);787788// CodeGenerationInstructions + CodeFeedbackInstructions for 'selection' kind789// Each setting config will be called, and each returns 1 instruction790assert.ok(result.length >= 1);791assert.strictEqual(result[0].type, 'github.coding_guideline');792});793794test('filters instructions by languageId when specified', async () => {795const testUri = URI.file('/test/instructions.md');796const customInstructionsService = {797...new MockCustomInstructionsService(),798getAgentInstructions: () => Promise.resolve([testUri]),799fetchInstructionsFromFile: () => Promise.resolve({800content: [801{ instruction: 'TypeScript only', languageId: 'typescript' },802{ instruction: 'Python only', languageId: 'python' },803{ instruction: 'All languages', languageId: undefined }804]805}),806fetchInstructionsFromSetting: () => Promise.resolve([])807};808const workspaceService = createMockWorkspaceService();809// Only TypeScript is in the map, so Python instruction should be skipped810const languageIdToFilePatterns = new Map<string, Set<string>>([811['typescript', new Set(['*.ts', '*.tsx'])]812]);813814const result = await loadCustomInstructions(815customInstructionsService as unknown as ICustomInstructionsService,816workspaceService,817'selection',818languageIdToFilePatterns,8191820);821822// Should have 2 instructions: TypeScript + All languages (Python skipped)823assert.strictEqual(result.length, 2);824const descriptions = result.map(r => r.data.description);825assert.ok(descriptions.includes('TypeScript only'));826assert.ok(descriptions.includes('All languages'));827assert.ok(!descriptions.includes('Python only'));828829// TypeScript instruction should have specific file patterns830const tsInstruction = result.find(r => r.data.description === 'TypeScript only');831assert.ok(tsInstruction);832assert.deepStrictEqual(tsInstruction.data.filePatterns.sort(), ['*.ts', '*.tsx']);833});834835test('filters settings instructions by languageId', async () => {836// Create a custom service that returns settings instructions with languageId837const customInstructionsService = {838...new MockCustomInstructionsService(),839getAgentInstructions: () => Promise.resolve([]),840fetchInstructionsFromFile: () => Promise.resolve(undefined),841fetchInstructionsFromSetting: () => Promise.resolve([{842content: [843{ instruction: 'JavaScript rule', languageId: 'javascript' },844{ instruction: 'Ruby rule', languageId: 'ruby' },845{ instruction: 'General rule', languageId: undefined }846]847}])848};849const workspaceService = createMockWorkspaceService();850// Only JavaScript is in the map, Ruby should be filtered out851const languageIdToFilePatterns = new Map<string, Set<string>>([852['javascript', new Set(['*.js'])]853]);854855const result = await loadCustomInstructions(856customInstructionsService as unknown as ICustomInstructionsService,857workspaceService,858'selection',859languageIdToFilePatterns,8601861);862863// JavaScript + General should be included, Ruby filtered out864const descriptions = result.map(r => r.data.description);865assert.ok(descriptions.includes('JavaScript rule'));866assert.ok(descriptions.includes('General rule'));867assert.ok(!descriptions.includes('Ruby rule'));868});869});870871describe('githubReview', () => {872// These tests verify the integration of githubReview with mocked services873// Following the pattern from chatMLFetcherRetry.spec.ts for extending mocks874875// Common mock services shared across tests876const createMockFetcherService = (): IFetcherService => ({877makeAbortController: () => ({ abort: () => { }, signal: {} }),878isAbortError: () => false,879} as unknown as IFetcherService);880881const createBaseMocks = () => ({882domainService: { _serviceBrand: undefined, onDidChangeDomains: Event.None } as IDomainService,883fetcherService: createMockFetcherService(),884envService: { sessionId: 'test' } as IEnvService,885});886887const createMockGitExtensionService = (): IGitExtensionService => {888const mockGitApi = {889getRepository: () => ({ rootUri: URI.file('/test') }),890repositories: [],891};892return {893getExtensionApi: () => mockGitApi,894extensionAvailable: true,895} as unknown as IGitExtensionService;896};897898test('returns success with empty comments when git extension is not available', async () => {899const { githubReview } = await import('../githubReviewAgent');900const { domainService, fetcherService, envService } = createBaseMocks();901902const result = await githubReview(903new TestLogService(),904new NullGitExtensionService(),905new MockAuthenticationService() as unknown as IAuthenticationService,906new MockCAPIClientService() as unknown as ICAPIClientService,907domainService,908fetcherService,909envService,910new NullIgnoreService(),911new MockWorkspaceService(),912new MockCustomInstructionsService(),913{ repositoryRoot: '/test', commitMessages: [], patches: [] },914undefined,915{ report: () => { } },916CancellationToken.None917);918919assert.strictEqual(result.type, 'success');920if (result.type === 'success') {921assert.deepStrictEqual(result.comments, []);922}923});924925test('returns success with empty comments when no patches provided', async () => {926const { githubReview } = await import('../githubReviewAgent');927const { domainService, fetcherService, envService } = createBaseMocks();928929const result = await githubReview(930new TestLogService(),931createMockGitExtensionService(),932new MockAuthenticationService() as unknown as IAuthenticationService,933new MockCAPIClientService() as unknown as ICAPIClientService,934domainService,935fetcherService,936envService,937new NullIgnoreService(),938new MockWorkspaceService(),939new MockCustomInstructionsService(),940{ repositoryRoot: '/test', commitMessages: [], patches: [] },941undefined,942{ report: () => { } },943CancellationToken.None944);945946assert.strictEqual(result.type, 'success');947if (result.type === 'success') {948assert.deepStrictEqual(result.comments, []);949}950});951952test('processes patches and returns review comments from API response', async () => {953const { githubReview } = await import('../githubReviewAgent');954const { domainService, fetcherService, envService } = createBaseMocks();955956// Extend MockAuthenticationService to return a valid token (following chatMLFetcherRetry.spec.ts pattern)957class TestAuthenticationService extends MockAuthenticationService {958override getCopilotToken(_force?: boolean): Promise<CopilotToken> {959return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));960}961}962963// Set up CAPI client to return a streaming response with a comment964const sseResponse = [965`data: ${JSON.stringify({966copilot_references: [{967type: 'github.generated-pull-request-comment',968data: {969path: 'file.ts',970line: 1,971body: 'Consider using const instead of let.'972}973}]974})}\n`,975'data: [DONE]\n'976];977class TestCAPIClientService extends MockCAPIClientService {978override makeRequest<T>(): Promise<T> {979return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);980}981}982983// Set up workspace service with a document (inline extension pattern)984const fileUri = URI.file('/test/file.ts');985const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');986class TestWorkspaceService extends MockWorkspaceService {987override openTextDocument(uri: URI): Promise<TextDocument> {988if (uri.toString() === fileUri.toString()) {989return Promise.resolve(docData.document);990}991return Promise.reject(new Error(`Document not found: ${uri.toString()}`));992}993}994995const reportedComments: ReviewComment[] = [];996const progress = {997report: (comments: ReviewComment[]) => reportedComments.push(...comments)998};9991000const result = await githubReview(1001new TestLogService(),1002createMockGitExtensionService(),1003new TestAuthenticationService() as unknown as IAuthenticationService,1004new TestCAPIClientService() as unknown as ICAPIClientService,1005domainService,1006fetcherService,1007envService,1008new NullIgnoreService(),1009new TestWorkspaceService(),1010new MockCustomInstructionsService(),1011{1012repositoryRoot: '/test',1013commitMessages: ['test commit'],1014patches: [{1015patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1016fileUri: fileUri.toString(),1017}]1018},1019undefined,1020progress,1021CancellationToken.None1022);10231024assert.strictEqual(result.type, 'success');1025if (result.type === 'success') {1026assert.strictEqual(result.comments.length, 1);1027assert.strictEqual(reportedComments.length, 1);1028}1029});10301031test('returns info error when all files are ignored', async () => {1032const { githubReview } = await import('../githubReviewAgent');1033const { domainService, fetcherService, envService } = createBaseMocks();10341035// Create an ignore service that ignores all files1036const ignoreService = {1037isCopilotIgnored: () => Promise.resolve(true),1038};10391040// Set up workspace service with a document (inline extension pattern)1041const fileUri = URI.file('/test/file.ts');1042const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');1043class TestWorkspaceService extends MockWorkspaceService {1044override openTextDocument(uri: URI): Promise<TextDocument> {1045if (uri.toString() === fileUri.toString()) {1046return Promise.resolve(docData.document);1047}1048return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1049}1050}10511052const result = await githubReview(1053new TestLogService(),1054createMockGitExtensionService(),1055new MockAuthenticationService() as unknown as IAuthenticationService,1056new MockCAPIClientService() as unknown as ICAPIClientService,1057domainService,1058fetcherService,1059envService,1060ignoreService as unknown as IIgnoreService,1061new TestWorkspaceService(),1062new MockCustomInstructionsService(),1063{1064repositoryRoot: '/test',1065commitMessages: [],1066patches: [{1067patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1068fileUri: fileUri.toString(),1069}]1070},1071undefined,1072{ report: () => { } },1073CancellationToken.None1074);10751076assert.strictEqual(result.type, 'error');1077if (result.type === 'error') {1078assert.strictEqual(result.severity, 'info');1079assert.ok(result.reason.includes('ignored'));1080}1081});10821083test('handles cancelled request via abort signal', async () => {1084const { githubReview } = await import('../githubReviewAgent');1085const { domainService, envService } = createBaseMocks();10861087// Create auth service with token1088class TestAuthenticationService extends MockAuthenticationService {1089override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1090return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));1091}1092}10931094const fileUri = URI.file('/test/file.ts');1095const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');10961097class TestWorkspaceService extends MockWorkspaceService {1098override openTextDocument(uri: URI): Promise<TextDocument> {1099if (uri.toString() === fileUri.toString()) {1100return Promise.resolve(docData.document);1101}1102return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1103}1104override asRelativePath(uri: URI): string {1105return uri.path.replace(/^\/test\//, '');1106}1107}11081109// Mock fetcher with abort support1110const abortError = new Error('Aborted');1111const fetcherService: IFetcherService = {1112makeAbortController: () => ({ abort: () => { }, signal: {} }),1113isAbortError: (err: unknown) => err === abortError,1114} as unknown as IFetcherService;11151116// Create CAPI client that throws abort error1117class TestCAPIClientService extends MockCAPIClientService {1118buildUrl(_ep: unknown, path: string): URL {1119return new URL('https://api.github.com' + path);1120}1121override makeRequest<T>(): Promise<T> {1122return Promise.reject(abortError);1123}1124}11251126const result = await githubReview(1127new TestLogService(),1128createMockGitExtensionService(),1129new TestAuthenticationService() as unknown as IAuthenticationService,1130new TestCAPIClientService() as unknown as ICAPIClientService,1131domainService,1132fetcherService,1133envService,1134new NullIgnoreService(),1135new TestWorkspaceService(),1136new MockCustomInstructionsService(),1137{1138repositoryRoot: '/test',1139commitMessages: ['test commit'],1140patches: [{1141patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1142fileUri: fileUri.toString(),1143}]1144},1145undefined,1146{ report: () => { } },1147CancellationToken.None1148);11491150// When aborted, should return cancelled1151assert.strictEqual(result.type, 'cancelled');1152});11531154test('handles HTTP 402 quota exceeded error', async () => {1155const { githubReview } = await import('../githubReviewAgent');1156const { domainService, fetcherService, envService } = createBaseMocks();11571158// Create auth service with token1159class TestAuthenticationService extends MockAuthenticationService {1160override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1161return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));1162}1163}11641165const fileUri = URI.file('/test/file.ts');1166const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');11671168class TestWorkspaceService extends MockWorkspaceService {1169override openTextDocument(uri: URI): Promise<TextDocument> {1170if (uri.toString() === fileUri.toString()) {1171return Promise.resolve(docData.document);1172}1173return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1174}1175override asRelativePath(uri: URI): string {1176return uri.path.replace(/^\/test\//, '');1177}1178}11791180// Create CAPI client that returns 4021181class TestCAPIClientService extends MockCAPIClientService {1182buildUrl(_ep: unknown, path: string): URL {1183return new URL('https://api.github.com' + path);1184}1185override makeRequest<T>(): Promise<T> {1186return Promise.resolve({1187ok: false,1188status: 402,1189headers: { get: (name: string) => name === 'x-github-request-id' ? 'test-req-id' : null },1190} as unknown as T);1191}1192}11931194try {1195await githubReview(1196new TestLogService(),1197createMockGitExtensionService(),1198new TestAuthenticationService() as unknown as IAuthenticationService,1199new TestCAPIClientService() as unknown as ICAPIClientService,1200domainService,1201fetcherService,1202envService,1203new NullIgnoreService(),1204new TestWorkspaceService(),1205new MockCustomInstructionsService(),1206{1207repositoryRoot: '/test',1208commitMessages: ['test commit'],1209patches: [{1210patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1211fileUri: fileUri.toString(),1212}]1213},1214undefined,1215{ report: () => { } },1216CancellationToken.None1217);1218assert.fail('Should have thrown an error');1219} catch (err: unknown) {1220const error = err as Error & { severity?: string };1221assert.ok(error.message.includes('quota'));1222assert.strictEqual(error.severity, 'info');1223}1224});12251226test('handles HTTP error response', async () => {1227const { githubReview } = await import('../githubReviewAgent');1228const { domainService, fetcherService, envService } = createBaseMocks();12291230// Create auth service with token1231class TestAuthenticationService extends MockAuthenticationService {1232override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1233return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));1234}1235}12361237const fileUri = URI.file('/test/file.ts');1238const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');12391240class TestWorkspaceService extends MockWorkspaceService {1241override openTextDocument(uri: URI): Promise<TextDocument> {1242if (uri.toString() === fileUri.toString()) {1243return Promise.resolve(docData.document);1244}1245return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1246}1247override asRelativePath(uri: URI): string {1248return uri.path.replace(/^\/test\//, '');1249}1250}12511252// Create CAPI client that returns 5001253class TestCAPIClientService extends MockCAPIClientService {1254buildUrl(_ep: unknown, path: string): URL {1255return new URL('https://api.github.com' + path);1256}1257override makeRequest<T>(): Promise<T> {1258return Promise.resolve({1259ok: false,1260status: 500,1261headers: { get: (name: string) => name === 'x-github-request-id' ? 'test-req-id' : null },1262} as unknown as T);1263}1264}12651266try {1267await githubReview(1268new TestLogService(),1269createMockGitExtensionService(),1270new TestAuthenticationService() as unknown as IAuthenticationService,1271new TestCAPIClientService() as unknown as ICAPIClientService,1272domainService,1273fetcherService,1274envService,1275new NullIgnoreService(),1276new TestWorkspaceService(),1277new MockCustomInstructionsService(),1278{1279repositoryRoot: '/test',1280commitMessages: ['test commit'],1281patches: [{1282patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1283fileUri: fileUri.toString(),1284}]1285},1286undefined,1287{ report: () => { } },1288CancellationToken.None1289);1290assert.fail('Should have thrown an error');1291} catch (err: unknown) {1292const error = err as Error;1293assert.ok(error.message.includes('500'));1294assert.ok(error.message.includes('test-req-id'));1295}1296});12971298test('propagates non-abort fetch errors', async () => {1299const { githubReview } = await import('../githubReviewAgent');1300const { domainService, envService } = createBaseMocks();13011302// Create auth service with token1303class TestAuthenticationService extends MockAuthenticationService {1304override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1305return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));1306}1307}13081309const fileUri = URI.file('/test/file.ts');1310const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');13111312class TestWorkspaceService extends MockWorkspaceService {1313override openTextDocument(uri: URI): Promise<TextDocument> {1314if (uri.toString() === fileUri.toString()) {1315return Promise.resolve(docData.document);1316}1317return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1318}1319override asRelativePath(uri: URI): string {1320return uri.path.replace(/^\/test\//, '');1321}1322}13231324// Mock fetcher that does NOT recognize this error as abort1325const networkError = new Error('Network failure');1326const fetcherService: IFetcherService = {1327makeAbortController: () => ({ abort: () => { }, signal: {} }),1328isAbortError: () => false, // Not an abort error1329} as unknown as IFetcherService;13301331// Create CAPI client that throws a network error1332class TestCAPIClientService extends MockCAPIClientService {1333buildUrl(_ep: unknown, path: string): URL {1334return new URL('https://api.github.com' + path);1335}1336override makeRequest<T>(): Promise<T> {1337return Promise.reject(networkError);1338}1339}13401341try {1342await githubReview(1343new TestLogService(),1344createMockGitExtensionService(),1345new TestAuthenticationService() as unknown as IAuthenticationService,1346new TestCAPIClientService() as unknown as ICAPIClientService,1347domainService,1348fetcherService,1349envService,1350new NullIgnoreService(),1351new TestWorkspaceService(),1352new MockCustomInstructionsService(),1353{1354repositoryRoot: '/test',1355commitMessages: ['test commit'],1356patches: [{1357patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1358fileUri: fileUri.toString(),1359}]1360},1361undefined,1362{ report: () => { } },1363CancellationToken.None1364);1365assert.fail('Should have thrown an error');1366} catch (err: unknown) {1367const error = err as Error;1368assert.strictEqual(error.message, 'Network failure');1369}1370});13711372test('ignores comments with paths not matching any change', async () => {1373const { githubReview } = await import('../githubReviewAgent');1374const { domainService, fetcherService, envService } = createBaseMocks();13751376// Extend MockAuthenticationService to return a valid token1377class TestAuthenticationService extends MockAuthenticationService {1378override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1379return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token' })));1380}1381}13821383// Set up workspace service with a document1384const fileUri = URI.file('/test/file.ts');1385const docData = createTextDocumentData(fileUri, 'const x = 1;', 'typescript');13861387class TestWorkspaceService extends MockWorkspaceService {1388override openTextDocument(uri: URI): Promise<TextDocument> {1389if (uri.toString() === fileUri.toString()) {1390return Promise.resolve(docData.document);1391}1392return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1393}1394override asRelativePath(uri: URI): string {1395return uri.path.replace(/^\/test\//, '');1396}1397}13981399// Response contains a comment for a different file - use proper SSE format1400const sseResponse = [1401`data: ${JSON.stringify({1402copilot_references: [{1403type: 'github.generated-pull-request-comment',1404data: {1405path: 'other-file.ts', // Different from file.ts1406line: 1,1407body: 'Comment on non-existent file'1408}1409}]1410})}\n`,1411'data: [DONE]\n'1412];1413class TestCAPIClientService extends MockCAPIClientService {1414override makeRequest<T>(): Promise<T> {1415return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);1416}1417}14181419const result = await githubReview(1420new TestLogService(),1421createMockGitExtensionService(),1422new TestAuthenticationService() as unknown as IAuthenticationService,1423new TestCAPIClientService() as unknown as ICAPIClientService,1424domainService,1425fetcherService,1426envService,1427new NullIgnoreService(),1428new TestWorkspaceService(),1429new MockCustomInstructionsService(),1430{1431repositoryRoot: '/test',1432commitMessages: ['test commit'],1433patches: [{1434patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1435fileUri: fileUri.toString(),1436}]1437},1438undefined,1439{ report: () => { } },1440CancellationToken.None1441);14421443// Should succeed but with no comments (the mismatched path comment is skipped)1444assert.strictEqual(result.type, 'success');1445if (result.type === 'success') {1446assert.strictEqual(result.comments.length, 0);1447}1448});14491450test('returns excluded comments in result', async () => {1451const { githubReview } = await import('../githubReviewAgent');1452const { domainService, fetcherService, envService } = createBaseMocks();14531454class TestAuthenticationService extends MockAuthenticationService {1455override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1456return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));1457}1458}14591460const fileUri = URI.file('/test/file.ts');1461const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');14621463class TestWorkspaceService extends MockWorkspaceService {1464override openTextDocument(uri: URI): Promise<TextDocument> {1465if (uri.toString() === fileUri.toString()) {1466return Promise.resolve(docData.document);1467}1468return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1469}1470}14711472// Response with excluded comment1473const sseResponse = [1474`data: ${JSON.stringify({1475copilot_references: [{1476type: 'github.excluded-pull-request-comment',1477data: {1478path: 'file.ts',1479line: 1,1480body: 'Low confidence comment',1481exclusion_reason: 'denylisted_type'1482}1483}]1484})}\n`,1485'data: [DONE]\n'1486];1487class TestCAPIClientService extends MockCAPIClientService {1488override makeRequest<T>(): Promise<T> {1489return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);1490}1491}14921493const result = await githubReview(1494new TestLogService(),1495createMockGitExtensionService(),1496new TestAuthenticationService() as unknown as IAuthenticationService,1497new TestCAPIClientService() as unknown as ICAPIClientService,1498domainService,1499fetcherService,1500envService,1501new NullIgnoreService(),1502new TestWorkspaceService(),1503new MockCustomInstructionsService(),1504{1505repositoryRoot: '/test',1506commitMessages: ['test commit'],1507patches: [{1508patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1509fileUri: fileUri.toString(),1510}]1511},1512undefined,1513{ report: () => { } },1514CancellationToken.None1515);15161517assert.strictEqual(result.type, 'success');1518if (result.type === 'success') {1519assert.strictEqual(result.comments.length, 0);1520assert.strictEqual(result.excludedComments?.length, 1);1521const bodyValue = typeof result.excludedComments![0].body === 'string' ? result.excludedComments![0].body : result.excludedComments![0].body.value;1522assert.ok(bodyValue.includes('Low confidence'));1523}1524});15251526test('returns unsupported language reason when no comments and excluded files exist', async () => {1527const { githubReview } = await import('../githubReviewAgent');1528const { domainService, fetcherService, envService } = createBaseMocks();15291530class TestAuthenticationService extends MockAuthenticationService {1531override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1532return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));1533}1534}15351536const fileUri = URI.file('/test/file.ts');1537const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');15381539class TestWorkspaceService extends MockWorkspaceService {1540override openTextDocument(uri: URI): Promise<TextDocument> {1541if (uri.toString() === fileUri.toString()) {1542return Promise.resolve(docData.document);1543}1544return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1545}1546}15471548// Response with excluded file due to unsupported language1549const sseResponse = [1550`data: ${JSON.stringify({1551copilot_references: [{1552type: 'github.excluded-file',1553data: {1554file_path: 'file.ts',1555language: 'cobol',1556reason: 'file_type_not_supported'1557}1558}]1559})}\n`,1560'data: [DONE]\n'1561];1562class TestCAPIClientService extends MockCAPIClientService {1563override makeRequest<T>(): Promise<T> {1564return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);1565}1566}15671568const result = await githubReview(1569new TestLogService(),1570createMockGitExtensionService(),1571new TestAuthenticationService() as unknown as IAuthenticationService,1572new TestCAPIClientService() as unknown as ICAPIClientService,1573domainService,1574fetcherService,1575envService,1576new NullIgnoreService(),1577new TestWorkspaceService(),1578new MockCustomInstructionsService(),1579{1580repositoryRoot: '/test',1581commitMessages: ['test commit'],1582patches: [{1583patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1584fileUri: fileUri.toString(),1585}]1586},1587undefined,1588{ report: () => { } },1589CancellationToken.None1590);15911592assert.strictEqual(result.type, 'success');1593if (result.type === 'success') {1594assert.strictEqual(result.comments.length, 0);1595assert.ok(result.reason);1596assert.ok(result.reason!.includes('cobol'));1597}1598});15991600test('does not report unsupported languages when comments exist', async () => {1601const { githubReview } = await import('../githubReviewAgent');1602const { domainService, fetcherService, envService } = createBaseMocks();16031604class TestAuthenticationService extends MockAuthenticationService {1605override getCopilotToken(_force?: boolean): Promise<CopilotToken> {1606return Promise.resolve(new CopilotToken(createTestExtendedTokenInfo({ token: 'test-token', code_review_enabled: true })));1607}1608}16091610const fileUri = URI.file('/test/file.ts');1611const docData = createTextDocumentData(fileUri, 'let x = 1;', 'typescript');16121613class TestWorkspaceService extends MockWorkspaceService {1614override openTextDocument(uri: URI): Promise<TextDocument> {1615if (uri.toString() === fileUri.toString()) {1616return Promise.resolve(docData.document);1617}1618return Promise.reject(new Error(`Document not found: ${uri.toString()}`));1619}1620}16211622// Response with both a comment and an excluded file1623const sseResponse = [1624`data: ${JSON.stringify({1625copilot_references: [1626{1627type: 'github.generated-pull-request-comment',1628data: {1629path: 'file.ts',1630line: 1,1631body: 'Use const instead of let'1632}1633},1634{1635type: 'github.excluded-file',1636data: {1637file_path: 'other.cobol',1638language: 'cobol',1639reason: 'file_type_not_supported'1640}1641}1642]1643})}\n`,1644'data: [DONE]\n'1645];1646class TestCAPIClientService extends MockCAPIClientService {1647override makeRequest<T>(): Promise<T> {1648return Promise.resolve(createFakeStreamResponse(sseResponse) as unknown as T);1649}1650}16511652const result = await githubReview(1653new TestLogService(),1654createMockGitExtensionService(),1655new TestAuthenticationService() as unknown as IAuthenticationService,1656new TestCAPIClientService() as unknown as ICAPIClientService,1657domainService,1658fetcherService,1659envService,1660new NullIgnoreService(),1661new TestWorkspaceService(),1662new MockCustomInstructionsService(),1663{1664repositoryRoot: '/test',1665commitMessages: ['test commit'],1666patches: [{1667patch: '@@ -1,1 +1,1 @@\n-const x = 1;\n+let x = 1;',1668fileUri: fileUri.toString(),1669}]1670},1671undefined,1672{ report: () => { } },1673CancellationToken.None1674);16751676assert.strictEqual(result.type, 'success');1677if (result.type === 'success') {1678assert.strictEqual(result.comments.length, 1);1679// When comments exist, unsupported languages are not reported1680assert.strictEqual(result.reason, undefined);1681}1682});1683});1684});168516861687