From a6efd43fee330f20df8de0a52b75e13bb01fde6f Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 00:05:28 +0000 Subject: [PATCH] Upload files to "src/mutator/branch" --- src/mutator/branch/resolver.ts | 31 ++++++++ src/mutator/branch/sources.ts | 127 +++++++++++++++++++++++++++++++++ src/mutator/branch/types.ts | 94 ++++++++++++++++++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/mutator/branch/resolver.ts create mode 100644 src/mutator/branch/sources.ts create mode 100644 src/mutator/branch/types.ts diff --git a/src/mutator/branch/resolver.ts b/src/mutator/branch/resolver.ts new file mode 100644 index 0000000..8dfc5db --- /dev/null +++ b/src/mutator/branch/resolver.ts @@ -0,0 +1,31 @@ +import type { RepoContext } from "./types"; + +/** + * Resolves the owner/repo from standard GitHub Actions environment variables. + * + * GitHub Actions automatically sets: + * - GITHUB_REPOSITORY e.g. "octocat/hello-world" + * - GITHUB_REPOSITORY_OWNER e.g. "octocat" + * + * @see https://docs.github.com/en/actions/learn-github-actions/variables + */ +export function resolveRepoFromEnv(): RepoContext { + const repository = process.env["GITHUB_REPOSITORY"]; + + if (!repository) { + throw new Error( + "GITHUB_REPOSITORY env var is not set. This must be run inside a GitHub Actions workflow, " + + "or you must set GITHUB_REPOSITORY=/ manually.", + ); + } + + const [owner, repo] = repository.split("/"); + + if (!owner || !repo) { + throw new Error( + `GITHUB_REPOSITORY is malformed: "${repository}". Expected "/".`, + ); + } + + return { owner, repo }; +} diff --git a/src/mutator/branch/sources.ts b/src/mutator/branch/sources.ts new file mode 100644 index 0000000..a36594d --- /dev/null +++ b/src/mutator/branch/sources.ts @@ -0,0 +1,127 @@ +import { readFile } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; + +import type { FileChange, FileSource } from "./types"; + +/** + * Mapping of repository-relative target paths to their content source. + * + * Keys are the destination paths to write inside the repository (e.g. + * "README.md", "config/license.txt"). Values describe where the content + * comes from — either inline or a local file path to read at runtime. + */ +export type FileSourceMap = Record; + +/** + * Resolves a {@link FileSourceMap} into concrete {@link FileChange}s by + * loading any disk-backed entries. + * + * Resolution rules: + * + * - If a value is a plain string, it is treated as inline UTF-8 content + * (shorthand for `{ content: }`). This preserves backwards + * compatibility with the original `Record` shape. + * - If a value is `{ content }`, it is used verbatim. + * - If a value is `{ sourcePath }`, the file at that path is read from + * the local filesystem. Relative paths are resolved against `baseDir` + * (default: `process.cwd()`). + * + * Encoding handling for disk-backed sources: + * + * - `"utf-8"` (default): file is read as text and used as-is. The + * downstream commit pipeline will base64-encode it before sending to + * the GitHub GraphQL API. + * - `"base64"`: file is read as raw bytes and base64-encoded. Useful + * when you want to ship the file through unchanged but the calling + * code expects a string. Note that the commit pipeline will then + * base64-encode the *already-base64* string a second time, which is + * almost never what you want — prefer `"binary"` instead. + * - `"binary"`: file is read as raw bytes and base64-encoded once for + * transport. The commit pipeline detects this via the + * `preEncoded: true` marker and skips its own base64 step. + * + * Each promise rejection from `readFile` is wrapped with the offending + * path so failures are easy to diagnose in bulk runs. + * + * @param sources The file source map to resolve. + * @param baseDir Directory to resolve relative `sourcePath`s against. + * Defaults to `process.cwd()`. + * + * @returns A list of {@link FileChange}s ready to be handed to the + * commit service. Order matches the iteration order of the + * input map's keys. + */ +export async function resolveFileSources( + sources: FileSourceMap, + baseDir: string = process.cwd(), +): Promise { + const entries = Object.entries(sources); + + const changes = await Promise.all( + entries.map(async ([path, source]) => loadOne(path, source, baseDir)), + ); + + return changes; +} + +/** + * Resolves a single map entry into a {@link FileChange}, dispatching to + * the appropriate loader based on the source shape. + */ +async function loadOne( + path: string, + source: FileSource | string, + baseDir: string, +): Promise { + // Shorthand: bare string ⇒ inline content. + if (typeof source === "string") { + return { path, content: source }; + } + + // Inline content branch. + if ("content" in source && source.content !== undefined) { + return { path, content: source.content }; + } + + // Disk-backed branch. + if ("sourcePath" in source && source.sourcePath !== undefined) { + const absolute = isAbsolute(source.sourcePath) + ? source.sourcePath + : resolve(baseDir, source.sourcePath); + + const encoding = source.encoding ?? "utf-8"; + + try { + if (encoding === "binary") { + // Read raw bytes and base64-encode once for transport. + const buf = await readFile(absolute); + return { + path, + content: buf.toString("base64"), + preEncoded: true, + }; + } + + if (encoding === "base64") { + // Read raw bytes and surface as a base64 string. The commit + // pipeline will base64-encode this *again*; this branch exists + // only for callers that explicitly want that behaviour. + const buf = await readFile(absolute); + return { path, content: buf.toString("base64") }; + } + + // Default: UTF-8 text. + const text = await readFile(absolute, "utf-8"); + return { path, content: text }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + throw new Error( + `Failed to load file source for "${path}" from "${absolute}": ${reason}`, + ); + } + } + + throw new Error( + `Invalid FileSource for "${path}": must provide either "content" or "sourcePath".`, + ); +} diff --git a/src/mutator/branch/types.ts b/src/mutator/branch/types.ts new file mode 100644 index 0000000..756cc49 --- /dev/null +++ b/src/mutator/branch/types.ts @@ -0,0 +1,94 @@ +export interface BranchInfo { + name: string; + headOid: string; +} + +/** + * A single file write to apply on a branch. + * + * @property path Repository-relative path including any directories + * (e.g. "README.md", "config/license.txt"). + * @property content UTF-8 file content. Will be base64-encoded before being + * sent to the GitHub GraphQL API. + */ +export interface FileChange { + path: string; + content: string; + /** + * When true, `content` is already base64-encoded and ready for transport + * to the GitHub GraphQL API. The commit pipeline will skip its own + * base64 step for this entry. + * + * Used by binary file sources loaded from disk, where re-encoding the + * raw bytes as UTF-8 would corrupt them. + */ + preEncoded?: boolean; +} + +/** + * Declarative description of where a file's contents come from. + * + * Either supply the content inline (`{ content: "..." }`), or point at a + * local file on disk that should be read at runtime (`{ sourcePath: "..." }`). + * Exactly one of `content` or `sourcePath` must be provided. + * + * @property content UTF-8 file content, supplied inline. + * @property sourcePath Path on the local filesystem to read the file + * contents from. Resolved relative to the current + * working directory unless absolute. The file is + * read as UTF-8. + * @property encoding Optional encoding override when reading from disk. + * Defaults to "utf-8". Use "binary" / "base64" for + * non-text files (the loader will base64-encode + * binary content directly without a UTF-8 round-trip). + */ +export type FileSource = + | { content: string; sourcePath?: never; encoding?: never } + | { + sourcePath: string; + content?: never; + encoding?: "utf-8" | "binary" | "base64"; + }; + +/** + * A single per-branch commit job, used as input to a batched + * `createCommitOnBranch` mutation. + * + * @property branchName The branch to commit to. + * @property expectedHeadOid The branch's current HEAD OID, used by GitHub + * for optimistic concurrency control. + * @property files One or more files to add/update in the commit. + * @property commitHeadline Commit message headline. + * @property commitBody Optional commit message body. Useful for + * including `Co-authored-by:` trailers, which + * GitHub renders as additional authors on the + * commit page. + */ +export interface BranchCommit { + branchName: string; + expectedHeadOid: string; + files: FileChange[]; + commitHeadline: string; + commitBody?: string; +} + +export interface ProtectionRule { + pattern: string; +} + +export interface UpdateResult { + branch: string; + success: boolean; + commitOid?: string; + error?: string; +} + +export interface GraphQLResponse { + data?: T; + errors?: Array<{ message: string; type?: string; path?: string[] }>; +} + +export interface RepoContext { + owner: string; + repo: string; +}