Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions packages/core/src/github/branch.ts
Original file line number Diff line number Diff line change
@@ -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<Result<void>> {
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;
}
}
});
}
99 changes: 99 additions & 0 deletions packages/core/src/github/client.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>();
const repoCache = new Map<string, RepoInfo>();
const octokitCache = new Map<string, Octokit>();

export async function getToken(cwd: string): Promise<string> {
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<Result<RepoInfo>> {
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<Octokit> {
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<T>(
cwd: string,
operation: string,
fn: (ctx: GitHubContext) => Promise<T>,
): Promise<Result<T>> {
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}`));
}
}
104 changes: 104 additions & 0 deletions packages/core/src/github/comments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { ok, type Result } from "../result";
import { withGitHub } from "./client";

const STACK_COMMENT_MARKER = "<!-- array-stack-comment -->";

export interface GitHubComment {
id: number;
body: string;
createdAt: string;
updatedAt: string;
}

function listComments(
prNumber: number,
cwd = process.cwd(),
): Promise<Result<GitHubComment[]>> {
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<Result<GitHubComment>> {
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<Result<void>> {
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<Result<GitHubComment | null>> {
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<Result<GitHubComment>> {
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);
}
Loading
Loading