From ff71c1f560eeb07aa174d130d744178d4ff5af77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 24 Sep 2024 14:05:17 +0200 Subject: [PATCH] Draft: github:artifact resource that unzips the doubly zipped files for the developer --- .../src/zip/decode-remote-zip.ts | 2 +- .../stream-compression/src/zip/decode-zip.ts | 37 +- .../public/blueprint-schema-validator.js | 502 +++++++++++++++++- .../blueprints/public/blueprint-schema.json | 46 ++ .../blueprints/src/lib/resources.ts | 137 ++++- 5 files changed, 685 insertions(+), 39 deletions(-) diff --git a/packages/php-wasm/stream-compression/src/zip/decode-remote-zip.ts b/packages/php-wasm/stream-compression/src/zip/decode-remote-zip.ts index 60dbbf5cb6..e20f7ca89c 100644 --- a/packages/php-wasm/stream-compression/src/zip/decode-remote-zip.ts +++ b/packages/php-wasm/stream-compression/src/zip/decode-remote-zip.ts @@ -124,7 +124,7 @@ function streamCentralDirectoryEntries(source: BytesSource) { * @param source * @returns */ -async function streamCentralDirectoryBytes(source: BytesSource) { +export async function streamCentralDirectoryBytes(source: BytesSource) { const chunkSize = CENTRAL_DIRECTORY_END_SCAN_CHUNK_SIZE; let centralDirectory: Uint8Array = new Uint8Array(); diff --git a/packages/php-wasm/stream-compression/src/zip/decode-zip.ts b/packages/php-wasm/stream-compression/src/zip/decode-zip.ts index 1113bc9e0f..98a32c6dcd 100644 --- a/packages/php-wasm/stream-compression/src/zip/decode-zip.ts +++ b/packages/php-wasm/stream-compression/src/zip/decode-zip.ts @@ -33,7 +33,9 @@ import { appendBytes } from '../utils/append-bytes'; */ export function decodeZip( stream: ReadableStream, - predicate?: () => boolean + predicate: ( + dirEntry: CentralDirectoryEntry | FileEntry + ) => boolean = DEFAULT_PREDICATE ) { return streamZippedFileEntries(stream, predicate).pipeThrough( new TransformStream({ @@ -143,6 +145,7 @@ export async function readFileEntry( } } const data = new DataView((await collectBytes(stream, 26))!.buffer); + console.log(data); const pathLength = data.getUint16(22, true); const extraLength = data.getUint16(24, true); const entry: Partial = { @@ -168,9 +171,15 @@ export async function readFileEntry( // eagerly. Ensure the next iteration exhausts // the last body stream before moving on. - let bodyStream = limitBytes(stream, entry['compressedSize']!); + console.log('entry', { entry }); + console.log('compressedSize', entry['compressedSize']!); + let bodyStream = stream; // limitBytes(stream, entry['compressedSize']!); if (entry['compressionMethod'] === COMPRESSION_DEFLATE) { + bodyStream = bodyStream.pipeThrough( + new DecompressionStream('deflate-raw') + ); + /** * We want to write raw deflate-compressed bytes into our * final ZIP file. CompressionStream supports "deflate-raw" @@ -193,23 +202,25 @@ export async function readFileEntry( * - 4 bytes for CRC32 of the uncompressed data * - 4 bytes for ISIZE (uncompressed size modulo 2^32) */ - const header = new Uint8Array(10); - header.set([0x1f, 0x8b, 0x08]); + // const header = new Uint8Array(10); + // header.set([0x1f, 0x8b, 0x08]); - const footer = new Uint8Array(8); - const footerView = new DataView(footer.buffer); - footerView.setUint32(0, entry.crc!, true); - footerView.setUint32(4, entry.uncompressedSize! % 2 ** 32, true); - bodyStream = bodyStream - .pipeThrough(prependBytes(header)) - .pipeThrough(appendBytes(footer)) - .pipeThrough(new DecompressionStream('gzip')); + // const footer = new Uint8Array(8); + // const footerView = new DataView(footer.buffer); + // footerView.setUint32(0, entry.crc!, true); + // footerView.setUint32(4, entry.uncompressedSize! % 2 ** 32, true); + // bodyStream = bodyStream + // .pipeThrough(prependBytes(header)) + // .pipeThrough(appendBytes(footer)) + // .pipeThrough(new DecompressionStream('gzip')); } entry['bytes'] = await bodyStream - .pipeThrough(concatBytes(entry['uncompressedSize'])) + // .pipeThrough(concatBytes(entry['uncompressedSize'])) .getReader() .read() .then(({ value }) => value!); + console.log({ entry }); + console.log(new TextDecoder().decode(entry.path!)); return entry as FileEntry; } diff --git a/packages/playground/blueprints/public/blueprint-schema-validator.js b/packages/playground/blueprints/public/blueprint-schema-validator.js index 4525be5fdd..102e7e8d08 100644 --- a/packages/playground/blueprints/public/blueprint-schema-validator.js +++ b/packages/playground/blueprints/public/blueprint-schema-validator.js @@ -177,6 +177,7 @@ const schema11 = { { $ref: '#/definitions/CoreThemeReference' }, { $ref: '#/definitions/CorePluginReference' }, { $ref: '#/definitions/UrlReference' }, + { $ref: '#/definitions/GitHubArtifactReference' }, ], }, VFSReference: { @@ -293,6 +294,48 @@ const schema11 = { required: ['resource', 'url'], additionalProperties: false, }, + GitHubArtifactReference: { + type: 'object', + properties: { + resource: { + type: 'string', + const: 'github:artifact', + description: + 'Identifies the file resource as a GitHub artifact', + }, + owner: { + type: 'string', + description: 'The URL of the artifact', + }, + repo: { + type: 'string', + description: 'The name of the repository', + }, + workflow: { + type: 'string', + description: 'The name of the workflow', + }, + artifact: { + type: 'string', + description: 'The name of the artifact', + }, + pr: { type: 'number', description: 'The pull request number' }, + caption: { + type: 'string', + description: + 'Optional caption for displaying a progress message', + }, + }, + required: [ + 'resource', + 'owner', + 'repo', + 'workflow', + 'artifact', + 'pr', + ], + additionalProperties: false, + }, SupportedPHPExtensionBundle: { type: 'string', enum: ['kitchen-sink', 'light'], @@ -1363,7 +1406,7 @@ const schema13 = { enum: ['8.3', '8.2', '8.1', '8.0', '7.4', '7.3', '7.2', '7.1', '7.0'], }; const schema14 = { type: 'string', const: 'wp-cli' }; -const schema21 = { type: 'string', enum: ['kitchen-sink', 'light'] }; +const schema22 = { type: 'string', enum: ['kitchen-sink', 'light'] }; const func2 = Object.prototype.hasOwnProperty; const schema15 = { anyOf: [ @@ -1372,6 +1415,7 @@ const schema15 = { { $ref: '#/definitions/CoreThemeReference' }, { $ref: '#/definitions/CorePluginReference' }, { $ref: '#/definitions/UrlReference' }, + { $ref: '#/definitions/GitHubArtifactReference' }, ], }; const schema16 = { @@ -1484,6 +1528,27 @@ const schema20 = { required: ['resource', 'url'], additionalProperties: false, }; +const schema21 = { + type: 'object', + properties: { + resource: { + type: 'string', + const: 'github:artifact', + description: 'Identifies the file resource as a GitHub artifact', + }, + owner: { type: 'string', description: 'The URL of the artifact' }, + repo: { type: 'string', description: 'The name of the repository' }, + workflow: { type: 'string', description: 'The name of the workflow' }, + artifact: { type: 'string', description: 'The name of the artifact' }, + pr: { type: 'number', description: 'The pull request number' }, + caption: { + type: 'string', + description: 'Optional caption for displaying a progress message', + }, + }, + required: ['resource', 'owner', 'repo', 'workflow', 'artifact', 'pr'], + additionalProperties: false, +}; function validate12( data, { instancePath = '', parentData, parentDataProperty, rootData = data } = {} @@ -2846,12 +2911,413 @@ function validate12( } var _valid0 = _errs55 === errors; valid0 = valid0 || _valid0; + if (!valid0) { + const _errs65 = errors; + const _errs66 = errors; + if (errors === _errs66) { + if ( + data && + typeof data == 'object' && + !Array.isArray(data) + ) { + let missing7; + if ( + (data.resource === undefined && + (missing7 = 'resource')) || + (data.owner === undefined && + (missing7 = 'owner')) || + (data.repo === undefined && + (missing7 = 'repo')) || + (data.workflow === undefined && + (missing7 = 'workflow')) || + (data.artifact === undefined && + (missing7 = 'artifact')) || + (data.pr === undefined && (missing7 = 'pr')) + ) { + const err44 = { + instancePath, + schemaPath: + '#/definitions/GitHubArtifactReference/required', + keyword: 'required', + params: { missingProperty: missing7 }, + message: + "must have required property '" + + missing7 + + "'", + }; + if (vErrors === null) { + vErrors = [err44]; + } else { + vErrors.push(err44); + } + errors++; + } else { + const _errs68 = errors; + for (const key7 in data) { + if ( + !( + key7 === 'resource' || + key7 === 'owner' || + key7 === 'repo' || + key7 === 'workflow' || + key7 === 'artifact' || + key7 === 'pr' || + key7 === 'caption' + ) + ) { + const err45 = { + instancePath, + schemaPath: + '#/definitions/GitHubArtifactReference/additionalProperties', + keyword: 'additionalProperties', + params: { + additionalProperty: key7, + }, + message: + 'must NOT have additional properties', + }; + if (vErrors === null) { + vErrors = [err45]; + } else { + vErrors.push(err45); + } + errors++; + break; + } + } + if (_errs68 === errors) { + if (data.resource !== undefined) { + let data19 = data.resource; + const _errs69 = errors; + if (typeof data19 !== 'string') { + const err46 = { + instancePath: + instancePath + + '/resource', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/resource/type', + keyword: 'type', + params: { type: 'string' }, + message: 'must be string', + }; + if (vErrors === null) { + vErrors = [err46]; + } else { + vErrors.push(err46); + } + errors++; + } + if ('github:artifact' !== data19) { + const err47 = { + instancePath: + instancePath + + '/resource', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/resource/const', + keyword: 'const', + params: { + allowedValue: + 'github:artifact', + }, + message: + 'must be equal to constant', + }; + if (vErrors === null) { + vErrors = [err47]; + } else { + vErrors.push(err47); + } + errors++; + } + var valid16 = _errs69 === errors; + } else { + var valid16 = true; + } + if (valid16) { + if (data.owner !== undefined) { + const _errs71 = errors; + if ( + typeof data.owner !== + 'string' + ) { + const err48 = { + instancePath: + instancePath + + '/owner', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/owner/type', + keyword: 'type', + params: { + type: 'string', + }, + message: + 'must be string', + }; + if (vErrors === null) { + vErrors = [err48]; + } else { + vErrors.push(err48); + } + errors++; + } + var valid16 = + _errs71 === errors; + } else { + var valid16 = true; + } + if (valid16) { + if (data.repo !== undefined) { + const _errs73 = errors; + if ( + typeof data.repo !== + 'string' + ) { + const err49 = { + instancePath: + instancePath + + '/repo', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/repo/type', + keyword: 'type', + params: { + type: 'string', + }, + message: + 'must be string', + }; + if (vErrors === null) { + vErrors = [err49]; + } else { + vErrors.push(err49); + } + errors++; + } + var valid16 = + _errs73 === errors; + } else { + var valid16 = true; + } + if (valid16) { + if ( + data.workflow !== + undefined + ) { + const _errs75 = errors; + if ( + typeof data.workflow !== + 'string' + ) { + const err50 = { + instancePath: + instancePath + + '/workflow', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/workflow/type', + keyword: 'type', + params: { + type: 'string', + }, + message: + 'must be string', + }; + if ( + vErrors === null + ) { + vErrors = [ + err50, + ]; + } else { + vErrors.push( + err50 + ); + } + errors++; + } + var valid16 = + _errs75 === errors; + } else { + var valid16 = true; + } + if (valid16) { + if ( + data.artifact !== + undefined + ) { + const _errs77 = + errors; + if ( + typeof data.artifact !== + 'string' + ) { + const err51 = { + instancePath: + instancePath + + '/artifact', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/artifact/type', + keyword: + 'type', + params: { + type: 'string', + }, + message: + 'must be string', + }; + if ( + vErrors === + null + ) { + vErrors = [ + err51, + ]; + } else { + vErrors.push( + err51 + ); + } + errors++; + } + var valid16 = + _errs77 === + errors; + } else { + var valid16 = true; + } + if (valid16) { + if ( + data.pr !== + undefined + ) { + let data24 = + data.pr; + const _errs79 = + errors; + if ( + !( + typeof data24 == + 'number' && + isFinite( + data24 + ) + ) + ) { + const err52 = + { + instancePath: + instancePath + + '/pr', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/pr/type', + keyword: + 'type', + params: { + type: 'number', + }, + message: + 'must be number', + }; + if ( + vErrors === + null + ) { + vErrors = + [ + err52, + ]; + } else { + vErrors.push( + err52 + ); + } + errors++; + } + var valid16 = + _errs79 === + errors; + } else { + var valid16 = true; + } + if (valid16) { + if ( + data.caption !== + undefined + ) { + const _errs81 = + errors; + if ( + typeof data.caption !== + 'string' + ) { + const err53 = + { + instancePath: + instancePath + + '/caption', + schemaPath: + '#/definitions/GitHubArtifactReference/properties/caption/type', + keyword: + 'type', + params: { + type: 'string', + }, + message: + 'must be string', + }; + if ( + vErrors === + null + ) { + vErrors = + [ + err53, + ]; + } else { + vErrors.push( + err53 + ); + } + errors++; + } + var valid16 = + _errs81 === + errors; + } else { + var valid16 = true; + } + } + } + } + } + } + } + } + } + } else { + const err54 = { + instancePath, + schemaPath: + '#/definitions/GitHubArtifactReference/type', + keyword: 'type', + params: { type: 'object' }, + message: 'must be object', + }; + if (vErrors === null) { + vErrors = [err54]; + } else { + vErrors.push(err54); + } + errors++; + } + } + var _valid0 = _errs65 === errors; + valid0 = valid0 || _valid0; + } } } } } if (!valid0) { - const err44 = { + const err55 = { instancePath, schemaPath: '#/anyOf', keyword: 'anyOf', @@ -2859,9 +3325,9 @@ function validate12( message: 'must match a schema in anyOf', }; if (vErrors === null) { - vErrors = [err44]; + vErrors = [err55]; } else { - vErrors.push(err44); + vErrors.push(err55); } errors++; validate12.errors = vErrors; @@ -2879,7 +3345,7 @@ function validate12( validate12.errors = vErrors; return errors === 0; } -const schema22 = { +const schema23 = { type: 'object', discriminator: { propertyName: 'step' }, required: ['step'], @@ -3562,7 +4028,7 @@ const schema22 = { }, ], }; -const schema23 = { +const schema24 = { type: 'object', properties: { activate: { @@ -3572,7 +4038,7 @@ const schema23 = { }, additionalProperties: false, }; -const schema30 = { +const schema31 = { type: 'object', properties: { adminUsername: { type: 'string' }, @@ -3580,7 +4046,7 @@ const schema30 = { }, additionalProperties: false, }; -const schema24 = { +const schema25 = { type: 'object', properties: { method: { @@ -3677,11 +4143,11 @@ const schema24 = { required: ['url'], additionalProperties: false, }; -const schema25 = { +const schema26 = { type: 'string', enum: ['GET', 'POST', 'HEAD', 'OPTIONS', 'PATCH', 'PUT', 'DELETE'], }; -const schema26 = { type: 'object', additionalProperties: { type: 'string' } }; +const schema27 = { type: 'object', additionalProperties: { type: 'string' } }; function validate19( data, { instancePath = '', parentData, parentDataProperty, rootData = data } = {} @@ -3759,7 +4225,7 @@ function validate19( instancePath: instancePath + '/method', schemaPath: '#/definitions/HTTPMethod/enum', keyword: 'enum', - params: { allowedValues: schema25.enum }, + params: { allowedValues: schema26.enum }, message: 'must be equal to one of the allowed values', }, @@ -5863,7 +6329,7 @@ function validate19( validate19.errors = vErrors; return errors === 0; } -const schema27 = { +const schema28 = { type: 'object', properties: { relativeUri: { @@ -5939,7 +6405,7 @@ function validate21( if (data && typeof data == 'object' && !Array.isArray(data)) { const _errs1 = errors; for (const key0 in data) { - if (!func2.call(schema27.properties, key0)) { + if (!func2.call(schema28.properties, key0)) { validate21.errors = [ { instancePath, @@ -6049,7 +6515,7 @@ function validate21( '#/definitions/HTTPMethod/enum', keyword: 'enum', params: { - allowedValues: schema25.enum, + allowedValues: schema26.enum, }, message: 'must be equal to one of the allowed values', @@ -8325,7 +8791,7 @@ function validate14( 'enum', params: { allowedValues: - schema22 + schema23 .oneOf[3] .properties .method @@ -10164,7 +10630,7 @@ function validate14( keyword: 'enum', params: { allowedValues: - schema22 + schema23 .oneOf[9] .properties .ifAlreadyInstalled @@ -10656,7 +11122,7 @@ function validate14( keyword: 'enum', params: { allowedValues: - schema22 + schema23 .oneOf[10] .properties .ifAlreadyInstalled @@ -18957,7 +19423,7 @@ function validate11( 'enum', params: { allowedValues: - schema21.enum, + schema22.enum, }, message: 'must be equal to one of the allowed values', diff --git a/packages/playground/blueprints/public/blueprint-schema.json b/packages/playground/blueprints/public/blueprint-schema.json index f1fb817e4f..6f827a264e 100644 --- a/packages/playground/blueprints/public/blueprint-schema.json +++ b/packages/playground/blueprints/public/blueprint-schema.json @@ -208,6 +208,9 @@ }, { "$ref": "#/definitions/UrlReference" + }, + { + "$ref": "#/definitions/GitHubArtifactReference" } ] }, @@ -340,6 +343,49 @@ "required": ["resource", "url"], "additionalProperties": false }, + "GitHubArtifactReference": { + "type": "object", + "properties": { + "resource": { + "type": "string", + "const": "github:artifact", + "description": "Identifies the file resource as a GitHub artifact" + }, + "owner": { + "type": "string", + "description": "The URL of the artifact" + }, + "repo": { + "type": "string", + "description": "The name of the repository" + }, + "workflow": { + "type": "string", + "description": "The name of the workflow" + }, + "artifact": { + "type": "string", + "description": "The name of the artifact" + }, + "pr": { + "type": "number", + "description": "The pull request number" + }, + "caption": { + "type": "string", + "description": "Optional caption for displaying a progress message" + } + }, + "required": [ + "resource", + "owner", + "repo", + "workflow", + "artifact", + "pr" + ], + "additionalProperties": false + }, "SupportedPHPExtensionBundle": { "type": "string", "enum": ["kitchen-sink", "light"] diff --git a/packages/playground/blueprints/src/lib/resources.ts b/packages/playground/blueprints/src/lib/resources.ts index 7e12c1b562..2b12277e48 100644 --- a/packages/playground/blueprints/src/lib/resources.ts +++ b/packages/playground/blueprints/src/lib/resources.ts @@ -5,6 +5,11 @@ import { import { UniversalPHP } from '@php-wasm/universal'; import { Semaphore } from '@php-wasm/util'; import { zipNameToHumanName } from './utils/zip-name-to-human-name'; +import { decodeZip, decodeRemoteZip } from '@php-wasm/stream-compression'; +import { streamCentralDirectoryBytes } from 'packages/php-wasm/stream-compression/src/zip'; +import { limitBytes } from 'packages/php-wasm/stream-compression/src/utils/limit-bytes'; +import { skipFirstBytes } from 'packages/php-wasm/stream-compression/src/utils/skip-first-bytes'; +import { skipLastBytes } from 'packages/php-wasm/stream-compression/src/utils/skip-last-bytes'; export const ResourceTypes = [ 'vfs', @@ -12,6 +17,7 @@ export const ResourceTypes = [ 'wordpress.org/themes', 'wordpress.org/plugins', 'url', + 'github:artifact', ] as const; export type VFSReference = { @@ -48,13 +54,43 @@ export type UrlReference = { /** Optional caption for displaying a progress message */ caption?: string; }; +/** + * @example + * ``` + * { + * "resource": "github:artifact", + * "owner": "WordPress", + * "repo": "gutenberg", + * "workflow": "Build Gutenberg Plugin Zip", + * "artifact": "gutenberg-plugin", + * "pr": 65590 + * } + * ``` + */ +export type GitHubArtifactReference = { + /** Identifies the file resource as a GitHub artifact */ + resource: 'github:artifact'; + /** The URL of the artifact */ + owner: string; + /** The name of the repository */ + repo: string; + /** The name of the workflow */ + workflow: string; + /** The name of the artifact */ + artifact: string; + /** The pull request number */ + pr: number; + /** Optional caption for displaying a progress message */ + caption?: string; +}; export type FileReference = | VFSReference | LiteralReference | CoreThemeReference | CorePluginReference - | UrlReference; + | UrlReference + | GitHubArtifactReference; export function isFileReference(ref: any): ref is FileReference { return ( @@ -106,6 +142,9 @@ export abstract class Resource { case 'url': resource = new UrlResource(ref, progress); break; + case 'github:artifact': + resource = new GitHubArtifactResource(ref, progress); + break; default: throw new Error(`Invalid resource: ${ref}`); } @@ -211,21 +250,24 @@ export abstract class FetchResource extends Resource { /** @inheritDoc */ async resolve() { + const response = await this.resolveResponse(); + // @TODO: Use StreamedFile once the Blueprints resource resolution + // mechanism knows how to deal with streams. + return new File([await response.blob()], this.name); + } + + protected async resolveResponse() { this.progress?.setCaption(this.caption); const url = this.getURL(); try { - let response = await fetch(url); + const response = await fetch(url); if (!response.ok) { throw new Error(`Could not download "${url}"`); } - response = await cloneResponseMonitorProgress( - response, - this.progress?.loadingListener ?? noop - ); if (response.status !== 200) { throw new Error(`Could not download "${url}"`); } - return new File([await response.blob()], this.name); + return response; } catch (e) { throw new Error( `Could not download "${url}". @@ -304,6 +346,15 @@ export class UrlResource extends FetchResource { super(progress); } + override async resolve(): Promise { + const response = cloneResponseMonitorProgress( + await this.resolveResponse(), + this.progress?.loadingListener ?? noop + ); + const file = await response.blob(); + return new File([file], this.name); + } + /** @inheritDoc */ getURL() { return this.resource.url; @@ -315,6 +366,78 @@ export class UrlResource extends FetchResource { } } +/** + * A `Resource` that represents a file available from a URL. + */ +export class GitHubArtifactResource extends FetchResource { + /** + * Creates a new instance of `UrlResource`. + * @param resource The URL reference. + * @param progress The progress tracker. + */ + constructor( + private resource: GitHubArtifactReference, + progress?: ProgressTracker + ) { + super(progress); + } + + override async resolve(): Promise { + const response = await this.resolveResponse(); + let responseStream = response.body!; + console.log(response); + const length = Number(response.headers.get('content-length')!); + const filesStreama = await streamCentralDirectoryBytes({ + length, + streamBytes: async (start, end) => { + const [left, right] = responseStream.tee(); + responseStream = left; + return right + .pipeThrough(skipFirstBytes(start)) + .pipeThrough(skipLastBytes(end - start, length - start)); + }, + }); + for await (const entry of filesStreama) { + console.log({ entry }); + } + return; + + const filesStream = decodeZip(response.body!); + console.log({ filesStream }); + // @TODO: Monitor the download progress. We can't do that before calling decodeZip() because of + // BYOB streams. + for await (const file of filesStream) { + //, (fileEntry) => new TextDecoder().decode(fileEntry.path).endsWith('.zip') + console.log({ file, name: file.name }); + if (file.name.endsWith('.zip')) { + // return file; + } + } + console.log('decoded data'); + throw new Error('No .zip file found in the requested GitHub artifact'); + } + + /** @inheritDoc */ + getURL() { + const url = new URL(import.meta.url); + url.pathname = '/plugin-proxy.php'; + url.searchParams.set('org', this.resource.owner); + url.searchParams.set('repo', this.resource.repo); + url.searchParams.set('workflow', this.resource.workflow); + url.searchParams.set('artifact', this.resource.artifact); + url.searchParams.set('pr', this.resource.pr.toString()); + return url.toString(); + } + + /** @inheritDoc */ + protected override get caption() { + return ( + this.resource.caption ?? + `Fetching ${this.resource.owner}/${this.resource.repo} PR #${this.resource.pr}` + ); + } +} + /** * A `Resource` that represents a WordPress core theme. */