Skip to the content

Capsid menu

src/github/client.ts

410 lines and 36 functions in DrDustinEdwards/capsid, at commit daa875b on 2026-10-24. This file on GitHub.

Functions

The functions of client.ts
FunctionLinesComplexityCoveredCRAPMutation
installKey29 to 291100%OkCRAP 1No data: mutation testing has not run on this repo yet
tokenKey30 to 301100%OkCRAP 1No data: mutation testing has not run on this repo yet
readKey31 to 311100%OkCRAP 1No data: mutation testing has not run on this repo yet
readPrefix33 to 331100%OkCRAP 1No data: mutation testing has not run on this repo yet
importPrivateKey43 to 593100%OkCRAP 3No data: mutation testing has not run on this repo yet
<anonymous:49>49 to 491100%OkCRAP 1No data: mutation testing has not run on this repo yet
createAppJwt61 to 723100%OkCRAP 3No data: mutation testing has not run on this repo yet
appFetch74 to 812100%OkCRAP 2No data: mutation testing has not run on this repo yet
getInstallationId84 to 1014100%OkCRAP 4No data: mutation testing has not run on this repo yet
getInstallationToken103 to 1203100%OkCRAP 3No data: mutation testing has not run on this repo yet
listInstalledRepos141 to 18414100%WatchCRAP 14No data: mutation testing has not run on this repo yet
<anonymous:148>148 to 1481100%OkCRAP 1No data: mutation testing has not run on this repo yet
repoApiUrl199 to 207378%OkCRAP 3.1No data: mutation testing has not run on this repo yet
ghFetch209 to 2232100%OkCRAP 2No data: mutation testing has not run on this repo yet
call211 to 2162100%OkCRAP 2No data: mutation testing has not run on this repo yet
cachedGet227 to 2403100%OkCRAP 3No data: mutation testing has not run on this repo yet
invalidateRepoReads251 to 274570%WatchCRAP 5.7No data: mutation testing has not run on this repo yet
<anonymous:261>261 to 2611100%OkCRAP 1No data: mutation testing has not run on this repo yet
repoTokenOk284 to 2885100%OkCRAP 5No data: mutation testing has not run on this repo yet
parseReposList299 to 31711100%WatchCRAP 11No data: mutation testing has not run on this repo yet
requireSinglePrimary321 to 3242100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:322>322 to 3221100%OkCRAP 1No data: mutation testing has not run on this repo yet
parsePrUrl342 to 3473100%OkCRAP 3No data: mutation testing has not run on this repo yet
resolveRepo355 to 39114100%WatchCRAP 14No data: mutation testing has not run on this repo yet
<anonymous:377>377 to 3771100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:377>377 to 3771100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:379>379 to 3791100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:380>380 to 3801100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:386>386 to 3861100%OkCRAP 1No data: mutation testing has not run on this repo yet
encodePath397 to 4081100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:401>401 to 4063100%OkCRAP 3No data: mutation testing has not run on this repo yet
assertRepoArg414 to 4172100%OkCRAP 2No data: mutation testing has not run on this repo yet
getDefaultBranch419 to 4232100%OkCRAP 2No data: mutation testing has not run on this repo yet
getRefSha425 to 4292100%OkCRAP 2No data: mutation testing has not run on this repo yet
getFileSha431 to 4384100%OkCRAP 4No data: mutation testing has not run on this repo yet
defaultBranchSha442 to 4461100%OkCRAP 1No 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);
}