Handle cancelling of remote queries

This change issues a cancel request when the user clicks on "cancel" for
a remote query.

The cancel can take quite a while to complete, so a message is popped up
to let the user know.
This commit is contained in:
Andrew Eisenberg
2022-04-11 19:05:00 -07:00
parent 47ec074cfb
commit 61d4305593
8 changed files with 105 additions and 8 deletions

View File

@@ -7,7 +7,7 @@ const GITHUB_AUTH_PROVIDER_ID = 'github';
// https://docs.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps
const SCOPES = ['repo'];
/**
/**
* Handles authentication to GitHub, using the VS Code [authentication API](https://code.visualstudio.com/api/references/vscode-api#authentication).
*/
export class Credentials {
@@ -18,6 +18,15 @@ export class Credentials {
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() { }
/**
* Initializes an instance of credentials with an octokit instance.
*
* Do not call this method until you know you actually need an instance of credentials.
* since calling this method will require the user to log in.
*
* @param context The extension context.
* @returns An instance of credentials.
*/
static async initialize(context: vscode.ExtensionContext): Promise<Credentials> {
const c = new Credentials();
c.registerListeners(context);

View File

@@ -455,6 +455,7 @@ async function activateWithInstalledDistribution(
queryStorageDir,
ctx,
queryHistoryConfigurationListener,
() => Credentials.initialize(ctx),
async (from: CompletedLocalQueryInfo, to: CompletedLocalQueryInfo) =>
showResultsForComparison(from, to),
);

View File

@@ -36,6 +36,8 @@ import { QueryStatus } from './query-status';
import { slurpQueryHistory, splatQueryHistory } from './query-serialization';
import * as fs from 'fs-extra';
import { CliVersionConstraint } from './cli';
import { Credentials } from './authentication';
import { cancelRemoteQuery } from './remote-queries/gh-actions-api-client';
/**
* query-history.ts
@@ -318,6 +320,7 @@ export class QueryHistoryManager extends DisposableObject {
private queryStorageDir: string,
ctx: ExtensionContext,
private queryHistoryConfigListener: QueryHistoryConfig,
private readonly getCredentials: () => Promise<Credentials>,
private doCompareCallback: (
from: CompletedLocalQueryInfo,
to: CompletedLocalQueryInfo
@@ -816,7 +819,7 @@ export class QueryHistoryManager extends DisposableObject {
}
if (finalSingleItem.evalLogSummaryLocation) {
await this.tryOpenExternalFile(finalSingleItem.evalLogSummaryLocation);
await this.tryOpenExternalFile(finalSingleItem.evalLogSummaryLocation);
} else {
this.warnNoEvalLogSummary();
}
@@ -830,11 +833,20 @@ export class QueryHistoryManager extends DisposableObject {
// In the future, we may support cancelling remote queries, but this is not a short term plan.
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
(finalMultiSelect || [finalSingleItem]).forEach((item) => {
if (item.status === QueryStatus.InProgress && item.t === 'local') {
item.cancel();
const selected = finalMultiSelect || [finalSingleItem];
const results = selected.map(async item => {
if (item.status === QueryStatus.InProgress) {
if (item.t === 'local') {
item.cancel();
} else if (item.t === 'remote') {
void showAndLogInformationMessage('Cancelling remote query. This may take a while.');
const credentials = await this.getCredentials();
await cancelRemoteQuery(credentials, item.remoteQuery);
}
}
});
await Promise.all(results);
}
async handleShowQueryText(

View File

@@ -74,6 +74,18 @@ export async function getRemoteQueryIndex(
};
}
export async function cancelRemoteQuery(
credentials: Credentials,
remoteQuery: RemoteQuery
): Promise<void> {
const octokit = await credentials.getOctokit();
const { actionsWorkflowRunId, controllerRepository: { owner, name } } = remoteQuery;
const response = await octokit.request(`POST /repos/${owner}/${name}/actions/runs/${actionsWorkflowRunId}/cancel`);
if (response.status >= 300) {
throw new Error(`Error cancelling remote query: ${response.status}`);
}
}
export async function downloadArtifactFromLink(
credentials: Credentials,
storagePath: string,

View File

@@ -155,9 +155,16 @@ export class RemoteQueriesManager extends DisposableObject {
queryItem.status = QueryStatus.Failed;
}
} else if (queryWorkflowResult.status === 'CompletedUnsuccessfully') {
queryItem.failureReason = queryWorkflowResult.error;
queryItem.status = QueryStatus.Failed;
void showAndLogErrorMessage(`Variant analysis execution failed. Error: ${queryWorkflowResult.error}`);
if (queryWorkflowResult.error?.includes('cancelled')) {
// workflow was cancelled on the server
queryItem.failureReason = 'Cancelled';
queryItem.status = QueryStatus.Failed;
void showAndLogErrorMessage('Variant analysis monitoring was cancelled');
} else {
queryItem.failureReason = queryWorkflowResult.error;
queryItem.status = QueryStatus.Failed;
void showAndLogErrorMessage(`Variant analysis execution failed. Error: ${queryWorkflowResult.error}`);
}
} else if (queryWorkflowResult.status === 'Cancelled') {
queryItem.failureReason = 'Cancelled';
queryItem.status = QueryStatus.Failed;

View File

@@ -27,6 +27,7 @@ describe('query-history', () => {
let showQuickPickSpy: sinon.SinonStub;
let queryHistoryManager: QueryHistoryManager | undefined;
let selectedCallback: sinon.SinonStub;
let getCredentialsCallback: sinon.SinonStub;
let doCompareCallback: sinon.SinonStub;
let tryOpenExternalFile: Function;
@@ -49,6 +50,7 @@ describe('query-history', () => {
tryOpenExternalFile = (QueryHistoryManager.prototype as any).tryOpenExternalFile;
configListener = new QueryHistoryConfigListener();
selectedCallback = sandbox.stub();
getCredentialsCallback = sandbox.stub();
doCompareCallback = sandbox.stub();
});
@@ -749,6 +751,7 @@ describe('query-history', () => {
extensionPath: vscode.Uri.file('/x/y/z').fsPath,
} as vscode.ExtensionContext,
configListener,
getCredentialsCallback,
doCompareCallback
);
qhm.onWillOpenQueryItem(selectedCallback);

View File

@@ -0,0 +1,52 @@
import { expect } from 'chai';
import * as sinon from 'sinon';
import { Credentials } from '../../../authentication';
import { cancelRemoteQuery } from '../../../remote-queries/gh-actions-api-client';
import { RemoteQuery } from '../../../remote-queries/remote-query';
describe('gh-actions-api-client', () => {
let sandbox: sinon.SinonSandbox;
let mockCredentials: Credentials;
let mockResponse: sinon.SinonStub<any, Promise<{ status: number }>>;
beforeEach(() => {
sandbox = sinon.createSandbox();
mockCredentials = {
getOctokit: () => Promise.resolve({
request: mockResponse
})
} as unknown as Credentials;
});
afterEach(() => {
sandbox.restore();
});
describe('cancelRemoteQuery', () => {
it('should cancel a remote query', async () => {
mockResponse = sinon.stub().resolves({ status: 202 });
await cancelRemoteQuery(mockCredentials, createMockRemoteQuery());
expect(mockResponse.calledOnce).to.be.true;
expect(mockResponse.firstCall.args[0]).to.equal('POST /repos/github/codeql/actions/runs/123/cancel');
});
it('should fail to cancel a remote query', async () => {
mockResponse = sinon.stub().resolves({ status: 409 });
await expect(cancelRemoteQuery(mockCredentials, createMockRemoteQuery())).to.be.rejectedWith(/Error cancelling remote query/);
expect(mockResponse.calledOnce).to.be.true;
expect(mockResponse.firstCall.args[0]).to.equal('POST /repos/github/codeql/actions/runs/123/cancel');
});
function createMockRemoteQuery(): RemoteQuery {
return {
actionsWorkflowRunId: 123,
controllerRepository: {
owner: 'github',
name: 'codeql'
}
} as unknown as RemoteQuery;
}
});
});

View File

@@ -71,6 +71,7 @@ describe('Remote queries and query history manager', function() {
{
onDidChangeConfiguration: () => new DisposableBucket(),
} as unknown as QueryHistoryConfig,
asyncNoop as any,
asyncNoop
);
disposables.push(qhm);