diff --git a/package.json b/package.json index 0b632cc..2e973e1 100644 --- a/package.json +++ b/package.json @@ -9111,6 +9111,7 @@ "dayjs": "1.10.4", "iconv-lite": "0.6.2", "lodash-es": "4.17.20", + "node-fetch": "3.0.0-beta.9", "sortablejs": "1.13.0", "vscode-codicons": "0.0.14", "vsls": "1.0.3015" diff --git a/src/constants.ts b/src/constants.ts index 602cac6..ad1dc9b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,6 +14,7 @@ export enum BuiltInCommands { ExecuteDocumentSymbolProvider = 'vscode.executeDocumentSymbolProvider', ExecuteCodeLensProvider = 'vscode.executeCodeLensProvider', FocusFilesExplorer = 'workbench.files.action.focusFilesExplorer', + InstallExtension = 'workbench.extensions.installExtension', Open = 'vscode.open', OpenFolder = 'vscode.openFolder', OpenInTerminal = 'openInTerminal', diff --git a/src/partners.ts b/src/partners.ts index 5336bc2..5fa35f3 100644 --- a/src/partners.ts +++ b/src/partners.ts @@ -1,13 +1,158 @@ 'use strict'; -import { ExtensionContext } from 'vscode'; +import fetch from 'node-fetch'; +import { + CancellationTokenSource, + commands, + Disposable, + env, + Extension, + ExtensionContext, + extensions, + Uri, + workspace, +} from 'vscode'; +import { ActionRunnerType } from './api/actionRunners'; import { ActionContext, HoverCommandsActionContext } from './api/gitlens'; import { Commands, executeCommand, InviteToLiveShareCommandArgs } from './commands'; +import { BuiltInCommands } from './constants'; import { Container } from './container'; +import { Strings } from './system'; + +export async function installExtension( + extensionId: string, + tokenSource: CancellationTokenSource, + timeout: number, + vsix?: Uri, +): Promise | undefined> { + try { + let timer: any = 0; + const extension = new Promise | undefined>(resolve => { + const disposable = extensions.onDidChange(() => { + const extension = extensions.getExtension(extensionId); + if (extension != null) { + clearTimeout(timer); + disposable.dispose(); + + resolve(extension); + } + }); + + tokenSource.token.onCancellationRequested(() => { + disposable.dispose(); + + resolve(undefined); + }); + }); + + await commands.executeCommand(BuiltInCommands.InstallExtension, vsix ?? extensionId); + // Wait for extension activation until timeout expires + timer = setTimeout(() => tokenSource.cancel(), timeout); + + return extension; + } catch { + tokenSource.cancel(); + return undefined; + } +} export function registerPartnerActionRunners(context: ExtensionContext): void { + registerCodeStream(context); registerLiveShare(context); } +function registerCodeStream(context: ExtensionContext): void { + if (extensions.getExtension('codestream.codestream') != null) return; + + const subscriptions: Disposable[] = []; + + const partnerId = 'codestream'; + + async function runner(ctx: ActionContext) { + const hashes = []; + + for (const repo of await Container.git.getRepositories()) { + const user = await Container.git.getCurrentUser(repo.path); + if (user?.email != null) { + hashes.push(Strings.sha1(`gitlens:${user.email.trim().toLowerCase()}`, 'hex')); + } + } + + const config = Container.config.partners?.[partnerId]; + + const url = (Container.insiders && config?.url) ?? 'https://api.codestream.com/no-auth/gitlens-user'; + const body: { emailHashes: string[]; machineIdHash: string; installed?: boolean } = { + emailHashes: hashes, + machineIdHash: Strings.sha1(`gitlens:${env.machineId.trim().toLowerCase()}`, 'hex'), + }; + + void sendPartnerJsonRequest(url, JSON.stringify(body), 0); + + const tokenSource = new CancellationTokenSource(); + + // Re-play action when/if we can find a matching newly installed runner + const { actionRunners } = Container; + const rerunDisposable = actionRunners.onDidChange(action => { + if (action != null && action !== ctx.type) return; + + const runners = actionRunners.get(ctx.type); + if (runners == null || runners.length === 0) return; + + const runner = runners.find( + r => r.type === ActionRunnerType.Partner && (r.partnerId === partnerId || r.name === 'CodeStream'), + ); + if (runner != null) { + rerunDisposable.dispose(); + + void runner.run(ctx); + } + }); + tokenSource.token.onCancellationRequested(() => rerunDisposable.dispose()); + + const extension = await installExtension( + 'codestream.codestream', + tokenSource, + 30000, + Container.insiders && config?.vsix != null ? Uri.file(config.vsix) : undefined, + ); + + if (extension == null) { + rerunDisposable.dispose(); + + return; + } + + void workspace.fs.writeFile(Uri.joinPath(extension.extensionUri, '.gitlens'), new Uint8Array()); + + // Unregister the partner runners + Disposable.from(...subscriptions).dispose(); + + body.installed = true; + void sendPartnerJsonRequest(url, JSON.stringify(body), 0); + + // Wait for 30s for new action runner registrations + setTimeout(() => tokenSource.cancel(), 30000); + } + + subscriptions.push( + Container.actionRunners.registerBuiltInPartnerInstaller(partnerId, 'createPullRequest', { + name: 'CodeStream', + label: 'Create Pull Request in VS Code', + run: runner, + }), + Container.actionRunners.registerBuiltInPartnerInstaller(partnerId, 'openPullRequest', { + name: 'CodeStream', + label: 'Open Pull Request in VS Code', + run: runner, + }), + Container.actionRunners.registerBuiltInPartnerInstaller(partnerId, 'hover.commands', { + name: 'CodeStream', + label: '$(comment) Leave a Comment', + run: runner, + }), + ); + context.subscriptions.push(...subscriptions); +} + function registerLiveShare(context: ExtensionContext) { context.subscriptions.push( Container.actionRunners.registerBuiltInPartner('liveshare', 'hover.commands', { @@ -40,3 +185,25 @@ function registerLiveShare(context: ExtensionContext) { }), ); } + +async function sendPartnerJsonRequest(url: string, body: string, retryCount: number) { + try { + const response = await fetch(url, { + method: 'POST', + body: body, + headers: { 'Content-Type': 'application/json' }, + }); + + if (response.ok) return; + + throw new Error(response.statusText); + } catch (ex) { + retryCount++; + if (retryCount > 6) { + // Give up + return; + } + + setTimeout(() => sendPartnerJsonRequest(url, body, retryCount), Math.pow(2, retryCount) * 2000); + } +} diff --git a/yarn.lock b/yarn.lock index 876098c..2374489 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1337,6 +1337,11 @@ dashdash@^1.12.0: dependencies: assert-plus "^1.0.0" +data-uri-to-buffer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-3.0.1.tgz#594b8973938c5bc2c33046535785341abc4f3636" + integrity sha512-WboRycPNsVw3B3TL559F7kuBUM4d8CgMEvk6xEJlOp7OBPjt6G7z8WMWlD2rOFZLk6OYfFIUGsCOWzcQH9K2og== + dayjs@1.10.4: version "1.10.4" resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.4.tgz#8e544a9b8683f61783f570980a8a80eaf54ab1e2" @@ -2075,6 +2080,11 @@ fd-slicer@~1.1.0: dependencies: pend "~1.2.0" +fetch-blob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-2.1.1.tgz#a54ab0d5ed7ccdb0691db77b6674308b23fb2237" + integrity sha512-Uf+gxPCe1hTOFXwkxYyckn8iUSk6CFXGy5VENZKifovUTZC9eUODWSBhOBS7zICGrAetKzdwLMr85KhIcePMAQ== + figures@^1.3.5: version "1.7.0" resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e" @@ -3503,6 +3513,14 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-fetch@3.0.0-beta.9: + version "3.0.0-beta.9" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-3.0.0-beta.9.tgz#0a7554cfb824380dd6812864389923c783c80d9b" + integrity sha512-RdbZCEynH2tH46+tj0ua9caUHVWrd/RHnRfvly2EVdqGmI3ndS1Vn/xjm5KuGejDt2RNDQsVRLPNd2QPwcewVg== + dependencies: + data-uri-to-buffer "^3.0.1" + fetch-blob "^2.1.1" + node-fetch@^2.6.1: version "2.6.1" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052"