Loading src/__mocks__/vscode.js +5 −1 Original line number Diff line number Diff line Loading @@ -86,7 +86,11 @@ module.exports = { StatusBarAlignment: { Left: 0 }, CommentThreadCollapsibleState: { Collapsed: 0, Expanded: 1 }, Position: function Position(line, character) { return { line, character }; const isBefore = position => line < position?.line || (position?.line === line && character < position?.character); const isEqual = position => position?.line === line && position?.character === character; return { line, character, isBefore, isEqual }; }, Range: function Range(...args) { if (typeof args[0] === 'number') { Loading src/common/code_suggestions/code_suggestions_changes_tracker.test.ts 0 → 100644 +58 −0 Original line number Diff line number Diff line import * as vscode from 'vscode'; import { DefaultCodeSuggestionsChangeTracker, SuggestionChangeType, CodeSuggestionsChangeTracker, } from './code_suggestions_changes_tracker'; import { createFakePartial } from '../test_utils/create_fake_partial'; describe('CodeSuggestionsChangeTracker', () => { let subject: CodeSuggestionsChangeTracker; const createTextDocument = (params?: { uri?: vscode.Uri; text?: string }) => createFakePartial<vscode.TextDocument>({ uri: params?.uri || vscode.Uri.parse('file://file.js'), getText: jest.fn().mockReturnValue(params?.text || ''), }); beforeEach(() => { subject = new DefaultCodeSuggestionsChangeTracker(); }); describe('lastChangeType', () => { describe('when delta between last two changes in negative', () => { it.each` positions | document | result ${[new vscode.Position(1, 5), new vscode.Position(1, 4)]} | ${createTextDocument()} | ${SuggestionChangeType.DeletedCharacter} ${[new vscode.Position(2, 5), new vscode.Position(1, 4)]} | ${createTextDocument()} | ${SuggestionChangeType.Unknown} ${[new vscode.Position(1, 5), new vscode.Position(1, 5)]} | ${createTextDocument()} | ${SuggestionChangeType.NoChange} ${[]} | ${createTextDocument()} | ${SuggestionChangeType.NoChange} ${[]} | ${createTextDocument()} | ${SuggestionChangeType.NoChange} ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(1, 8)]} | ${createTextDocument({ text: ' ' })} | ${SuggestionChangeType.RepeatedSpaces} ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(1, 8)]} | ${createTextDocument({ text: ' a' })} | ${SuggestionChangeType.Unknown} ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(2, 8)]} | ${createTextDocument({ text: ' ' })} | ${SuggestionChangeType.Unknown} `('returns $result', ({ positions, document, result }) => { positions.forEach((position: vscode.Position) => { subject.trackCompletionRequest(document, position); }); expect(subject.getLastChangeType(document)).toBe(result); }); }); }); describe('garbage collection', () => { it('cleans half of the events history when the history is larger than 50', () => { const document = createTextDocument(); for (let i = 0; i < 50; i += 1) { subject.trackCompletionRequest(document, new vscode.Position(i, i)); } expect(subject.eventsHistorySize).toBe(50); subject.trackCompletionRequest(document, new vscode.Position(10, 10)); expect(subject.eventsHistorySize).toBe(26); }); }); }); src/common/code_suggestions/code_suggestions_changes_tracker.ts 0 → 100644 +117 −0 Original line number Diff line number Diff line import * as vscode from 'vscode'; // eslint-disable-next-line no-shadow export enum SuggestionChangeType { Unknown = 'Unknown', InvalidDocument = 'InvalidDocument', NoChange = 'NoChange', DeletedCharacter = 'DeletedCharacter', RepeatedSpaces = 'RepeatedSpaces', } export interface CodeSuggestionsChangeTracker extends vscode.Disposable { trackCompletionRequest(document: vscode.TextDocument, position: vscode.Position): void; getLastChangeType: (document: vscode.TextDocument) => SuggestionChangeType; readonly eventsHistorySize: number; } const HISTORY_SIZE = 50; export class DefaultCodeSuggestionsChangeTracker implements CodeSuggestionsChangeTracker { #changeHistory: Array<{ document: vscode.TextDocument; position: vscode.Position; }>; constructor() { this.#changeHistory = []; } get eventsHistorySize(): number { return this.#changeHistory.length; } trackCompletionRequest(document: vscode.TextDocument, position: vscode.Position): void { this.#garbageCollectHistory(); this.#changeHistory.push({ document, position }); } getLastChangeType(document: vscode.TextDocument): SuggestionChangeType { if (this.#isNoChange()) { return SuggestionChangeType.NoChange; } if (this.#isInvalidDocument(document)) { return SuggestionChangeType.InvalidDocument; } if (this.#isDeletedCharacter()) { return SuggestionChangeType.DeletedCharacter; } if (this.#isRepeatedSpaces()) { return SuggestionChangeType.RepeatedSpaces; } return SuggestionChangeType.Unknown; } dispose() { this.#changeHistory.length = 0; } #isNoChange(): boolean { if (this.#changeHistory.length === 0) { return true; } if (this.#changeHistory.length === 1) { return false; } const [beforeLast, last] = this.#changeHistory.slice(-2); return last.position.isEqual(beforeLast.position); } #isInvalidDocument(document: vscode.TextDocument): boolean { const last = this.#changeHistory.slice(-1)[0]; return last.document.uri.toString() !== document.uri.toString(); } #isDeletedCharacter(): boolean { if (this.#changeHistory.length < 2) { return false; } const [beforeLast, last] = this.#changeHistory.slice(-2); return ( last.position.line === beforeLast.position.line && last.position.isBefore(beforeLast.position) ); } #isRepeatedSpaces(): boolean { if (this.#changeHistory.length < 4) { return false; } const lastThreeChanges = this.#changeHistory.slice(-4); const first = lastThreeChanges[0]; const last = lastThreeChanges[lastThreeChanges.length - 1]; const text = last.document.getText(new vscode.Range(first.position, last.position)); return ( lastThreeChanges.every(({ position }) => position.line === last.position.line) && text.length === 3 && text.trim().length === 0 ); } #garbageCollectHistory() { if (this.#changeHistory.length >= HISTORY_SIZE) { this.#changeHistory.splice(0, HISTORY_SIZE / 2); } } } src/common/code_suggestions/code_suggestions_provider.ts +38 −0 Original line number Diff line number Diff line Loading @@ -29,8 +29,13 @@ import { GitLabPlatformManagerForCodeSuggestions } from './gitlab_platform_manag import { CodeSuggestionTelemetryState, CodeSuggestionsTelemetryManager, RejectCodeSuggestionReason, } from './code_suggestions_telemetry_manager'; import { GitLabPlatformBase } from '../platform/gitlab_platform'; import { DefaultCodeSuggestionsChangeTracker, SuggestionChangeType, } from './code_suggestions_changes_tracker'; export const CIRCUIT_BREAK_INTERVAL_MS = 10000; export const MAX_ERRORS_BEFORE_CIRCUIT_BREAK = 4; Loading Loading @@ -90,6 +95,8 @@ export class CodeSuggestionsProvider implements vscode.InlineCompletionItemProvi CIRCUIT_BREAK_INTERVAL_MS, ); private documentChangesTracker = new DefaultCodeSuggestionsChangeTracker(); constructor({ manager, stateManager, Loading Loading @@ -340,10 +347,41 @@ export class CodeSuggestionsProvider implements vscode.InlineCompletionItemProvi ): Promise<vscode.InlineCompletionItem[]> { CodeSuggestionsTelemetryManager.rejectOpenedSuggestions(); this.documentChangesTracker.trackCompletionRequest(document, position); if (this.debouncedCall !== undefined) { clearTimeout(this.debouncedCall); } // selectedCompletionInfo has an assigned value when the user is selecting // an intellisense sugggestion if (this.documentChangesTracker.getLastChangeType(document) === SuggestionChangeType.NoChange) { CodeSuggestionsTelemetryManager.rejectSuggestionRequest( RejectCodeSuggestionReason.UnchangedDocument, ).catch(() => {}); return []; } if ( this.documentChangesTracker.getLastChangeType(document) === SuggestionChangeType.DeletedCharacter ) { CodeSuggestionsTelemetryManager.rejectSuggestionRequest( RejectCodeSuggestionReason.DeletingSingleCharacter, ).catch(() => {}); return []; } if ( this.documentChangesTracker.getLastChangeType(document) === SuggestionChangeType.RepeatedSpaces ) { CodeSuggestionsTelemetryManager.rejectSuggestionRequest( RejectCodeSuggestionReason.TypingRepeatedSpaces, ).catch(() => {}); return []; } return new Promise(resolve => { // In case of a hover, this will be triggered which is not desired as it calls for a new prediction if (context.triggerKind === vscode.InlineCompletionTriggerKind.Automatic) { Loading src/common/code_suggestions/code_suggestions_telemetry_manager.ts +40 −15 Original line number Diff line number Diff line Loading @@ -15,6 +15,14 @@ export enum CodeSuggestionTelemetryState { NOT_PROVIDED = 'suggestion_not_provided', } // eslint-disable-next-line no-shadow export enum RejectCodeSuggestionReason { DeletingSingleCharacter = 'deleting_single_character', TypingRepeatedSpaces = 'typing_repeated_spaces', PastedCodeBlock = 'pasted_code_block', UnchangedDocument = 'unchanged_document', } const stateGraph = new Map<CodeSuggestionTelemetryState, CodeSuggestionTelemetryState[]>([ [ CodeSuggestionTelemetryState.REQUESTED, Loading Loading @@ -82,6 +90,23 @@ const endStates = [...stateGraph] const GC_TIME = 60000; const getIdeVersionContext = () => { // this logic is duplicated in common/language_server/get_client_context.ts // the idea is that we'll delete this whole file once we switch to LS const extension = vscode.extensions.getExtension('Gitlab.gitlab-workflow'); return { schema: 'iglu:com.gitlab/ide_extension_version/jsonschema/1-0-0', data: { ide_name: 'Visual Studio Code', ide_vendor: 'Microsoft Corporation', ide_version: vscode.version, extension_name: 'GitLab Workflow', extension_version: extension?.packageJSON?.version, }, }; }; export class CodeSuggestionsTelemetryManager { // eslint-disable-next-line no-use-before-define private static instance: CodeSuggestionsTelemetryManager; Loading Loading @@ -212,29 +237,29 @@ export class CodeSuggestionsTelemetryManager { return this.instance; } public static async rejectSuggestionRequest(reason: RejectCodeSuggestionReason) { log.debug(`Telemetry: Sending event for rejected suggestion request. Reason "${reason}".`); await Snowplow.getInstance().trackStructEvent( { category: 'code_suggestions', action: 'reject_suggestion_request', label: reason, }, [getIdeVersionContext()], ); } private static async sendTelemetry(suggestionID: string) { log.debug(`Telemetry: Sending event for suggestion ${suggestionID}`); const suggestion = this.getInstance().suggestions.get(suggestionID); if (!suggestion) { log.debug(`Telemetry: The suggestion with ${suggestionID} can't be found`); return; } const extension = vscode.extensions.getExtension('Gitlab.gitlab-workflow'); // this logic is duplicated in common/language_server/get_client_context.ts // the idea is that we'll delete this whole file once we switch to LS const ideVersionContext = { schema: 'iglu:com.gitlab/ide_extension_version/jsonschema/1-0-0', data: { ide_name: 'Visual Studio Code', ide_vendor: 'Microsoft Corporation', ide_version: vscode.version, extension_name: 'GitLab Workflow', extension_version: extension?.packageJSON?.version, }, }; const codeSuggestionContexts = { schema: 'iglu:com.gitlab/code_suggestions_context/jsonschema/2-0-1', data: { Loading @@ -253,7 +278,7 @@ export class CodeSuggestionsTelemetryManager { action: suggestion.state, label: suggestionID, }, [ideVersionContext, codeSuggestionContexts], [getIdeVersionContext(), codeSuggestionContexts], ); } } Loading
src/__mocks__/vscode.js +5 −1 Original line number Diff line number Diff line Loading @@ -86,7 +86,11 @@ module.exports = { StatusBarAlignment: { Left: 0 }, CommentThreadCollapsibleState: { Collapsed: 0, Expanded: 1 }, Position: function Position(line, character) { return { line, character }; const isBefore = position => line < position?.line || (position?.line === line && character < position?.character); const isEqual = position => position?.line === line && position?.character === character; return { line, character, isBefore, isEqual }; }, Range: function Range(...args) { if (typeof args[0] === 'number') { Loading
src/common/code_suggestions/code_suggestions_changes_tracker.test.ts 0 → 100644 +58 −0 Original line number Diff line number Diff line import * as vscode from 'vscode'; import { DefaultCodeSuggestionsChangeTracker, SuggestionChangeType, CodeSuggestionsChangeTracker, } from './code_suggestions_changes_tracker'; import { createFakePartial } from '../test_utils/create_fake_partial'; describe('CodeSuggestionsChangeTracker', () => { let subject: CodeSuggestionsChangeTracker; const createTextDocument = (params?: { uri?: vscode.Uri; text?: string }) => createFakePartial<vscode.TextDocument>({ uri: params?.uri || vscode.Uri.parse('file://file.js'), getText: jest.fn().mockReturnValue(params?.text || ''), }); beforeEach(() => { subject = new DefaultCodeSuggestionsChangeTracker(); }); describe('lastChangeType', () => { describe('when delta between last two changes in negative', () => { it.each` positions | document | result ${[new vscode.Position(1, 5), new vscode.Position(1, 4)]} | ${createTextDocument()} | ${SuggestionChangeType.DeletedCharacter} ${[new vscode.Position(2, 5), new vscode.Position(1, 4)]} | ${createTextDocument()} | ${SuggestionChangeType.Unknown} ${[new vscode.Position(1, 5), new vscode.Position(1, 5)]} | ${createTextDocument()} | ${SuggestionChangeType.NoChange} ${[]} | ${createTextDocument()} | ${SuggestionChangeType.NoChange} ${[]} | ${createTextDocument()} | ${SuggestionChangeType.NoChange} ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(1, 8)]} | ${createTextDocument({ text: ' ' })} | ${SuggestionChangeType.RepeatedSpaces} ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(1, 8)]} | ${createTextDocument({ text: ' a' })} | ${SuggestionChangeType.Unknown} ${[new vscode.Position(1, 5), new vscode.Position(1, 6), new vscode.Position(1, 7), new vscode.Position(2, 8)]} | ${createTextDocument({ text: ' ' })} | ${SuggestionChangeType.Unknown} `('returns $result', ({ positions, document, result }) => { positions.forEach((position: vscode.Position) => { subject.trackCompletionRequest(document, position); }); expect(subject.getLastChangeType(document)).toBe(result); }); }); }); describe('garbage collection', () => { it('cleans half of the events history when the history is larger than 50', () => { const document = createTextDocument(); for (let i = 0; i < 50; i += 1) { subject.trackCompletionRequest(document, new vscode.Position(i, i)); } expect(subject.eventsHistorySize).toBe(50); subject.trackCompletionRequest(document, new vscode.Position(10, 10)); expect(subject.eventsHistorySize).toBe(26); }); }); });
src/common/code_suggestions/code_suggestions_changes_tracker.ts 0 → 100644 +117 −0 Original line number Diff line number Diff line import * as vscode from 'vscode'; // eslint-disable-next-line no-shadow export enum SuggestionChangeType { Unknown = 'Unknown', InvalidDocument = 'InvalidDocument', NoChange = 'NoChange', DeletedCharacter = 'DeletedCharacter', RepeatedSpaces = 'RepeatedSpaces', } export interface CodeSuggestionsChangeTracker extends vscode.Disposable { trackCompletionRequest(document: vscode.TextDocument, position: vscode.Position): void; getLastChangeType: (document: vscode.TextDocument) => SuggestionChangeType; readonly eventsHistorySize: number; } const HISTORY_SIZE = 50; export class DefaultCodeSuggestionsChangeTracker implements CodeSuggestionsChangeTracker { #changeHistory: Array<{ document: vscode.TextDocument; position: vscode.Position; }>; constructor() { this.#changeHistory = []; } get eventsHistorySize(): number { return this.#changeHistory.length; } trackCompletionRequest(document: vscode.TextDocument, position: vscode.Position): void { this.#garbageCollectHistory(); this.#changeHistory.push({ document, position }); } getLastChangeType(document: vscode.TextDocument): SuggestionChangeType { if (this.#isNoChange()) { return SuggestionChangeType.NoChange; } if (this.#isInvalidDocument(document)) { return SuggestionChangeType.InvalidDocument; } if (this.#isDeletedCharacter()) { return SuggestionChangeType.DeletedCharacter; } if (this.#isRepeatedSpaces()) { return SuggestionChangeType.RepeatedSpaces; } return SuggestionChangeType.Unknown; } dispose() { this.#changeHistory.length = 0; } #isNoChange(): boolean { if (this.#changeHistory.length === 0) { return true; } if (this.#changeHistory.length === 1) { return false; } const [beforeLast, last] = this.#changeHistory.slice(-2); return last.position.isEqual(beforeLast.position); } #isInvalidDocument(document: vscode.TextDocument): boolean { const last = this.#changeHistory.slice(-1)[0]; return last.document.uri.toString() !== document.uri.toString(); } #isDeletedCharacter(): boolean { if (this.#changeHistory.length < 2) { return false; } const [beforeLast, last] = this.#changeHistory.slice(-2); return ( last.position.line === beforeLast.position.line && last.position.isBefore(beforeLast.position) ); } #isRepeatedSpaces(): boolean { if (this.#changeHistory.length < 4) { return false; } const lastThreeChanges = this.#changeHistory.slice(-4); const first = lastThreeChanges[0]; const last = lastThreeChanges[lastThreeChanges.length - 1]; const text = last.document.getText(new vscode.Range(first.position, last.position)); return ( lastThreeChanges.every(({ position }) => position.line === last.position.line) && text.length === 3 && text.trim().length === 0 ); } #garbageCollectHistory() { if (this.#changeHistory.length >= HISTORY_SIZE) { this.#changeHistory.splice(0, HISTORY_SIZE / 2); } } }
src/common/code_suggestions/code_suggestions_provider.ts +38 −0 Original line number Diff line number Diff line Loading @@ -29,8 +29,13 @@ import { GitLabPlatformManagerForCodeSuggestions } from './gitlab_platform_manag import { CodeSuggestionTelemetryState, CodeSuggestionsTelemetryManager, RejectCodeSuggestionReason, } from './code_suggestions_telemetry_manager'; import { GitLabPlatformBase } from '../platform/gitlab_platform'; import { DefaultCodeSuggestionsChangeTracker, SuggestionChangeType, } from './code_suggestions_changes_tracker'; export const CIRCUIT_BREAK_INTERVAL_MS = 10000; export const MAX_ERRORS_BEFORE_CIRCUIT_BREAK = 4; Loading Loading @@ -90,6 +95,8 @@ export class CodeSuggestionsProvider implements vscode.InlineCompletionItemProvi CIRCUIT_BREAK_INTERVAL_MS, ); private documentChangesTracker = new DefaultCodeSuggestionsChangeTracker(); constructor({ manager, stateManager, Loading Loading @@ -340,10 +347,41 @@ export class CodeSuggestionsProvider implements vscode.InlineCompletionItemProvi ): Promise<vscode.InlineCompletionItem[]> { CodeSuggestionsTelemetryManager.rejectOpenedSuggestions(); this.documentChangesTracker.trackCompletionRequest(document, position); if (this.debouncedCall !== undefined) { clearTimeout(this.debouncedCall); } // selectedCompletionInfo has an assigned value when the user is selecting // an intellisense sugggestion if (this.documentChangesTracker.getLastChangeType(document) === SuggestionChangeType.NoChange) { CodeSuggestionsTelemetryManager.rejectSuggestionRequest( RejectCodeSuggestionReason.UnchangedDocument, ).catch(() => {}); return []; } if ( this.documentChangesTracker.getLastChangeType(document) === SuggestionChangeType.DeletedCharacter ) { CodeSuggestionsTelemetryManager.rejectSuggestionRequest( RejectCodeSuggestionReason.DeletingSingleCharacter, ).catch(() => {}); return []; } if ( this.documentChangesTracker.getLastChangeType(document) === SuggestionChangeType.RepeatedSpaces ) { CodeSuggestionsTelemetryManager.rejectSuggestionRequest( RejectCodeSuggestionReason.TypingRepeatedSpaces, ).catch(() => {}); return []; } return new Promise(resolve => { // In case of a hover, this will be triggered which is not desired as it calls for a new prediction if (context.triggerKind === vscode.InlineCompletionTriggerKind.Automatic) { Loading
src/common/code_suggestions/code_suggestions_telemetry_manager.ts +40 −15 Original line number Diff line number Diff line Loading @@ -15,6 +15,14 @@ export enum CodeSuggestionTelemetryState { NOT_PROVIDED = 'suggestion_not_provided', } // eslint-disable-next-line no-shadow export enum RejectCodeSuggestionReason { DeletingSingleCharacter = 'deleting_single_character', TypingRepeatedSpaces = 'typing_repeated_spaces', PastedCodeBlock = 'pasted_code_block', UnchangedDocument = 'unchanged_document', } const stateGraph = new Map<CodeSuggestionTelemetryState, CodeSuggestionTelemetryState[]>([ [ CodeSuggestionTelemetryState.REQUESTED, Loading Loading @@ -82,6 +90,23 @@ const endStates = [...stateGraph] const GC_TIME = 60000; const getIdeVersionContext = () => { // this logic is duplicated in common/language_server/get_client_context.ts // the idea is that we'll delete this whole file once we switch to LS const extension = vscode.extensions.getExtension('Gitlab.gitlab-workflow'); return { schema: 'iglu:com.gitlab/ide_extension_version/jsonschema/1-0-0', data: { ide_name: 'Visual Studio Code', ide_vendor: 'Microsoft Corporation', ide_version: vscode.version, extension_name: 'GitLab Workflow', extension_version: extension?.packageJSON?.version, }, }; }; export class CodeSuggestionsTelemetryManager { // eslint-disable-next-line no-use-before-define private static instance: CodeSuggestionsTelemetryManager; Loading Loading @@ -212,29 +237,29 @@ export class CodeSuggestionsTelemetryManager { return this.instance; } public static async rejectSuggestionRequest(reason: RejectCodeSuggestionReason) { log.debug(`Telemetry: Sending event for rejected suggestion request. Reason "${reason}".`); await Snowplow.getInstance().trackStructEvent( { category: 'code_suggestions', action: 'reject_suggestion_request', label: reason, }, [getIdeVersionContext()], ); } private static async sendTelemetry(suggestionID: string) { log.debug(`Telemetry: Sending event for suggestion ${suggestionID}`); const suggestion = this.getInstance().suggestions.get(suggestionID); if (!suggestion) { log.debug(`Telemetry: The suggestion with ${suggestionID} can't be found`); return; } const extension = vscode.extensions.getExtension('Gitlab.gitlab-workflow'); // this logic is duplicated in common/language_server/get_client_context.ts // the idea is that we'll delete this whole file once we switch to LS const ideVersionContext = { schema: 'iglu:com.gitlab/ide_extension_version/jsonschema/1-0-0', data: { ide_name: 'Visual Studio Code', ide_vendor: 'Microsoft Corporation', ide_version: vscode.version, extension_name: 'GitLab Workflow', extension_version: extension?.packageJSON?.version, }, }; const codeSuggestionContexts = { schema: 'iglu:com.gitlab/code_suggestions_context/jsonschema/2-0-1', data: { Loading @@ -253,7 +278,7 @@ export class CodeSuggestionsTelemetryManager { action: suggestion.state, label: suggestionID, }, [ideVersionContext, codeSuggestionContexts], [getIdeVersionContext(), codeSuggestionContexts], ); } }