Skip to content

Commit 61d4305

Browse files
committed
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.
1 parent 47ec074 commit 61d4305

8 files changed

Lines changed: 105 additions & 8 deletions

File tree

extensions/ql-vscode/src/authentication.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const GITHUB_AUTH_PROVIDER_ID = 'github';
77
// https://docs.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps
88
const SCOPES = ['repo'];
99

10-
/**
10+
/**
1111
* Handles authentication to GitHub, using the VS Code [authentication API](https://code.visualstudio.com/api/references/vscode-api#authentication).
1212
*/
1313
export class Credentials {
@@ -18,6 +18,15 @@ export class Credentials {
1818
// eslint-disable-next-line @typescript-eslint/no-empty-function
1919
private constructor() { }
2020

21+
/**
22+
* Initializes an instance of credentials with an octokit instance.
23+
*
24+
* Do not call this method until you know you actually need an instance of credentials.
25+
* since calling this method will require the user to log in.
26+
*
27+
* @param context The extension context.
28+
* @returns An instance of credentials.
29+
*/
2130
static async initialize(context: vscode.ExtensionContext): Promise<Credentials> {
2231
const c = new Credentials();
2332
c.registerListeners(context);

extensions/ql-vscode/src/extension.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,7 @@ async function activateWithInstalledDistribution(
455455
queryStorageDir,
456456
ctx,
457457
queryHistoryConfigurationListener,
458+
() => Credentials.initialize(ctx),
458459
async (from: CompletedLocalQueryInfo, to: CompletedLocalQueryInfo) =>
459460
showResultsForComparison(from, to),
460461
);

extensions/ql-vscode/src/query-history.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import { QueryStatus } from './query-status';
3636
import { slurpQueryHistory, splatQueryHistory } from './query-serialization';
3737
import * as fs from 'fs-extra';
3838
import { CliVersionConstraint } from './cli';
39+
import { Credentials } from './authentication';
40+
import { cancelRemoteQuery } from './remote-queries/gh-actions-api-client';
3941

4042
/**
4143
* query-history.ts
@@ -318,6 +320,7 @@ export class QueryHistoryManager extends DisposableObject {
318320
private queryStorageDir: string,
319321
ctx: ExtensionContext,
320322
private queryHistoryConfigListener: QueryHistoryConfig,
323+
private readonly getCredentials: () => Promise<Credentials>,
321324
private doCompareCallback: (
322325
from: CompletedLocalQueryInfo,
323326
to: CompletedLocalQueryInfo
@@ -816,7 +819,7 @@ export class QueryHistoryManager extends DisposableObject {
816819
}
817820

818821
if (finalSingleItem.evalLogSummaryLocation) {
819-
await this.tryOpenExternalFile(finalSingleItem.evalLogSummaryLocation);
822+
await this.tryOpenExternalFile(finalSingleItem.evalLogSummaryLocation);
820823
} else {
821824
this.warnNoEvalLogSummary();
822825
}
@@ -830,11 +833,20 @@ export class QueryHistoryManager extends DisposableObject {
830833
// In the future, we may support cancelling remote queries, but this is not a short term plan.
831834
const { finalSingleItem, finalMultiSelect } = this.determineSelection(singleItem, multiSelect);
832835

833-
(finalMultiSelect || [finalSingleItem]).forEach((item) => {
834-
if (item.status === QueryStatus.InProgress && item.t === 'local') {
835-
item.cancel();
836+
const selected = finalMultiSelect || [finalSingleItem];
837+
const results = selected.map(async item => {
838+
if (item.status === QueryStatus.InProgress) {
839+
if (item.t === 'local') {
840+
item.cancel();
841+
} else if (item.t === 'remote') {
842+
void showAndLogInformationMessage('Cancelling remote query. This may take a while.');
843+
const credentials = await this.getCredentials();
844+
await cancelRemoteQuery(credentials, item.remoteQuery);
845+
}
836846
}
837847
});
848+
849+
await Promise.all(results);
838850
}
839851

840852
async handleShowQueryText(

extensions/ql-vscode/src/remote-queries/gh-actions-api-client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,18 @@ export async function getRemoteQueryIndex(
7474
};
7575
}
7676

77+
export async function cancelRemoteQuery(
78+
credentials: Credentials,
79+
remoteQuery: RemoteQuery
80+
): Promise<void> {
81+
const octokit = await credentials.getOctokit();
82+
const { actionsWorkflowRunId, controllerRepository: { owner, name } } = remoteQuery;
83+
const response = await octokit.request(`POST /repos/${owner}/${name}/actions/runs/${actionsWorkflowRunId}/cancel`);
84+
if (response.status >= 300) {
85+
throw new Error(`Error cancelling remote query: ${response.status}`);
86+
}
87+
}
88+
7789
export async function downloadArtifactFromLink(
7890
credentials: Credentials,
7991
storagePath: string,

extensions/ql-vscode/src/remote-queries/remote-queries-manager.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,9 +155,16 @@ export class RemoteQueriesManager extends DisposableObject {
155155
queryItem.status = QueryStatus.Failed;
156156
}
157157
} else if (queryWorkflowResult.status === 'CompletedUnsuccessfully') {
158-
queryItem.failureReason = queryWorkflowResult.error;
159-
queryItem.status = QueryStatus.Failed;
160-
void showAndLogErrorMessage(`Variant analysis execution failed. Error: ${queryWorkflowResult.error}`);
158+
if (queryWorkflowResult.error?.includes('cancelled')) {
159+
// workflow was cancelled on the server
160+
queryItem.failureReason = 'Cancelled';
161+
queryItem.status = QueryStatus.Failed;
162+
void showAndLogErrorMessage('Variant analysis monitoring was cancelled');
163+
} else {
164+
queryItem.failureReason = queryWorkflowResult.error;
165+
queryItem.status = QueryStatus.Failed;
166+
void showAndLogErrorMessage(`Variant analysis execution failed. Error: ${queryWorkflowResult.error}`);
167+
}
161168
} else if (queryWorkflowResult.status === 'Cancelled') {
162169
queryItem.failureReason = 'Cancelled';
163170
queryItem.status = QueryStatus.Failed;

extensions/ql-vscode/src/vscode-tests/no-workspace/query-history.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ describe('query-history', () => {
2727
let showQuickPickSpy: sinon.SinonStub;
2828
let queryHistoryManager: QueryHistoryManager | undefined;
2929
let selectedCallback: sinon.SinonStub;
30+
let getCredentialsCallback: sinon.SinonStub;
3031
let doCompareCallback: sinon.SinonStub;
3132

3233
let tryOpenExternalFile: Function;
@@ -49,6 +50,7 @@ describe('query-history', () => {
4950
tryOpenExternalFile = (QueryHistoryManager.prototype as any).tryOpenExternalFile;
5051
configListener = new QueryHistoryConfigListener();
5152
selectedCallback = sandbox.stub();
53+
getCredentialsCallback = sandbox.stub();
5254
doCompareCallback = sandbox.stub();
5355
});
5456

@@ -749,6 +751,7 @@ describe('query-history', () => {
749751
extensionPath: vscode.Uri.file('/x/y/z').fsPath,
750752
} as vscode.ExtensionContext,
751753
configListener,
754+
getCredentialsCallback,
752755
doCompareCallback
753756
);
754757
qhm.onWillOpenQueryItem(selectedCallback);
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { expect } from 'chai';
2+
import * as sinon from 'sinon';
3+
import { Credentials } from '../../../authentication';
4+
import { cancelRemoteQuery } from '../../../remote-queries/gh-actions-api-client';
5+
import { RemoteQuery } from '../../../remote-queries/remote-query';
6+
7+
describe('gh-actions-api-client', () => {
8+
let sandbox: sinon.SinonSandbox;
9+
let mockCredentials: Credentials;
10+
let mockResponse: sinon.SinonStub<any, Promise<{ status: number }>>;
11+
12+
beforeEach(() => {
13+
sandbox = sinon.createSandbox();
14+
mockCredentials = {
15+
getOctokit: () => Promise.resolve({
16+
request: mockResponse
17+
})
18+
} as unknown as Credentials;
19+
});
20+
21+
afterEach(() => {
22+
sandbox.restore();
23+
});
24+
25+
describe('cancelRemoteQuery', () => {
26+
it('should cancel a remote query', async () => {
27+
mockResponse = sinon.stub().resolves({ status: 202 });
28+
await cancelRemoteQuery(mockCredentials, createMockRemoteQuery());
29+
30+
expect(mockResponse.calledOnce).to.be.true;
31+
expect(mockResponse.firstCall.args[0]).to.equal('POST /repos/github/codeql/actions/runs/123/cancel');
32+
});
33+
34+
it('should fail to cancel a remote query', async () => {
35+
mockResponse = sinon.stub().resolves({ status: 409 });
36+
37+
await expect(cancelRemoteQuery(mockCredentials, createMockRemoteQuery())).to.be.rejectedWith(/Error cancelling remote query/);
38+
expect(mockResponse.calledOnce).to.be.true;
39+
expect(mockResponse.firstCall.args[0]).to.equal('POST /repos/github/codeql/actions/runs/123/cancel');
40+
});
41+
42+
function createMockRemoteQuery(): RemoteQuery {
43+
return {
44+
actionsWorkflowRunId: 123,
45+
controllerRepository: {
46+
owner: 'github',
47+
name: 'codeql'
48+
}
49+
} as unknown as RemoteQuery;
50+
}
51+
});
52+
});

extensions/ql-vscode/src/vscode-tests/no-workspace/remote-queries/remote-query-history.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ describe('Remote queries and query history manager', function() {
7171
{
7272
onDidChangeConfiguration: () => new DisposableBucket(),
7373
} as unknown as QueryHistoryConfig,
74+
asyncNoop as any,
7475
asyncNoop
7576
);
7677
disposables.push(qhm);

0 commit comments

Comments
 (0)