From d542fbf72c210bfc50e2cf93747b6d4a23a571f9 Mon Sep 17 00:00:00 2001
From: Ramya Achutha Rao <ramyar@microsoft.com>
Date: Sun, 28 Jan 2018 23:29:57 -0800
Subject: [PATCH] Workspace recommendations based on telemetry

---
 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<any>;
 
 	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<void> {
+		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<string[]> {
+	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<string[]>(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 {