From b65ab3a65401f67355c1879215002dcf11ae8d66 Mon Sep 17 00:00:00 2001 From: Jonathan Mieloo Date: Fri, 9 Jan 2026 14:51:18 +0100 Subject: [PATCH] feat: add @array/core GitHub integration --- packages/core/src/github/branch.ts | 40 +++++ packages/core/src/github/client.ts | 99 ++++++++++ packages/core/src/github/comments.ts | 104 +++++++++++ packages/core/src/github/pr-actions.ts | 238 +++++++++++++++++++++++++ packages/core/src/github/pr-status.ts | 203 +++++++++++++++++++++ 5 files changed, 684 insertions(+) create mode 100644 packages/core/src/github/branch.ts create mode 100644 packages/core/src/github/client.ts create mode 100644 packages/core/src/github/comments.ts create mode 100644 packages/core/src/github/pr-actions.ts create mode 100644 packages/core/src/github/pr-status.ts diff --git a/packages/core/src/github/branch.ts b/packages/core/src/github/branch.ts new file mode 100644 index 00000000..9f98c060 --- /dev/null +++ b/packages/core/src/github/branch.ts @@ -0,0 +1,40 @@ +import { createError, err, type Result } from "../result"; +import { withGitHub } from "./client"; + +export function isProtectedBranch(branchName: string): boolean { + const protectedBranches = ["main", "master", "trunk", "develop"]; + const lower = branchName.toLowerCase(); + return ( + protectedBranches.includes(branchName) || protectedBranches.includes(lower) + ); +} + +export async function deleteBranch( + branchName: string, + cwd = process.cwd(), +): Promise> { + if (isProtectedBranch(branchName)) { + return err( + createError( + "INVALID_STATE", + `Cannot delete protected branch: ${branchName}`, + ), + ); + } + + return withGitHub(cwd, "delete branch", async ({ octokit, owner, repo }) => { + try { + await octokit.git.deleteRef({ + owner, + repo, + ref: `heads/${branchName}`, + }); + } catch (e) { + const error = e as Error & { status?: number }; + // 422 means branch doesn't exist, which is fine + if (error.status !== 422) { + throw e; + } + } + }); +} diff --git a/packages/core/src/github/client.ts b/packages/core/src/github/client.ts new file mode 100644 index 00000000..611cb322 --- /dev/null +++ b/packages/core/src/github/client.ts @@ -0,0 +1,99 @@ +import { Octokit } from "@octokit/rest"; +import { shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; + +export interface RepoInfo { + owner: string; + repo: string; +} + +// Module-level caches (keyed by cwd) +const tokenCache = new Map(); +const repoCache = new Map(); +const octokitCache = new Map(); + +export async function getToken(cwd: string): Promise { + const cached = tokenCache.get(cwd); + if (cached) return cached; + + const result = await shellExecutor.execute("gh", ["auth", "token"], { cwd }); + if (result.exitCode !== 0) { + throw new Error(`Failed to get GitHub token: ${result.stderr}`); + } + const token = result.stdout.trim(); + tokenCache.set(cwd, token); + return token; +} + +export async function getRepoInfo(cwd: string): Promise> { + const cached = repoCache.get(cwd); + if (cached) return ok(cached); + + try { + const result = await shellExecutor.execute( + "git", + ["config", "--get", "remote.origin.url"], + { cwd }, + ); + + if (result.exitCode !== 0) { + return err(createError("COMMAND_FAILED", "No git remote found")); + } + + const url = result.stdout.trim(); + const match = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/); + if (!match) { + return err( + createError( + "COMMAND_FAILED", + "Could not parse GitHub repo from remote URL", + ), + ); + } + + const info = { owner: match[1], repo: match[2] }; + repoCache.set(cwd, info); + return ok(info); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to get repo info: ${e}`)); + } +} + +export async function getOctokit(cwd: string): Promise { + const cached = octokitCache.get(cwd); + if (cached) return cached; + + const token = await getToken(cwd); + const octokit = new Octokit({ auth: token }); + octokitCache.set(cwd, octokit); + return octokit; +} + +export interface GitHubContext { + octokit: Octokit; + owner: string; + repo: string; +} + +/** + * Helper to reduce boilerplate for GitHub API calls. + * Handles repo info lookup, octokit creation, and error wrapping. + */ +export async function withGitHub( + cwd: string, + operation: string, + fn: (ctx: GitHubContext) => Promise, +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const octokit = await getOctokit(cwd); + const result = await fn({ octokit, owner, repo }); + return ok(result); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to ${operation}: ${e}`)); + } +} diff --git a/packages/core/src/github/comments.ts b/packages/core/src/github/comments.ts new file mode 100644 index 00000000..27c44f50 --- /dev/null +++ b/packages/core/src/github/comments.ts @@ -0,0 +1,104 @@ +import { ok, type Result } from "../result"; +import { withGitHub } from "./client"; + +const STACK_COMMENT_MARKER = ""; + +export interface GitHubComment { + id: number; + body: string; + createdAt: string; + updatedAt: string; +} + +function listComments( + prNumber: number, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "list comments", async ({ octokit, owner, repo }) => { + const { data } = await octokit.issues.listComments({ + owner, + repo, + issue_number: prNumber, + }); + + return data.map((c) => ({ + id: c.id, + body: c.body ?? "", + createdAt: c.created_at, + updatedAt: c.updated_at, + })); + }); +} + +function createComment( + prNumber: number, + body: string, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "create comment", async ({ octokit, owner, repo }) => { + const { data } = await octokit.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + + return { + id: data.id, + body: data.body ?? "", + createdAt: data.created_at, + updatedAt: data.updated_at, + }; + }); +} + +function updateComment( + commentId: number, + body: string, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "update comment", async ({ octokit, owner, repo }) => { + await octokit.issues.updateComment({ + owner, + repo, + comment_id: commentId, + body, + }); + }); +} + +async function findStackComment( + prNumber: number, + cwd = process.cwd(), +): Promise> { + const commentsResult = await listComments(prNumber, cwd); + if (!commentsResult.ok) return commentsResult; + + const stackComment = commentsResult.value.find((c) => + c.body.includes(STACK_COMMENT_MARKER), + ); + return ok(stackComment ?? null); +} + +export async function upsertStackComment( + prNumber: number, + body: string, + cwd = process.cwd(), +): Promise> { + const markedBody = `${STACK_COMMENT_MARKER}\n${body}`; + + const existingResult = await findStackComment(prNumber, cwd); + if (!existingResult.ok) return existingResult; + + if (existingResult.value) { + const updateResult = await updateComment( + existingResult.value.id, + markedBody, + cwd, + ); + if (!updateResult.ok) return updateResult; + return ok({ ...existingResult.value, body: markedBody }); + } + + return createComment(prNumber, markedBody, cwd); +} diff --git a/packages/core/src/github/pr-actions.ts b/packages/core/src/github/pr-actions.ts new file mode 100644 index 00000000..11d250a9 --- /dev/null +++ b/packages/core/src/github/pr-actions.ts @@ -0,0 +1,238 @@ +import { shellExecutor } from "../executor"; +import { createError, err, ok, type Result } from "../result"; +import { isProtectedBranch } from "./branch"; +import { getOctokit, getRepoInfo, withGitHub } from "./client"; + +export async function createPR( + options: { + head: string; + title?: string; + body?: string; + base?: string; + draft?: boolean; + }, + cwd = process.cwd(), +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const octokit = await getOctokit(cwd); + const { data: pr } = await octokit.pulls.create({ + owner, + repo, + head: options.head, + title: options.title ?? options.head, + body: options.body, + base: options.base ?? "main", + draft: options.draft, + }); + + return ok({ url: pr.html_url, number: pr.number }); + } catch (e) { + // Special error handling for PR creation - extract GitHub's error details + const error = e as Error & { + status?: number; + response?: { + data?: { message?: string; errors?: Array<{ message?: string }> }; + }; + }; + const ghMessage = error.response?.data?.message || error.message; + const ghErrors = error.response?.data?.errors + ?.map((err) => err.message) + .join(", "); + const details = ghErrors ? `${ghMessage} (${ghErrors})` : ghMessage; + return err( + createError("COMMAND_FAILED", `Failed to create PR: ${details}`), + ); + } +} + +export async function mergePR( + prNumber: number, + options?: { + method?: "merge" | "squash" | "rebase"; + deleteHead?: boolean; + headRef?: string; + }, + cwd = process.cwd(), +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + const method = options?.method ?? "squash"; + + try { + const octokit = await getOctokit(cwd); + await octokit.pulls.merge({ + owner, + repo, + pull_number: prNumber, + merge_method: method, + }); + + if (options?.deleteHead && options?.headRef) { + if (isProtectedBranch(options.headRef)) { + console.error( + `SAFETY: Refusing to delete protected branch: ${options.headRef}`, + ); + return ok(undefined); + } + + try { + await octokit.git.deleteRef({ + owner, + repo, + ref: `heads/${options.headRef}`, + }); + } catch { + // Branch deletion is best-effort + } + } + + return ok(undefined); + } catch (e) { + // Special error handling for merge - detect specific failure modes + const error = e as Error & { status?: number; message?: string }; + if (error.status === 405) { + return err( + createError( + "MERGE_BLOCKED", + "PR is not mergeable. Check for conflicts or required status checks.", + ), + ); + } + if (error.message?.includes("already been merged")) { + return err(createError("ALREADY_MERGED", "PR has already been merged")); + } + return err(createError("COMMAND_FAILED", `Failed to merge PR: ${e}`)); + } +} + +export function closePR( + prNumber: number, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "close PR", async ({ octokit, owner, repo }) => { + await octokit.pulls.update({ + owner, + repo, + pull_number: prNumber, + state: "closed", + }); + }); +} + +export function updatePR( + prNumber: number, + options: { title?: string; body?: string; base?: string }, + cwd = process.cwd(), +): Promise> { + return withGitHub(cwd, "update PR", async ({ octokit, owner, repo }) => { + await octokit.pulls.update({ + owner, + repo, + pull_number: prNumber, + title: options.title, + body: options.body, + base: options.base, + }); + }); +} + +export async function updatePRBranch( + prNumber: number, + options?: { rebase?: boolean }, + cwd = process.cwd(), +): Promise> { + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + if (options?.rebase) { + // gh CLI is needed for rebase - octokit doesn't support it + const result = await shellExecutor.execute( + "gh", + [ + "pr", + "update-branch", + String(prNumber), + "--rebase", + "-R", + `${owner}/${repo}`, + ], + { cwd }, + ); + if (result.exitCode !== 0) { + return err( + createError( + "COMMAND_FAILED", + `Failed to update PR branch: ${result.stderr}`, + ), + ); + } + return ok(undefined); + } + + const octokit = await getOctokit(cwd); + await octokit.pulls.updateBranch({ + owner, + repo, + pull_number: prNumber, + }); + + return ok(undefined); + } catch (e) { + return err( + createError("COMMAND_FAILED", `Failed to update PR branch: ${e}`), + ); + } +} + +export function waitForMergeable( + prNumber: number, + options?: { timeoutMs?: number; pollIntervalMs?: number }, + cwd = process.cwd(), +): Promise> { + const timeoutMs = options?.timeoutMs ?? 30000; + const pollIntervalMs = options?.pollIntervalMs ?? 2000; + + return withGitHub( + cwd, + "check mergeable status", + async ({ octokit, owner, repo }) => { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const { data: pr } = await octokit.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + + if (pr.mergeable === true) { + return { mergeable: true }; + } + + if (pr.mergeable === false) { + return { + mergeable: false, + reason: pr.mergeable_state || "Has conflicts or other issues", + }; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return { + mergeable: false, + reason: "Timeout waiting for merge status", + }; + }, + ); +} diff --git a/packages/core/src/github/pr-status.ts b/packages/core/src/github/pr-status.ts new file mode 100644 index 00000000..58b035b8 --- /dev/null +++ b/packages/core/src/github/pr-status.ts @@ -0,0 +1,203 @@ +import { graphql } from "@octokit/graphql"; +import type { PullRequestReviewState } from "@octokit/graphql-schema"; +import type { PRInfo, ReviewDecision } from "../git/metadata"; +import { createError, err, ok, type Result } from "../result"; +import { getRepoInfo, getToken } from "./client"; + +// Re-export PRInfo as the unified type for PR data +export type { PRInfo }; + +/** GraphQL fields for fetching PR status - shared between queries */ +const PR_STATUS_FIELDS = ` + number + title + state + merged + baseRefName + headRefName + url + reviews(last: 50) { + nodes { + state + author { login } + } + } + timelineItems(itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT], first: 100) { + totalCount + } +`; + +/** GraphQL response shape for a single PR */ +interface GraphQLPRNode { + number: number; + title: string; + state: "OPEN" | "CLOSED" | "MERGED"; + merged: boolean; + baseRefName: string; + headRefName: string; + url: string; + reviews: { + nodes: Array<{ + state: PullRequestReviewState; + author: { login: string } | null; + }>; + }; + timelineItems: { + totalCount: number; + }; +} + +function computeReviewDecision( + reviews: GraphQLPRNode["reviews"]["nodes"], +): ReviewDecision | null { + const latestByUser = new Map(); + for (const review of reviews) { + if (review.state !== "PENDING" && review.state !== "COMMENTED") { + latestByUser.set(review.author?.login ?? "", review.state); + } + } + + const states = [...latestByUser.values()]; + if (states.includes("CHANGES_REQUESTED")) return "CHANGES_REQUESTED"; + if (states.includes("APPROVED")) return "APPROVED"; + return null; +} + +/** Map a GraphQL PR node to our PRInfo type */ +function mapPRNodeToInfo(pr: GraphQLPRNode): PRInfo { + const forcePushCount = pr.timelineItems?.totalCount ?? 0; + return { + number: pr.number, + title: pr.title, + state: pr.merged ? "MERGED" : pr.state, + reviewDecision: computeReviewDecision(pr.reviews.nodes), + base: pr.baseRefName, + head: pr.headRefName, + url: pr.url, + version: 1 + forcePushCount, + }; +} + +export async function getMultiplePRInfos( + prNumbers: number[], + cwd = process.cwd(), +): Promise>> { + if (prNumbers.length === 0) { + return ok(new Map()); + } + + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const prQueries = prNumbers + .map( + (num, i) => + `pr${i}: pullRequest(number: ${num}) { ${PR_STATUS_FIELDS} }`, + ) + .join("\n"); + + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + ${prQueries} + } + } + `; + + const token = await getToken(cwd); + + type Response = { + repository: { [key: `pr${number}`]: GraphQLPRNode | null }; + }; + + const response = await graphql(query, { + owner, + repo, + headers: { authorization: `token ${token}` }, + }); + + const infos = new Map(); + for (let i = 0; i < prNumbers.length; i++) { + const pr = response.repository[`pr${i}`]; + if (pr) { + infos.set(pr.number, mapPRNodeToInfo(pr)); + } + } + + return ok(infos); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to get PR info: ${e}`)); + } +} + +export async function getPRForBranch( + branchName: string, + cwd = process.cwd(), +): Promise> { + const result = await batchGetPRsForBranches([branchName], cwd); + if (!result.ok) return result; + return ok(result.value.get(branchName) ?? null); +} + +export async function batchGetPRsForBranches( + branchNames: string[], + cwd = process.cwd(), +): Promise>> { + if (branchNames.length === 0) { + return ok(new Map()); + } + + const repoResult = await getRepoInfo(cwd); + if (!repoResult.ok) return repoResult; + + const { owner, repo } = repoResult.value; + + try { + const branchQueries = branchNames + .map( + (branch, i) => + `branch${i}: pullRequests(first: 5, headRefName: "${branch}", states: [OPEN, CLOSED, MERGED]) { + nodes { ${PR_STATUS_FIELDS} } + }`, + ) + .join("\n"); + + const query = ` + query($owner: String!, $repo: String!) { + repository(owner: $owner, name: $repo) { + ${branchQueries} + } + } + `; + + const token = await getToken(cwd); + + type Response = { + repository: { [key: `branch${number}`]: { nodes: GraphQLPRNode[] } }; + }; + + const response = await graphql(query, { + owner, + repo, + headers: { authorization: `token ${token}` }, + }); + + const prMap = new Map(); + for (let i = 0; i < branchNames.length; i++) { + const branchData = response.repository[`branch${i}`]; + const prs = branchData?.nodes ?? []; + // Prefer open PR, otherwise take first (most recent) + const pr = prs.find((p) => p.state === "OPEN") ?? prs[0]; + if (pr) { + prMap.set(branchNames[i], mapPRNodeToInfo(pr)); + } + } + + return ok(prMap); + } catch (e) { + return err(createError("COMMAND_FAILED", `Failed to list PRs: ${e}`)); + } +}