1- import { ViewColumn } from "vscode" ;
2-
1+ import { execFileSync } from "child_process" ;
2+ import {
3+ createWriteStream ,
4+ ensureDir ,
5+ existsSync ,
6+ readdirSync ,
7+ remove ,
8+ } from "fs-extra" ;
9+ import path , { basename , join } from "path" ;
10+ import { Uri , ViewColumn } from "vscode" ;
311import type { CodeQLCliServer } from "../codeql-cli/cli" ;
412import type { App } from "../common/app" ;
513import { redactableError } from "../common/errors" ;
14+ import { createTimeoutSignal } from "../common/fetch-stream" ;
615import type {
716 FromComparePerformanceViewMessage ,
817 ToComparePerformanceViewMessage ,
@@ -12,16 +21,27 @@ import { showAndLogExceptionWithTelemetry } from "../common/logging";
1221import { extLogger } from "../common/logging/vscode" ;
1322import type { WebviewPanelConfig } from "../common/vscode/abstract-webview" ;
1423import { AbstractWebview } from "../common/vscode/abstract-webview" ;
24+ import type { ProgressCallback } from "../common/vscode/progress" ;
25+ import { reportStreamProgress , withProgress } from "../common/vscode/progress" ;
1526import { telemetryListener } from "../common/vscode/telemetry" ;
16- import type { HistoryItemLabelProvider } from "../query-history/history-item-label-provider" ;
17- import { PerformanceOverviewScanner } from "../log-insights/performance-comparison" ;
18- import { scanLog } from "../log-insights/log-scanner" ;
27+ import { downloadTimeout } from "../config" ;
1928import type { ResultsView } from "../local-queries" ;
29+ import { scanLog } from "../log-insights/log-scanner" ;
30+ import { PerformanceOverviewScanner } from "../log-insights/performance-comparison" ;
31+ import type { HistoryItemLabelProvider } from "../query-history/history-item-label-provider" ;
32+ import { tmpDir } from "../tmp-dir" ;
33+
34+ type ComparePerformanceCommands = {
35+ "codeQL.compare-performance.downloadExternalLogs" : ( ) => Promise < void > ;
36+ } ;
2037
2138export class ComparePerformanceView extends AbstractWebview <
2239 ToComparePerformanceViewMessage ,
2340 FromComparePerformanceViewMessage
2441> {
42+ private workingDirectory ;
43+ private LOG_DOWNLOAD_PROGRESS_STEPS = 3 ;
44+
2545 constructor (
2646 app : App ,
2747 public cliServer : CodeQLCliServer , // TODO: make private
@@ -30,6 +50,10 @@ export class ComparePerformanceView extends AbstractWebview<
3050 private resultsView : ResultsView ,
3151 ) {
3252 super ( app ) ;
53+ this . workingDirectory = path . join (
54+ app . globalStoragePath ,
55+ "compare-performance" ,
56+ ) ;
3357 }
3458
3559 async showResults ( fromJsonLog : string , toJsonLog : string ) {
@@ -94,4 +118,287 @@ export class ComparePerformanceView extends AbstractWebview<
94118 break ;
95119 }
96120 }
121+
122+ async downloadExternalLogs ( ) : Promise < void > {
123+ const client = await this . app . credentials . getOctokit ( ) ;
124+ async function getArtifactDownloadUrl (
125+ url : string ,
126+ ) : Promise < { url : string ; bytes : number ; id : string } > {
127+ const pattern =
128+ / h t t p s : \/ \/ g i t h u b .c o m \/ ( [ ^ / ] + ) \/ ( [ ^ / ] + ) \/ a c t i o n s \/ r u n s \/ ( [ ^ / ] + ) \/ a r t i f a c t s \/ ( [ ^ / ] + ) / ;
129+ const match = url . match ( pattern ) ;
130+ if ( ! match ) {
131+ throw new Error ( `Invalid artifact URL: ${ url } ` ) ;
132+ }
133+ const [ , owner , repo , , artifact_id ] = match ;
134+ const response = await client . request (
135+ "HEAD /repos/{owner}/{repo}/actions/artifacts/{artifact_id}/{archive_format}" ,
136+ {
137+ owner,
138+ repo,
139+ artifact_id,
140+ archive_format : "zip" ,
141+ } ,
142+ ) ;
143+ if ( ! response . headers [ "content-length" ] ) {
144+ throw new Error (
145+ `No content-length header found for artifact URL: ${ url } ` ,
146+ ) ;
147+ }
148+ return {
149+ url : response . url ,
150+ bytes : response . headers [ "content-length" ] ,
151+ id : `artifacts/${ owner } /${ repo } /${ artifact_id } ` ,
152+ } ;
153+ }
154+
155+ const downloadLog = async ( originalUrl : string ) => {
156+ const {
157+ url,
158+ bytes,
159+ id : artifactDiskId ,
160+ } = await getArtifactDownloadUrl ( originalUrl ) ;
161+ const logPath = path . join (
162+ this . workingDirectory ,
163+ `logs-of/${ artifactDiskId } ` ,
164+ ) ;
165+ if ( existsSync ( logPath ) && readdirSync ( logPath ) . length > 0 ) {
166+ void extLogger . log (
167+ `Skipping log download and extraction to existing '${ logPath } '...` ,
168+ ) ;
169+ }
170+ await withProgress (
171+ async ( progress ) => {
172+ const downloadPath = path . join ( this . workingDirectory , artifactDiskId ) ;
173+ if (
174+ existsSync ( downloadPath ) &&
175+ readdirSync ( downloadPath ) . length > 0
176+ ) {
177+ void extLogger . log (
178+ `Skipping download to existing '${ artifactDiskId } '...` ,
179+ ) ;
180+ } else {
181+ await ensureDir ( downloadPath ) ;
182+ void extLogger . log (
183+ `Downloading from ${ artifactDiskId } (bytes: ${ bytes } ) ${ downloadPath } ...` ,
184+ ) ;
185+ await this . fetchAndUnzip ( url , downloadPath , progress ) ;
186+ }
187+ if ( existsSync ( logPath ) && readdirSync ( logPath ) . length >= 0 ) {
188+ void extLogger . log (
189+ `Skipping log extraction to existing '${ logPath } '...` ,
190+ ) ;
191+ } else {
192+ await ensureDir ( logPath ) ;
193+ // find the lone tar.gz file in the unzipped directory
194+ const unzippedFiles = readdirSync ( downloadPath ) ;
195+ const tarGzFiles = unzippedFiles . filter ( ( f ) =>
196+ f . endsWith ( ".tar.gz" ) ,
197+ ) ;
198+ if ( tarGzFiles . length !== 1 ) {
199+ throw new Error (
200+ `Expected exactly one .tar.gz file in the unzipped directory, but found: ${ tarGzFiles . join (
201+ ", " ,
202+ ) } `,
203+ ) ;
204+ }
205+ await this . untargz (
206+ path . join ( downloadPath , tarGzFiles [ 0 ] ) ,
207+ logPath ,
208+ progress ,
209+ ) ;
210+ }
211+ } ,
212+ {
213+ title : `Downloading evaluator logs (${ ( bytes / 1024 / 1024 ) . toFixed ( 1 ) } MB}` ,
214+ } ,
215+ ) ;
216+ } ;
217+ // hardcoded URLs from https://github.com/codeql-dca-runners/codeql-dca-worker_javascript/actions/runs/11816721194
218+ const url1 =
219+ "https://github.com/codeql-dca-runners/codeql-dca-worker_javascript/actions/runs/11816721194/artifacts/2181621080" ;
220+ const url2 =
221+ "https://github.com/codeql-dca-runners/codeql-dca-worker_javascript/actions/runs/11816721194/artifacts/2181601861" ;
222+
223+ await Promise . all ( [ downloadLog ( url1 ) , downloadLog ( url2 ) ] ) ;
224+ void extLogger . log ( `Downloaded logs to ${ this . workingDirectory } ` ) ;
225+
226+ return ;
227+ }
228+
229+ private async fetchAndUnzip (
230+ contentUrl : string ,
231+ // (see below) requestHeaders: { [key: string]: string },
232+ unzipPath : string ,
233+ progress ?: ProgressCallback ,
234+ ) {
235+ // Although it is possible to download and stream directly to an unzipped directory,
236+ // we need to avoid this for two reasons. The central directory is located at the
237+ // end of the zip file. It is the source of truth of the content locations. Individual
238+ // file headers may be incorrect. Additionally, saving to file first will reduce memory
239+ // pressure compared with unzipping while downloading the archive.
240+
241+ const archivePath = join ( tmpDir . name , `archive-${ Date . now ( ) } .zip` ) ;
242+
243+ progress ?.( {
244+ maxStep : this . LOG_DOWNLOAD_PROGRESS_STEPS ,
245+ message : "Downloading content" ,
246+ step : 1 ,
247+ } ) ;
248+
249+ const {
250+ signal,
251+ onData,
252+ dispose : disposeTimeout ,
253+ } = createTimeoutSignal ( downloadTimeout ( ) ) ;
254+
255+ let response : Response ;
256+ try {
257+ response = await this . checkForFailingResponse (
258+ await fetch ( contentUrl , {
259+ // XXX disabled header forwarding headers: requestHeaders,
260+ signal,
261+ } ) ,
262+ "Error downloading content" ,
263+ ) ;
264+ } catch ( e ) {
265+ disposeTimeout ( ) ;
266+
267+ if ( e instanceof DOMException && e . name === "AbortError" ) {
268+ const thrownError = new Error ( "The request timed out." ) ;
269+ thrownError . stack = e . stack ;
270+ throw thrownError ;
271+ }
272+
273+ throw e ;
274+ }
275+
276+ const body = response . body ;
277+ if ( ! body ) {
278+ throw new Error ( "No response body found" ) ;
279+ }
280+
281+ const archiveFileStream = createWriteStream ( archivePath ) ;
282+
283+ const contentLength = response . headers . get ( "content-length" ) ;
284+ const totalNumBytes = contentLength
285+ ? parseInt ( contentLength , 10 )
286+ : undefined ;
287+
288+ const reportProgress = reportStreamProgress (
289+ "Downloading log" ,
290+ totalNumBytes ,
291+ progress ,
292+ ) ;
293+
294+ try {
295+ const reader = body . getReader ( ) ;
296+ for ( ; ; ) {
297+ const { done, value } = await reader . read ( ) ;
298+ if ( done ) {
299+ break ;
300+ }
301+
302+ onData ( ) ;
303+ reportProgress ( value ?. length ?? 0 ) ;
304+
305+ await new Promise ( ( resolve , reject ) => {
306+ archiveFileStream . write ( value , ( err ) => {
307+ if ( err ) {
308+ reject ( err ) ;
309+ }
310+ resolve ( undefined ) ;
311+ } ) ;
312+ } ) ;
313+ }
314+
315+ await new Promise ( ( resolve , reject ) => {
316+ archiveFileStream . close ( ( err ) => {
317+ if ( err ) {
318+ reject ( err ) ;
319+ }
320+ resolve ( undefined ) ;
321+ } ) ;
322+ } ) ;
323+ } catch ( e ) {
324+ // Close and remove the file if an error occurs
325+ archiveFileStream . close ( ( ) => {
326+ void remove ( archivePath ) ;
327+ } ) ;
328+
329+ if ( e instanceof DOMException && e . name === "AbortError" ) {
330+ const thrownError = new Error ( "The download timed out." ) ;
331+ thrownError . stack = e . stack ;
332+ throw thrownError ;
333+ }
334+
335+ throw e ;
336+ } finally {
337+ disposeTimeout ( ) ;
338+ }
339+
340+ await this . readAndUnzip (
341+ Uri . file ( archivePath ) . toString ( true ) ,
342+ unzipPath ,
343+ progress ,
344+ ) ;
345+
346+ // remove archivePath eagerly since these archives can be large.
347+ await remove ( archivePath ) ;
348+ }
349+
350+ private async checkForFailingResponse (
351+ response : Response ,
352+ errorMessage : string ,
353+ ) : Promise < Response | never > {
354+ if ( response . ok ) {
355+ return response ;
356+ }
357+
358+ // An error downloading the content. Attempt to extract the reason behind it.
359+ const text = await response . text ( ) ;
360+ let msg : string ;
361+ try {
362+ const obj = JSON . parse ( text ) ;
363+ msg =
364+ obj . error || obj . message || obj . reason || JSON . stringify ( obj , null , 2 ) ;
365+ } catch {
366+ msg = text ;
367+ }
368+ throw new Error ( `${ errorMessage } .\n\nReason: ${ msg } ` ) ;
369+ }
370+
371+ private async readAndUnzip (
372+ zipUrl : string ,
373+ unzipPath : string ,
374+ progress ?: ProgressCallback ,
375+ ) {
376+ const zipFile = Uri . parse ( zipUrl ) . fsPath ;
377+ progress ?.( {
378+ maxStep : this . LOG_DOWNLOAD_PROGRESS_STEPS ,
379+ step : 2 ,
380+ message : `Unzipping into ${ basename ( unzipPath ) } ` ,
381+ } ) ;
382+ execFileSync ( "unzip" , [ "-q" , "-d" , unzipPath , zipFile ] ) ;
383+ }
384+ private async untargz (
385+ tarballPath : string ,
386+ untarPath : string ,
387+ progress ?: ProgressCallback ,
388+ ) {
389+ progress ?.( {
390+ maxStep : this . LOG_DOWNLOAD_PROGRESS_STEPS ,
391+ step : 3 ,
392+ message : `Untarring into ${ basename ( untarPath ) } ` ,
393+ } ) ;
394+ void extLogger . log ( `Untarring ${ tarballPath } into ${ untarPath } ` ) ;
395+ execFileSync ( "tar" , [ "-xzf" , tarballPath , "-C" , untarPath ] ) ;
396+ }
397+
398+ public getCommands ( ) : ComparePerformanceCommands {
399+ return {
400+ "codeQL.compare-performance.downloadExternalLogs" :
401+ this . downloadExternalLogs . bind ( this ) ,
402+ } ;
403+ }
97404}
0 commit comments