Introduce a VariantAnalysisMonitor class

This will poll the API every 5 seconds for changes to the variant
analysis. By default it will continue to run for a maximum of 2 days,
or when the user closes VSCode.

The monitor will receive a variantAnalysis summary from the API that
will contain an up-to-date list of scanned repos.

The monitor will then return a list of scanned repo ids.

In a future PR we'll add the functionality to:
- update the UI for in progress/completed states
- raise error on timeout
- download the results
This commit is contained in:
Elena Tanasoiu
2022-09-27 13:14:57 +01:00
parent d9e9c1b885
commit 487cc7b088
3 changed files with 262 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { VariantAnalysis } from './variant-analysis';
export type VariantAnalysisMonitorStatus =
| 'InProgress'
| 'CompletedSuccessfully'
| 'CompletedUnsuccessfully'
| 'Failed'
| 'Cancelled'
| 'TimedOut';
export interface VariantAnalysisMonitorResult {
status: VariantAnalysisMonitorStatus;
error?: string;
scannedReposDownloaded?: number[],
variantAnalysis?: VariantAnalysis
}

View File

@@ -0,0 +1,81 @@
import * as vscode from 'vscode';
import { Credentials } from '../authentication';
import { Logger } from '../logging';
import * as ghApiClient from './gh-api/gh-api-client';
import { VariantAnalysis, VariantAnalysisStatus } from './shared/variant-analysis';
import {
VariantAnalysis as VariantAnalysisApiResponse
} from './gh-api/variant-analysis';
import { VariantAnalysisMonitorResult } from './shared/variant-analysis-monitor-result';
import { processFailureReason } from './variant-analysis-processor';
export class VariantAnalysisMonitor {
// With a sleep of 5 seconds, the maximum number of attempts takes
// us to just over 2 days worth of monitoring.
public static maxAttemptCount = 17280;
public static sleepTime = 5000;
constructor(
private readonly extensionContext: vscode.ExtensionContext,
private readonly logger: Logger
) {
}
public async monitorVariantAnalysis(
variantAnalysis: VariantAnalysis,
cancellationToken: vscode.CancellationToken
): Promise<VariantAnalysisMonitorResult> {
const credentials = await Credentials.initialize(this.extensionContext);
if (!credentials) {
throw Error('Error authenticating with GitHub');
}
let variantAnalysisSummary: VariantAnalysisApiResponse;
let attemptCount = 0;
const scannedReposDownloaded: number[] = [];
while (attemptCount <= VariantAnalysisMonitor.maxAttemptCount) {
await this.sleep(VariantAnalysisMonitor.sleepTime);
if (cancellationToken && cancellationToken.isCancellationRequested) {
return { status: 'Cancelled', error: 'Variant Analysis was canceled.' };
}
variantAnalysisSummary = await ghApiClient.getVariantAnalysis(
credentials,
variantAnalysis.controllerRepoId,
variantAnalysis.id
);
if (variantAnalysisSummary.status == 'in_progress' && variantAnalysisSummary.failure_reason) {
variantAnalysis.status = VariantAnalysisStatus.Failed;
variantAnalysis.failureReason = processFailureReason(variantAnalysisSummary.failure_reason);
return {
status: 'Failed',
error: `Variant Analysis has failed: ${variantAnalysisSummary.failure_reason}`,
variantAnalysis: variantAnalysis
};
}
void this.logger.log('****** Retrieved variant analysis' + JSON.stringify(variantAnalysisSummary));
if (variantAnalysisSummary.scanned_repositories) {
variantAnalysisSummary.scanned_repositories.forEach(scannedRepo => {
if (!scannedReposDownloaded.includes(scannedRepo.repository.id) && scannedRepo.analysis_status === 'succeeded') {
scannedReposDownloaded.push(scannedRepo.repository.id);
}
});
}
attemptCount++;
}
return { status: 'CompletedSuccessfully', scannedReposDownloaded: scannedReposDownloaded };
}
private async sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
}

View File

@@ -0,0 +1,165 @@
import * as sinon from 'sinon';
import { expect } from 'chai';
import { CancellationToken, extensions } from 'vscode';
import { CodeQLExtensionInterface } from '../../../extension';
import { logger } from '../../../logging';
import * as config from '../../../config';
import * as ghApiClient from '../../../remote-queries/gh-api/gh-api-client';
import { VariantAnalysisMonitor } from '../../../remote-queries/variant-analysis-monitor';
import {
VariantAnalysis as VariantAnalysisApiResponse,
VariantAnalysisScannedRepository as ApiVariantAnalysisScannedRepository,
VariantAnalysisFailureReason
} from '../../../remote-queries/gh-api/variant-analysis';
import { createFailedMockApiResponse, createMockApiResponse } from '../../factories/remote-queries/gh-api/variant-analysis-api-response';
import { VariantAnalysisStatus } from '../../../remote-queries/shared/variant-analysis';
import { createMockScannedRepos } from '../../factories/remote-queries/gh-api/scanned-repositories';
import { processFailureReason } from '../../../remote-queries/variant-analysis-processor';
import { Credentials } from '../../../authentication';
describe('Variant Analysis Monitor', async function() {
let sandbox: sinon.SinonSandbox;
let mockGetVariantAnalysis: sinon.SinonStub;
let cancellationToken: CancellationToken;
let variantAnalysisMonitor: VariantAnalysisMonitor;
let variantAnalysis: any;
beforeEach(async () => {
sandbox = sinon.createSandbox();
sandbox.stub(logger, 'log');
sandbox.stub(config, 'isVariantAnalysisLiveResultsEnabled').returns(false);
cancellationToken = {
isCancellationRequested: false
} as unknown as CancellationToken;
variantAnalysis = {
id: 123,
controllerRepoId: 1,
};
try {
const extension = await extensions.getExtension<CodeQLExtensionInterface | Record<string, never>>('GitHub.vscode-codeql')!.activate();
variantAnalysisMonitor = new VariantAnalysisMonitor(extension.ctx, logger);
} catch (e) {
fail(e as Error);
}
limitNumberOfAttemptsToMonitor();
});
afterEach(async () => {
sandbox.restore();
});
describe('when credentials are invalid', async () => {
beforeEach(async () => { sandbox.stub(Credentials, 'initialize').resolves(undefined); });
it('should return early if credentials are wrong', async () => {
try {
await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis, cancellationToken);
} catch (error: any) {
expect(error.message).to.equal('Error authenticating with GitHub');
}
});
});
describe('when credentials are valid', async () => {
beforeEach(async () => {
const mockCredentials = {
getOctokit: () => Promise.resolve({
request: mockGetVariantAnalysis
})
} as unknown as Credentials;
sandbox.stub(Credentials, 'initialize').resolves(mockCredentials);
});
it('should return early if variant analysis is cancelled', async () => {
cancellationToken.isCancellationRequested = true;
const result = await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis, cancellationToken);
expect(result).to.eql({ status: 'Cancelled', error: 'Variant Analysis was canceled.' });
});
describe('when the variant analysis fails', async () => {
let mockFailedApiResponse: VariantAnalysisApiResponse;
beforeEach(async function() {
mockFailedApiResponse = createFailedMockApiResponse('in_progress');
mockGetVariantAnalysis = sandbox.stub(ghApiClient, 'getVariantAnalysis').resolves(mockFailedApiResponse);
});
it('should mark as failed locally and stop monitoring', async () => {
const result = await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis, cancellationToken);
variantAnalysis = result.variantAnalysis;
expect(mockGetVariantAnalysis.calledOnce).to.be.true;
expect(result.status).to.eql('Failed');
expect(result.error).to.eql(`Variant Analysis has failed: ${mockFailedApiResponse.failure_reason}`);
expect(variantAnalysis.status).to.equal(VariantAnalysisStatus.Failed);
expect(variantAnalysis.failureReason).to.equal(processFailureReason(mockFailedApiResponse.failure_reason as VariantAnalysisFailureReason));
});
});
describe('when the variant analysis completes', async () => {
let mockApiResponse: VariantAnalysisApiResponse;
let scannedRepos: ApiVariantAnalysisScannedRepository[];
describe('when there are successfully scanned repos', async () => {
beforeEach(async function() {
scannedRepos = createMockScannedRepos(['pending', 'in_progress', 'succeeded']);
mockApiResponse = createMockApiResponse('completed', scannedRepos);
mockGetVariantAnalysis = sandbox.stub(ghApiClient, 'getVariantAnalysis').resolves(mockApiResponse);
});
it('should succeed and return a list of scanned repo ids', async () => {
const result = await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis, cancellationToken);
const scannedRepoIds = scannedRepos.filter(r => r.analysis_status == 'succeeded').map(r => r.repository.id);
expect(result.status).to.equal('CompletedSuccessfully');
expect(result.scannedReposDownloaded).to.eql(scannedRepoIds);
});
});
describe('when there are only in progress repos', async () => {
let scannedRepos: ApiVariantAnalysisScannedRepository[];
beforeEach(async function() {
scannedRepos = createMockScannedRepos(['pending', 'in_progress']);
mockApiResponse = createMockApiResponse('in_progress', scannedRepos);
mockGetVariantAnalysis = sandbox.stub(ghApiClient, 'getVariantAnalysis').resolves(mockApiResponse);
});
it('should succeed and return an empty list of scanned repo ids', async () => {
const result = await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis, cancellationToken);
expect(result.status).to.equal('CompletedSuccessfully');
expect(result.scannedReposDownloaded).to.eql([]);
});
});
describe('when there are no repos to scan', async () => {
beforeEach(async function() {
scannedRepos = [];
mockApiResponse = createMockApiResponse('completed', scannedRepos);
mockGetVariantAnalysis = sandbox.stub(ghApiClient, 'getVariantAnalysis').resolves(mockApiResponse);
});
it('should succeed and return an empty list of scanned repo ids', async () => {
const result = await variantAnalysisMonitor.monitorVariantAnalysis(variantAnalysis, cancellationToken);
expect(result.status).to.equal('CompletedSuccessfully');
expect(result.scannedReposDownloaded).to.eql([]);
});
});
});
});
function limitNumberOfAttemptsToMonitor() {
VariantAnalysisMonitor.maxAttemptCount = 3;
VariantAnalysisMonitor.sleepTime = 1;
}
});