ever-brain / packages /cli /src /services /github-writer-service.ts
AlexKurian's picture
feat: complete Ever-Brain engine, MCP server, CLI evb mcp command, Dockerfile and release workflow
61b1da1
Raw History Blame Contribute Delete
9.16 kB
export interface FileChange {
path: string;
content: string | null; // null indicates deletion
}
export interface DirectCommitResult {
commitSha: string;
branch: string;
commitUrl: string;
}
export interface ProposalResult {
pullRequestUrl: string;
pullRequestNumber: number;
branch: string;
commitSha: string;
isFork: boolean;
}
export class GitHubWriterService {
private getHeaders(token: string): Record<string, string> {
return {
'User-Agent': 'Ever-Brain-CLI/1.0',
Accept: 'application/vnd.github.v3+json',
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
}
async getLatestCommitSha(
owner: string,
repo: string,
branch: string,
token: string
): Promise<{ commitSha: string; treeSha: string }> {
const refUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/ref/heads/${encodeURIComponent(branch)}`;
const refRes = await fetch(refUrl, { headers: this.getHeaders(token) });
if (!refRes.ok) {
throw new Error(`Failed to resolve branch '${branch}' in ${owner}/${repo} (${refRes.status})`);
}
const refData = (await refRes.json()) as { object: { sha: string } };
const commitSha = refData.object.sha;
const commitUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/commits/${commitSha}`;
const commitRes = await fetch(commitUrl, { headers: this.getHeaders(token) });
if (!commitRes.ok) {
throw new Error(`Failed to fetch commit object ${commitSha} (${commitRes.status})`);
}
const commitData = (await commitRes.json()) as { tree: { sha: string } };
return { commitSha, treeSha: commitData.tree.sha };
}
async createDirectCommit(
owner: string,
repo: string,
branch: string,
changes: FileChange[],
message: string,
token: string
): Promise<DirectCommitResult> {
const { commitSha, treeSha } = await this.getLatestCommitSha(owner, repo, branch, token);
// 1. Build Git tree payload
const treePayload = changes.map((c) => {
if (c.content === null) {
return {
path: c.path,
mode: '100644',
type: 'blob',
sha: null,
};
}
return {
path: c.path,
mode: '100644',
type: 'blob',
content: c.content,
};
});
const createTreeUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/trees`;
const treeRes = await fetch(createTreeUrl, {
method: 'POST',
headers: this.getHeaders(token),
body: JSON.stringify({
base_tree: treeSha,
tree: treePayload,
}),
});
if (!treeRes.ok) {
const err = await treeRes.text();
throw new Error(`Failed to create Git tree on ${owner}/${repo} (${treeRes.status}): ${err}`);
}
const newTreeData = (await treeRes.json()) as { sha: string };
const newTreeSha = newTreeData.sha;
// 2. Create commit
const createCommitUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/commits`;
const newCommitRes = await fetch(createCommitUrl, {
method: 'POST',
headers: this.getHeaders(token),
body: JSON.stringify({
message,
tree: newTreeSha,
parents: [commitSha],
}),
});
if (!newCommitRes.ok) {
const err = await newCommitRes.text();
throw new Error(`Failed to create commit on ${owner}/${repo} (${newCommitRes.status}): ${err}`);
}
const newCommitData = (await newCommitRes.json()) as { sha: string; html_url?: string };
const newCommitSha = newCommitData.sha;
// 3. Update branch ref
const updateRefUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/refs/heads/${encodeURIComponent(branch)}`;
const updateRefRes = await fetch(updateRefUrl, {
method: 'PATCH',
headers: this.getHeaders(token),
body: JSON.stringify({
sha: newCommitSha,
}),
});
if (!updateRefRes.ok) {
const err = await updateRefRes.text();
throw new Error(`Failed to update branch ref '${branch}' (${updateRefRes.status}): ${err}`);
}
return {
commitSha: newCommitSha,
branch,
commitUrl: `https://github.com/${owner}/${repo}/commit/${newCommitSha}`,
};
}
async createProposalPullRequest(
owner: string,
repo: string,
baseBranch: string,
changes: FileChange[],
title: string,
message: string,
token: string,
username: string
): Promise<ProposalResult> {
const { commitSha, treeSha } = await this.getLatestCommitSha(owner, repo, baseBranch, token);
// Create unique proposal branch name
const timestamp = Date.now();
const branchName = `evb/proposal-${timestamp}`;
// 1. Create the proposal branch ref pointing to baseBranch HEAD
const createRefUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/git/refs`;
const createRefRes = await fetch(createRefUrl, {
method: 'POST',
headers: this.getHeaders(token),
body: JSON.stringify({
ref: `refs/heads/${branchName}`,
sha: commitSha,
}),
});
let targetOwner = owner;
let isFork = false;
// If 403 or 404 on upstream ref creation, user needs a fork
if (!createRefRes.ok && (createRefRes.status === 403 || createRefRes.status === 404)) {
// 1. Check if user already has a fork repository
const checkForkUrl = `https://api.github.com/repos/${encodeURIComponent(username)}/${encodeURIComponent(repo)}`;
const checkForkRes = await fetch(checkForkUrl, {
headers: this.getHeaders(token),
});
if (!checkForkRes.ok) {
// Fork does not exist yet; attempt to create it
const forkUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/forks`;
const forkRes = await fetch(forkUrl, {
method: 'POST',
headers: this.getHeaders(token),
});
if (!forkRes.ok && forkRes.status !== 202 && forkRes.status !== 200) {
const err = await forkRes.text();
throw new Error(
`Cannot create proposal branch on ${owner}/${repo} (status ${createRefRes.status}) and failed to fork to ${username}/${repo} (${forkRes.status}): ${err}.\n` +
`Ensure you have forked ${owner}/${repo} to @${username} or your GitHub token has permissions to fork repositories.`
);
}
// Poll for newly created fork readiness
for (let attempt = 0; attempt < 5; attempt++) {
await new Promise((r) => setTimeout(r, 1000));
const pollRes = await fetch(checkForkUrl, { headers: this.getHeaders(token) });
if (pollRes.ok) {
break;
}
}
}
targetOwner = username;
isFork = true;
let forkCommitSha = commitSha;
try {
const forkBase = await this.getLatestCommitSha(username, repo, baseBranch, token);
forkCommitSha = forkBase.commitSha;
} catch {
// Fallback to upstream commitSha if fork base branch lookup is unavailable
}
const forkRefUrl = `https://api.github.com/repos/${encodeURIComponent(username)}/${encodeURIComponent(repo)}/git/refs`;
const forkRefRes = await fetch(forkRefUrl, {
method: 'POST',
headers: this.getHeaders(token),
body: JSON.stringify({
ref: `refs/heads/${branchName}`,
sha: forkCommitSha,
}),
});
if (!forkRefRes.ok) {
const err = await forkRefRes.text();
throw new Error(
`Failed to create proposal branch '${branchName}' in fork ${username}/${repo} (${forkRefRes.status}): ${err}`
);
}
}
// 2. Commit changes to the proposal branch
const commitResult = await this.createDirectCommit(
targetOwner,
repo,
branchName,
changes,
message,
token
);
// 3. Open Pull Request on upstream repository
const prUrl = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls`;
const headRef = isFork ? `${username}:${branchName}` : branchName;
const prRes = await fetch(prUrl, {
method: 'POST',
headers: this.getHeaders(token),
body: JSON.stringify({
title: title || message.split('\n')[0] || 'Ever-Brain Intelligence Update',
head: headRef,
base: baseBranch,
body: message,
}),
});
if (!prRes.ok) {
const err = await prRes.text();
throw new Error(`Failed to open Pull Request on ${owner}/${repo} (${prRes.status}): ${err}`);
}
const prData = (await prRes.json()) as { html_url: string; number: number };
return {
pullRequestUrl: prData.html_url,
pullRequestNumber: prData.number,
branch: branchName,
commitSha: commitResult.commitSha,
isFork,
};
}
}
export const githubWriterService = new GitHubWriterService();