src/goVulncheck: add actions to upgrade modules

If fixed version is known, present two options in the webview:
  go get <the fixed version>
  go get @latest

The webview html page listens the click event, and sends a 'fix'
type message to the extension side. The extension then invokes
gopls.upgrade_dependency command.

Currently, this dependency upgrade command can result in inconsistent
go.sum error and users will need to address them separately.

Change-Id: Iceef1ce49a7eff57bb65d4d78e1ecab0b3b74f73
Reviewed-on: https://go-review.googlesource.com/c/vscode-go/+/412318
TryBot-Result: kokoro <noreply+kokoro@google.com>
Reviewed-by: Jamal Carvalho <jamal@golang.org>
Run-TryBot: Hyang-Ah Hana Kim <hyangah@gmail.com>
diff --git a/media/vulncheckView.css b/media/vulncheckView.css
index 96cc78f..289c8d4 100644
--- a/media/vulncheckView.css
+++ b/media/vulncheckView.css
@@ -21,6 +21,16 @@
 	padding-bottom: 0.5em;
 }
 
+.vuln-fix:hover,
+.vuln-fix:active {
+	color: var(--vscode-textLink-activeForeground);
+}
+.vuln-fix {
+	cursor:pointer;
+	color: var(--vscode-textLink-foreground);
+	text-decoration:underline;
+}
+
 details summary {
 	cursor: pointer;
 	position: relative;
diff --git a/media/vulncheckView.js b/media/vulncheckView.js
index 7a61b84..d5e5ef4 100644
--- a/media/vulncheckView.js
+++ b/media/vulncheckView.js
@@ -16,12 +16,18 @@
 
 	vulnsContainer.addEventListener('click', (event) => {
 		let node = event && event.target;
+		let handled = false;
+		console.log(`${node.type} ${node.tagName} ${node.className} ${node.id} data:${node.dataset?.target} dir:${node.dataset?.dir}`);
 		if (node?.tagName === 'A' && node.href) {
 			// Ask vscode to handle link opening.
 			vscode.postMessage({ type: 'open', target: node.href });
+		} else if (node?.tagName === 'SPAN' && node.className === 'vuln-fix' && node.dataset?.target && node.dataset?.dir) {
+			vscode.postMessage({ type: 'fix', target: node.dataset?.target, dir: node.dataset?.dir });
+		}
+
+		if (handled) {
 			event.preventDefault();
 			event.stopPropagation();
-			return;
 		}
 	});
 
@@ -37,6 +43,13 @@
 		return 'N/A'
 	}
 
+	function offerUpgrade(/** @type {string} */dir, /** @type {string} */mod, /** @type {string|undefined} */ver) {
+		if (dir && mod && ver) {
+			return ` [<span class="vuln-fix" data-target="${mod}@${ver}" data-dir="${dir}">go get</span> | <span class="vuln-fix" data-target="${mod}@latest" data-dir="${dir}">go get latest</span>]`
+		}
+		return '';
+	}
+
 	function snapshotContent() {
 		const res = {
 			'log': logContainer.innerHTML,
@@ -102,7 +115,7 @@
 			details.innerHTML = `
 			<tr><td>Package</td><td>${vuln.PkgPath}</td></tr>
 			<tr><td>Found in Version</td><td>${moduleVersion(vuln.ModPath, vuln.CurrentVersion)}</td></tr>
-			<tr><td>Fixed Version</td><td>${moduleVersion(vuln.ModPath, vuln.FixedVersion)}</td></tr>
+			<tr><td>Fixed Version</td><td>${moduleVersion(vuln.ModPath, vuln.FixedVersion)} ${offerUpgrade(json.Dir, vuln.ModPath, vuln.FixedVersion)}</td></tr>
 			<tr><td>Affecting</td><td>${vuln.AffectedPkgs?.join('<br>')}</td></tr>
 			`;
 			element.appendChild(details);
diff --git a/src/goMain.ts b/src/goMain.ts
index 0213e03..db31bbb 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -170,7 +170,7 @@
 
 	GoExplorerProvider.setup(ctx);
 	VulncheckProvider.setup(ctx, goCtx);
-	VulncheckResultViewProvider.register(ctx);
+	VulncheckResultViewProvider.register(ctx, goCtx);
 
 	registerCommand('go.test.generate.package', goGenerateTests.generateTestCurrentPackage);
 	registerCommand('go.test.generate.file', goGenerateTests.generateTestCurrentFile);
diff --git a/src/goVulncheck.ts b/src/goVulncheck.ts
index c070583..ff4bed4 100644
--- a/src/goVulncheck.ts
+++ b/src/goVulncheck.ts
@@ -14,17 +14,22 @@
 import * as readline from 'readline';
 import { URI } from 'vscode-uri';
 import { promisify } from 'util';
+import { runGoEnv } from './goModules';
+import { ExecuteCommandParams, ExecuteCommandRequest } from 'vscode-languageserver-protocol';
 
 export class VulncheckResultViewProvider implements vscode.CustomTextEditorProvider {
 	public static readonly viewType = 'vulncheck.view';
 
-	public static register({ extensionUri, subscriptions }: vscode.ExtensionContext): VulncheckResultViewProvider {
-		const provider = new VulncheckResultViewProvider(extensionUri);
+	public static register(
+		{ extensionUri, subscriptions }: vscode.ExtensionContext,
+		goCtx: GoExtensionContext
+	): VulncheckResultViewProvider {
+		const provider = new VulncheckResultViewProvider(extensionUri, goCtx);
 		subscriptions.push(vscode.window.registerCustomEditorProvider(VulncheckResultViewProvider.viewType, provider));
 		return provider;
 	}
 
-	constructor(private readonly extensionUri: vscode.Uri) {}
+	constructor(private readonly extensionUri: vscode.Uri, private readonly goCtx: GoExtensionContext) {}
 
 	/**
 	 * Called when our custom editor is opened.
@@ -39,7 +44,7 @@
 		webviewPanel.webview.html = this.getHtmlForWebview(webviewPanel.webview);
 
 		// Receive message from the webview.
-		webviewPanel.webview.onDidReceiveMessage(this.handleMessage);
+		webviewPanel.webview.onDidReceiveMessage(this.handleMessage, this);
 
 		function updateWebview() {
 			webviewPanel.webview.postMessage({ type: 'update', text: document.getText() });
@@ -105,7 +110,7 @@
 			</html>`;
 	}
 
-	private handleMessage(e: { type: string; target?: string }): void {
+	private async handleMessage(e: { type: string; target?: string; dir?: string }): Promise<void> {
 		switch (e.type) {
 			case 'open':
 				{
@@ -125,6 +130,16 @@
 					}
 				}
 				return;
+			case 'fix':
+				{
+					if (!e.target || !e.dir) return;
+					const modFile = await getGoModFile(vscode.Uri.file(e.dir));
+					if (modFile) {
+						await goplsUpgradeDependency(this.goCtx, vscode.Uri.file(modFile), [e.target], false);
+						// TODO: run go mod tidy?
+					}
+				}
+				return;
 			case 'snapshot-result':
 				// response for `snapshot-request`.
 				return;
@@ -134,6 +149,38 @@
 	}
 }
 
+const GOPLS_UPGRADE_DEPENDENCY = 'gopls.upgrade_dependency';
+async function goplsUpgradeDependency(
+	goCtx: GoExtensionContext,
+	goModFileUri: vscode.Uri,
+	goCmdArgs: string[],
+	addRequire: boolean
+): Promise<void> {
+	const { languageClient } = goCtx;
+	const uri = languageClient?.code2ProtocolConverter.asUri(goModFileUri);
+	const params: ExecuteCommandParams = {
+		command: GOPLS_UPGRADE_DEPENDENCY,
+		arguments: [
+			{
+				URI: uri,
+				GoCmdArgs: goCmdArgs,
+				AddRequire: addRequire
+			}
+		]
+	};
+	return await languageClient?.sendRequest(ExecuteCommandRequest.type, params);
+}
+
+async function getGoModFile(dir: vscode.Uri): Promise<string | undefined> {
+	try {
+		const p = await runGoEnv(dir, ['GOMOD']);
+		return p['GOMOD'] === '/dev/null' || p['GOMOD'] === 'NUL' ? '' : p['GOMOD'];
+	} catch (e) {
+		vscode.window.showErrorMessage(`Failed to find 'go.mod' for ${dir}: ${e}`);
+	}
+	return;
+}
+
 export class VulncheckProvider {
 	static scheme = 'govulncheck';
 	static setup({ subscriptions }: vscode.ExtensionContext, goCtx: GoExtensionContext) {
diff --git a/test/gopls/vulncheck.test.ts b/test/gopls/vulncheck.test.ts
index 75fa8fc..70a622f 100644
--- a/test/gopls/vulncheck.test.ts
+++ b/test/gopls/vulncheck.test.ts
@@ -21,7 +21,7 @@
 	let provider: goVulncheck.VulncheckResultViewProvider;
 
 	setup(() => {
-		provider = new goVulncheck.VulncheckResultViewProvider(extensionUri);
+		provider = new goVulncheck.VulncheckResultViewProvider(extensionUri, {});
 	});
 
 	teardown(async () => {