Skip to content
Open
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
49 changes: 49 additions & 0 deletions src/lib/endgitConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { parse } from "yaml";

export interface EndgitConfig {
name?: string;
icon?: string;
branch?: string[];
}

export function parseEndgitConfig(rawYaml: string): EndgitConfig {
const doc = parse(rawYaml);

if (!doc || typeof doc !== "object") {
throw new Error("Invalid .endgit.yml: must be a YAML object");
}

const config: EndgitConfig = {};

if (doc.name !== undefined) {
if (typeof doc.name !== "string") {
throw new Error("Invalid .endgit.yml: 'name' must be a string");
}
if (doc.name.length > 64) {
throw new Error("Invalid .endgit.yml: 'name' must be 64 characters or less");
}
config.name = doc.name;
}

if (doc.icon !== undefined) {
if (typeof doc.icon !== "string") {
throw new Error("Invalid .endgit.yml: 'icon' must be a string");
}
config.icon = doc.icon;
}

if (doc.branch !== undefined) {
if (typeof doc.branch === "string") {
config.branch = [doc.branch];
} else if (Array.isArray(doc.branch)) {
if (!doc.branch.every((b: unknown) => typeof b === "string")) {
throw new Error("Invalid .endgit.yml: 'branch' must be a string or array of strings");
}
config.branch = doc.branch;
} else {
throw new Error("Invalid .endgit.yml: 'branch' must be a string or array of strings");
}
}

return config;
}
102 changes: 102 additions & 0 deletions src/lib/githubApp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import * as crypto from "crypto";

const GITHUB_APP_ID = process.env.GITHUB_APP_ID || "";
const GITHUB_APP_PRIVATE_KEY = (
process.env.GITHUB_APP_PRIVATE_KEY || ""
).replace(/\\n/g, "\n");

function generateJWT(): string {
const now = Math.floor(Date.now() / 1000);
const header = Buffer.from(
JSON.stringify({ alg: "RS256", typ: "JWT" }),
).toString("base64url");
const payload = Buffer.from(
JSON.stringify({ iat: now - 60, exp: now + 600, iss: GITHUB_APP_ID }),
).toString("base64url");

const signature = crypto
.createSign("RSA-SHA256")
.update(`${header}.${payload}`)
.sign(GITHUB_APP_PRIVATE_KEY, "base64url");

return `${header}.${payload}.${signature}`;
}

export async function getInstallationId(
accessToken: string,
): Promise<number | null> {
const res = await fetch("https://api.github.com/user/installations", {
headers: {
Authorization: `Bearer ${accessToken}`,
Accept: "application/vnd.github.v3+json",
},
});

if (!res.ok) return null;

const data = (await res.json()) as any;
const appId = parseInt(GITHUB_APP_ID);
const appSlug = process.env.GITHUB_APP_SLUG || "endgit-local-dev";

const installation = data.installations?.find(
(inst: any) =>
inst.app_id === appId ||
inst.app_slug === appSlug ||
(inst.app_slug && inst.app_slug.includes("endgit")),
);

return installation?.id || null;
}

export async function getInstallationToken(
installationId: number,
): Promise<string | null> {
const jwt = generateJWT();

const res = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
Authorization: `Bearer ${jwt}`,
Accept: "application/vnd.github.v3+json",
},
},
);

if (!res.ok) return null;

const data = (await res.json()) as any;
return data.token || null;
}

export async function commitFileToRepo(
installationToken: string,
owner: string,
repo: string,
path: string,
content: string,
message: string,
branch: string,
): Promise<boolean> {
const encoded = Buffer.from(content).toString("base64");

const res = await fetch(
`https://api.github.com/repos/${owner}/${repo}/contents/${path}`,
{
method: "PUT",
headers: {
Authorization: `Bearer ${installationToken}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json",
},
body: JSON.stringify({
message,
content: encoded,
branch,
}),
},
);

return res.ok;
}
Loading