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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| constructor | 16 to 21 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| token | 27 to 36 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| gh | 38 to 71 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getHead | 73 to 83 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readFile | 86 to 107 | 6 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listDirectory | 110 to 128 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| publicFileEntry | 135 to 146 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:140> | 140 to 140 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listPostFiles | 149 to 154 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:152> | 152 to 152 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:153> | 153 to 153 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| readBinaryFile | 157 to 172 | 5 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| blobGuard | 182 to 184 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| commitFiles | 190 to 279 | 10 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:230> | 230 to 243 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| listCommitsForPath | 288 to 310 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:304> | 304 to 309 | 2 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| fileRevisions | 315 to 324 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:318> | 318 to 323 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| fileRevisionSource | 327 to 336 | 7 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| getCommitPatch | 338 to 345 | 3 | No data: no coverage report in this map | No data: no coverage report in this map | No data: no mutants in this code |
| <anonymous:343> | 343 to 343 | 1 | No data: no coverage report in this map | No data: no coverage report in this map | No 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;
}