From 361f7030d0f551ad35b53e3bf74ea95c087a7ec9 Mon Sep 17 00:00:00 2001 From: Ramya Rao Date: Mon, 29 Jan 2018 16:14:28 -0800 Subject: [PATCH] Workspace recommendations based on telemetry (#42294) --- src/vs/platform/node/product.ts | 1 + .../electron-browser/extensionTipsService.ts | 96 ++++++++++++++++++- .../parts/stats/node/workspaceStats.ts | 26 ++--- .../parts/stats/test/workspaceStats.test.ts | 12 +-- 4 files changed, 113 insertions(+), 22 deletions(-) diff --git a/src/vs/platform/node/product.ts b/src/vs/platform/node/product.ts index ebd1d8e22a4b1..0e902be82db23 100644 --- a/src/vs/platform/node/product.ts +++ b/src/vs/platform/node/product.ts @@ -26,6 +26,7 @@ export interface IProductConfiguration { serviceUrl: string; itemUrl: string; controlUrl: string; + recommendationsUrl: string; }; extensionTips: { [id: string]: string; }; extensionImportantTips: { [id: string]: { name: string; pattern: string; }; }; diff --git a/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts b/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts index c852151139332..1ad875ea0eec0 100644 --- a/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts +++ b/src/vs/workbench/parts/extensions/electron-browser/extensionTipsService.ts @@ -19,7 +19,7 @@ import { IChoiceService, IMessageService } from 'vs/platform/message/common/mess import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { ShowRecommendedExtensionsAction, InstallWorkspaceRecommendedExtensionsAction, InstallRecommendedExtensionAction } from 'vs/workbench/parts/extensions/browser/extensionsActions'; import Severity from 'vs/base/common/severity'; -import { IWorkspaceContextService, IWorkspaceFolder, IWorkspace, IWorkspaceFoldersChangeEvent } from 'vs/platform/workspace/common/workspace'; +import { IWorkspaceContextService, IWorkspaceFolder, IWorkspace, IWorkspaceFoldersChangeEvent, WorkbenchState } from 'vs/platform/workspace/common/workspace'; import { Schemas } from 'vs/base/common/network'; import { IFileService } from 'vs/platform/files/common/files'; import { IExtensionsConfiguration, ConfigurationKey } from 'vs/workbench/parts/extensions/common/extensions'; @@ -32,6 +32,10 @@ import { IEnvironmentService } from 'vs/platform/environment/common/environment' import { guessMimeTypes, MIME_UNKNOWN } from 'vs/base/common/mime'; import { ShowLanguageExtensionsAction } from 'vs/workbench/browser/parts/editor/editorStatus'; import { IExtensionService } from 'vs/platform/extensions/common/extensions'; +import { getHashedRemotesFromUri } from 'vs/workbench/parts/stats/node/workspaceStats'; +import { IRequestService } from 'vs/platform/request/node/request'; +import { asJson } from 'vs/base/node/request'; +import { isNumber } from 'vs/base/common/types'; interface IExtensionsContent { recommendations: string[]; @@ -42,6 +46,11 @@ const milliSecondsInADay = 1000 * 60 * 60 * 24; const choiceNever = localize('neverShowAgain', "Don't show again"); const choiceClose = localize('close', "Close"); +interface IDynamicWorkspaceRecommendations { + remoteSet: string[]; + recommendations: string[]; +} + export class ExtensionTipsService extends Disposable implements IExtensionTipsService { _serviceBrand: any; @@ -49,9 +58,10 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe private _fileBasedRecommendations: { [id: string]: number; } = Object.create(null); private _exeBasedRecommendations: { [id: string]: string; } = Object.create(null); private _availableRecommendations: { [pattern: string]: string[] } = Object.create(null); - private _disposables: IDisposable[] = []; - private _allWorkspaceRecommendedExtensions: string[] = []; + private _dynamicWorkspaceRecommendations: string[] = []; + private _extensionsRecommendationsUrl: string; + private _disposables: IDisposable[] = []; public promptWorkspaceRecommendationsPromise: TPromise; constructor( @@ -67,7 +77,8 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe @IMessageService private messageService: IMessageService, @ITelemetryService private telemetryService: ITelemetryService, @IEnvironmentService private environmentService: IEnvironmentService, - @IExtensionService private extensionService: IExtensionService + @IExtensionService private extensionService: IExtensionService, + @IRequestService private requestService: IRequestService ) { super(); @@ -75,6 +86,10 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe return; } + if (product.extensionsGallery && product.extensionsGallery.recommendationsUrl) { + this._extensionsRecommendationsUrl = product.extensionsGallery.recommendationsUrl; + } + this.getDynamicWorkspaceRecommendations(); this._suggestFileBasedRecommendations(); this.promptWorkspaceRecommendationsPromise = this._suggestWorkspaceRecommendations(); @@ -94,6 +109,7 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe this._allWorkspaceRecommendedExtensions.forEach(x => output[x.toLowerCase()] = localize('workspaceRecommendation', "This extension is recommended by users of the current workspace.")); Object.keys(this._fileBasedRecommendations).forEach(x => output[x.toLowerCase()] = output[x.toLowerCase()] || localize('fileBasedRecommendation', "This extension is recommended based on the files you recently opened.")); forEach(this._exeBasedRecommendations, entry => output[entry.key.toLowerCase()] = output[entry.key.toLowerCase()] || localize('exeBasedRecommendation', "This extension is recommended because you have {0} installed.", entry.value)); + this._dynamicWorkspaceRecommendations.forEach(x => output[x.toLowerCase()] = output[x.toLowerCase()] || localize('dynamicWorkspaceRecommendation', "This extension might interest you because many other users of the current workspace use it.")); return output; } @@ -202,7 +218,13 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe } getOtherRecommendations(): string[] { - return Object.keys(this._exeBasedRecommendations); + if (!this._dynamicWorkspaceRecommendations || !this._dynamicWorkspaceRecommendations.length) { + return Object.keys(this._exeBasedRecommendations); + } + const coinToss = Math.round(Math.random()); + return distinct(coinToss + ? [...Object.keys(this._exeBasedRecommendations), ...this._dynamicWorkspaceRecommendations] + : [...this._dynamicWorkspaceRecommendations, ...Object.keys(this._exeBasedRecommendations)]); } getKeymapRecommendations(): string[] { @@ -620,6 +642,70 @@ export class ExtensionTipsService extends Disposable implements IExtensionTipsSe } } + private getDynamicWorkspaceRecommendations(): TPromise { + if (this.contextService.getWorkbenchState() !== WorkbenchState.FOLDER) { + return TPromise.as(null); + } + + const storageKey = 'extensionsAssistant/dynamicWorkspaceRecommendations'; + let storedRecommendationsJson = {}; + try { + storedRecommendationsJson = JSON.parse(this.storageService.get(storageKey, StorageScope.WORKSPACE, '{}')); + } catch (e) { + this.storageService.remove(storageKey, StorageScope.WORKSPACE); + } + + if (Array.isArray(storedRecommendationsJson['recommendations']) + && isNumber(storedRecommendationsJson['timestamp']) + && storedRecommendationsJson['timestamp'] > 0 + && (Date.now() - storedRecommendationsJson['timestamp']) / milliSecondsInADay < 14) { + this._dynamicWorkspaceRecommendations = storedRecommendationsJson['recommendations']; + return TPromise.as(null); + } + + if (!this._extensionsRecommendationsUrl) { + return TPromise.as(null); + } + + return getHashedRemotesFromUri(this.contextService.getWorkspace().folders[0].uri, this.fileService).then(hashedRemotes => { + if (!hashedRemotes || !hashedRemotes.length) { + return null; + } + + return new TPromise((c, e) => { + setTimeout(() => { + this.requestService.request({ type: 'GET', url: this._extensionsRecommendationsUrl }).then(context => { + if (context.res.statusCode !== 200) { + return c(null); + } + return asJson(context).then((result) => { + const allRecommendations: IDynamicWorkspaceRecommendations[] = Array.isArray(result['workspaceRecommendations']) ? result['workspaceRecommendations'] : []; + if (!allRecommendations.length) { + return c(null); + } + + let foundRemote = false; + for (let i = 0; i < hashedRemotes.length && !foundRemote; i++) { + for (let j = 0; j < allRecommendations.length && !foundRemote; j++) { + if (Array.isArray(allRecommendations[j].remoteSet) && allRecommendations[j].remoteSet.indexOf(hashedRemotes[i]) > -1) { + foundRemote = true; + this._dynamicWorkspaceRecommendations = allRecommendations[j].recommendations || []; + this.storageService.store(storageKey, JSON.stringify({ + recommendations: this._dynamicWorkspaceRecommendations, + timestamp: Date.now() + }), StorageScope.WORKSPACE); + } + } + } + + return c(null); + }); + }); + }, 10000); + }); + }); + } + getKeywordsForExtension(extension: string): string[] { const keywords = product.extensionKeywords || {}; return keywords[extension] || []; diff --git a/src/vs/workbench/parts/stats/node/workspaceStats.ts b/src/vs/workbench/parts/stats/node/workspaceStats.ts index 6ea817f0a7011..146d7b06522b8 100644 --- a/src/vs/workbench/parts/stats/node/workspaceStats.ts +++ b/src/vs/workbench/parts/stats/node/workspaceStats.ts @@ -126,12 +126,21 @@ export function getRemotes(text: string): string[] { return remotes; } -export function getHashedRemotes(text: string): string[] { +export function getHashedRemotesFromConfig(text: string): string[] { return getRemotes(text).map(r => { return crypto.createHash('sha1').update(r).digest('hex'); }); } +export function getHashedRemotesFromUri(workspaceUri: URI, fileService: IFileService): TPromise { + let path = workspaceUri.path; + let uri = workspaceUri.with({ path: `${path !== '/' ? path : ''}/.git/config` }); + return fileService.resolveContent(uri, { acceptTextOnly: true }).then( + content => getHashedRemotesFromConfig(content.value), + err => [] // ignore missing or binary file + ); +} + export class WorkspaceStats implements IWorkbenchContribution { constructor( @IFileService private fileService: IFileService, @@ -329,18 +338,13 @@ export class WorkspaceStats implements IWorkbenchContribution { private reportRemotes(workspaceUris: URI[]): void { TPromise.join(workspaceUris.map(workspaceUri => { - let path = workspaceUri.path; - let uri = workspaceUri.with({ path: `${path !== '/' ? path : ''}/.git/config` }); - return this.fileService.resolveContent(uri, { acceptTextOnly: true }).then( - content => getHashedRemotes(content.value), - err => [] // ignore missing or binary file - ); + return getHashedRemotesFromUri(workspaceUri, this.fileService); })).then(hashedRemotes => { /* __GDPR__ - "workspace.hashedRemotes" : { - "remotes" : { "classification": "CustomerContent", "purpose": "FeatureInsight" } - } - */ + "workspace.hashedRemotes" : { + "remotes" : { "classification": "CustomerContent", "purpose": "FeatureInsight" } + } + */ this.telemetryService.publicLog('workspace.hashedRemotes', { remotes: hashedRemotes }); }, onUnexpectedError); } diff --git a/src/vs/workbench/parts/stats/test/workspaceStats.test.ts b/src/vs/workbench/parts/stats/test/workspaceStats.test.ts index 3c2b03c1434fc..3fd8b44248db9 100644 --- a/src/vs/workbench/parts/stats/test/workspaceStats.test.ts +++ b/src/vs/workbench/parts/stats/test/workspaceStats.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert'; import * as crypto from 'crypto'; -import { getDomainsOfRemotes, getRemotes, getHashedRemotes } from 'vs/workbench/parts/stats/node/workspaceStats'; +import { getDomainsOfRemotes, getRemotes, getHashedRemotesFromConfig } from 'vs/workbench/parts/stats/node/workspaceStats'; function hash(value: string): string { return crypto.createHash('sha1').update(value.toString()).digest('hex'); @@ -90,15 +90,15 @@ suite('Telemetry - WorkspaceStats', () => { }); test('Single remote hashed', function () { - assert.deepStrictEqual(getHashedRemotes(remote('https://username:password@github3.com/username/repository.git')), [hash('github3.com/username/repository.git')]); - assert.deepStrictEqual(getHashedRemotes(remote('ssh://user@git.server.org/project.git')), [hash('git.server.org/project.git')]); - assert.deepStrictEqual(getHashedRemotes(remote('user@git.server.org:project.git')), [hash('git.server.org/project.git')]); - assert.deepStrictEqual(getHashedRemotes(remote('/opt/git/project.git')), []); + assert.deepStrictEqual(getHashedRemotesFromConfig(remote('https://username:password@github3.com/username/repository.git')), [hash('github3.com/username/repository.git')]); + assert.deepStrictEqual(getHashedRemotesFromConfig(remote('ssh://user@git.server.org/project.git')), [hash('git.server.org/project.git')]); + assert.deepStrictEqual(getHashedRemotesFromConfig(remote('user@git.server.org:project.git')), [hash('git.server.org/project.git')]); + assert.deepStrictEqual(getHashedRemotesFromConfig(remote('/opt/git/project.git')), []); }); test('Multiple remotes hashed', function () { const config = ['https://github.com/Microsoft/vscode.git', 'https://git.example.com/gitproject.git'].map(remote).join(' '); - assert.deepStrictEqual(getHashedRemotes(config), [hash('github.com/Microsoft/vscode.git'), hash('git.example.com/gitproject.git')]); + assert.deepStrictEqual(getHashedRemotesFromConfig(config), [hash('github.com/Microsoft/vscode.git'), hash('git.example.com/gitproject.git')]); }); function remote(url: string): string {