diff --git a/extensions/ql-vscode/src/extension.ts b/extensions/ql-vscode/src/extension.ts index 8499201c88e..23c11dbb372 100644 --- a/extensions/ql-vscode/src/extension.ts +++ b/extensions/ql-vscode/src/extension.ts @@ -944,6 +944,12 @@ async function activateWithInstalledDistribution( }) ); + ctx.subscriptions.push( + commandRunner('codeQL.copyVariantAnalysisRepoList', async (variantAnalysisId: number) => { + await variantAnalysisManager.copyRepoListToClipboard(variantAnalysisId); + }) + ); + ctx.subscriptions.push( commandRunner('codeQL.monitorVariantAnalysis', async ( variantAnalysis: VariantAnalysis, diff --git a/extensions/ql-vscode/src/query-history.ts b/extensions/ql-vscode/src/query-history.ts index 09dc82861c6..87d304fe44e 100644 --- a/extensions/ql-vscode/src/query-history.ts +++ b/extensions/ql-vscode/src/query-history.ts @@ -1256,11 +1256,15 @@ export class QueryHistoryManager extends DisposableObject { const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect); // Remote queries only - if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem || finalSingleItem.t !== 'remote') { + if (!this.assertSingleQuery(finalMultiSelect) || !finalSingleItem) { return; } - await commands.executeCommand('codeQL.copyRepoList', finalSingleItem.queryId); + if (finalSingleItem.t === 'remote') { + await commands.executeCommand('codeQL.copyRepoList', finalSingleItem.queryId); + } else if (finalSingleItem.t === 'variant-analysis') { + await commands.executeCommand('codeQL.copyVariantAnalysisRepoList', finalSingleItem.variantAnalysis.id); + } } async handleExportResults(): Promise { diff --git a/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts b/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts index 63a127444d4..e29c1ade52c 100644 --- a/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts +++ b/extensions/ql-vscode/src/remote-queries/variant-analysis-manager.ts @@ -1,7 +1,7 @@ import * as path from 'path'; import * as ghApiClient from './gh-api/gh-api-client'; -import { CancellationToken, commands, EventEmitter, ExtensionContext, Uri, window } from 'vscode'; +import { CancellationToken, commands, env, EventEmitter, ExtensionContext, Uri, window } from 'vscode'; import { DisposableObject } from '../pure/disposable-object'; import { Credentials } from '../authentication'; import { VariantAnalysisMonitor } from './variant-analysis-monitor'; @@ -28,6 +28,7 @@ import { import PQueue from 'p-queue'; import { createTimestampFile, showAndLogErrorMessage, showAndLogInformationMessage } from '../helpers'; import * as fs from 'fs-extra'; +import * as os from 'os'; import { cancelVariantAnalysis } from './gh-api/gh-actions-api-client'; import { ProgressCallback, UserCancellationException } from '../commandRunner'; import { CodeQLCliServer } from '../cli'; @@ -367,6 +368,27 @@ export class VariantAnalysisManager extends DisposableObject implements VariantA await cancelVariantAnalysis(credentials, variantAnalysis); } + public async copyRepoListToClipboard(variantAnalysisId: number) { + const variantAnalysis = this.variantAnalyses.get(variantAnalysisId); + if (!variantAnalysis) { + throw new Error(`No variant analysis with id: ${variantAnalysisId}`); + } + + const fullNames = variantAnalysis.scannedRepos?.filter(a => a.resultCount && a.resultCount > 0).map(a => a.repository.fullName); + if (!fullNames || fullNames.length === 0) { + return; + } + + const text = [ + '"new-repo-list": [', + ...fullNames.slice(0, -1).map(repo => ` "${repo}",`), + ` "${fullNames[fullNames.length - 1]}"`, + ']' + ]; + + await env.clipboard.writeText(text.join(os.EOL)); + } + private getRepoStatesStoragePath(variantAnalysisId: number): string { return path.join( this.getVariantAnalysisStorageLocation(variantAnalysisId), diff --git a/extensions/ql-vscode/src/vscode-tests/cli-integration/remote-queries/variant-analysis-manager.test.ts b/extensions/ql-vscode/src/vscode-tests/cli-integration/remote-queries/variant-analysis-manager.test.ts index f259d6b27b1..1124cc70ceb 100644 --- a/extensions/ql-vscode/src/vscode-tests/cli-integration/remote-queries/variant-analysis-manager.test.ts +++ b/extensions/ql-vscode/src/vscode-tests/cli-integration/remote-queries/variant-analysis-manager.test.ts @@ -1,6 +1,6 @@ import * as sinon from 'sinon'; import { assert, expect } from 'chai'; -import { CancellationTokenSource, commands, extensions, QuickPickItem, Uri, window } from 'vscode'; +import { CancellationTokenSource, commands, env, extensions, QuickPickItem, Uri, window } from 'vscode'; import { CodeQLExtensionInterface } from '../../../extension'; import { logger } from '../../../logging'; import * as config from '../../../config'; @@ -16,7 +16,10 @@ import { storagePath } from '../global.helper'; import { VariantAnalysisResultsManager } from '../../../remote-queries/variant-analysis-results-manager'; import { createMockVariantAnalysis } from '../../factories/remote-queries/shared/variant-analysis'; import * as VariantAnalysisModule from '../../../remote-queries/shared/variant-analysis'; -import { createMockScannedRepos } from '../../factories/remote-queries/shared/scanned-repositories'; +import { + createMockScannedRepo, + createMockScannedRepos +} from '../../factories/remote-queries/shared/scanned-repositories'; import { VariantAnalysis, VariantAnalysisScannedRepository, @@ -252,7 +255,9 @@ describe('Variant Analysis Manager', async function() { }); describe('when credentials are invalid', async () => { - beforeEach(async () => { sandbox.stub(Credentials, 'initialize').resolves(undefined); }); + beforeEach(async () => { + sandbox.stub(Credentials, 'initialize').resolves(undefined); + }); it('should return early if credentials are wrong', async () => { try { @@ -695,4 +700,121 @@ describe('Variant Analysis Manager', async function() { }); }); }); + + describe('copyRepoListToClipboard', async () => { + let variantAnalysis: VariantAnalysis; + let variantAnalysisStorageLocation: string; + + let writeTextStub: sinon.SinonStub; + + beforeEach(async () => { + variantAnalysis = createMockVariantAnalysis({}); + + variantAnalysisStorageLocation = variantAnalysisManager.getVariantAnalysisStorageLocation(variantAnalysis.id); + await createTimestampFile(variantAnalysisStorageLocation); + await variantAnalysisManager.rehydrateVariantAnalysis(variantAnalysis); + + writeTextStub = sinon.stub(); + sinon.stub(env, 'clipboard').value({ + writeText: writeTextStub, + }); + }); + + afterEach(() => { + fs.rmSync(variantAnalysisStorageLocation, { recursive: true }); + }); + + describe('when the variant analysis does not have any repositories', () => { + beforeEach(async () => { + await variantAnalysisManager.rehydrateVariantAnalysis({ + ...variantAnalysis, + scannedRepos: [], + }); + }); + + it('should not copy any text', async () => { + await variantAnalysisManager.copyRepoListToClipboard(variantAnalysis.id); + + expect(writeTextStub).not.to.have.been.called; + }); + }); + + describe('when the variant analysis does not have any repositories with results', () => { + beforeEach(async () => { + await variantAnalysisManager.rehydrateVariantAnalysis({ + ...variantAnalysis, + scannedRepos: [ + { + ...createMockScannedRepo(), + resultCount: 0, + }, + { + ...createMockScannedRepo(), + resultCount: undefined, + } + ], + }); + }); + + it('should not copy any text', async () => { + await variantAnalysisManager.copyRepoListToClipboard(variantAnalysis.id); + + expect(writeTextStub).not.to.have.been.called; + }); + }); + + describe('when the variant analysis has repositories with results', () => { + const scannedRepos = [ + { + ...createMockScannedRepo(), + resultCount: 100, + }, + { + ...createMockScannedRepo(), + resultCount: 0, + }, + { + ...createMockScannedRepo(), + resultCount: 200, + }, + { + ...createMockScannedRepo(), + resultCount: undefined, + }, + { + ...createMockScannedRepo(), + resultCount: 5, + }, + ]; + + beforeEach(async () => { + await variantAnalysisManager.rehydrateVariantAnalysis({ + ...variantAnalysis, + scannedRepos, + }); + }); + + it('should copy text', async () => { + await variantAnalysisManager.copyRepoListToClipboard(variantAnalysis.id); + + expect(writeTextStub).to.have.been.calledOnce; + }); + + it('should be valid JSON when put in object', async () => { + await variantAnalysisManager.copyRepoListToClipboard(variantAnalysis.id); + + const text = writeTextStub.getCalls()[0].lastArg; + + const parsed = JSON.parse('{' + text + '}'); + + expect(parsed).to.deep.eq({ + 'new-repo-list': [ + scannedRepos[0].repository.fullName, + scannedRepos[2].repository.fullName, + scannedRepos[4].repository.fullName, + ], + }); + }); + }); + }); });