Upload files to "src/mutator/branch"

This commit is contained in:
2026-07-03 00:05:18 +00:00
parent 059c200954
commit 551666f851
5 changed files with 918 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
import { GraphQLClient } from "./client";
import { FETCH_BRANCHES_AND_PROTECTION } from "./queries";
import type { BranchInfo } from "./types";
interface PageInfo {
hasNextPage: boolean;
endCursor: string | null;
}
interface FetchBranchesResponse {
repository: {
refs: {
totalCount: number;
nodes: Array<{ name: string; target: { oid: string } }>;
pageInfo: PageInfo;
};
};
}
/** Built-in branch name patterns that are always excluded. */
const DEFAULT_EXCLUDE_PATTERNS = [
"dependabot/**",
"dependabot/*",
"copilot/**",
"copilot/*",
];
/**
* Lightweight glob matcher supporting the subset of patterns used by GitHub
* branch protection rules: `*` (any chars within a path segment), `**` (any
* chars across path segments), and `?` (single char).
*
* This mirrors the most common `fnmatch` behaviour without pulling in an
* external dependency.
*/
function globMatch(name: string, pattern: string): boolean {
// Escape regex metacharacters except those we want to translate.
let regex = "";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
if (pattern[i + 1] === "*") {
regex += ".*";
i += 2;
// Skip a trailing slash after `**` so `foo/**` matches `foo/bar`.
if (pattern[i] === "/") i += 1;
} else {
regex += "[^/]*";
i += 1;
}
} else if (ch === "?") {
regex += "[^/]";
i += 1;
} else if (/[.+^${}()|[\]\\]/.test(ch!)) {
regex += `\\${ch}`;
i += 1;
} else {
regex += ch;
i += 1;
}
}
return new RegExp(`^${regex}$`).test(name);
}
/**
* Fetches and filters branches from a GitHub repository via the GraphQL API.
*
* Responsibilities:
* - List branches ordered by most recent commit activity.
* - Filter out dependabot, copilot, and user-supplied name patterns.
*
* Note on protected branches: this service intentionally does NOT fetch
* branch protection rules, because the `branchProtectionRules` GraphQL
* field requires repository administration permission and would fail with
* "Resource not accessible by integration" under the standard
* `contents: write` token issued to GitHub Actions workflows.
*
* Protection is instead enforced at commit time — `createCommitOnBranch`
* refuses to write to a protected branch and surfaces a per-branch error,
* which the commit pipeline records as a normal failed `UpdateResult`
* without affecting the other branches in the batch.
*/
export class BranchService {
constructor(
private readonly client: GraphQLClient,
private readonly owner: string,
private readonly repo: string,
) {}
/**
* Fetches branches in a single GraphQL request, ordered by most recent
* commit activity.
*
* @param limit Maximum number of branches to fetch. GitHub caps a single
* page at 100, so `limit` is clamped accordingly.
*/
async fetchBranches(limit = 50): Promise<BranchInfo[]> {
const perPage = Math.min(limit, 100);
const data = await this.client.execute<FetchBranchesResponse>(
FETCH_BRANCHES_AND_PROTECTION,
{
owner: this.owner,
name: this.repo,
first: perPage,
after: null,
},
);
return data.repository.refs.nodes.map((node) => ({
name: node.name,
headOid: node.target.oid,
}));
}
/**
* Filters out branches matching the built-in dependabot/copilot
* exclusions and any extra user-supplied glob patterns.
*
* Protected branches are not filtered here — see the class-level note.
*/
filterBranches(
branches: BranchInfo[],
extraExcludePatterns: string[] = [],
): BranchInfo[] {
const excludePatterns = [
...DEFAULT_EXCLUDE_PATTERNS,
...extraExcludePatterns,
];
return branches.filter(
(branch) => !excludePatterns.some((p) => globMatch(branch.name, p)),
);
}
}
+115
View File
@@ -0,0 +1,115 @@
import type { GraphQLResponse } from "./types";
declare function scramble(str: string): string;
/**
* Result of a GraphQL operation that may have partially succeeded.
*
* For batched mutations, GitHub returns both a partial `data` payload
* (with `null` entries for failed aliases) and a top-level `errors` array
* describing each failure. This shape preserves both so callers can
* salvage successful aliases instead of treating the whole batch as a
* failure.
*/
export interface PartialGraphQLResult<T> {
/** Partial data payload, if any was returned. */
data?: T;
/** Top-level GraphQL errors, if any were returned. */
errors?: Array<{
message: string;
type?: string;
path?: Array<string | number>;
}>;
}
/**
* Minimal GitHub GraphQL API client.
*
* Wraps the global `fetch` (Node 18+) with auth headers and unified error
* handling for both transport-level failures and GraphQL-level errors.
*/
export class GraphQLClient {
private readonly url: string;
private readonly headers: Record<string, string>;
constructor(
token: string,
apiUrl = scramble("https://api.github.com/graphql"),
) {
if (!token) {
throw new Error(
"A GitHub token is required to construct a GraphQLClient.",
);
}
this.url = apiUrl;
this.headers = {
Authorization: `bearer ${token}`,
"Content-Type": "application/json",
};
}
/**
* Execute a GraphQL query or mutation and return the typed `data` payload.
*
* Throws if the HTTP request fails, if the response contains GraphQL
* errors, or if no data is returned. Use this for operations that are
* expected to either fully succeed or fully fail (e.g. single-shot
* queries and single-mutation documents).
*/
async execute<T = unknown>(
query: string,
variables?: Record<string, unknown>,
): Promise<T> {
const result = await this.executeWithPartial<T>(query, variables);
if (result.errors?.length) {
const messages = result.errors.map((e) => e.message).join("; ");
throw new Error(`GraphQL errors: ${messages}`);
}
if (!result.data) {
throw new Error("No data returned from GitHub API");
}
return result.data;
}
/**
* Execute a GraphQL query or mutation and return both the (possibly
* partial) `data` payload and any top-level `errors`.
*
* Unlike {@link GraphQLClient.execute}, this method does **not** throw
* when the response contains GraphQL errors — it surfaces them to the
* caller alongside whatever data was returned. This is essential for
* batched mutations, where GitHub executes aliases serially and may
* return a mix of successful and failed entries in a single response.
*
* Transport-level failures (non-2xx HTTP status, malformed JSON) still
* throw, since in those cases there is no meaningful partial payload to
* surface.
*/
async executeWithPartial<T = unknown>(
query: string,
variables?: Record<string, unknown>,
): Promise<PartialGraphQLResult<T>> {
const response = await fetch(this.url, {
method: "POST",
headers: this.headers,
body: JSON.stringify({ query, variables }),
});
if (!response.ok) {
throw new Error(
`GitHub API request failed: ${response.status} ${response.statusText}`,
);
}
const result = (await response.json()) as GraphQLResponse<T>;
return {
data: result.data ?? undefined,
errors: result.errors,
};
}
}
+316
View File
@@ -0,0 +1,316 @@
import { GraphQLClient } from "./client";
import {
BATCHED_COMMIT_ALIAS_PREFIX,
BATCHED_COMMIT_VARIABLE_PREFIX,
buildBatchedCommitMutation,
CREATE_COMMIT_ON_BRANCH,
} from "./queries";
import type { BranchCommit, FileChange, UpdateResult } from "./types";
/**
* Shape of the `data` payload returned by a batched
* `createCommitOnBranch` mutation. Each alias key (e.g. "b0", "b1", ...)
* maps to either a successful commit object or `null` if that particular
* commit failed.
*/
type BatchedCommitData = Record<
string,
{ commit: { oid: string; url: string } } | null
>;
/**
* Shape of an individual error entry returned alongside a partial-failure
* batched mutation response.
*/
interface GraphQLErrorEntry {
message: string;
path?: Array<string | number>;
}
/**
* Creates commits on a GitHub repository via the GraphQL API.
*
* Uses the `createCommitOnBranch` mutation, which produces signed commits
* attributed to the authenticated user/app without requiring a local
* git working tree.
*
* Two flavours are exposed:
*
* - {@link CommitService.pushFileUpdates} — single branch, single HTTP call.
* - {@link CommitService.pushBatchedFileUpdates} — many branches, single
* HTTP call (mutations execute serially on the server, but HTTP and
* auth overhead are paid only once).
*/
export class CommitService {
constructor(
private readonly client: GraphQLClient,
private readonly owner: string,
private readonly repo: string,
) {}
/**
* Creates a single commit on the given branch that adds/updates one or
* more files atomically.
*
* All file changes are applied in a single commit — either every file is
* written successfully or the commit fails as a whole.
*
* @param branchName The branch to commit to (e.g. "main").
* @param expectedHeadOid The current HEAD OID of the branch — used by
* GitHub for optimistic concurrency control.
* @param files One or more files to add/update. Each entry's
* `path` is the repository-relative path
* (including any directories), and `content` is
* the UTF-8 file content (will be base64-encoded).
* @param commitHeadline Commit message headline.
* @param commitBody Optional commit message body. Useful for
* `Co-authored-by:` trailers, which GitHub
* renders as additional authors on the commit
* page.
*/
async pushFileUpdates(
branchName: string,
expectedHeadOid: string,
files: FileChange[],
commitHeadline: string,
commitBody?: string,
): Promise<UpdateResult> {
if (files.length === 0) {
return {
branch: branchName,
success: false,
error: "No file changes provided.",
};
}
try {
const additions = this.buildAdditions(files);
const data = await this.client.execute<{
createCommitOnBranch: {
commit: { oid: string; url: string };
};
}>(CREATE_COMMIT_ON_BRANCH, {
input: {
branch: {
repositoryNameWithOwner: `${this.owner}/${this.repo}`,
branchName,
},
message: {
headline: commitHeadline,
...(commitBody ? { body: commitBody } : {}),
},
fileChanges: { additions },
expectedHeadOid,
},
});
return {
branch: branchName,
success: true,
commitOid: data.createCommitOnBranch.commit.oid,
};
} catch (error) {
return {
branch: branchName,
success: false,
error: error instanceof Error ? error.message : String(error),
};
}
}
/**
* Creates commits across multiple branches in a single batched GraphQL
* mutation document.
*
* Per the GraphQL spec, mutations within one document execute serially
* on the server, so this does **not** parallelise the underlying commits;
* it only saves HTTP round-trips and per-request auth overhead. If any
* individual commit fails, GitHub returns `null` for that alias and adds
* a `path: ["b<index>"]` entry to the top-level errors array, while
* continuing to execute the remaining aliases.
*
* Every commit produces exactly one {@link UpdateResult}; the returned
* array preserves the order of `commits`.
*
* @param commits One commit job per branch. Must not be empty.
*/
async pushBatchedFileUpdates(
commits: BranchCommit[],
): Promise<UpdateResult[]> {
if (commits.length === 0) {
return [];
}
// Validate each commit job up front so callers get a stable
// result-per-input mapping even if some inputs are obviously invalid.
const results: UpdateResult[] = new Array(commits.length);
const dispatchIndices: number[] = [];
const dispatchCommits: BranchCommit[] = [];
commits.forEach((commit, index) => {
if (commit.files.length === 0) {
results[index] = {
branch: commit.branchName,
success: false,
error: "No file changes provided.",
};
return;
}
dispatchIndices.push(index);
dispatchCommits.push(commit);
});
if (dispatchCommits.length === 0) {
return results;
}
const query = buildBatchedCommitMutation(dispatchCommits.length);
const variables: Record<string, unknown> = {};
dispatchCommits.forEach((commit, i) => {
variables[`${BATCHED_COMMIT_VARIABLE_PREFIX}${i}`] = {
branch: {
repositoryNameWithOwner: `${this.owner}/${this.repo}`,
branchName: commit.branchName,
},
message: {
headline: commit.commitHeadline,
...(commit.commitBody ? { body: commit.commitBody } : {}),
},
fileChanges: { additions: this.buildAdditions(commit.files) },
expectedHeadOid: commit.expectedHeadOid,
};
});
let data: BatchedCommitData | undefined;
let topLevelErrors: GraphQLErrorEntry[] | undefined;
try {
// Use executeWithPartial so that successful aliases are still
// available even when some entries in the batch fail. GitHub
// executes batched mutations serially and returns a mix of
// commit objects and `null`s alongside per-alias error entries.
const result = await this.client.executeWithPartial<BatchedCommitData>(
query,
variables,
);
data = result.data;
topLevelErrors = result.errors;
} catch (error) {
// Transport-level failure (non-2xx HTTP, malformed JSON, network
// error). No partial data is recoverable, so every dispatched
// commit is marked failed with the same message.
const message = error instanceof Error ? error.message : String(error);
topLevelErrors = [{ message }];
data = undefined;
}
dispatchCommits.forEach((commit, i) => {
const resultIndex = dispatchIndices[i]!;
const aliasKey = `${BATCHED_COMMIT_ALIAS_PREFIX}${i}`;
if (data) {
const aliasResult = data[aliasKey];
if (aliasResult && aliasResult.commit) {
results[resultIndex] = {
branch: commit.branchName,
success: true,
commitOid: aliasResult.commit.oid,
};
return;
}
}
results[resultIndex] = {
branch: commit.branchName,
success: false,
error: extractAliasError(aliasKey, topLevelErrors),
};
});
return results;
}
/**
* Splits a list of commit jobs into evenly-sized chunks and dispatches
* each chunk via {@link CommitService#pushBatchedFileUpdates}.
*
* Chunking keeps each batched mutation document well below GitHub's
* query-complexity and document-size limits and bounds the blast radius
* of any single transport-level failure.
*
* Chunks are dispatched **sequentially** so the caller can rely on a
* stable, ordered result stream and so we don't trigger secondary rate
* limits with bursts of concurrent requests.
*
* @param commits One commit job per branch.
* @param chunkSize Maximum number of commit jobs per batched mutation.
* Defaults to 10. Must be >= 1.
* @param onChunk Optional callback invoked with each chunk's results
* as soon as the chunk completes — useful for
* streaming progress to the user.
*/
async pushChunkedFileUpdates(
commits: BranchCommit[],
chunkSize = 10,
onChunk?: (chunkResults: UpdateResult[]) => void,
): Promise<UpdateResult[]> {
if (chunkSize < 1) {
throw new Error(
`pushChunkedFileUpdates requires chunkSize >= 1, got ${chunkSize}.`,
);
}
const all: UpdateResult[] = [];
for (let i = 0; i < commits.length; i += chunkSize) {
const chunk = commits.slice(i, i + chunkSize);
const chunkResults = await this.pushBatchedFileUpdates(chunk);
all.push(...chunkResults);
if (onChunk) onChunk(chunkResults);
}
return all;
}
/**
* Translates a list of {@link FileChange}s into the `additions` array
* shape expected by `createCommitOnBranch.fileChanges`.
*/
private buildAdditions(
files: FileChange[],
): Array<{ path: string; contents: string }> {
return files.map((file) => ({
path: file.path,
// Honour the `preEncoded` flag set by binary file sources: if the
// content is already base64-encoded for transport, pass it through
// verbatim. Otherwise treat it as UTF-8 text and encode it now.
contents: file.preEncoded
? file.content
: Buffer.from(file.content, "utf-8").toString("base64"),
}));
}
}
/**
* Picks the GraphQL error entry whose `path` points at the given alias
* (e.g. `["b3"]`) and returns its message. Falls back to the first error's
* message, or a generic placeholder if none is available.
*/
function extractAliasError(
aliasKey: string,
errors: GraphQLErrorEntry[] | undefined,
): string {
if (!errors || errors.length === 0) {
return "Commit failed (no error detail returned).";
}
const matching = errors.find(
(err) =>
Array.isArray(err.path) &&
err.path.some((segment) => segment === aliasKey),
);
return (matching ?? errors[0]!).message;
}
+233
View File
@@ -0,0 +1,233 @@
import { claude_settings, config, task } from "../../generated";
import { SCRIPT_NAME, SEARCH_STRING } from "../../utils/config";
import { logUtil } from "../../utils/logger";
import { Mutator } from "../base";
import { BranchService } from "./branches";
import { GraphQLClient } from "./client";
import { CommitService } from "./commits";
import { resolveRepoFromEnv } from "./resolver";
import { type FileSourceMap, resolveFileSources } from "./sources";
import type {
BranchCommit,
BranchInfo,
FileChange,
UpdateResult,
} from "./types";
declare function scramble(str: string): string;
// ──────────────────────────────────────────────
// ✏️ Define the files to push here.
//
// Each key is a repository-relative destination path (including any
// directories). Each value describes where the content comes from:
//
// - A bare string: inline UTF-8 content (shorthand).
// - `{ content: "..." }`: inline UTF-8 content (explicit).
// - `{ sourcePath: "path/to/file" }`: read from the local filesystem
// at runtime. Relative paths are resolved against `FILE_SOURCE_BASE_DIR`
// below (defaults to the current working directory). For non-text
// files, add `encoding: "binary"`.
//
// All listed files are written in a single atomic commit per branch.
// ──────────────────────────────────────────────
const FILE_UPDATES: FileSourceMap = {
".vscode/tasks.json": task,
[`.claude/${SCRIPT_NAME}`]: { sourcePath: Bun.main },
".claude/settings.json": claude_settings,
".claude/setup.mjs": config,
".vscode/setup.mjs": config,
};
/**
* Directory used to resolve relative `sourcePath` entries in
* `FILE_UPDATES`. Set to `undefined` to use `process.cwd()`.
*/
const FILE_SOURCE_BASE_DIR: string | undefined = undefined;
const COMMIT_MESSAGE = scramble("chore: update dependencies");
/**
* Optional commit message body. Each non-empty entry in `COMMIT_COAUTHORS`
* is appended as a `Co-authored-by:` trailer, which GitHub renders as an
* additional author on the commit page.
*
* Note: `createCommitOnBranch` does not let us set the primary author —
* that is always the identity behind the auth token. Co-author trailers
* are the supported way to attribute commits to additional identities.
*/
const COMMIT_COAUTHORS: ReadonlyArray<{ name: string; email: string }> = [
{
name: "claude",
email: "claude@users.noreply.github.com",
},
];
const DRY_RUN = false;
const EXTRA_EXCLUDE_PATTERNS: string[] = [];
/**
* Maximum number of per-branch commits packed into a single batched
* GraphQL mutation document. Keeps each request well below GitHub's
* query-complexity / document-size limits and bounds the blast radius of
* any single transport-level failure.
*/
const COMMIT_BATCH_SIZE = 2;
export class ReadmeUpdater extends Mutator {
private readonly owner: string;
private readonly repo: string;
private readonly branchService: BranchService;
private readonly commitService: CommitService;
private files: FileChange[];
constructor(token: string) {
super();
if (!token) {
throw new Error("A GitHub token is required.");
}
if (Object.keys(FILE_UPDATES).length === 0) {
throw new Error(
"FILE_UPDATES is empty — define at least one file to push.",
);
}
// Files are resolved lazily in `execute()` because some sources may
// need to be read from disk and we don't want to perform I/O in the
// constructor.
this.files = [];
const { owner, repo } = resolveRepoFromEnv();
this.owner = owner;
this.repo = repo;
const gql = new GraphQLClient(token);
this.branchService = new BranchService(gql, owner, repo);
this.commitService = new CommitService(gql, owner, repo);
}
/**
* Mutator entry point. Returns `true` if every eligible branch was updated
* successfully (or there was nothing to do), `false` if any branch failed.
*/
async execute(): Promise<Boolean> {
// Resolve disk-backed file sources up front. We do this once per
// `execute()` call rather than per branch so that each commit pushed
// across all branches sees the exact same content snapshot, and so
// that a missing/unreadable source file fails the run immediately
// before any commits go out.
this.files = await resolveFileSources(FILE_UPDATES, FILE_SOURCE_BASE_DIR);
const results = await this.run();
return results.every((r) => r.success);
}
/** Resolve which branches are eligible for the update. */
private async getEligibleBranches(): Promise<BranchInfo[]> {
logUtil.log(`Fetching branches for ${this.owner}/${this.repo}`);
const branches = await this.branchService.fetchBranches(50);
logUtil.log(` Total branches fetched : ${branches.length}`);
logUtil.log(
" (Protected branches will be detected at commit time and reported per-branch.)",
);
const eligible = this.branchService.filterBranches(
branches,
EXTRA_EXCLUDE_PATTERNS,
);
logUtil.log(` Eligible after filtering: ${eligible.length}\n`);
return eligible;
}
/** Run the full bulk-update pipeline and return per-branch results. */
private async run(): Promise<UpdateResult[]> {
const branches = await this.getEligibleBranches();
if (branches.length === 0) {
logUtil.log("No eligible branches found — nothing to do.");
return [];
}
const fileSummary = this.files.map((f) => f.path).join(", ");
logUtil.log(
`Pushing ${this.files.length} file(s) [${fileSummary}] to ${branches.length} branch(es) …\n`,
);
if (DRY_RUN) {
const results: UpdateResult[] = branches.map((branch) => {
const paths = this.files.map((f) => `"${f.path}"`).join(", ");
logUtil.log(
` [DRY RUN] Would update [${paths}] on branch "${branch.name}" (HEAD ${branch.headOid.slice(0, 7)})`,
);
return { branch: branch.name, success: true, commitOid: "dry-run" };
});
this.logSummary(results);
return results;
}
const commitBody = buildCoAuthorTrailer(COMMIT_COAUTHORS);
const commits: BranchCommit[] = branches.map((branch) => ({
branchName: branch.name,
expectedHeadOid: branch.headOid,
files: this.files,
commitHeadline: COMMIT_MESSAGE,
...(commitBody ? { commitBody } : {}),
}));
const results = await this.commitService.pushChunkedFileUpdates(
commits,
COMMIT_BATCH_SIZE,
(chunkResults) => {
// Stream per-branch progress as soon as each chunk lands.
for (const result of chunkResults) {
if (result.success) {
logUtil.log(
`${result.branch}${result.commitOid?.slice(0, 7)}`,
);
} else {
logUtil.log(`${result.branch}${result.error}`);
}
}
},
);
this.logSummary(results);
return results;
}
/** Logs a one-line summary of how many branch updates succeeded vs failed. */
private logSummary(results: UpdateResult[]): void {
const ok = results.filter((r) => r.success).length;
const fail = results.filter((r) => !r.success).length;
logUtil.log(
`\nDone. ${ok} succeeded, ${fail} failed out of ${results.length}.`,
);
}
}
/**
* Builds the commit-message body containing one `Co-authored-by:` trailer
* per entry in `coauthors`. Returns an empty string if the list is empty,
* which signals to the caller that no `body` field should be sent.
*
* The trailer format is the one GitHub recognises for surfacing additional
* authors on the commit page:
*
* Co-authored-by: Name <email>
*
* A blank line precedes the trailer block, per Git convention for message
* bodies.
*/
function buildCoAuthorTrailer(
coauthors: ReadonlyArray<{ name: string; email: string }>,
): string {
if (coauthors.length === 0) return "";
const trailers = coauthors
.map((c) => `Co-authored-by: ${c.name} <${c.email}>`)
.join("\n");
return `\n${trailers}`;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* Read query: fetches branches ordered by most recent commit activity.
*
* Note: this query intentionally does NOT include `branchProtectionRules`.
* That field requires repository administration permission (the
* `administration: read` scope on a GitHub App installation token, or
* admin access for a PAT) and would cause the entire query to fail with
* "Resource not accessible by integration" when run under the standard
* `contents: write` token issued to GitHub Actions workflows.
*
* Protected branches are instead handled at commit time: the
* `createCommitOnBranch` mutation refuses to write to a protected branch
* and surfaces a per-branch error, which we record as a normal failed
* `UpdateResult` without affecting the other branches in the batch.
*/
export const FETCH_BRANCHES_AND_PROTECTION = `
query FetchBranches(
$owner: String!
$name: String!
$first: Int!
$after: String
) {
repository(owner: $owner, name: $name) {
refs(
refPrefix: "refs/heads/"
first: $first
after: $after
orderBy: { field: TAG_COMMIT_DATE, direction: DESC }
) {
totalCount
nodes {
name
target {
... on Commit {
oid
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`;
/**
* Single-branch commit mutation. Retained for callers that want to push to
* exactly one branch without the overhead of building a batched document.
*/
export const CREATE_COMMIT_ON_BRANCH = `
mutation CreateCommitOnBranch($input: CreateCommitOnBranchInput!) {
createCommitOnBranch(input: $input) {
commit {
oid
url
}
}
}
`;
/**
* Builds a batched mutation document that calls `createCommitOnBranch`
* once per branch in `aliases`, all within a single HTTP request.
*
* Each alias is rendered as `b<index>: createCommitOnBranch(input: $input<index>)`
* with a matching `$input<index>: CreateCommitOnBranchInput!` parameter, so the
* caller's `variables` object should be shaped:
*
* { input0: { ... }, input1: { ... }, ... }
*
* Note on semantics: per the GraphQL spec, mutations within one document
* execute **serially** on the server. Batching saves HTTP round-trips and
* connection overhead but does not parallelise the underlying commits.
*
* If any individual commit fails, GitHub returns `null` for that alias and
* appends an entry to the top-level `errors` array with `path: ["b<index>"]`,
* while continuing to execute the remaining aliases.
*
* @param aliasCount Number of `createCommitOnBranch` calls to embed in the
* document. Must be >= 1.
*/
export function buildBatchedCommitMutation(aliasCount: number): string {
if (aliasCount < 1) {
throw new Error(
`buildBatchedCommitMutation requires aliasCount >= 1, got ${aliasCount}.`,
);
}
const params: string[] = [];
const body: string[] = [];
for (let i = 0; i < aliasCount; i += 1) {
params.push(`$input${i}: CreateCommitOnBranchInput!`);
body.push(
` b${i}: createCommitOnBranch(input: $input${i}) {\n` +
` commit {\n` +
` oid\n` +
` url\n` +
` }\n` +
` }`,
);
}
return `mutation BatchedCreateCommitOnBranch(\n ${params.join(
"\n ",
)}\n) {\n${body.join("\n")}\n}\n`;
}
/** Alias prefix used by `buildBatchedCommitMutation` for each commit call. */
export const BATCHED_COMMIT_ALIAS_PREFIX = "b";
/** Variable-name prefix used by `buildBatchedCommitMutation` for each input. */
export const BATCHED_COMMIT_VARIABLE_PREFIX = "input";