mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-01 23:12:29 +00:00
5b6da4c588
* Add PR Artifact Explorer canvas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d * Address PR #2341 review feedback and codespell CI failures PR review fixes: - zip.mjs: bounded streaming inflate for readZipEntry (output cap = entry.uncompressedSize) to prevent memory exhaustion from malformed entries - zip.mjs: new bounded verify Transform in streamZipEntry that enforces decompressed byte cap and verifies CRC32 on flush - zip.mjs: new readEntryPrefix() helper that inflates only up to a byte cap for indexing use cases - preview.mjs: move URL/path parsing inside the try block so URIError becomes a 400 response instead of an unhandled rejection - cache.mjs: buildMetadata uses bounded readEntryPrefix (8 KiB) instead of full readEntry+slice, eliminating unbounded decompression during indexing - cache.mjs: clearArtifactCache aborts and awaits in-flight downloads via AbortController before removing cache dirs so clear cannot be repopulated - server.mjs / preview.mjs: track static preview servers by canvas origin and stop them on canvas close, preventing loopback server leaks - trx-preview.js: prefer authoritative ResultSummary.outcome when it is a recognized TRX value; only infer from counters when unknown - extension.mjs: set_account validates the requested id resolves to the active account before persisting; unknown ids now return an error - server.mjs: reject progressive-pull offsets that are not multiples of PROGRESSIVE_PULL_BATCH_SIZE to prevent caching incomplete pulls as complete - README.md: remove Markdown-rendering claim (files are shown as escaped text) CI fix: - .codespellrc: skip vendored asciinema-player(*.min.js) and primer-*.css bundles Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address second round of PR #2341 review feedback - github.mjs: request isDraft in the pullRequestSignals GraphQL enrichment and propagate draft into search-sourced pulls (search REST API has no draft field) - cache.mjs: give each writeJsonAtomic write a unique temp filename via a module sequence and clean up the temp file on failure to avoid concurrent-write races - cache.mjs: deleteCachedArtifact now aborts and awaits any in-flight download for the artifact before removing files; internal mismatched-metadata purge uses a raw removeArtifactFiles helper to avoid aborting its own operation - accounts.mjs: skip non-github.com CLI accounts before reading tokens so GitHub Enterprise Server credentials are never sent to api.github.com - preview.mjs: require the requested entry to be the root index.html before launching a static preview (previously any nested HTML file could start one) - zip.mjs: validate the EOCD comment length ends exactly at the archive tail so a file comment containing the EOCD signature is not mistaken for the record Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f * Address remaining PR artifact explorer feedback Fix malformed preference normalization, replace artifact-presence request fanout with cached repository artifact pagination, restrict static previews to the root index, and derive completed TRX outcomes from counters. Add focused regression coverage for all four behaviors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 * Fix PR artifact explorer review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 * Stabilize artifact discovery and cache cleanup Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132 --------- Co-authored-by: David Pine <7679720+IEvangelist@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 33fefd65-ed18-4eda-9c7d-48008f4a9c9d Copilot-Session: 47369ff4-859a-425f-8a47-763cc1a5f25f Copilot-Session: db486df1-17f1-4623-ac7e-61eff2c76da4 Copilot-Session: 6ee2bc81-2114-4b1f-987a-cb47ea35e132
532 lines
16 KiB
JavaScript
532 lines
16 KiB
JavaScript
import { createWriteStream } from "node:fs";
|
|
import {
|
|
mkdir,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
stat,
|
|
statfs,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { Readable, Transform } from "node:stream";
|
|
import { pipeline } from "node:stream/promises";
|
|
import {
|
|
ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS,
|
|
CACHE_ROOT,
|
|
MAX_ARCHIVE_BYTES,
|
|
MAX_INLINE_PREVIEW_BYTES,
|
|
MIN_CACHE_FREE_BYTES,
|
|
} from "./constants.mjs";
|
|
import { CacheMaintenanceCoordinator } from "./cache-coordinator.mjs";
|
|
import { analyzeArtifact, hasRootIndexHtml } from "./detector.mjs";
|
|
import { getArtifact, openArtifactDownload } from "./github.mjs";
|
|
import { findZipEntry, readEntryPrefix, readZipEntry, readZipIndex } from "./zip.mjs";
|
|
|
|
const downloads = new Map();
|
|
const indexCache = new Map();
|
|
const maintenance = new CacheMaintenanceCoordinator();
|
|
let reservedDownloadBytes = 0;
|
|
let metadataWriteSequence = 0;
|
|
|
|
function normalizeArtifactId(value) {
|
|
const id = Number.parseInt(value, 10);
|
|
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
throw new Error("Artifact id must be a positive integer.");
|
|
}
|
|
return String(id);
|
|
}
|
|
|
|
function pathsFor(value) {
|
|
const id = normalizeArtifactId(value);
|
|
return {
|
|
id,
|
|
archive: join(CACHE_ROOT, `${id}.zip`),
|
|
metadata: join(CACHE_ROOT, `${id}.json`),
|
|
};
|
|
}
|
|
|
|
async function writeJsonAtomic(path, value) {
|
|
const temporary = `${path}.${process.pid}.${++metadataWriteSequence}.tmp`;
|
|
try {
|
|
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
await rename(temporary, path);
|
|
} catch (error) {
|
|
await rm(temporary, { force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function readMetadataFile(path) {
|
|
try {
|
|
return JSON.parse(await readFile(path, "utf8"));
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return null;
|
|
if (error instanceof SyntaxError) {
|
|
throw new Error(`Cached artifact metadata is invalid: ${path}`, { cause: error });
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function formatBytes(value) {
|
|
const bytes = Math.max(0, Number(value) || 0);
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
const units = ["KiB", "MiB", "GiB", "TiB"];
|
|
let size = bytes;
|
|
let unit = -1;
|
|
do {
|
|
size /= 1024;
|
|
unit++;
|
|
} while (size >= 1024 && unit < units.length - 1);
|
|
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
|
|
}
|
|
|
|
async function downloadCapacityBytes() {
|
|
const filesystem = await statfs(CACHE_ROOT);
|
|
const available = Number(filesystem.bavail) * Number(filesystem.bsize);
|
|
return Math.max(
|
|
0,
|
|
Math.min(
|
|
MAX_ARCHIVE_BYTES,
|
|
available - MIN_CACHE_FREE_BYTES - reservedDownloadBytes,
|
|
),
|
|
);
|
|
}
|
|
|
|
function assertDownloadFits(bytes, capacity) {
|
|
if (!Number.isFinite(bytes) || bytes <= 0) return;
|
|
if (bytes > MAX_ARCHIVE_BYTES) {
|
|
throw new Error("Artifact exceeds the 4 GiB ZIP download limit.");
|
|
}
|
|
if (bytes > capacity) {
|
|
throw new Error(
|
|
`Not enough free disk space to cache this artifact. ${formatBytes(bytes)} is required, with ${formatBytes(capacity)} available after the safety reserve.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function downloadArchive(
|
|
token,
|
|
repository,
|
|
artifact,
|
|
archivePath,
|
|
onProgress,
|
|
externalSignal = null,
|
|
) {
|
|
const declaredBytes = Number(artifact.sizeInBytes);
|
|
let reservedBytes = 0;
|
|
const reserve = async (totalBytes) => {
|
|
if (!Number.isFinite(totalBytes) || totalBytes <= reservedBytes) return;
|
|
assertDownloadFits(totalBytes, MAX_ARCHIVE_BYTES);
|
|
const additionalBytes = totalBytes - reservedBytes;
|
|
const capacity = await downloadCapacityBytes();
|
|
if (additionalBytes > capacity) {
|
|
throw new Error(
|
|
`Not enough free disk space to cache this artifact. ${formatBytes(totalBytes)} is required, with ${formatBytes(capacity + reservedBytes)} available after the safety reserve.`,
|
|
);
|
|
}
|
|
reservedDownloadBytes += additionalBytes;
|
|
reservedBytes = totalBytes;
|
|
};
|
|
const releaseReservation = () => {
|
|
reservedDownloadBytes = Math.max(0, reservedDownloadBytes - reservedBytes);
|
|
reservedBytes = 0;
|
|
};
|
|
await reserve(declaredBytes);
|
|
|
|
const controller = new AbortController();
|
|
if (externalSignal?.aborted) {
|
|
controller.abort(externalSignal.reason);
|
|
} else if (externalSignal) {
|
|
externalSignal.addEventListener("abort", () => controller.abort(externalSignal.reason), { once: true });
|
|
}
|
|
let idleTimer;
|
|
const resetIdleTimeout = () => {
|
|
clearTimeout(idleTimer);
|
|
idleTimer = setTimeout(() => {
|
|
controller.abort(new Error("Artifact download stalled for 60 seconds."));
|
|
}, ARTIFACT_DOWNLOAD_IDLE_TIMEOUT_MS);
|
|
};
|
|
resetIdleTimeout();
|
|
|
|
let response;
|
|
try {
|
|
response = await openArtifactDownload(token, repository, artifact.id, {
|
|
signal: controller.signal,
|
|
});
|
|
} catch (error) {
|
|
clearTimeout(idleTimer);
|
|
releaseReservation();
|
|
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
|
throw controller.signal.reason;
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
const expectedLength = Number.parseInt(response.headers.get("content-length") ?? "", 10);
|
|
try {
|
|
await reserve(expectedLength);
|
|
} catch (error) {
|
|
clearTimeout(idleTimer);
|
|
controller.abort(error);
|
|
releaseReservation();
|
|
throw error;
|
|
}
|
|
const totalBytes =
|
|
Number.isFinite(expectedLength) && expectedLength > 0
|
|
? expectedLength
|
|
: Number.isFinite(declaredBytes) && declaredBytes > 0
|
|
? declaredBytes
|
|
: null;
|
|
|
|
const temporary = `${archivePath}.${process.pid}.${Date.now()}.part`;
|
|
let received = 0;
|
|
let lastReportAt = 0;
|
|
let bytesPerSecond = 0;
|
|
const samples = [{ at: Date.now(), bytes: 0 }];
|
|
const report = (force = false) => {
|
|
if (!onProgress) return;
|
|
const now = Date.now();
|
|
if (!force && now - lastReportAt < 250) return;
|
|
samples.push({ at: now, bytes: received });
|
|
while (samples.length > 2 && samples[1].at < now - 5_000) samples.shift();
|
|
const first = samples[0];
|
|
const elapsedSeconds = (now - first.at) / 1_000;
|
|
bytesPerSecond =
|
|
elapsedSeconds >= 0.2 ? (received - first.bytes) / elapsedSeconds : 0;
|
|
const percent = totalBytes
|
|
? Math.min(100, (received / totalBytes) * 100)
|
|
: null;
|
|
onProgress({
|
|
artifactId: artifact.id,
|
|
artifactName: artifact.name,
|
|
stage: "downloading",
|
|
receivedBytes: received,
|
|
totalBytes,
|
|
percent,
|
|
bytesPerSecond,
|
|
etaSeconds:
|
|
totalBytes && bytesPerSecond > 0
|
|
? Math.max(0, (totalBytes - received) / bytesPerSecond)
|
|
: null,
|
|
});
|
|
lastReportAt = now;
|
|
};
|
|
report(true);
|
|
|
|
const limiter = new Transform({
|
|
transform(chunk, _encoding, callback) {
|
|
received += chunk.length;
|
|
if (received > MAX_ARCHIVE_BYTES) {
|
|
callback(new Error("Artifact exceeded the 4 GiB ZIP download limit."));
|
|
return;
|
|
}
|
|
if (reservedBytes > 0 && received > reservedBytes) {
|
|
callback(new Error("Artifact download exhausted the available cache space."));
|
|
return;
|
|
}
|
|
resetIdleTimeout();
|
|
report();
|
|
callback(null, chunk);
|
|
},
|
|
});
|
|
|
|
try {
|
|
await pipeline(
|
|
Readable.fromWeb(response.body),
|
|
limiter,
|
|
createWriteStream(temporary, { flags: "wx" }),
|
|
);
|
|
report(true);
|
|
await rename(temporary, archivePath);
|
|
} catch (error) {
|
|
await rm(temporary, { force: true });
|
|
if (controller.signal.aborted && controller.signal.reason instanceof Error) {
|
|
throw controller.signal.reason;
|
|
}
|
|
throw error;
|
|
} finally {
|
|
clearTimeout(idleTimer);
|
|
releaseReservation();
|
|
}
|
|
return {
|
|
bytesPerSecond,
|
|
receivedBytes: received,
|
|
totalBytes: totalBytes ?? received,
|
|
};
|
|
}
|
|
|
|
async function buildMetadata(token, repository, artifactId, onProgress, signal = null) {
|
|
const paths = pathsFor(artifactId);
|
|
const artifact = await getArtifact(token, repository, paths.id);
|
|
if (artifact.expired) {
|
|
throw new Error("This GitHub Actions artifact has expired.");
|
|
}
|
|
|
|
await mkdir(CACHE_ROOT, { recursive: true });
|
|
onProgress?.({
|
|
artifactId: artifact.id,
|
|
artifactName: artifact.name,
|
|
stage: "preparing",
|
|
receivedBytes: 0,
|
|
totalBytes: artifact.sizeInBytes,
|
|
percent: 0,
|
|
bytesPerSecond: 0,
|
|
etaSeconds: null,
|
|
});
|
|
const transfer = await downloadArchive(
|
|
token,
|
|
repository,
|
|
artifact,
|
|
paths.archive,
|
|
onProgress,
|
|
signal,
|
|
);
|
|
const compressedBytes = transfer.receivedBytes;
|
|
try {
|
|
onProgress?.({
|
|
artifactId: artifact.id,
|
|
artifactName: artifact.name,
|
|
stage: "indexing",
|
|
receivedBytes: compressedBytes,
|
|
totalBytes: transfer.totalBytes,
|
|
percent: 100,
|
|
bytesPerSecond: transfer.bytesPerSecond,
|
|
etaSeconds: 0,
|
|
});
|
|
const index = await readZipIndex(paths.archive);
|
|
const PREFIX_BYTES = 8 * 1024;
|
|
const analysis = await analyzeArtifact(index, async (entry) => {
|
|
return readEntryPrefix(paths.archive, entry, PREFIX_BYTES);
|
|
});
|
|
const timestamp = new Date().toISOString();
|
|
const metadata = {
|
|
artifact,
|
|
repository,
|
|
downloadedAt: timestamp,
|
|
lastAccessedAt: timestamp,
|
|
compressedBytes,
|
|
analysis,
|
|
};
|
|
await writeJsonAtomic(paths.metadata, metadata);
|
|
indexCache.set(paths.id, { archivePath: paths.archive, index });
|
|
onProgress?.({
|
|
artifactId: artifact.id,
|
|
artifactName: artifact.name,
|
|
stage: "ready",
|
|
receivedBytes: compressedBytes,
|
|
totalBytes: transfer.totalBytes,
|
|
percent: 100,
|
|
bytesPerSecond: transfer.bytesPerSecond,
|
|
etaSeconds: 0,
|
|
});
|
|
return metadata;
|
|
} catch (error) {
|
|
await Promise.all([
|
|
rm(paths.archive, { force: true }),
|
|
rm(paths.metadata, { force: true }),
|
|
]);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function cachedMetadata(artifactId) {
|
|
const paths = pathsFor(artifactId);
|
|
const metadata = await readMetadataFile(paths.metadata);
|
|
if (!metadata) return null;
|
|
try {
|
|
const archive = await stat(paths.archive);
|
|
if (!archive.isFile()) return null;
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") return null;
|
|
throw error;
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
export async function inspectArtifact(
|
|
token,
|
|
repository,
|
|
artifactId,
|
|
{ onProgress } = {},
|
|
) {
|
|
const paths = pathsFor(artifactId);
|
|
while (true) {
|
|
const barrier = maintenance.inspectionBarrier(paths.id);
|
|
if (!barrier) break;
|
|
await barrier;
|
|
}
|
|
const key = `${repository}:${paths.id}`;
|
|
const active = downloads.get(key);
|
|
if (active) {
|
|
if (onProgress) active.listeners.add(onProgress);
|
|
try {
|
|
return await active.operation;
|
|
} finally {
|
|
if (onProgress) active.listeners.delete(onProgress);
|
|
}
|
|
}
|
|
|
|
const listeners = new Set(onProgress ? [onProgress] : []);
|
|
const emit = (progress) => {
|
|
for (const listener of listeners) listener(progress);
|
|
};
|
|
const externalController = new AbortController();
|
|
const operation = (async () => {
|
|
const existing = await cachedMetadata(paths.id);
|
|
if (existing?.repository === repository && existing.analysis) {
|
|
existing.lastAccessedAt = new Date().toISOString();
|
|
await writeJsonAtomic(paths.metadata, existing);
|
|
emit({
|
|
artifactId: Number(paths.id),
|
|
artifactName: existing.artifact?.name ?? `Artifact ${paths.id}`,
|
|
stage: "ready",
|
|
receivedBytes: existing.compressedBytes,
|
|
totalBytes: existing.compressedBytes,
|
|
percent: 100,
|
|
bytesPerSecond: 0,
|
|
etaSeconds: 0,
|
|
cached: true,
|
|
});
|
|
return existing;
|
|
}
|
|
if (existing) {
|
|
indexCache.delete(paths.id);
|
|
await removeArtifactFiles(paths);
|
|
}
|
|
return buildMetadata(token, repository, paths.id, emit, externalController.signal);
|
|
})();
|
|
const download = { listeners, operation, abort: () => externalController.abort(new Error("Cache cleared.")) };
|
|
downloads.set(key, download);
|
|
try {
|
|
return await operation;
|
|
} catch (error) {
|
|
emit({
|
|
artifactId: Number(paths.id),
|
|
stage: "error",
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
throw error;
|
|
} finally {
|
|
if (downloads.get(key) === download) downloads.delete(key);
|
|
listeners.clear();
|
|
}
|
|
}
|
|
|
|
export async function getCachedArtifact(artifactId, { touch = true } = {}) {
|
|
const paths = pathsFor(artifactId);
|
|
const metadata = await cachedMetadata(paths.id);
|
|
if (!metadata) return null;
|
|
if (touch) {
|
|
metadata.lastAccessedAt = new Date().toISOString();
|
|
await writeJsonAtomic(paths.metadata, metadata);
|
|
}
|
|
return metadata;
|
|
}
|
|
|
|
async function getIndex(artifactId) {
|
|
const paths = pathsFor(artifactId);
|
|
const cached = indexCache.get(paths.id);
|
|
if (cached?.archivePath === paths.archive) return cached.index;
|
|
const index = await readZipIndex(paths.archive);
|
|
indexCache.set(paths.id, { archivePath: paths.archive, index });
|
|
return index;
|
|
}
|
|
|
|
export async function getCachedEntry(artifactId, entryPath) {
|
|
const paths = pathsFor(artifactId);
|
|
const metadata = await cachedMetadata(paths.id);
|
|
if (!metadata) throw new Error("Artifact is not cached. Inspect it first.");
|
|
const index = await getIndex(paths.id);
|
|
const entry = findZipEntry(index, entryPath);
|
|
if (!entry) throw new Error(`File was not found in the artifact: ${entryPath}`);
|
|
return { metadata, archivePath: paths.archive, index, entry };
|
|
}
|
|
|
|
export async function readCachedEntry(artifactId, entryPath, maxBytes = MAX_INLINE_PREVIEW_BYTES) {
|
|
const context = await getCachedEntry(artifactId, entryPath);
|
|
return {
|
|
...context,
|
|
content: await readZipEntry(context.archivePath, context.entry, maxBytes),
|
|
};
|
|
}
|
|
|
|
async function removeArtifactFiles(paths) {
|
|
await Promise.all([
|
|
rm(paths.archive, { force: true }),
|
|
rm(paths.metadata, { force: true }),
|
|
]);
|
|
}
|
|
|
|
export async function deleteCachedArtifact(artifactId) {
|
|
const paths = pathsFor(artifactId);
|
|
return maintenance.deleteArtifact(paths.id, async () => {
|
|
indexCache.delete(paths.id);
|
|
const suffix = `:${paths.id}`;
|
|
const matches = [...downloads.entries()]
|
|
.filter(([key]) => key.endsWith(suffix))
|
|
.map(([, entry]) => entry);
|
|
for (const entry of matches) entry.abort?.();
|
|
await Promise.allSettled(matches.map((entry) => entry.operation));
|
|
await removeArtifactFiles(paths);
|
|
return { deleted: paths.id };
|
|
});
|
|
}
|
|
|
|
export async function clearArtifactCache() {
|
|
return maintenance.clearCache(async () => {
|
|
const inflight = [...downloads.values()];
|
|
for (const entry of inflight) entry.abort?.();
|
|
await Promise.allSettled(inflight.map((entry) => entry.operation));
|
|
indexCache.clear();
|
|
await rm(CACHE_ROOT, { recursive: true, force: true });
|
|
await mkdir(CACHE_ROOT, { recursive: true });
|
|
return { cleared: true };
|
|
});
|
|
}
|
|
|
|
export async function getCacheSummary() {
|
|
await mkdir(CACHE_ROOT, { recursive: true });
|
|
const files = await readdir(CACHE_ROOT, { withFileTypes: true });
|
|
const metadataFiles = files.filter(
|
|
(entry) => entry.isFile() && /^\d+\.json$/.test(entry.name),
|
|
);
|
|
const artifacts = [];
|
|
const errors = [];
|
|
let totalBytes = 0;
|
|
|
|
for (const file of metadataFiles) {
|
|
const id = file.name.slice(0, -5);
|
|
try {
|
|
const metadata = await cachedMetadata(id);
|
|
if (!metadata) continue;
|
|
const archive = await stat(pathsFor(id).archive);
|
|
totalBytes += archive.size;
|
|
artifacts.push({
|
|
id,
|
|
name: metadata.artifact?.name ?? `Artifact ${id}`,
|
|
repository: metadata.repository,
|
|
bytes: archive.size,
|
|
downloadedAt: metadata.downloadedAt,
|
|
lastAccessedAt: metadata.lastAccessedAt,
|
|
primary:
|
|
["html", "static-site"].includes(metadata.analysis?.primary?.kind) &&
|
|
!hasRootIndexHtml(metadata.analysis?.entries ?? [])
|
|
? null
|
|
: metadata.analysis?.primary ?? null,
|
|
});
|
|
} catch (error) {
|
|
errors.push({
|
|
id,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
artifacts.sort((left, right) =>
|
|
String(right.lastAccessedAt).localeCompare(String(left.lastAccessedAt)));
|
|
return { totalBytes, count: artifacts.length, artifacts, errors };
|
|
}
|