Skip to the content

Capsid menu

app/lib/editor/github.server.ts

310 lines and 22 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of github.server.ts
FunctionLinesComplexityCoveredCRAPMutation
constructor16 to 211No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
token27 to 362No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
gh38 to 715No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getHead73 to 831No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readFile86 to 1076No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listDirectory110 to 1283No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
publicFileEntry135 to 1465No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:140>140 to 1402No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listPostFiles149 to 1541No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:152>152 to 1522No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:153>153 to 1531No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
readBinaryFile157 to 1725No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
blobGuard182 to 1842No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
commitFiles190 to 27910No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:230>230 to 2432No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
listCommitsForPath288 to 3101No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:304>304 to 3092No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
fileRevisions315 to 3243No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:318>318 to 3231No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
fileRevisionSource327 to 3367No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
getCommitPatch338 to 3453No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code
<anonymous:343>343 to 3431No data: no coverage report in this mapNo data: no coverage report in this mapNo data: no mutants in this code

Imports

It imports app/lib/bytes.mjs, app/lib/editor/contents-cap.mjs. Imported by app/kb/editor.server.ts, app/kb/procedures/primer-dependents.server.ts, app/kb/procedures/save.server.ts, app/kb/registry/host.server.ts, app/kb/registry/save.server.ts, app/lib/admin/posts-actions.server.ts, app/lib/carrel/cv-handler.server.ts, app/lib/carrel/dictionary-handler.server.ts, app/lib/carrel/document-handler.server.ts, app/lib/carrel/errors.server.ts, app/lib/carrel/page-handler.server.ts, app/lib/carrel/phage-handler.server.ts, app/lib/carrel/procedure-handler.server.ts, app/lib/carrel/publication-handler.server.ts, app/lib/carrel/registry-handler.server.ts, app/lib/carrel/roster-handler.server.ts, app/lib/carrel/site-adapter.server.ts, app/lib/cv/save.server.ts, app/lib/dictionary/save.server.ts, app/lib/editor/preview-link-action.server.ts, app/lib/editor/publish.server.ts, app/lib/editor/write-path.server.ts, app/lib/health/checks.server.ts, app/lib/llms/save.server.ts, app/lib/operator/api.server.ts, app/lib/operator/sync-tools.server.ts, app/lib/pages/save.server.ts, app/lib/phages/save.server.ts, app/lib/publications/save.server.ts, app/lib/roster/save.server.ts, app/routes/admin.posts.$slug.edit.tsx, app/routes/admin.posts.$slug.history.tsx, app/routes/admin.posts.$slug.revisions.tsx.

Source

// The Git Data API commit shape stays because it carries the expectedHeadSha conflict guard, which
// the Contents API write path has no seam for.

import { base64ToBytes } from "../bytes.mjs";
import { contentsCapMessage } from "./contents-cap.mjs";

const API = "https://api.github.com";
const OWNER = "DrDustinEdwards";
const REPO = "dustinedwards-info";
const BRANCH = "main";

export class GitHubError extends Error {
  status: number;
  conflict: boolean;

  constructor(message: string, status: number, conflict = false) {
    super(message);
    this.name = "GitHubError";
    this.status = status;
    this.conflict = conflict;
  }
}

/** GITHUB_TOKEN is a secret, which wrangler types does not know about. */
type GhEnv = Env & { GITHUB_TOKEN?: string };

function token(env: GhEnv) {
  const value = env.GITHUB_TOKEN;
  if (!value) {
    throw new GitHubError(
      "GITHUB_TOKEN is not configured on this Worker, so the editor cannot commit.",
      500,
    );
  }
  return value;
}

async function gh<T>(
  env: GhEnv,
  path: string,
  init: RequestInit & { method?: string } = {},
): Promise<T> {
  const response = await fetch(`${API}${path}`, {
    ...init,
    headers: {
      accept: "application/vnd.github+json",
      authorization: `Bearer ${token(env)}`,
      "content-type": "application/json",
      // GitHub rejects requests without one.
      "user-agent": "dustinedwards-info-editor",
      "x-github-api-version": "2022-11-28",
      ...init.headers,
    },
  });

  if (!response.ok) {
    let detail = "";
    try {
      const body = (await response.json()) as { message?: string };
      detail = body.message ? `: ${body.message}` : "";
    } catch {
      detail = "";
    }
    throw new GitHubError(
      `GitHub ${init.method ?? "GET"} ${path} failed with ${response.status}${detail}`,
      response.status,
    );
  }

  return (await response.json()) as T;
}

export async function getHead(env: GhEnv) {
  const ref = await gh<{ object: { sha: string } }>(
    env,
    `/repos/${OWNER}/${REPO}/git/ref/heads/${BRANCH}`,
  );
  const commit = await gh<{ tree: { sha: string } }>(
    env,
    `/repos/${OWNER}/${REPO}/git/commits/${ref.object.sha}`,
  );
  return { commitSha: ref.object.sha, treeSha: commit.tree.sha };
}

/** Null on 404, so a caller can tell "absent" (create a post) from "failed" (an outage). */
export async function readFile(env: GhEnv, path: string, ref = BRANCH) {
  try {
    const file = await gh<{
      content: string;
      encoding: string;
      sha: string;
      size: number;
    }>(env, `/repos/${OWNER}/${REPO}/contents/${encodeURI(path)}?ref=${ref}`);
    const capped = contentsCapMessage(file, path);
    if (capped) {
      throw new GitHubError(capped, 422);
    }
    const content =
      file.encoding === "base64"
        ? new TextDecoder().decode(base64ToBytes(file.content))
        : file.content;
    return { content, sha: file.sha };
  } catch (error) {
    if (error instanceof GitHubError && error.status === 404) return null;
    throw error;
  }
}

/** Not recursive: content/posts/ is flat. The directory form caps at 1000 entries, hence the guard. */
export async function listDirectory(env: GhEnv, path: string, ref = BRANCH) {
  const entries = await gh<
    Array<{ name: string; path: string; sha: string; type: string; size: number }>
  >(env, `/repos/${OWNER}/${REPO}/contents/${encodeURI(path)}?ref=${ref}`);

  if (!Array.isArray(entries)) {
    // A file path answers with an object. Refuse rather than iterate its keys.
    throw new GitHubError(`"${path}" is not a directory at ${ref}.`, 422);
  }
  if (entries.length >= 1000) {
    throw new GitHubError(
      `"${path}" returned ${entries.length} entries, at the Contents API's ` +
        `1000-entry directory cap; the listing may be truncated. Move the ` +
        `rebuild to the Trees API before trusting it again.`,
      422,
    );
  }
  return entries;
}

/**
 * A static asset's directory entry, by its site path, or null when the repository has none: existence and
 * size, never the bytes, which can pass the Contents API's 1 MB cap. The PDFs and the roster photographs
 * are checked through it.
 */
export async function publicFileEntry(env: GhEnv, sitePath: string) {
  const at = sitePath.lastIndexOf("/");
  const dir = `public${sitePath.slice(0, at)}`;
  const name = sitePath.slice(at + 1);
  try {
    const entry = (await listDirectory(env, dir)).find((e) => e.type === "file" && e.name === name);
    return entry ? { size: entry.size } : null;
  } catch (error) {
    if (error instanceof GitHubError && error.status === 404) return null;
    throw error;
  }
}

/** The repository's posts: each `content/posts/*.md` file, with the slug its name carries. */
export async function listPostFiles(env: GhEnv) {
  const entries = await listDirectory(env, "content/posts");
  return entries
    .filter((e) => e.type === "file" && e.name.endsWith(".md"))
    .map((e) => ({ ...e, slug: e.name.slice(0, -".md".length) }));
}

/** Raw bytes: readFile's TextDecoder replaces invalid UTF-8, so an image would come back a different length. */
export async function readBinaryFile(env: GhEnv, path: string, ref = BRANCH) {
  try {
    const file = await gh<{ content: string; encoding: string; size: number }>(
      env,
      `/repos/${OWNER}/${REPO}/contents/${encodeURI(path)}?ref=${ref}`,
    );
    const capped = contentsCapMessage(file, path);
    if (capped) {
      throw new GitHubError(capped, 422);
    }
    return base64ToBytes(file.content);
  } catch (error) {
    if (error instanceof GitHubError && error.status === 404) return null;
    throw error;
  }
}

type FileChange =
  | { path: string; content: string }
  | { path: string; content: null };

/**
 * `commitFiles`' expectedBlobs for one file, or undefined when the caller named no version. Carrel's version of
 * a file-backed item is its blob sha, so a save is refused only when THAT file changed.
 */
export function blobGuard(path: string, expectedBlobSha: string | null | undefined) {
  return expectedBlobSha === undefined ? undefined : { [path]: expectedBlobSha };
}

/**
 * expectedHeadSha is the conflict gate: if main moved since the editor loaded, the save is refused,
 * checked up front so it fails with a clean message before any blob exists.
 */
export async function commitFiles(
  env: GhEnv,
  options: {
    changes: FileChange[];
    message: string;
    expectedHeadSha?: string | null;
    /**
     * Per path, the blob sha the caller loaded (null: the path must not exist). A path that holds anything
     * else at the head this commit is built on is refused, so a writer is stopped by a change to ITS file
     * and not by any other commit. Checked against the same head the commit is parented on, and the ref
     * update below is never forced, so a change that lands in between still cannot be overwritten.
     */
    expectedBlobs?: Record<string, string | null>;
  },
) {
  const head = await getHead(env);

  if (options.expectedHeadSha && options.expectedHeadSha !== head.commitSha) {
    throw new GitHubError(
      `The repository changed since this post was opened (main is now ${head.commitSha.slice(0, 7)}, ` +
        `you loaded ${options.expectedHeadSha.slice(0, 7)}). Reload and reapply your edit.`,
      409,
      true,
    );
  }

  for (const [path, expected] of Object.entries(options.expectedBlobs ?? {})) {
    const current = (await readFile(env, path, head.commitSha))?.sha ?? null;
    if (current !== expected) {
      throw new GitHubError(
        `${path} changed since it was opened (it is now ${current ? current.slice(0, 7) : "absent"}, ` +
          `you loaded ${expected ? expected.slice(0, 7) : "none"}). Reload and reapply your edit.`,
        409,
        true,
      );
    }
  }

  // A tree entry with sha: null deletes the path.
  const tree = await Promise.all(
    options.changes.map(async (change) => {
      if (change.content === null) {
        return { path: change.path, mode: "100644", type: "blob", sha: null };
      }
      const blob = await gh<{ sha: string }>(
        env,
        `/repos/${OWNER}/${REPO}/git/blobs`,
        {
          method: "POST",
          body: JSON.stringify({ content: change.content, encoding: "utf-8" }),
        },
      );
      return { path: change.path, mode: "100644", type: "blob", sha: blob.sha };
    }),
  );

  const newTree = await gh<{ sha: string }>(
    env,
    `/repos/${OWNER}/${REPO}/git/trees`,
    {
      method: "POST",
      body: JSON.stringify({ base_tree: head.treeSha, tree }),
    },
  );

  const commit = await gh<{ sha: string }>(
    env,
    `/repos/${OWNER}/${REPO}/git/commits`,
    {
      method: "POST",
      body: JSON.stringify({
        message: options.message,
        tree: newTree.sha,
        parents: [head.commitSha],
      }),
    },
  );

  await gh(env, `/repos/${OWNER}/${REPO}/git/refs/heads/${BRANCH}`, {
    method: "PATCH",
    // Never force: a rejection means main moved since the check, and losing that race must not cost a commit.
    body: JSON.stringify({ sha: commit.sha, force: false }),
  });

  // Content-addressed, so each equals gitBlobSha() of the same bytes; renderAndWrite relies on that.
  const blobShas: Record<string, string | null> = {};
  for (const entry of tree) blobShas[entry.path] = entry.sha;

  return { commitSha: commit.sha, blobShas };
}

type PostCommit = {
  sha: string;
  message: string;
  author: string;
  date: string;
};

export async function listCommitsForPath(
  env: GhEnv,
  path: string,
  limit = 20,
): Promise<PostCommit[]> {
  const commits = await gh<
    Array<{
      sha: string;
      commit: { message: string; author: { name: string; date: string } };
    }>
  >(
    env,
    `/repos/${OWNER}/${REPO}/commits?path=${encodeURIComponent(path)}` +
      `&sha=${BRANCH}&per_page=${limit}`,
  );

  return commits.map((entry) => ({
    sha: entry.sha,
    message: entry.commit.message.split("\n")[0] ?? "",
    author: entry.commit.author.name,
    date: entry.commit.author.date,
  }));
}

const COMMIT_SHA = /^[0-9a-f]{7,40}$/i;

/** Carrel's revision list for one file: null when the file (or the id's owner) is absent. */
export async function fileRevisions(env: GhEnv, path: string | null) {
  if (!path || !(await readFile(env, path))) return null;
  const commits = await listCommitsForPath(env, path, 50);
  return commits.map((c) => ({
    version: c.sha,
    at: new Date(c.date).toISOString(),
    author: c.author.slice(0, 200),
    message: c.message.slice(0, 2000),
  }));
}

/** One file as it stood at a commit, for Carrel: null for an absent path, a malformed version or an unknown ref. */
export async function fileRevisionSource(env: GhEnv, path: string | null, version: string) {
  if (!path || !COMMIT_SHA.test(version)) return null;
  try {
    return (await readFile(env, path, version))?.content ?? null;
  } catch (error) {
    // An unknown ref is a missing version, not a failure of the site.
    if (error instanceof GitHubError && error.status === 422) return null;
    throw error;
  }
}

export async function getCommitPatch(env: GhEnv, sha: string, path: string) {
  const commit = await gh<{
    files?: Array<{ filename: string; patch?: string; status: string }>;
  }>(env, `/repos/${OWNER}/${REPO}/commits/${sha}`);

  const file = commit.files?.find((entry) => entry.filename === path);
  return file ? { patch: file.patch ?? null, status: file.status } : null;
}