Upload files to "src/mutator/branch"
This commit is contained in:
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user