Skip to content

Commit e9e41e0

Browse files
authored
Implement download behaviour in remote queries view (#1046)
1 parent b435df4 commit e9e41e0

10 files changed

Lines changed: 214 additions & 62 deletions

extensions/ql-vscode/src/pure/interface-types.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as sarif from 'sarif';
2+
import { DownloadLink } from '../remote-queries/download-link';
23
import { RemoteQueryResult } from '../remote-queries/shared/remote-query-result';
34
import { RawResultSet, ResultRow, ResultSetSchema, Column, ResolvableLocationValue } from './bqrs-cli-types';
45

@@ -375,7 +376,8 @@ export type FromRemoteQueriesMessage =
375376
| RemoteQueryLoadedMessage
376377
| RemoteQueryErrorMessage
377378
| OpenFileMsg
378-
| OpenVirtualFileMsg;
379+
| OpenVirtualFileMsg
380+
| RemoteQueryDownloadLinkClickedMessage;
379381

380382
export type ToRemoteQueriesMessage =
381383
| SetRemoteQueryResultMessage;
@@ -393,3 +395,8 @@ export interface RemoteQueryErrorMessage {
393395
t: 'remoteQueryError';
394396
error: string;
395397
}
398+
399+
export interface RemoteQueryDownloadLinkClickedMessage {
400+
t: 'remoteQueryDownloadLinkClicked';
401+
downloadLink: DownloadLink;
402+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Represents a link to an artifact to be downloaded.
3+
*/
4+
export interface DownloadLink {
5+
/**
6+
* A unique id of the artifact being downloaded.
7+
*/
8+
id: string;
9+
10+
/**
11+
* The URL path to use against the GitHub API to download the
12+
* linked artifact.
13+
*/
14+
urlPath: string;
15+
16+
/**
17+
* An optional path to follow inside the downloaded archive containing the artifact.
18+
*/
19+
innerFilePath?: string;
20+
}

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

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,38 +6,91 @@ import { Credentials } from '../authentication';
66
import { logger } from '../logging';
77
import { tmpDir } from '../run-queries';
88
import { RemoteQueryWorkflowResult } from './remote-query-workflow-result';
9+
import { DownloadLink } from './download-link';
10+
import { RemoteQuery } from './remote-query';
11+
import { RemoteQueryResultIndex, RemoteQueryResultIndexItem } from './remote-query-result-index';
912

10-
export interface ResultIndexItem {
13+
interface ApiResultIndexItem {
1114
nwo: string;
1215
id: string;
1316
results_count: number;
1417
bqrs_file_size: number;
1518
sarif_file_size?: number;
1619
}
1720

21+
export async function getRemoteQueryIndex(
22+
credentials: Credentials,
23+
remoteQuery: RemoteQuery
24+
): Promise<RemoteQueryResultIndex | undefined> {
25+
const controllerRepo = remoteQuery.controllerRepository;
26+
const owner = controllerRepo.owner;
27+
const repoName = controllerRepo.name;
28+
const workflowRunId = remoteQuery.actionsWorkflowRunId;
29+
30+
const workflowUri = `https://github.com/${owner}/${repoName}/actions/runs/${workflowRunId}`;
31+
const artifactsUrlPath = `/repos/${owner}/${repoName}/actions/artifacts`;
32+
33+
const artifactList = await listWorkflowRunArtifacts(credentials, owner, repoName, workflowRunId);
34+
const resultIndexArtifactId = getArtifactIDfromName('result-index', workflowUri, artifactList);
35+
const resultIndexItems = await getResultIndexItems(credentials, owner, repoName, resultIndexArtifactId);
36+
37+
const allResultsArtifactId = getArtifactIDfromName('all-results', workflowUri, artifactList);
38+
39+
const items = resultIndexItems.map(item => {
40+
const artifactId = getArtifactIDfromName(item.id, workflowUri, artifactList);
41+
42+
return {
43+
id: item.id.toString(),
44+
artifactId: artifactId,
45+
nwo: item.nwo,
46+
resultCount: item.results_count,
47+
bqrsFileSize: item.bqrs_file_size,
48+
sarifFileSize: item.sarif_file_size,
49+
} as RemoteQueryResultIndexItem;
50+
});
51+
52+
return {
53+
allResultsArtifactId,
54+
artifactsUrlPath,
55+
items,
56+
};
57+
}
58+
59+
export async function downloadArtifactFromLink(
60+
credentials: Credentials,
61+
downloadLink: DownloadLink
62+
): Promise<string> {
63+
const octokit = await credentials.getOctokit();
64+
65+
// Download the zipped artifact.
66+
const response = await octokit.request(`GET ${downloadLink.urlPath}/zip`, {});
67+
68+
const zipFilePath = path.join(tmpDir.name, `${downloadLink.id}.zip`);
69+
await saveFile(`${zipFilePath}`, response.data as ArrayBuffer);
70+
71+
// Extract the zipped artifact.
72+
const extractedPath = path.join(tmpDir.name, downloadLink.id);
73+
await unzipFile(zipFilePath, extractedPath);
74+
75+
return downloadLink.innerFilePath
76+
? path.join(extractedPath, downloadLink.innerFilePath)
77+
: extractedPath;
78+
}
79+
1880
/**
19-
* Gets the result index file for a given remote queries run.
81+
* Downloads the result index artifact and extracts the result index items.
2082
* @param credentials Credentials for authenticating to the GitHub API.
2183
* @param owner
2284
* @param repo
2385
* @param workflowRunId The ID of the workflow run to get the result index for.
2486
* @returns An object containing the result index.
2587
*/
26-
export async function getResultIndex(
88+
async function getResultIndexItems(
2789
credentials: Credentials,
2890
owner: string,
2991
repo: string,
30-
workflowRunId: number
31-
): Promise<ResultIndexItem[]> {
32-
const artifactList = await listWorkflowRunArtifacts(credentials, owner, repo, workflowRunId);
33-
const artifactId = getArtifactIDfromName('result-index', artifactList);
34-
if (!artifactId) {
35-
void showAndLogWarningMessage(
36-
`Could not find a result index for the [specified workflow](https://github.com/${owner}/${repo}/actions/runs/${workflowRunId}).
37-
Please check whether the workflow run has successfully completed.`
38-
);
39-
return [];
40-
}
92+
artifactId: number
93+
): Promise<ApiResultIndexItem[]> {
4194
const artifactPath = await downloadArtifact(credentials, owner, repo, artifactId);
4295
const indexFilePath = path.join(artifactPath, 'index.json');
4396
if (!(await fs.pathExists(indexFilePath))) {
@@ -115,8 +168,20 @@ async function listWorkflowRunArtifacts(
115168
* @param artifacts An array of artifact details (from the "list workflow run artifacts" API response).
116169
* @returns The artifact ID corresponding to the given artifact name.
117170
*/
118-
function getArtifactIDfromName(artifactName: string, artifacts: Array<{ id: number, name: string }>): number | undefined {
171+
function getArtifactIDfromName(
172+
artifactName: string,
173+
workflowUri: string,
174+
artifacts: Array<{ id: number, name: string }>
175+
): number {
119176
const artifact = artifacts.find(a => a.name === artifactName);
177+
178+
if (!artifact) {
179+
const errorMessage =
180+
`Could not find artifact with name ${artifactName} in workflow ${workflowUri}.
181+
Please check whether the workflow run has successfully completed.`;
182+
throw Error(errorMessage);
183+
}
184+
120185
return artifact?.id;
121186
}
122187

@@ -142,19 +207,22 @@ async function downloadArtifact(
142207
archive_format: 'zip',
143208
});
144209
const artifactPath = path.join(tmpDir.name, `${artifactId}`);
145-
void logger.log(`Downloading artifact to ${artifactPath}.zip`);
146-
await fs.writeFile(
147-
`${artifactPath}.zip`,
148-
Buffer.from(response.data as ArrayBuffer)
149-
);
150-
151-
void logger.log(`Extracting artifact to ${artifactPath}`);
152-
await (
153-
await unzipper.Open.file(`${artifactPath}.zip`)
154-
).extract({ path: artifactPath });
210+
await saveFile(`${artifactPath}.zip`, response.data as ArrayBuffer);
211+
await unzipFile(`${artifactPath}.zip`, artifactPath);
155212
return artifactPath;
156213
}
157214

215+
async function saveFile(filePath: string, data: ArrayBuffer): Promise<void> {
216+
void logger.log(`Saving file to ${filePath}`);
217+
await fs.writeFile(filePath, Buffer.from(data));
218+
}
219+
220+
async function unzipFile(sourcePath: string, destinationPath: string) {
221+
void logger.log(`Unzipping file to ${destinationPath}`);
222+
const file = await unzipper.Open.file(sourcePath);
223+
await file.extract({ path: destinationPath });
224+
}
225+
158226
function getWorkflowError(conclusion: string | null): string {
159227
if (!conclusion) {
160228
return 'Workflow finished without a conclusion';

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

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import {
77
workspace,
88
} from 'vscode';
99
import * as path from 'path';
10+
import * as vscode from 'vscode';
11+
import * as fs from 'fs-extra';
1012

1113
import { tmpDir } from '../run-queries';
1214
import {
1315
ToRemoteQueriesMessage,
1416
FromRemoteQueriesMessage,
17+
RemoteQueryDownloadLinkClickedMessage,
1518
} from '../pure/interface-types';
1619
import { Logger } from '../logging';
1720
import { getHtmlForWebview } from '../interface-utils';
@@ -20,7 +23,9 @@ import { AnalysisResult, RemoteQueryResult } from './remote-query-result';
2023
import { RemoteQuery } from './remote-query';
2124
import { RemoteQueryResult as RemoteQueryResultViewModel } from './shared/remote-query-result';
2225
import { AnalysisResult as AnalysisResultViewModel } from './shared/remote-query-result';
23-
import { showAndLogWarningMessage } from '../helpers';
26+
import { downloadArtifactFromLink } from './gh-actions-api-client';
27+
import { Credentials } from '../authentication';
28+
import { showAndLogWarningMessage, showInformationMessageWithAction } from '../helpers';
2429
import { URLSearchParams } from 'url';
2530
import { SHOW_QUERY_TEXT_MSG } from '../query-history';
2631

@@ -73,7 +78,7 @@ export class RemoteQueriesInterfaceManager {
7378
totalResultCount: totalResultCount,
7479
executionTimestamp: this.formatDate(query.executionStartTime),
7580
executionDuration: executionDuration,
76-
downloadLink: queryResult.allResultsDownloadUri,
81+
downloadLink: queryResult.allResultsDownloadLink,
7782
results: analysisResults
7883
};
7984
}
@@ -180,11 +185,32 @@ export class RemoteQueriesInterfaceManager {
180185
case 'openVirtualFile':
181186
await this.openVirtualFile(msg.queryText);
182187
break;
188+
case 'remoteQueryDownloadLinkClicked':
189+
await this.handleDownloadLinkClicked(msg);
190+
break;
183191
default:
184192
assertNever(msg);
185193
}
186194
}
187195

196+
private async handleDownloadLinkClicked(msg: RemoteQueryDownloadLinkClickedMessage): Promise<void> {
197+
const credentials = await Credentials.initialize(this.ctx);
198+
199+
const filePath = await downloadArtifactFromLink(credentials, msg.downloadLink);
200+
const isDir = (await fs.stat(filePath)).isDirectory();
201+
const message = `Result file saved at ${filePath}`;
202+
if (isDir) {
203+
await vscode.window.showInformationMessage(message);
204+
}
205+
else {
206+
const shouldOpenResults = await showInformationMessageWithAction(message, 'Open');
207+
if (shouldOpenResults) {
208+
const textDocument = await vscode.workspace.openTextDocument(filePath);
209+
await vscode.window.showTextDocument(textDocument, vscode.ViewColumn.One);
210+
}
211+
}
212+
}
213+
188214
private postMessage(msg: ToRemoteQueriesMessage): Thenable<boolean> {
189215
return this.getPanel().webview.postMessage(msg);
190216
}
@@ -245,9 +271,8 @@ export class RemoteQueriesInterfaceManager {
245271
return sortedAnalysisResults.map((analysisResult) => ({
246272
nwo: analysisResult.nwo,
247273
resultCount: analysisResult.resultCount,
248-
downloadLink: analysisResult.downloadUri,
274+
downloadLink: analysisResult.downloadLink,
249275
fileSize: this.formatFileSize(analysisResult.fileSizeInBytes)
250276
}));
251277
}
252278
}
253-

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

Lines changed: 26 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import { ProgressCallback } from '../commandRunner';
55
import { showAndLogErrorMessage, showInformationMessageWithAction } from '../helpers';
66
import { Logger } from '../logging';
77
import { runRemoteQuery } from './run-remote-query';
8-
import { getResultIndex, ResultIndexItem } from './gh-actions-api-client';
98
import { RemoteQueriesInterfaceManager } from './remote-queries-interface';
109
import { RemoteQuery } from './remote-query';
1110
import { RemoteQueriesMonitor } from './remote-queries-monitor';
11+
import { getRemoteQueryIndex } from './gh-actions-api-client';
12+
import { RemoteQueryResultIndex } from './remote-query-result-index';
1213
import { RemoteQueryResult } from './remote-query-result';
14+
import { DownloadLink } from './download-link';
1315

1416
export class RemoteQueriesManager {
1517
private readonly remoteQueriesMonitor: RemoteQueriesMonitor;
@@ -52,14 +54,19 @@ export class RemoteQueriesManager {
5254
const executionEndTime = new Date();
5355

5456
if (queryResult.status === 'CompletedSuccessfully') {
55-
const resultIndexItems = await this.downloadResultIndex(credentials, query);
57+
const resultIndex = await getRemoteQueryIndex(credentials, query);
58+
if (!resultIndex) {
59+
void showAndLogErrorMessage(`There was an issue retrieving the result for the query ${query.queryName}`);
60+
return;
61+
}
62+
63+
const queryResult = this.mapQueryResult(executionEndTime, resultIndex);
5664

57-
const totalResultCount = resultIndexItems.reduce((acc, cur) => acc + cur.results_count, 0);
65+
const totalResultCount = queryResult.analysisResults.reduce((acc, cur) => acc + cur.resultCount, 0);
5866
const message = `Query "${query.queryName}" run on ${query.repositories.length} repositories and returned ${totalResultCount} results`;
5967

6068
const shouldOpenView = await showInformationMessageWithAction(message, 'View');
6169
if (shouldOpenView) {
62-
const queryResult = this.mapQueryResult(executionEndTime, resultIndexItems);
6370
const rqim = new RemoteQueriesInterfaceManager(this.ctx, this.logger);
6471
await rqim.showResults(query, queryResult);
6572
}
@@ -71,31 +78,25 @@ export class RemoteQueriesManager {
7178
}
7279
}
7380

74-
private async downloadResultIndex(credentials: Credentials, query: RemoteQuery) {
75-
return await getResultIndex(
76-
credentials,
77-
query.controllerRepository.owner,
78-
query.controllerRepository.name,
79-
query.actionsWorkflowRunId);
80-
}
81-
82-
private mapQueryResult(executionEndTime: Date, resultindexItems: ResultIndexItem[]): RemoteQueryResult {
83-
// Example URIs are used for now, but a solution for downloading the results will soon be implemented.
84-
const allResultsDownloadUri = 'www.example.com';
85-
const analysisDownloadUri = 'www.example.com';
86-
87-
const analysisResults = resultindexItems.map(ri => ({
88-
nwo: ri.nwo,
89-
resultCount: ri.results_count,
90-
downloadUri: analysisDownloadUri,
91-
fileSizeInBytes: ri.sarif_file_size || ri.bqrs_file_size,
92-
})
93-
);
81+
private mapQueryResult(executionEndTime: Date, resultIndex: RemoteQueryResultIndex): RemoteQueryResult {
82+
const analysisResults = resultIndex.items.map(item => ({
83+
nwo: item.nwo,
84+
resultCount: item.resultCount,
85+
fileSizeInBytes: item.sarifFileSize ? item.sarifFileSize : item.bqrsFileSize,
86+
downloadLink: {
87+
id: item.artifactId.toString(),
88+
urlPath: `${resultIndex.artifactsUrlPath}/${item.artifactId}`,
89+
innerFilePath: item.sarifFileSize ? 'results.sarif' : 'results.bqrs'
90+
} as DownloadLink
91+
}));
9492

9593
return {
9694
executionEndTime,
9795
analysisResults,
98-
allResultsDownloadUri,
96+
allResultsDownloadLink: {
97+
id: resultIndex.allResultsArtifactId.toString(),
98+
urlPath: `${resultIndex.artifactsUrlPath}/${resultIndex.allResultsArtifactId}`
99+
}
99100
};
100101
}
101102
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export interface RemoteQueryResultIndex {
2+
artifactsUrlPath: string;
3+
allResultsArtifactId: number;
4+
items: RemoteQueryResultIndexItem[];
5+
}
6+
7+
export interface RemoteQueryResultIndexItem {
8+
id: string;
9+
artifactId: number;
10+
nwo: string;
11+
resultCount: number;
12+
bqrsFileSize: number;
13+
sarifFileSize?: number;
14+
}

0 commit comments

Comments
 (0)