src/github/client.ts
410 lines and 36 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| installKey | 29 to 29 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| tokenKey | 30 to 30 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| readKey | 31 to 31 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| readPrefix | 33 to 33 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| importPrivateKey | 43 to 59 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| <anonymous:49> | 49 to 49 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| createAppJwt | 61 to 72 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| appFetch | 74 to 81 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| getInstallationId | 84 to 101 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| getInstallationToken | 103 to 120 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| listInstalledRepos | 141 to 184 | 14 | 100% | WatchCRAP 14 | No data: mutation testing has not run on this repo yet |
| <anonymous:148> | 148 to 148 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| repoApiUrl | 199 to 207 | 3 | 78% | OkCRAP 3.1 | No data: mutation testing has not run on this repo yet |
| ghFetch | 209 to 223 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| call | 211 to 216 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| cachedGet | 227 to 240 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| invalidateRepoReads | 251 to 274 | 5 | 70% | WatchCRAP 5.7 | No data: mutation testing has not run on this repo yet |
| <anonymous:261> | 261 to 261 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| repoTokenOk | 284 to 288 | 5 | 100% | OkCRAP 5 | No data: mutation testing has not run on this repo yet |
| parseReposList | 299 to 317 | 11 | 100% | WatchCRAP 11 | No data: mutation testing has not run on this repo yet |
| requireSinglePrimary | 321 to 324 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| <anonymous:322> | 322 to 322 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| parsePrUrl | 342 to 347 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| resolveRepo | 355 to 391 | 14 | 100% | WatchCRAP 14 | No data: mutation testing has not run on this repo yet |
| <anonymous:377> | 377 to 377 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:377> | 377 to 377 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:379> | 379 to 379 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:380> | 380 to 380 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:386> | 386 to 386 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| encodePath | 397 to 408 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
| <anonymous:401> | 401 to 406 | 3 | 100% | OkCRAP 3 | No data: mutation testing has not run on this repo yet |
| assertRepoArg | 414 to 417 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| getDefaultBranch | 419 to 423 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| getRefSha | 425 to 429 | 2 | 100% | OkCRAP 2 | No data: mutation testing has not run on this repo yet |
| getFileSha | 431 to 438 | 4 | 100% | OkCRAP 4 | No data: mutation testing has not run on this repo yet |
| defaultBranchSha | 442 to 446 | 1 | 100% | OkCRAP 1 | No data: mutation testing has not run on this repo yet |
Imports
It imports src/encoding.ts, src/env.ts, src/limits.ts, src/log.ts. Imported by src/auto-merge-tick.ts, src/complete-ci.ts, src/deploy-outcomes.ts, src/fail-exemption.ts, src/github.ts, src/github/actions.ts, src/github/contents.ts, src/github/dispatch.ts, src/github/pr-files.ts, src/github/prune.ts, src/github/refs.ts, src/github/releases.ts, src/github/search.ts, src/job-fact-check.ts, src/job-outcomes.ts, src/job-overlaps.ts, src/jobs-holder.ts, src/jobs-seat.ts, src/live-checks.ts, src/maintenance-branches.ts, src/maintenance-deploys.ts, src/maintenance-prs.ts, src/maintenance-releases.ts, src/manual-steps.ts, src/merge-resume.ts, src/merge-safety.ts, src/model-drift.ts, src/model-routing-pr.ts, src/ops-packages.ts, src/outcome-prs.ts, src/overnight-plan.ts, src/pr-approvals.ts, src/public-code.ts, src/public-data.ts, src/review.ts, src/shadow.ts, src/shared-code.ts, src/stale-canon.ts, src/tools/repo-guards.ts, src/trends.ts, src/unmapped-repos.ts, src/whats-next.ts.
Source
// GitHub App repo access for Capsid's repo fallthrough.
//
// Mints short-lived installation tokens from the App private key (RS256 JWT via Web
// Crypto) and caches them in APP_KV for ~55 minutes. Resolves the target repo per
// namespace from the D1 namespaces table, then reads and writes over the GitHub
// REST API. No PAT, no clone.
import { b64urlFromBytes, b64urlEncode } from "../encoding";
import { repoPathProblem } from "../limits";
// RepoEnv, not Env: this module must not be able to name the credentials it omits.
import type { RepoEnv as Env } from "../env";
import { logEvent } from "../log";
const GH = "https://api.github.com";
const GH_HEADERS: Record<string, string> = {
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": "capsid",
};
const TOKEN_TTL_SECONDS = 3300; // installation tokens live 60 min; refresh a little early
const INSTALL_TTL_SECONDS = 86400; // installation id is stable
const READ_CACHE_TTL_SECONDS = 60; // brief cache for read tools
// Every GitHub call is bounded, so a hung connection fails the call instead of holding
// the request (or a cron pass) until the runtime kills it. Long enough for a large
// contents PUT; a caller's own signal wins.
const GH_TIMEOUT_MS = 30_000;
const installKey = (owner: string) => `gh:install:v2:${owner}`;
const tokenKey = (owner: string, repo: string) => `gh:token:v3:${owner}/${repo}`;
const readKey = (path: string) => `gh:get:${path}`;
// The trailing slash matters: without it, owner/r would also match owner/repo2.
const readPrefix = (owner: string, repo: string) => `gh:get:/repos/${owner}/${repo}/`;
export interface RepoRef {
owner: string;
repo: string;
full: string;
}
let cachedKey: { pem: string; key: CryptoKey } | null = null;
async function importPrivateKey(pem: string): Promise<CryptoKey> {
if (cachedKey && cachedKey.pem === pem) return cachedKey.key;
const body = pem
.replace(/-----BEGIN [^-]+-----/, "")
.replace(/-----END [^-]+-----/, "")
.replace(/\s+/g, "");
const der = Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
const key = await crypto.subtle.importKey(
"pkcs8",
der,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"]
);
cachedKey = { pem, key };
return key;
}
async function createAppJwt(env: Env): Promise<string> {
if (!env.GITHUB_APP_CLIENT_ID || !env.GITHUB_APP_PRIVATE_KEY) {
throw new Error("GitHub App not configured: set GITHUB_APP_CLIENT_ID and GITHUB_APP_PRIVATE_KEY");
}
const now = Math.floor(Date.now() / 1000);
const header = b64urlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
const payload = b64urlEncode(JSON.stringify({ iss: env.GITHUB_APP_CLIENT_ID, iat: now - 60, exp: now + 540 }));
const signingInput = `${header}.${payload}`;
const key = await importPrivateKey(env.GITHUB_APP_PRIVATE_KEY);
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
return `${signingInput}.${b64urlFromBytes(new Uint8Array(signature))}`;
}
async function appFetch(env: Env, path: string, init?: RequestInit): Promise<Response> {
const jwt = await createAppJwt(env);
return fetch(`${GH}${path}`, {
...init,
headers: { ...GH_HEADERS, ...(init?.headers as Record<string, string>), Authorization: `Bearer ${jwt}` },
signal: init?.signal ?? AbortSignal.timeout(GH_TIMEOUT_MS),
});
}
async function getInstallationId(env: Env, owner: string, repo: string): Promise<string> {
const cached = await env.APP_KV.get(installKey(owner));
if (cached) return cached;
const resp = await appFetch(env, `/repos/${owner}/${repo}/installation`);
if (!resp.ok) {
// A bad JWT answers 401; a valid one with no installation covering the repo
// answers 404. So a 404 means the credentials are fine and the App is not
// installed on that repo.
throw new Error(
`could not resolve GitHub App installation for ${owner}/${repo} (${resp.status}): ${await resp.text()}` +
(resp.status === 404 ? " (404 means the App is not installed on this repo; the credentials are fine)" : "")
);
}
const data = (await resp.json()) as { id: number };
const id = String(data.id);
await env.APP_KV.put(installKey(owner), id, { expirationTtl: INSTALL_TTL_SECONDS });
return id;
}
async function getInstallationToken(env: Env, owner: string, repo: string): Promise<string> {
const cacheKey = tokenKey(owner, repo);
const cached = await env.APP_KV.get(cacheKey);
if (cached) return cached;
const installationId = await getInstallationId(env, owner, repo);
// Scoped to the one repo asked about. An access_tokens POST with no body mints a
// token for every repo the installation covers, and that token then sits in APP_KV.
// With `repositories` it holds exactly the repo the caller resolved.
const resp = await appFetch(env, `/app/installations/${installationId}/access_tokens`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ repositories: [repo] }),
});
if (!resp.ok) throw new Error(`installation token request failed (${resp.status}): ${await resp.text()}`);
const data = (await resp.json()) as { token: string };
await env.APP_KV.put(cacheKey, data.token, { expirationTtl: TOKEN_TTL_SECONDS });
return data.token;
}
// What the App can see, for the watcher's unmapped-repo check (src/unmapped-repos.ts).
// Listing /installation/repositories needs an installation token covering every repo,
// which getInstallationToken deliberately never mints. This one is minted for the call,
// limited to the metadata:read permission (names, visibility, archived and push date, and
// nothing else: no contents, no writes), held in a local variable and never stored in
// KV or logged. It is the only unscoped token in the Worker.
export interface InstalledRepo {
full_name: string;
private: boolean;
archived: boolean;
pushed_at: string | null;
}
const LIST_PER_PAGE = 100;
const LIST_MAX_PAGES = 10;
/** Every repo every installation of the App can see. Throws on any failed or partial
* read: a list read in part would read as "everything else is unmapped" or, worse,
* "nothing is". */
export async function listInstalledRepos(env: Env): Promise<{ repos: InstalledRepo[]; installations: number }> {
const installations: number[] = [];
for (let page = 1; ; page++) {
if (page > LIST_MAX_PAGES) throw new Error(`the App has more than ${LIST_MAX_PAGES * LIST_PER_PAGE} installations`);
const resp = await appFetch(env, `/app/installations?per_page=${LIST_PER_PAGE}&page=${page}`);
if (!resp.ok) throw new Error(`could not list the App's installations (${resp.status})`);
const rows = (await resp.json()) as Array<{ id: number }>;
installations.push(...rows.map((r) => r.id));
if (rows.length < LIST_PER_PAGE) break;
}
const repos: InstalledRepo[] = [];
for (const id of installations) {
const minted = await appFetch(env, `/app/installations/${id}/access_tokens`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ permissions: { metadata: "read" } }),
});
if (!minted.ok) throw new Error(`could not mint a metadata-only token for installation ${id} (${minted.status})`);
const { token } = (await minted.json()) as { token: string };
let total = Infinity;
const seen: InstalledRepo[] = [];
for (let page = 1; seen.length < total; page++) {
if (page > LIST_MAX_PAGES) throw new Error(`installation ${id} lists more than ${LIST_MAX_PAGES * LIST_PER_PAGE} repos`);
const resp = await fetch(`${GH}/installation/repositories?per_page=${LIST_PER_PAGE}&page=${page}`, {
headers: { ...GH_HEADERS, Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(GH_TIMEOUT_MS),
});
if (!resp.ok) throw new Error(`could not list the repos of installation ${id} (${resp.status})`);
const body = (await resp.json()) as {
total_count: number;
repositories: Array<{ full_name: string; private: boolean; archived?: boolean; pushed_at?: string | null }>;
};
total = body.total_count;
if (body.repositories.length === 0) break;
for (const r of body.repositories) {
seen.push({ full_name: r.full_name, private: r.private, archived: r.archived === true, pushed_at: r.pushed_at ?? null });
}
}
if (seen.length < total) throw new Error(`installation ${id} listed ${seen.length} of ${total} repos`);
repos.push(...seen);
}
return { repos, installations: installations.length };
}
// One installation covers every repo under a single owner, but each minted token is
// scoped to one repo, so the token cache is per owner+repo to match.
// The URL built for a repo call cannot escape /repos/<owner>/<repo>/. A "../.."
// smuggled through a file path or branch would normalize out of it once fetch()
// parsed the string as a URL. This check runs after assembly and after WHATWG
// normalization, so it sees the escape the input-level repoPathProblem guard is also
// there to stop. owner and repo come from the namespace mapping (REPO_SHAPE excludes
// "." and ".."), so the expected base is itself traversal-free.
//
// It builds the same URL new URL() will, which is why a fake fetch keyed on the raw
// string cannot substitute for this guard: the raw string still contains the "..",
// the parsed pathname does not.
function repoApiUrl(owner: string, repo: string, path: string): string {
const url = `${GH}${path}`;
const base = `/repos/${owner}/${repo}`;
const { pathname } = new URL(url);
if (pathname !== base && !pathname.startsWith(`${base}/`)) {
throw new Error(`refusing GitHub request that escapes ${base}: it resolves to ${pathname}`);
}
return url;
}
export async function ghFetch(env: Env, owner: string, repo: string, path: string, init?: RequestInit): Promise<Response> {
const url = repoApiUrl(owner, repo, path);
const call = (token: string) =>
fetch(url, {
...init,
headers: { ...GH_HEADERS, ...(init?.headers as Record<string, string>), Authorization: `Bearer ${token}` },
signal: init?.signal ?? AbortSignal.timeout(GH_TIMEOUT_MS),
});
let resp = await call(await getInstallationToken(env, owner, repo));
if (resp.status === 401) {
await env.APP_KV.delete(tokenKey(owner, repo));
resp = await call(await getInstallationToken(env, owner, repo));
}
return resp;
}
// The cache key is the full API path, including ?ref=, so different refs are
// different keys.
export async function cachedGet(env: Env, owner: string, repo: string, path: string): Promise<Response> {
// Assert the URL is in-bounds before it is ever used as a cache key, so a
// traversal path cannot be stored under a key the invalidator will not find.
repoApiUrl(owner, repo, path);
const cacheKey = readKey(path);
const cached = (await env.APP_KV.get(cacheKey, "json")) as { status: number; body: string } | null;
if (cached) return new Response(cached.body, { status: cached.status });
const resp = await ghFetch(env, owner, repo, path);
const body = await resp.text();
if (resp.ok) {
await env.APP_KV.put(cacheKey, JSON.stringify({ status: resp.status, body }), { expirationTtl: READ_CACHE_TTL_SECONDS });
}
return new Response(body, { status: resp.status });
}
// Invalidation after a write. Without it, for up to READ_CACHE_TTL_SECONDS after a
// commit a read returns the body the write replaced, and Capsid writes and then
// reads back as its ordinary path.
//
// Swept by repo prefix rather than by computed key. A key-precise invalidation has to
// reproduce the exact spelling of every affected entry (the encoded path, the parent
// listing, the root listing's trailing slash, each with and without ?ref=), and one
// missed spelling is a stale read. The prefix sweep also covers a merge, whose
// affected paths are not known here. It over-invalidates by one GitHub GET.
export async function invalidateRepoReads(env: Env, owner: string, repo: string): Promise<number> {
const prefix = readPrefix(owner, repo);
let cursor: string | undefined;
let deleted = 0;
try {
do {
const page = await env.APP_KV.list({ prefix, cursor });
// Deletes within a page run in parallel, since they are independent and this
// runs on the write path where a caller is waiting. Pages stay sequential
// because the next cursor is only known once the current page returns.
await Promise.all(page.keys.map((key) => env.APP_KV.delete(key.name)));
deleted += page.keys.length;
cursor = page.list_complete ? undefined : page.cursor;
} while (cursor);
} catch (err) {
// The commit already landed. Failing the tool call because a cache sweep failed
// would misreport the write, so this is logged by name and the stale window
// stays bounded by the 60 second TTL.
logEvent("error", "GH_CACHE_INVALIDATION_FAILED", {
message: `GH_CACHE_INVALIDATION_FAILED ${owner}/${repo}: ${err instanceof Error ? err.message : String(err)}`,
});
}
return deleted;
}
// The shape of a single repos entry: "owner/name". A looser shape would admit
// "../other" as a legal mapping and make the authorization boundary traversable.
// GitHub owner and repo names are drawn from [A-Za-z0-9._-]; the charset still
// matches "..", so repoTokenOk below also rejects "." and "..".
export const REPO_SHAPE = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
// Neither side of an owner/name may be "." or "..": those walk a URL out of
// /repos/<owner>/<repo>/ even though they satisfy the charset.
export function repoTokenOk(repoFull: string): boolean {
if (!REPO_SHAPE.test(repoFull)) return false;
const [owner, name] = repoFull.split("/");
return owner !== "." && owner !== ".." && name !== "." && name !== "..";
}
export interface RepoEntry {
repo: string;
label: string;
}
// Parse and validate a repos JSON array, shared by register_namespace and
// update_namespace: a non-empty array of { repo: "owner/name", label? } with label
// defaulting to "primary". Returns the normalized list or a caller-facing error.
// It does NOT enforce a single primary; update_namespace layers that on top.
export function parseReposList(reposJson: string): { list: RepoEntry[] } | { error: string } {
let parsed: unknown;
try {
parsed = JSON.parse(reposJson);
} catch (err) {
return { error: `invalid repos JSON: ${err instanceof Error ? err.message : String(err)}` };
}
if (!Array.isArray(parsed) || parsed.length === 0) {
return { error: "repos must be a non-empty JSON array of { repo, label } entries" };
}
const list: RepoEntry[] = [];
for (const r of parsed) {
if (!r || typeof r.repo !== "string" || !repoTokenOk(r.repo)) {
return { error: `each repos entry needs a "repo" of the form owner/name (got ${JSON.stringify(r)})` };
}
list.push({ repo: r.repo, label: typeof r.label === "string" && r.label.trim() ? r.label.trim() : "primary" });
}
return { list };
}
// update_namespace requires exactly one primary (register_namespace does not, so
// this is not folded into parseReposList). Returns an error string or null.
export function requireSinglePrimary(list: RepoEntry[]): string | null {
const primaries = list.filter((r) => r.label === "primary").length;
return primaries === 1 ? null : `repos must have exactly one entry labeled "primary" (found ${primaries})`;
}
// https://github.com/<owner>/<repo>/pull/<number>, as capsid_open_pr action open returns it and a
// driver pastes it. The one spelling for every site that reads one (the review gate,
// the evidence verifier and the prose scan in outcome-prs.ts): separate spellings
// with different character classes let the gate accept owner/repo strings the
// verifier refused. The owner and repo are what the URL says, not what the namespace
// maps: a caller resolves them through resolveRepo before reaching GitHub.
export const PR_URL_SOURCE = String.raw`https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/pull\/(\d+)`;
const PR_URL_EXACT = new RegExp(`^${PR_URL_SOURCE}(?:[/?#].*)?$`);
export interface PrUrl {
owner: string;
repo: string;
number: number;
}
// Anchored at both ends: a reference that merely mentions a pull request is not one.
export function parsePrUrl(ref: string | null | undefined): PrUrl | null {
if (!ref) return null;
const match = PR_URL_EXACT.exec(ref.trim());
if (!match) return null;
return { owner: match[1], repo: match[2], number: Number(match[3]) };
}
// Resolve a namespace to one of its mapped repos. `selector` is the optional `repo`
// tool argument: a label from the namespace's repos array ("primary", "legacy") or a
// full "owner/name" that MUST appear in that array. The namespace mapping is the
// authorization boundary, so an unknown selector is rejected with the valid values
// rather than falling through to an arbitrary repo. With no selector the default is
// the entry labeled "primary", or the first entry.
export async function resolveRepo(env: Env, namespace: string, selector?: string): Promise<RepoRef> {
const row = await env.DB.prepare("SELECT repos FROM namespaces WHERE namespace = ?1")
.bind(namespace)
.first<{ repos: string }>();
if (!row) throw new Error(`unknown namespace: ${namespace}`);
// Fails closed: a corrupt repos column is reported as corrupt, not as "has no repo
// mapping". That is a different fact with a different fix, and reading the damage
// as an unconfigured namespace invites "fixing" it by overwriting the mapping.
let list: Array<{ repo: string; label?: string }>;
try {
list = JSON.parse(row.repos || "[]");
} catch (err) {
throw new Error(
`namespace ${namespace} has a CORRUPT repos mapping: the stored value is not valid JSON (${err instanceof Error ? err.message : String(err)}). Nothing was resolved. Repair it with capsid_admin_manage_namespaces action update.`
);
}
if (!Array.isArray(list)) {
throw new Error(`namespace ${namespace} has a CORRUPT repos mapping: expected a JSON array, got ${typeof list}.`);
}
if (list.length === 0) throw new Error(`namespace ${namespace} has no repo mapping`);
let chosen: { repo: string; label?: string } | undefined;
if (selector) {
chosen = list.find((r) => r.label === selector) ?? list.find((r) => r.repo === selector);
if (!chosen) {
const labels = list.map((r) => r.label).filter(Boolean).join(", ") || "(none)";
const repos = list.map((r) => r.repo).join(", ");
throw new Error(
`repo '${selector}' is not mapped to namespace ${namespace}. Valid labels: ${labels}. Valid repos: ${repos}.`
);
}
} else {
chosen = list.find((r) => r.label === "primary") ?? list[0];
}
const [owner, repo] = chosen.repo.split("/");
if (!owner || !repo) throw new Error(`invalid repo entry for ${namespace}: ${chosen.repo}`);
return { owner, repo, full: chosen.repo };
}
// encodePath keeps the "/" between segments, so it cannot stop traversal on its own.
// It refuses a "." or ".." segment as an inner guard, and repoApiUrl asserts the
// assembled URL as the outer one. Both are kept: this gives a clear caller-facing
// error, that catches anything this misses. Exported for the traversal test.
export function encodePath(path: string): string {
return path
.split("/")
.filter(Boolean)
.map((segment) => {
if (segment === "." || segment === "..") {
throw new Error(`refusing repo path with a '${segment}' segment: ${path}`);
}
return encodeURIComponent(segment);
})
.join("/");
}
// Refuse a caller-supplied repo argument (path, branch, ref or workflow) at the
// door, naming the argument. The URL assertion in repoApiUrl is the backstop; this
// is the friendly error, and the guard for arguments that reach GitHub through
// encodeURIComponent (refs, workflow names) rather than encodePath.
export function assertRepoArg(kind: string, value: string): void {
const problem = repoPathProblem(value);
if (problem) throw new Error(`${kind} ${problem}`);
}
export async function getDefaultBranch(env: Env, owner: string, repo: string): Promise<string> {
const resp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}`);
if (!resp.ok) throw new Error(`repo lookup failed (${resp.status}): ${await resp.text()}`);
return ((await resp.json()) as { default_branch: string }).default_branch;
}
export async function getRefSha(env: Env, owner: string, repo: string, branch: string): Promise<string> {
const resp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/git/ref/heads/${encodeURIComponent(branch)}`);
if (!resp.ok) throw new Error(`ref lookup failed for ${branch} (${resp.status}): ${await resp.text()}`);
return ((await resp.json()) as { object: { sha: string } }).object.sha;
}
export async function getFileSha(env: Env, owner: string, repo: string, path: string, ref: string): Promise<string | undefined> {
const resp = await ghFetch(env, owner, repo, `/repos/${owner}/${repo}/contents/${encodePath(path)}?ref=${encodeURIComponent(ref)}`);
if (resp.status === 404) return undefined;
if (!resp.ok) throw new Error(`file sha lookup failed (${resp.status}): ${await resp.text()}`);
const data = (await resp.json()) as { sha: string } | unknown[];
if (Array.isArray(data)) throw new Error(`${path} is a directory`);
return data.sha;
}
// The default branch's head commit sha. listRepoTree has no top-level sha (its shas
// are per-entry blob shas), so it cannot answer this.
export async function defaultBranchSha(env: Env, namespace: string, repoSelector?: string): Promise<string> {
const { owner, repo } = await resolveRepo(env, namespace, repoSelector);
const branch = await getDefaultBranch(env, owner, repo);
return getRefSha(env, owner, repo, branch);
}