mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-02 07:22:32 +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
713 lines
21 KiB
JavaScript
713 lines
21 KiB
JavaScript
import { GITHUB_API, USER_AGENT } from "./constants.mjs";
|
|
import { normalizeRepository } from "./state.mjs";
|
|
|
|
const PULL_ARTIFACT_FILTERS = new Set(["all", "with", "without"]);
|
|
const PULL_CI_FILTERS = new Set(["all", "failing", "passing", "pending", "none"]);
|
|
const ARTIFACT_PRESENCE_TTL_MS = 5 * 60 * 1_000;
|
|
const MISSING_ARTIFACT_TTL_MS = 30 * 1_000;
|
|
const ARTIFACT_PRESENCE_CACHE_LIMIT = 1_000;
|
|
// Each run fans out into paginated artifact requests, so keep the initial view bounded.
|
|
const MAX_PULL_REQUEST_RUNS = 30;
|
|
const MAX_PULL_REQUEST_ARTIFACTS = 2_000;
|
|
const artifactPresenceCache = new Map();
|
|
|
|
export class GitHubApiError extends Error {
|
|
constructor(message, status, details = null) {
|
|
super(message);
|
|
this.name = "GitHubApiError";
|
|
this.status = status;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
function repositoryPath(repository) {
|
|
return normalizeRepository(repository).split("/").map(encodeURIComponent).join("/");
|
|
}
|
|
|
|
function headersFor(token, extra = {}) {
|
|
return {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${token}`,
|
|
"User-Agent": USER_AGENT,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
async function readPayload(response) {
|
|
const text = await response.text();
|
|
if (!text) return null;
|
|
const type = response.headers.get("content-type") ?? "";
|
|
if (type.includes("json")) {
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (error) {
|
|
throw new GitHubApiError("GitHub returned malformed JSON.", response.status, {
|
|
cause: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
return text;
|
|
}
|
|
|
|
export async function githubRequest(token, path, options = {}) {
|
|
if (!token) throw new GitHubApiError("No GitHub account is selected.", 401);
|
|
if (!path.startsWith("/")) throw new Error("GitHub API paths must be absolute.");
|
|
|
|
const response = await fetch(`${GITHUB_API}${path}`, {
|
|
method: options.method ?? "GET",
|
|
headers: headersFor(token, options.headers),
|
|
body: options.body,
|
|
signal: options.signal ?? AbortSignal.timeout(30_000),
|
|
});
|
|
const payload = await readPayload(response);
|
|
if (!response.ok) {
|
|
const message =
|
|
payload && typeof payload === "object" && payload.message
|
|
? payload.message
|
|
: `GitHub API request failed (${response.status}).`;
|
|
throw new GitHubApiError(message, response.status, payload);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
function repositorySummary(repository) {
|
|
return {
|
|
fullName: repository.full_name,
|
|
name: repository.name,
|
|
owner: {
|
|
login: repository.owner?.login ?? "",
|
|
avatarUrl: repository.owner?.avatar_url ?? null,
|
|
type: repository.owner?.type ?? null,
|
|
},
|
|
description: repository.description ?? null,
|
|
private: repository.private === true,
|
|
archived: repository.archived === true,
|
|
visibility: repository.visibility ?? (repository.private ? "private" : "public"),
|
|
url: repository.html_url,
|
|
updatedAt: repository.updated_at ?? null,
|
|
};
|
|
}
|
|
|
|
export async function getRepository(token, repository) {
|
|
return repositorySummary(
|
|
await githubRequest(token, `/repos/${repositoryPath(repository)}`),
|
|
);
|
|
}
|
|
|
|
async function contributorProfilesByNodeId(token, contributors) {
|
|
const ids = contributors
|
|
.map((contributor) => contributor.node_id)
|
|
.filter(Boolean);
|
|
if (!ids.length) return new Map();
|
|
|
|
const payload = await githubRequest(token, "/graphql", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
query: `
|
|
query ContributorProfiles($ids: [ID!]!) {
|
|
nodes(ids: $ids) {
|
|
id
|
|
... on User {
|
|
login
|
|
name
|
|
}
|
|
... on Bot {
|
|
login
|
|
}
|
|
}
|
|
}
|
|
`,
|
|
variables: { ids },
|
|
}),
|
|
});
|
|
if (payload?.errors?.length) {
|
|
throw new GitHubApiError(
|
|
payload.errors[0]?.message ?? "GitHub could not load contributor profiles.",
|
|
502,
|
|
payload.errors,
|
|
);
|
|
}
|
|
if (!Array.isArray(payload?.data?.nodes)) {
|
|
throw new GitHubApiError("GitHub returned invalid contributor profile data.", 502, payload);
|
|
}
|
|
|
|
return new Map(
|
|
payload.data.nodes
|
|
.filter((profile) => profile?.id)
|
|
.map((profile) => [profile.id, profile]),
|
|
);
|
|
}
|
|
|
|
export async function listRepositoryContributors(token, repository) {
|
|
const contributors = (
|
|
await githubRequest(
|
|
token,
|
|
`/repos/${repositoryPath(repository)}/contributors?anon=0&per_page=100`,
|
|
)
|
|
).filter((contributor) => contributor?.login);
|
|
const profilesById = await contributorProfilesByNodeId(token, contributors);
|
|
|
|
return contributors
|
|
.map((contributor) => ({
|
|
login: contributor.login,
|
|
name:
|
|
typeof profilesById.get(contributor.node_id)?.name === "string"
|
|
? profilesById.get(contributor.node_id).name.trim() || null
|
|
: null,
|
|
avatarUrl: contributor.avatar_url ?? null,
|
|
profileUrl: contributor.html_url ?? null,
|
|
type: contributor.type ?? null,
|
|
contributions: Number(contributor.contributions) || 0,
|
|
}));
|
|
}
|
|
|
|
export async function suggestRepositories(token, query) {
|
|
const value = String(query ?? "").trim();
|
|
if (!value || value.length > 100) return [];
|
|
|
|
const slash = value.indexOf("/");
|
|
if (slash > 0) {
|
|
const owner = value.slice(0, slash).trim();
|
|
const partial = value.slice(slash + 1).trim();
|
|
if (!/^[A-Za-z0-9_.-]+$/.test(owner)) return [];
|
|
|
|
const profile = await githubRequest(token, `/users/${encodeURIComponent(owner)}`);
|
|
if (partial) {
|
|
const qualifier = profile.type === "Organization" ? "org" : "user";
|
|
const result = await githubRequest(
|
|
token,
|
|
`/search/repositories?q=${encodeURIComponent(`${partial} in:name ${qualifier}:${owner}`)}&sort=updated&order=desc&per_page=12`,
|
|
);
|
|
return (result.items ?? []).map(repositorySummary);
|
|
}
|
|
|
|
const endpoint =
|
|
profile.type === "Organization"
|
|
? `/orgs/${encodeURIComponent(owner)}/repos?type=all&sort=updated&direction=desc&per_page=20`
|
|
: `/users/${encodeURIComponent(owner)}/repos?type=all&sort=updated&direction=desc&per_page=20`;
|
|
return (await githubRequest(token, endpoint)).map(repositorySummary);
|
|
}
|
|
|
|
if (value.length < 2) return [];
|
|
const result = await githubRequest(
|
|
token,
|
|
`/search/repositories?q=${encodeURIComponent(`${value} in:name`)}&sort=updated&order=desc&per_page=12`,
|
|
);
|
|
return (result.items ?? []).map(repositorySummary);
|
|
}
|
|
|
|
function mapPullRequest(item) {
|
|
return {
|
|
number: item.number,
|
|
title: item.title,
|
|
state: item.state,
|
|
draft: Boolean(item.draft),
|
|
url: item.html_url,
|
|
author: item.user?.login ?? "ghost",
|
|
authorAvatarUrl: item.user?.avatar_url ?? null,
|
|
authorType: item.user?.type ?? null,
|
|
authorAssociation: item.author_association ?? null,
|
|
comments: Number(item.comments) || 0,
|
|
createdAt: item.created_at,
|
|
updatedAt: item.updated_at,
|
|
closedAt: item.closed_at,
|
|
mergedAt: item.merged_at ?? null,
|
|
headSha: item.head?.sha ?? null,
|
|
headRef: item.head?.ref ?? null,
|
|
baseRef: item.base?.ref ?? null,
|
|
labels: (item.labels ?? []).map((label) => ({
|
|
name: label.name,
|
|
color: label.color,
|
|
description: label.description ?? null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function mapSearchPullRequest(item) {
|
|
return {
|
|
number: item.number,
|
|
title: item.title,
|
|
state: item.state,
|
|
draft: Boolean(item.draft),
|
|
url: item.html_url,
|
|
author: item.user?.login ?? "ghost",
|
|
authorAvatarUrl: item.user?.avatar_url ?? null,
|
|
authorType: item.user?.type ?? null,
|
|
authorAssociation: item.author_association ?? null,
|
|
comments: Number(item.comments) || 0,
|
|
createdAt: item.created_at,
|
|
updatedAt: item.updated_at,
|
|
closedAt: item.closed_at,
|
|
mergedAt: item.pull_request?.merged_at ?? null,
|
|
headSha: null,
|
|
headRef: null,
|
|
baseRef: null,
|
|
labels: (item.labels ?? []).map((label) => ({
|
|
name: label.name,
|
|
color: label.color,
|
|
description: label.description ?? null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
function normalizePullFilters(filters = {}) {
|
|
return {
|
|
artifacts: PULL_ARTIFACT_FILTERS.has(filters.artifacts) ? filters.artifacts : "all",
|
|
ci: PULL_CI_FILTERS.has(filters.ci) ? filters.ci : "all",
|
|
};
|
|
}
|
|
|
|
function ciStatusForRollup(state) {
|
|
if (state === "FAILURE" || state === "ERROR") return "failing";
|
|
if (state === "SUCCESS") return "passing";
|
|
if (state === "PENDING" || state === "EXPECTED") return "pending";
|
|
return "none";
|
|
}
|
|
|
|
async function pullRequestSignals(token, repository, pulls) {
|
|
if (!pulls.length) return new Map();
|
|
const [owner, name] = normalizeRepository(repository).split("/");
|
|
const selections = pulls
|
|
.map(
|
|
(pull, index) => `
|
|
pull${index}: pullRequest(number: ${Number(pull.number)}) {
|
|
number
|
|
isDraft
|
|
headRefOid
|
|
reviewDecision
|
|
totalCommentsCount
|
|
commits(last: 1) {
|
|
nodes {
|
|
commit {
|
|
statusCheckRollup {
|
|
state
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`,
|
|
)
|
|
.join("\n");
|
|
const payload = await githubRequest(token, "/graphql", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
query: `
|
|
query PullRequestSignals($owner: String!, $name: String!) {
|
|
repository(owner: $owner, name: $name) {
|
|
${selections}
|
|
}
|
|
}
|
|
`,
|
|
variables: { owner, name },
|
|
}),
|
|
});
|
|
if (payload?.errors?.length) {
|
|
throw new GitHubApiError(
|
|
payload.errors[0]?.message ?? "GitHub could not load pull request signals.",
|
|
502,
|
|
payload.errors,
|
|
);
|
|
}
|
|
const result = payload?.data?.repository;
|
|
if (!result || typeof result !== "object") {
|
|
throw new GitHubApiError("GitHub returned invalid pull request signal data.", 502, payload);
|
|
}
|
|
|
|
return new Map(
|
|
pulls.map((pull, index) => {
|
|
const signal = result[`pull${index}`];
|
|
if (!signal) {
|
|
throw new GitHubApiError(
|
|
`GitHub did not return signal data for pull request #${pull.number}.`,
|
|
502,
|
|
payload,
|
|
);
|
|
}
|
|
const rollupState =
|
|
signal.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null;
|
|
return [
|
|
Number(pull.number),
|
|
{
|
|
draft: Boolean(signal.isDraft),
|
|
headSha: signal.headRefOid ?? null,
|
|
ciStatus: ciStatusForRollup(rollupState),
|
|
reviewDecision: signal.reviewDecision ?? null,
|
|
comments: Number(signal.totalCommentsCount) || 0,
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function findArtifactHeads(token, repository) {
|
|
const heads = new Set();
|
|
let artifactCount = 0;
|
|
for (let page = 1; ; page++) {
|
|
const payload = await githubRequest(
|
|
token,
|
|
`/repos/${repositoryPath(repository)}/actions/artifacts?per_page=100&page=${page}`,
|
|
);
|
|
const artifacts = payload.artifacts ?? [];
|
|
artifactCount += artifacts.length;
|
|
for (const artifact of artifacts) {
|
|
const headSha = artifact.workflow_run?.head_sha;
|
|
if (headSha) heads.add(headSha);
|
|
}
|
|
const totalCount = Number(payload.total_count) || 0;
|
|
if (artifacts.length < 100 || artifactCount >= totalCount) return heads;
|
|
}
|
|
}
|
|
|
|
function pruneArtifactPresenceCache() {
|
|
const now = Date.now();
|
|
for (const [key, entry] of artifactPresenceCache) {
|
|
if (entry.positiveExpiresAt <= now) artifactPresenceCache.delete(key);
|
|
}
|
|
while (artifactPresenceCache.size >= ARTIFACT_PRESENCE_CACHE_LIMIT) {
|
|
artifactPresenceCache.delete(artifactPresenceCache.keys().next().value);
|
|
}
|
|
}
|
|
|
|
async function artifactHeadsForPulls(token, repository, headShas) {
|
|
const requestedHeads = new Set(headShas.filter(Boolean));
|
|
if (!requestedHeads.size) return new Set();
|
|
const key = normalizeRepository(repository).toLocaleLowerCase();
|
|
const now = Date.now();
|
|
const cached = artifactPresenceCache.get(key);
|
|
if (cached?.missingExpiresAt > now) return cached.promise;
|
|
if (cached?.positiveExpiresAt > now) {
|
|
const cachedHeads = await cached.promise;
|
|
if ([...requestedHeads].every((headSha) => cachedHeads.has(headSha))) {
|
|
return cachedHeads;
|
|
}
|
|
}
|
|
|
|
pruneArtifactPresenceCache();
|
|
const entry = {
|
|
missingExpiresAt: now + MISSING_ARTIFACT_TTL_MS,
|
|
positiveExpiresAt: now + ARTIFACT_PRESENCE_TTL_MS,
|
|
promise: findArtifactHeads(token, repository),
|
|
};
|
|
artifactPresenceCache.set(key, entry);
|
|
try {
|
|
return await entry.promise;
|
|
} catch (error) {
|
|
if (artifactPresenceCache.get(key) === entry) {
|
|
artifactPresenceCache.delete(key);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export async function listPullRequests(token, repository, state = "open") {
|
|
const normalizedState = ["open", "closed", "all"].includes(state) ? state : "open";
|
|
const query = new URLSearchParams({
|
|
state: normalizedState,
|
|
sort: "updated",
|
|
direction: "desc",
|
|
per_page: "50",
|
|
});
|
|
const payload = await githubRequest(
|
|
token,
|
|
`/repos/${repositoryPath(repository)}/pulls?${query}`,
|
|
);
|
|
return payload.map(mapPullRequest);
|
|
}
|
|
|
|
function normalizePullSearchQuery(filter, state) {
|
|
const normalizedState = ["open", "closed", "all"].includes(state) ? state : "open";
|
|
let query = String(filter ?? "")
|
|
.trim()
|
|
.slice(0, 500)
|
|
.replace(/(^|\s)(?:repo|org|user):(?:"[^"]+"|\S+)/gi, " ")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
if (!/\bis:pr\b/i.test(query)) query = `is:pr ${query}`.trim();
|
|
if (
|
|
normalizedState !== "all" &&
|
|
!/\bis:(?:open|closed|merged|unmerged)\b/i.test(query)
|
|
) {
|
|
query = `${query} is:${normalizedState}`;
|
|
}
|
|
return query;
|
|
}
|
|
|
|
export async function searchPullRequestBase(
|
|
token,
|
|
repository,
|
|
filter = "",
|
|
state = "open",
|
|
filters = {},
|
|
{ perPage = 100 } = {},
|
|
) {
|
|
const normalizedFilters = normalizePullFilters(filters);
|
|
const query = normalizePullSearchQuery(filter, state);
|
|
const normalizedPerPage = Math.max(1, Math.min(100, Number(perPage) || 100));
|
|
const parameters = new URLSearchParams({
|
|
q: `repo:${normalizeRepository(repository)} ${query}`,
|
|
sort: "updated",
|
|
order: "desc",
|
|
per_page: String(normalizedPerPage),
|
|
});
|
|
const payload = await githubRequest(token, `/search/issues?${parameters}`);
|
|
const sourcePulls = (payload.items ?? []).map(mapSearchPullRequest);
|
|
return {
|
|
query,
|
|
totalCount: Number(payload.total_count) || 0,
|
|
incomplete: payload.incomplete_results === true,
|
|
filters: normalizedFilters,
|
|
evaluatedCount: sourcePulls.length,
|
|
filtered: false,
|
|
pulls: sourcePulls,
|
|
};
|
|
}
|
|
|
|
export async function enrichPullRequests(token, repository, pulls, filters = {}) {
|
|
const normalizedFilters = normalizePullFilters(filters);
|
|
let enriched = pulls;
|
|
if (pulls.length) {
|
|
const signals = await pullRequestSignals(token, repository, pulls);
|
|
enriched = pulls.map((pull) => ({
|
|
...pull,
|
|
...signals.get(Number(pull.number)),
|
|
}));
|
|
}
|
|
if (normalizedFilters.artifacts !== "all") {
|
|
const artifactHeads = await artifactHeadsForPulls(
|
|
token,
|
|
repository,
|
|
enriched.map((pull) => pull.headSha),
|
|
);
|
|
enriched = enriched.map((pull) => ({
|
|
...pull,
|
|
hasArtifacts: Boolean(pull.headSha && artifactHeads.has(pull.headSha)),
|
|
}));
|
|
}
|
|
return enriched;
|
|
}
|
|
|
|
export function filterPullRequests(pulls, filters = {}) {
|
|
const normalizedFilters = normalizePullFilters(filters);
|
|
let filtered = pulls;
|
|
if (normalizedFilters.ci !== "all") {
|
|
filtered = filtered.filter((pull) => pull.ciStatus === normalizedFilters.ci);
|
|
}
|
|
if (normalizedFilters.artifacts !== "all") {
|
|
const expected = normalizedFilters.artifacts === "with";
|
|
filtered = filtered.filter((pull) => pull.hasArtifacts === expected);
|
|
}
|
|
return filtered;
|
|
}
|
|
|
|
export async function searchPullRequests(
|
|
token,
|
|
repository,
|
|
filter = "",
|
|
state = "open",
|
|
filters = {},
|
|
) {
|
|
const base = await searchPullRequestBase(
|
|
token,
|
|
repository,
|
|
filter,
|
|
state,
|
|
filters,
|
|
);
|
|
const enriched = await enrichPullRequests(
|
|
token,
|
|
repository,
|
|
base.pulls,
|
|
base.filters,
|
|
);
|
|
const derivedFilterActive =
|
|
base.filters.artifacts !== "all" || base.filters.ci !== "all";
|
|
return {
|
|
...base,
|
|
filtered: derivedFilterActive,
|
|
pulls: filterPullRequests(enriched, base.filters),
|
|
};
|
|
}
|
|
|
|
export async function getPullRequest(token, repository, pullNumber) {
|
|
const number = Number.parseInt(pullNumber, 10);
|
|
if (!Number.isSafeInteger(number) || number <= 0) {
|
|
throw new Error("Pull request number must be a positive integer.");
|
|
}
|
|
return mapPullRequest(
|
|
await githubRequest(token, `/repos/${repositoryPath(repository)}/pulls/${number}`),
|
|
);
|
|
}
|
|
|
|
async function mapLimit(items, limit, callback) {
|
|
const results = new Array(items.length);
|
|
let cursor = 0;
|
|
async function worker() {
|
|
while (cursor < items.length) {
|
|
const index = cursor++;
|
|
results[index] = await callback(items[index], index);
|
|
}
|
|
}
|
|
await Promise.all(
|
|
Array.from({ length: Math.min(limit, items.length) }, () => worker()),
|
|
);
|
|
return results;
|
|
}
|
|
|
|
async function listRunArtifacts(token, repository, runId) {
|
|
const artifacts = [];
|
|
for (let page = 1; page <= 10; page++) {
|
|
const payload = await githubRequest(
|
|
token,
|
|
`/repos/${repositoryPath(repository)}/actions/runs/${runId}/artifacts?per_page=100&page=${page}`,
|
|
);
|
|
const pageArtifacts = payload.artifacts ?? [];
|
|
artifacts.push(...pageArtifacts);
|
|
if (pageArtifacts.length < 100 || artifacts.length >= Number(payload.total_count ?? 0)) {
|
|
break;
|
|
}
|
|
}
|
|
return artifacts;
|
|
}
|
|
|
|
export async function listPullRequestArtifacts(token, repository, pullNumber) {
|
|
const pullRequest = await getPullRequest(token, repository, pullNumber);
|
|
if (!pullRequest.headSha) {
|
|
return {
|
|
pullRequest,
|
|
runs: [],
|
|
runCount: 0,
|
|
runsTruncated: false,
|
|
artifacts: [],
|
|
artifactCount: 0,
|
|
artifactsTruncated: false,
|
|
};
|
|
}
|
|
|
|
const query = new URLSearchParams({
|
|
head_sha: pullRequest.headSha,
|
|
per_page: String(MAX_PULL_REQUEST_RUNS),
|
|
});
|
|
const runsPayload = await githubRequest(
|
|
token,
|
|
`/repos/${repositoryPath(repository)}/actions/runs?${query}`,
|
|
);
|
|
const returnedRuns = (runsPayload.workflow_runs ?? [])
|
|
.sort((left, right) => new Date(right.created_at) - new Date(left.created_at));
|
|
const runCount = Math.max(
|
|
returnedRuns.length,
|
|
Number(runsPayload.total_count) || 0,
|
|
);
|
|
const runs = returnedRuns.slice(0, MAX_PULL_REQUEST_RUNS);
|
|
const runsTruncated = runs.length < runCount;
|
|
|
|
const artifactGroups = await mapLimit(runs, 5, async (run) => {
|
|
const runArtifacts = await listRunArtifacts(token, repository, run.id);
|
|
return runArtifacts.map((artifact) => ({
|
|
id: artifact.id,
|
|
name: artifact.name,
|
|
sizeInBytes: artifact.size_in_bytes,
|
|
expired: Boolean(artifact.expired),
|
|
createdAt: artifact.created_at,
|
|
updatedAt: artifact.updated_at,
|
|
expiresAt: artifact.expires_at,
|
|
run: {
|
|
id: run.id,
|
|
name: run.name,
|
|
number: run.run_number,
|
|
attempt: run.run_attempt,
|
|
event: run.event,
|
|
status: run.status,
|
|
conclusion: run.conclusion,
|
|
createdAt: run.created_at,
|
|
url: run.html_url,
|
|
},
|
|
}));
|
|
});
|
|
|
|
const allArtifacts = [...new Map(
|
|
artifactGroups.flat().map((artifact) => [String(artifact.id), artifact]),
|
|
).values()].sort((left, right) => new Date(right.createdAt) - new Date(left.createdAt));
|
|
const artifacts = allArtifacts.slice(0, MAX_PULL_REQUEST_ARTIFACTS);
|
|
|
|
return {
|
|
pullRequest,
|
|
runs: runs.map((run) => ({
|
|
id: run.id,
|
|
name: run.name,
|
|
number: run.run_number,
|
|
attempt: run.run_attempt,
|
|
event: run.event,
|
|
status: run.status,
|
|
conclusion: run.conclusion,
|
|
createdAt: run.created_at,
|
|
url: run.html_url,
|
|
})),
|
|
runCount,
|
|
runsTruncated,
|
|
artifacts,
|
|
artifactCount: allArtifacts.length,
|
|
artifactsTruncated:
|
|
runsTruncated || artifacts.length < allArtifacts.length,
|
|
};
|
|
}
|
|
|
|
export async function getArtifact(token, repository, artifactId) {
|
|
const id = Number.parseInt(artifactId, 10);
|
|
if (!Number.isSafeInteger(id) || id <= 0) {
|
|
throw new Error("Artifact id must be a positive integer.");
|
|
}
|
|
const artifact = await githubRequest(
|
|
token,
|
|
`/repos/${repositoryPath(repository)}/actions/artifacts/${id}`,
|
|
);
|
|
return {
|
|
id: artifact.id,
|
|
name: artifact.name,
|
|
sizeInBytes: artifact.size_in_bytes,
|
|
expired: Boolean(artifact.expired),
|
|
createdAt: artifact.created_at,
|
|
updatedAt: artifact.updated_at,
|
|
expiresAt: artifact.expires_at,
|
|
run: artifact.workflow_run
|
|
? {
|
|
id: artifact.workflow_run.id,
|
|
headBranch: artifact.workflow_run.head_branch,
|
|
headSha: artifact.workflow_run.head_sha,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
export async function openArtifactDownload(
|
|
token,
|
|
repository,
|
|
artifactId,
|
|
{ signal } = {},
|
|
) {
|
|
if (!token) throw new GitHubApiError("No GitHub account is selected.", 401);
|
|
const id = Number.parseInt(artifactId, 10);
|
|
const response = await fetch(
|
|
`${GITHUB_API}/repos/${repositoryPath(repository)}/actions/artifacts/${id}/zip`,
|
|
{
|
|
headers: headersFor(token),
|
|
redirect: "follow",
|
|
signal,
|
|
},
|
|
);
|
|
if (!response.ok) {
|
|
const payload = await readPayload(response);
|
|
const message =
|
|
payload && typeof payload === "object" && payload.message
|
|
? payload.message
|
|
: `Artifact download failed (${response.status}).`;
|
|
throw new GitHubApiError(message, response.status, payload);
|
|
}
|
|
if (!response.body) {
|
|
throw new GitHubApiError("Artifact download returned an empty response.", response.status);
|
|
}
|
|
return response;
|
|
}
|