mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-19 05:02:49 +00:00
746 lines
24 KiB
TypeScript
746 lines
24 KiB
TypeScript
import { createHmac, createPrivateKey, createSign, timingSafeEqual } from "node:crypto";
|
|
import { logger } from "../logging.js";
|
|
|
|
export class GitHubAppError extends Error {
|
|
readonly status: number;
|
|
|
|
constructor(message: string, status = 500) {
|
|
super(message);
|
|
this.name = "GitHubAppError";
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
export interface GitHubOAuthSession {
|
|
accessToken: string;
|
|
scopes: string[];
|
|
}
|
|
|
|
export interface GitHubViewerIdentity {
|
|
id: string;
|
|
login: string;
|
|
name: string;
|
|
email: string | null;
|
|
}
|
|
|
|
export interface GitHubOrgIdentity {
|
|
id: string;
|
|
login: string;
|
|
name: string | null;
|
|
}
|
|
|
|
export interface GitHubInstallationRecord {
|
|
id: number;
|
|
accountLogin: string;
|
|
}
|
|
|
|
export interface GitHubRepositoryRecord {
|
|
fullName: string;
|
|
cloneUrl: string;
|
|
private: boolean;
|
|
defaultBranch: string;
|
|
}
|
|
|
|
export interface GitHubBranchRecord {
|
|
name: string;
|
|
commitSha: string;
|
|
}
|
|
|
|
export interface GitHubMemberRecord {
|
|
id: string;
|
|
login: string;
|
|
name: string;
|
|
email: string | null;
|
|
role: string | null;
|
|
state: string;
|
|
}
|
|
|
|
export interface GitHubPullRequestRecord {
|
|
repoFullName: string;
|
|
cloneUrl: string;
|
|
number: number;
|
|
title: string;
|
|
body: string | null;
|
|
state: string;
|
|
url: string;
|
|
headRefName: string;
|
|
baseRefName: string;
|
|
authorLogin: string | null;
|
|
isDraft: boolean;
|
|
merged: boolean;
|
|
}
|
|
|
|
interface GitHubTokenResponse {
|
|
access_token?: string;
|
|
scope?: string;
|
|
error?: string;
|
|
error_description?: string;
|
|
}
|
|
|
|
interface GitHubPageResponse<T> {
|
|
items: T[];
|
|
nextUrl: string | null;
|
|
}
|
|
|
|
const githubOAuthLogger = logger.child({
|
|
scope: "github-oauth",
|
|
});
|
|
|
|
export interface GitHubWebhookEvent {
|
|
action?: string;
|
|
organization?: { login?: string; id?: number };
|
|
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
|
|
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
|
|
repositories_removed?: Array<{ id: number; full_name: string }>;
|
|
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
|
|
pull_request?: {
|
|
number: number;
|
|
title?: string;
|
|
body?: string | null;
|
|
state?: string;
|
|
html_url?: string;
|
|
draft?: boolean;
|
|
merged?: boolean;
|
|
user?: { login?: string } | null;
|
|
head?: { ref?: string };
|
|
base?: { ref?: string };
|
|
};
|
|
sender?: { login?: string; id?: number };
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
export interface GitHubAppClientOptions {
|
|
apiBaseUrl?: string;
|
|
authBaseUrl?: string;
|
|
clientId?: string;
|
|
clientSecret?: string;
|
|
redirectUri?: string;
|
|
appId?: string;
|
|
appPrivateKey?: string;
|
|
webhookSecret?: string;
|
|
}
|
|
|
|
function normalizePem(value: string | undefined): string | undefined {
|
|
if (!value) {
|
|
return value;
|
|
}
|
|
|
|
return value.includes("\\n") ? value.replace(/\\n/g, "\n") : value;
|
|
}
|
|
|
|
export class GitHubAppClient {
|
|
private readonly apiBaseUrl: string;
|
|
private readonly authBaseUrl: string;
|
|
private readonly clientId?: string;
|
|
private readonly clientSecret?: string;
|
|
private readonly redirectUri?: string;
|
|
private readonly appId?: string;
|
|
private readonly appPrivateKey?: string;
|
|
private readonly webhookSecret?: string;
|
|
|
|
constructor(options: GitHubAppClientOptions = {}) {
|
|
this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/$/, "");
|
|
this.authBaseUrl = (options.authBaseUrl ?? "https://github.com").replace(/\/$/, "");
|
|
this.clientId = options.clientId ?? process.env.GITHUB_CLIENT_ID;
|
|
this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET;
|
|
this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI;
|
|
this.appId = options.appId ?? process.env.GITHUB_APP_ID;
|
|
this.appPrivateKey = normalizePem(options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY);
|
|
this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET;
|
|
}
|
|
|
|
isOauthConfigured(): boolean {
|
|
return Boolean(this.clientId && this.clientSecret && this.redirectUri);
|
|
}
|
|
|
|
isAppConfigured(): boolean {
|
|
return Boolean(this.appId && this.appPrivateKey);
|
|
}
|
|
|
|
isWebhookConfigured(): boolean {
|
|
return Boolean(this.webhookSecret);
|
|
}
|
|
|
|
verifyWebhookEvent(payload: string, signatureHeader: string | null, eventHeader: string | null): { event: string; body: GitHubWebhookEvent } {
|
|
if (!this.webhookSecret) {
|
|
throw new GitHubAppError("GitHub webhook secret is not configured", 500);
|
|
}
|
|
if (!signatureHeader) {
|
|
throw new GitHubAppError("Missing GitHub signature header", 400);
|
|
}
|
|
if (!eventHeader) {
|
|
throw new GitHubAppError("Missing GitHub event header", 400);
|
|
}
|
|
|
|
const expectedSignature = signatureHeader.startsWith("sha256=") ? signatureHeader.slice(7) : null;
|
|
if (!expectedSignature) {
|
|
throw new GitHubAppError("Malformed GitHub signature header", 400);
|
|
}
|
|
|
|
const computed = createHmac("sha256", this.webhookSecret).update(payload).digest("hex");
|
|
const computedBuffer = Buffer.from(computed, "utf8");
|
|
const expectedBuffer = Buffer.from(expectedSignature, "utf8");
|
|
if (computedBuffer.length !== expectedBuffer.length || !timingSafeEqual(computedBuffer, expectedBuffer)) {
|
|
throw new GitHubAppError("GitHub webhook signature verification failed", 400);
|
|
}
|
|
|
|
return {
|
|
event: eventHeader,
|
|
body: JSON.parse(payload) as GitHubWebhookEvent,
|
|
};
|
|
}
|
|
|
|
buildAuthorizeUrl(state: string): string {
|
|
if (!this.clientId || !this.redirectUri) {
|
|
throw new GitHubAppError("GitHub OAuth is not configured", 500);
|
|
}
|
|
|
|
const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`);
|
|
url.searchParams.set("client_id", this.clientId);
|
|
url.searchParams.set("redirect_uri", this.redirectUri);
|
|
url.searchParams.set("scope", "read:user user:email read:org repo");
|
|
url.searchParams.set("state", state);
|
|
return url.toString();
|
|
}
|
|
|
|
async exchangeCode(code: string): Promise<GitHubOAuthSession> {
|
|
if (!this.clientId || !this.clientSecret || !this.redirectUri) {
|
|
throw new GitHubAppError("GitHub OAuth is not configured", 500);
|
|
}
|
|
|
|
const exchangeBody = {
|
|
client_id: this.clientId,
|
|
client_secret: this.clientSecret,
|
|
code,
|
|
redirect_uri: this.redirectUri,
|
|
};
|
|
githubOAuthLogger.debug(
|
|
{
|
|
url: `${this.authBaseUrl}/login/oauth/access_token`,
|
|
clientId: this.clientId,
|
|
redirectUri: this.redirectUri,
|
|
codeLength: code.length,
|
|
codePrefix: code.slice(0, 6),
|
|
},
|
|
"exchange_code_request",
|
|
);
|
|
|
|
const response = await fetch(`${this.authBaseUrl}/login/oauth/access_token`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/json",
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify(exchangeBody),
|
|
});
|
|
|
|
const responseText = await response.text();
|
|
githubOAuthLogger.debug(
|
|
{
|
|
status: response.status,
|
|
bodyPreview: responseText.slice(0, 300),
|
|
},
|
|
"exchange_code_response",
|
|
);
|
|
let payload: GitHubTokenResponse;
|
|
try {
|
|
payload = JSON.parse(responseText) as GitHubTokenResponse;
|
|
} catch {
|
|
// GitHub may return URL-encoded responses despite Accept: application/json
|
|
const params = new URLSearchParams(responseText);
|
|
if (params.has("access_token")) {
|
|
payload = {
|
|
access_token: params.get("access_token")!,
|
|
scope: params.get("scope") ?? "",
|
|
};
|
|
} else {
|
|
throw new GitHubAppError(
|
|
params.get("error_description") ?? params.get("error") ?? `GitHub token exchange failed: ${responseText.slice(0, 200)}`,
|
|
response.status || 502,
|
|
);
|
|
}
|
|
}
|
|
if (!response.ok || !payload.access_token) {
|
|
throw new GitHubAppError(payload.error_description ?? payload.error ?? `GitHub token exchange failed with ${response.status}`, response.status);
|
|
}
|
|
|
|
return {
|
|
accessToken: payload.access_token,
|
|
scopes:
|
|
payload.scope
|
|
?.split(",")
|
|
.map((value) => value.trim())
|
|
.filter((value) => value.length > 0) ?? [],
|
|
};
|
|
}
|
|
|
|
async getViewer(accessToken: string): Promise<GitHubViewerIdentity> {
|
|
const user = await this.requestJson<{
|
|
id: number;
|
|
login: string;
|
|
name?: string | null;
|
|
email?: string | null;
|
|
}>("/user", accessToken);
|
|
|
|
let email = user.email ?? null;
|
|
if (!email) {
|
|
try {
|
|
const emails = await this.requestJson<Array<{ email: string; primary?: boolean; verified?: boolean }>>("/user/emails", accessToken);
|
|
const primary = emails.find((candidate) => candidate.primary && candidate.verified) ?? emails[0] ?? null;
|
|
email = primary?.email ?? null;
|
|
} catch (error) {
|
|
if (!(error instanceof GitHubAppError) || error.status !== 404) {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
id: String(user.id),
|
|
login: user.login,
|
|
name: user.name?.trim() || user.login,
|
|
email,
|
|
};
|
|
}
|
|
|
|
async listOrganizations(accessToken: string): Promise<GitHubOrgIdentity[]> {
|
|
const organizations = await this.paginate<{ id: number; login: string; name?: string | null }>("/user/orgs?per_page=100", accessToken);
|
|
return organizations.map((organization) => ({
|
|
id: String(organization.id),
|
|
login: organization.login,
|
|
name: organization.name?.trim() || organization.login,
|
|
}));
|
|
}
|
|
|
|
async listInstallations(accessToken: string): Promise<GitHubInstallationRecord[]> {
|
|
if (!this.isAppConfigured()) {
|
|
return [];
|
|
}
|
|
try {
|
|
const payload = await this.requestJson<{
|
|
installations?: Array<{ id: number; account?: { login?: string } | null }>;
|
|
}>("/user/installations", accessToken);
|
|
|
|
return (payload.installations ?? [])
|
|
.map((installation) => ({
|
|
id: installation.id,
|
|
accountLogin: installation.account?.login?.trim() ?? "",
|
|
}))
|
|
.filter((installation) => installation.accountLogin.length > 0);
|
|
} catch (error) {
|
|
if (!(error instanceof GitHubAppError) || (error.status !== 401 && error.status !== 403)) {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
const installations = await this.paginateApp<{ id: number; account?: { login?: string } | null }>("/app/installations?per_page=100");
|
|
return installations
|
|
.map((installation) => ({
|
|
id: installation.id,
|
|
accountLogin: installation.account?.login?.trim() ?? "",
|
|
}))
|
|
.filter((installation) => installation.accountLogin.length > 0);
|
|
}
|
|
|
|
async listUserRepositories(accessToken: string): Promise<GitHubRepositoryRecord[]> {
|
|
const repositories = await this.paginate<{
|
|
full_name: string;
|
|
clone_url: string;
|
|
private: boolean;
|
|
default_branch: string;
|
|
}>("/user/repos?per_page=100&affiliation=owner,collaborator,organization_member&sort=updated", accessToken);
|
|
|
|
return repositories.map((repository) => ({
|
|
fullName: repository.full_name,
|
|
cloneUrl: repository.clone_url,
|
|
private: repository.private,
|
|
defaultBranch: repository.default_branch,
|
|
}));
|
|
}
|
|
|
|
async listInstallationRepositories(installationId: number): Promise<GitHubRepositoryRecord[]> {
|
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
|
const repositories = await this.paginate<{
|
|
full_name: string;
|
|
clone_url: string;
|
|
private: boolean;
|
|
default_branch: string;
|
|
}>("/installation/repositories?per_page=100", accessToken);
|
|
|
|
return repositories.map((repository) => ({
|
|
fullName: repository.full_name,
|
|
cloneUrl: repository.clone_url,
|
|
private: repository.private,
|
|
defaultBranch: repository.default_branch,
|
|
}));
|
|
}
|
|
|
|
async getUserRepository(accessToken: string, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
|
try {
|
|
const repository = await this.requestJson<{
|
|
full_name: string;
|
|
clone_url: string;
|
|
private: boolean;
|
|
default_branch: string;
|
|
}>(`/repos/${fullName}`, accessToken);
|
|
return {
|
|
fullName: repository.full_name,
|
|
cloneUrl: repository.clone_url,
|
|
private: repository.private,
|
|
defaultBranch: repository.default_branch,
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof GitHubAppError && error.status === 404) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getInstallationRepository(installationId: number, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
|
return await this.getUserRepository(accessToken, fullName);
|
|
}
|
|
|
|
async listUserRepositoryBranches(accessToken: string, fullName: string): Promise<GitHubBranchRecord[]> {
|
|
return await this.listRepositoryBranches(accessToken, fullName);
|
|
}
|
|
|
|
async listInstallationRepositoryBranches(installationId: number, fullName: string): Promise<GitHubBranchRecord[]> {
|
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
|
return await this.listRepositoryBranches(accessToken, fullName);
|
|
}
|
|
|
|
async listOrganizationMembers(accessToken: string, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
|
const members = await this.paginate<{
|
|
id: number;
|
|
login: string;
|
|
role?: string | null;
|
|
}>(`/orgs/${organizationLogin}/members?per_page=100&role=all`, accessToken);
|
|
|
|
const detailedMembers = await Promise.all(
|
|
members.map(async (member) => {
|
|
try {
|
|
const detail = await this.requestJson<{
|
|
id: number;
|
|
login: string;
|
|
name?: string | null;
|
|
email?: string | null;
|
|
}>(`/users/${member.login}`, accessToken);
|
|
return {
|
|
id: String(detail.id),
|
|
login: detail.login,
|
|
name: detail.name?.trim() || detail.login,
|
|
email: detail.email ?? null,
|
|
role: member.role ?? null,
|
|
state: "active",
|
|
};
|
|
} catch {
|
|
return {
|
|
id: String(member.id),
|
|
login: member.login,
|
|
name: member.login,
|
|
email: null,
|
|
role: member.role ?? null,
|
|
state: "active",
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
|
|
return detailedMembers;
|
|
}
|
|
|
|
async listInstallationMembers(installationId: number, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
|
return await this.listOrganizationMembers(accessToken, organizationLogin);
|
|
}
|
|
|
|
async listPullRequestsForUserRepositories(accessToken: string, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
|
return (await Promise.all(repositories.map((repository) => this.listRepositoryPullRequests(accessToken, repository.fullName, repository.cloneUrl)))).flat();
|
|
}
|
|
|
|
async listInstallationPullRequestsForRepositories(installationId: number, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
|
return await this.listPullRequestsForUserRepositories(accessToken, repositories);
|
|
}
|
|
|
|
async getUserPullRequest(accessToken: string, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
|
try {
|
|
const pullRequest = await this.requestJson<{
|
|
number: number;
|
|
title: string;
|
|
body?: string | null;
|
|
state: string;
|
|
html_url: string;
|
|
draft?: boolean;
|
|
merged?: boolean;
|
|
user?: { login?: string } | null;
|
|
head?: { ref?: string } | null;
|
|
base?: { ref?: string } | null;
|
|
}>(`/repos/${fullName}/pulls/${prNumber}`, accessToken);
|
|
const repository = await this.getUserRepository(accessToken, fullName);
|
|
if (!repository) {
|
|
return null;
|
|
}
|
|
return {
|
|
repoFullName: fullName,
|
|
cloneUrl: repository.cloneUrl,
|
|
number: pullRequest.number,
|
|
title: pullRequest.title,
|
|
body: pullRequest.body ?? null,
|
|
state: pullRequest.state,
|
|
url: pullRequest.html_url,
|
|
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
|
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
|
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
|
isDraft: Boolean(pullRequest.draft),
|
|
merged: Boolean(pullRequest.merged),
|
|
};
|
|
} catch (error) {
|
|
if (error instanceof GitHubAppError && error.status === 404) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async getInstallationPullRequest(installationId: number, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
|
return await this.getUserPullRequest(accessToken, fullName, prNumber);
|
|
}
|
|
|
|
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
|
|
if (!this.isAppConfigured()) {
|
|
throw new GitHubAppError("GitHub App is not configured", 500);
|
|
}
|
|
const app = await this.requestAppJson<{ slug?: string }>("/app");
|
|
if (!app.slug) {
|
|
throw new GitHubAppError("GitHub App slug is unavailable", 500);
|
|
}
|
|
const url = new URL(`${this.authBaseUrl}/apps/${app.slug}/installations/new`);
|
|
url.searchParams.set("state", state);
|
|
void organizationLogin;
|
|
return url.toString();
|
|
}
|
|
|
|
private async createInstallationAccessToken(installationId: number): Promise<string> {
|
|
if (!this.appId || !this.appPrivateKey) {
|
|
throw new GitHubAppError("GitHub App is not configured", 500);
|
|
}
|
|
|
|
const response = await fetch(`${this.apiBaseUrl}/app/installations/${installationId}/access_tokens`, {
|
|
method: "POST",
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${this.createAppJwt()}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
const payload = (await response.json()) as { token?: string; message?: string };
|
|
if (!response.ok || !payload.token) {
|
|
throw new GitHubAppError(payload.message ?? "Unable to mint GitHub installation token", response.status);
|
|
}
|
|
return payload.token;
|
|
}
|
|
|
|
private createAppJwt(): string {
|
|
if (!this.appId || !this.appPrivateKey) {
|
|
throw new GitHubAppError("GitHub App is not configured", 500);
|
|
}
|
|
|
|
const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = base64UrlEncode(
|
|
JSON.stringify({
|
|
iat: now - 60,
|
|
exp: now + 540,
|
|
iss: this.appId,
|
|
}),
|
|
);
|
|
const signer = createSign("RSA-SHA256");
|
|
signer.update(`${header}.${payload}`);
|
|
signer.end();
|
|
const key = createPrivateKey(this.appPrivateKey);
|
|
const signature = signer.sign(key);
|
|
return `${header}.${payload}.${base64UrlEncode(signature)}`;
|
|
}
|
|
|
|
private async requestAppJson<T>(path: string): Promise<T> {
|
|
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${this.createAppJwt()}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
const payload = (await response.json()) as T | { message?: string };
|
|
if (!response.ok) {
|
|
throw new GitHubAppError(
|
|
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
|
|
response.status,
|
|
);
|
|
}
|
|
return payload as T;
|
|
}
|
|
|
|
private async paginateApp<T>(path: string): Promise<T[]> {
|
|
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
const items: T[] = [];
|
|
|
|
while (nextUrl) {
|
|
const page = await this.requestAppPage<T>(nextUrl);
|
|
items.push(...page.items);
|
|
nextUrl = page.nextUrl ?? "";
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private async requestJson<T>(path: string, accessToken: string): Promise<T> {
|
|
const response = await fetch(`${this.apiBaseUrl}${path}`, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
const payload = (await response.json()) as T | { message?: string };
|
|
if (!response.ok) {
|
|
throw new GitHubAppError(
|
|
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
|
|
response.status,
|
|
);
|
|
}
|
|
return payload as T;
|
|
}
|
|
|
|
private async listRepositoryPullRequests(accessToken: string, fullName: string, cloneUrl: string): Promise<GitHubPullRequestRecord[]> {
|
|
const pullRequests = await this.paginate<{
|
|
number: number;
|
|
title: string;
|
|
body?: string | null;
|
|
state: string;
|
|
html_url: string;
|
|
draft?: boolean;
|
|
merged?: boolean;
|
|
user?: { login?: string } | null;
|
|
head?: { ref?: string } | null;
|
|
base?: { ref?: string } | null;
|
|
}>(`/repos/${fullName}/pulls?state=open&per_page=100&sort=updated&direction=desc`, accessToken);
|
|
|
|
return pullRequests.map((pullRequest) => ({
|
|
repoFullName: fullName,
|
|
cloneUrl,
|
|
number: pullRequest.number,
|
|
title: pullRequest.title,
|
|
body: pullRequest.body ?? null,
|
|
state: pullRequest.state,
|
|
url: pullRequest.html_url,
|
|
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
|
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
|
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
|
isDraft: Boolean(pullRequest.draft),
|
|
merged: Boolean(pullRequest.merged),
|
|
}));
|
|
}
|
|
|
|
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
|
|
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
|
const items: T[] = [];
|
|
|
|
while (nextUrl) {
|
|
const page = await this.requestPage<T>(nextUrl, accessToken);
|
|
items.push(...page.items);
|
|
nextUrl = page.nextUrl ?? "";
|
|
}
|
|
|
|
return items;
|
|
}
|
|
|
|
private async requestPage<T>(url: string, accessToken: string): Promise<GitHubPageResponse<T>> {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${accessToken}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
const payload = (await response.json()) as T[] | { repositories?: T[]; message?: string };
|
|
if (!response.ok) {
|
|
throw new GitHubAppError(
|
|
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
|
|
response.status,
|
|
);
|
|
}
|
|
|
|
const items = Array.isArray(payload) ? payload : (payload.repositories ?? []);
|
|
return {
|
|
items,
|
|
nextUrl: parseNextLink(response.headers.get("link")),
|
|
};
|
|
}
|
|
|
|
private async requestAppPage<T>(url: string): Promise<GitHubPageResponse<T>> {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${this.createAppJwt()}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
const payload = (await response.json()) as T[] | { installations?: T[]; message?: string };
|
|
if (!response.ok) {
|
|
throw new GitHubAppError(
|
|
typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed",
|
|
response.status,
|
|
);
|
|
}
|
|
|
|
const items = Array.isArray(payload) ? payload : (payload.installations ?? []);
|
|
return {
|
|
items,
|
|
nextUrl: parseNextLink(response.headers.get("link")),
|
|
};
|
|
}
|
|
|
|
private async listRepositoryBranches(accessToken: string, fullName: string): Promise<GitHubBranchRecord[]> {
|
|
const branches = await this.paginate<{
|
|
name: string;
|
|
commit?: { sha?: string | null } | null;
|
|
}>(`/repos/${fullName}/branches?per_page=100`, accessToken);
|
|
|
|
return branches
|
|
.map((branch) => ({
|
|
name: branch.name?.trim() ?? "",
|
|
commitSha: branch.commit?.sha?.trim() ?? "",
|
|
}))
|
|
.filter((branch) => branch.name.length > 0 && branch.commitSha.length > 0);
|
|
}
|
|
}
|
|
|
|
function parseNextLink(linkHeader: string | null): string | null {
|
|
if (!linkHeader) {
|
|
return null;
|
|
}
|
|
|
|
for (const part of linkHeader.split(",")) {
|
|
const [urlPart, relPart] = part.split(";").map((value) => value.trim());
|
|
if (!urlPart || !relPart || !relPart.includes('rel="next"')) {
|
|
continue;
|
|
}
|
|
return urlPart.replace(/^<|>$/g, "");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function base64UrlEncode(value: string | Buffer): string {
|
|
const source = typeof value === "string" ? Buffer.from(value, "utf8") : value;
|
|
return source.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
}
|