mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-01-11 19:56:44 +00:00
519 lines
15 KiB
TypeScript
519 lines
15 KiB
TypeScript
import Execute from "../../Execute";
|
|
import logger from "../../Logger";
|
|
import HostedCodeRepository from "../HostedCodeRepository/HostedCodeRepository";
|
|
import HTTPErrorResponse from "../../../../Types/API/HTTPErrorResponse";
|
|
import HTTPResponse from "../../../../Types/API/HTTPResponse";
|
|
import URL from "../../../../Types/API/URL";
|
|
import PullRequest from "../../../../Types/CodeRepository/PullRequest";
|
|
import PullRequestState from "../../../../Types/CodeRepository/PullRequestState";
|
|
import OneUptimeDate from "../../../../Types/Date";
|
|
import { JSONArray, JSONObject } from "../../../../Types/JSON";
|
|
import API from "../../../../Utils/API";
|
|
import CaptureSpan from "../../Telemetry/CaptureSpan";
|
|
import {
|
|
GitHubAppId,
|
|
GitHubAppPrivateKey,
|
|
GitHubAppWebhookSecret,
|
|
} from "../../../EnvironmentConfig";
|
|
import BadDataException from "../../../../Types/Exception/BadDataException";
|
|
import * as crypto from "crypto";
|
|
|
|
export interface GitHubRepository {
|
|
id: number;
|
|
name: string;
|
|
fullName: string;
|
|
private: boolean;
|
|
htmlUrl: string;
|
|
description: string | null;
|
|
defaultBranch: string;
|
|
ownerLogin: string;
|
|
}
|
|
|
|
export interface GitHubInstallationToken {
|
|
token: string;
|
|
expiresAt: Date;
|
|
}
|
|
|
|
export default class GitHubUtil extends HostedCodeRepository {
|
|
private getPullRequestFromJSONObject(data: {
|
|
pullRequest: JSONObject;
|
|
organizationName: string;
|
|
repositoryName: string;
|
|
}): PullRequest {
|
|
let pullRequestState: PullRequestState =
|
|
data.pullRequest["state"] === "open"
|
|
? PullRequestState.Open
|
|
: PullRequestState.Closed;
|
|
|
|
if (data.pullRequest["merged_at"]) {
|
|
pullRequestState = PullRequestState.Merged;
|
|
}
|
|
|
|
return {
|
|
pullRequestId: data.pullRequest["id"] as number,
|
|
pullRequestNumber: data.pullRequest["number"] as number,
|
|
title: data.pullRequest["title"] as string,
|
|
body: data.pullRequest["body"] as string,
|
|
url: URL.fromString(data.pullRequest["url"] as string),
|
|
state: pullRequestState,
|
|
createdAt: OneUptimeDate.fromString(
|
|
data.pullRequest["created_at"] as string,
|
|
),
|
|
updatedAt: OneUptimeDate.fromString(
|
|
data.pullRequest["updated_at"] as string,
|
|
),
|
|
repoOrganizationName: data.organizationName,
|
|
repoName: data.repositoryName,
|
|
headRefName:
|
|
data.pullRequest["head"] &&
|
|
(data.pullRequest["head"] as JSONObject)["ref"]
|
|
? ((data.pullRequest["head"] as JSONObject)["ref"] as string)
|
|
: "",
|
|
};
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public async getPullRequestByNumber(data: {
|
|
organizationName: string;
|
|
repositoryName: string;
|
|
pullRequestId: string;
|
|
}): Promise<PullRequest> {
|
|
const gitHubToken: string = this.authToken;
|
|
|
|
const url: URL = URL.fromString(
|
|
`https://api.github.com/repos/${data.organizationName}/${data.repositoryName}/pulls/${data.pullRequestId}`,
|
|
);
|
|
|
|
const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.get({
|
|
url: url,
|
|
data: {},
|
|
headers: {
|
|
Authorization: `Bearer ${gitHubToken}`,
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (result instanceof HTTPErrorResponse) {
|
|
throw result;
|
|
}
|
|
|
|
return this.getPullRequestFromJSONObject({
|
|
pullRequest: result.data,
|
|
organizationName: data.organizationName,
|
|
repositoryName: data.repositoryName,
|
|
});
|
|
}
|
|
|
|
private async getPullRequestsByPage(data: {
|
|
organizationName: string;
|
|
repositoryName: string;
|
|
pullRequestState: PullRequestState;
|
|
baseBranchName: string;
|
|
page: number;
|
|
}): Promise<Array<PullRequest>> {
|
|
const gitHubToken: string = this.authToken;
|
|
|
|
const url: URL = URL.fromString(
|
|
`https://api.github.com/repos/${data.organizationName}/${data.repositoryName}/pulls?base=${data.baseBranchName}&state=${data.pullRequestState}&per_page=100&page=${data.page}`,
|
|
);
|
|
|
|
const result: HTTPErrorResponse | HTTPResponse<JSONArray> = await API.get({
|
|
url: url,
|
|
data: {},
|
|
headers: {
|
|
Authorization: `Bearer ${gitHubToken}`,
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (result instanceof HTTPErrorResponse) {
|
|
throw result;
|
|
}
|
|
|
|
const pullRequests: Array<PullRequest> = result.data.map(
|
|
(pullRequest: JSONObject) => {
|
|
return this.getPullRequestFromJSONObject({
|
|
pullRequest: pullRequest,
|
|
organizationName: data.organizationName,
|
|
repositoryName: data.repositoryName,
|
|
});
|
|
},
|
|
);
|
|
|
|
return pullRequests;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public override async getPullRequests(data: {
|
|
pullRequestState: PullRequestState;
|
|
baseBranchName: string;
|
|
organizationName: string;
|
|
repositoryName: string;
|
|
}): Promise<Array<PullRequest>> {
|
|
const allPullRequests: Array<PullRequest> = [];
|
|
|
|
let page: number = 1;
|
|
|
|
let pullRequests: Array<PullRequest> = await this.getPullRequestsByPage({
|
|
organizationName: data.organizationName,
|
|
repositoryName: data.repositoryName,
|
|
pullRequestState: data.pullRequestState,
|
|
baseBranchName: data.baseBranchName,
|
|
page: page,
|
|
});
|
|
|
|
/*
|
|
* Fetch all pull requests by paginating through the results
|
|
* 100 pull requests per page is the limit of the GitHub API
|
|
*/
|
|
while (pullRequests.length === page * 100 || page === 1) {
|
|
pullRequests = await this.getPullRequestsByPage({
|
|
organizationName: data.organizationName,
|
|
repositoryName: data.repositoryName,
|
|
pullRequestState: data.pullRequestState,
|
|
baseBranchName: data.baseBranchName,
|
|
page: page,
|
|
});
|
|
page++;
|
|
allPullRequests.push(...pullRequests);
|
|
}
|
|
|
|
return allPullRequests;
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public override async addRemote(data: {
|
|
remoteName: string;
|
|
organizationName: string;
|
|
repositoryName: string;
|
|
}): Promise<void> {
|
|
const url: URL = URL.fromString(
|
|
`https://github.com/${data.organizationName}/${data.repositoryName}.git`,
|
|
);
|
|
|
|
logger.debug(
|
|
`Adding remote '${data.remoteName}' for ${data.organizationName}/${data.repositoryName}`,
|
|
);
|
|
|
|
const result: string = await Execute.executeCommandFile({
|
|
command: "git",
|
|
args: ["remote", "add", data.remoteName, url.toString()],
|
|
cwd: process.cwd(),
|
|
});
|
|
|
|
logger.debug(result);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public override async pushChanges(data: {
|
|
branchName: string;
|
|
organizationName: string;
|
|
repositoryName: string;
|
|
repoPath: string;
|
|
}): Promise<void> {
|
|
const branchName: string = data.branchName;
|
|
|
|
const username: string = this.username;
|
|
const password: string = this.authToken;
|
|
|
|
logger.debug(
|
|
"Pushing changes to remote repository with username: " + username,
|
|
);
|
|
|
|
const encodedUsername: string = encodeURIComponent(username);
|
|
const encodedPassword: string = encodeURIComponent(password);
|
|
const remoteUrl: string = `https://${encodedUsername}:${encodedPassword}@github.com/${data.organizationName}/${data.repositoryName}.git`;
|
|
|
|
logger.debug(
|
|
`Pushing branch '${branchName}' to ${data.organizationName}/${data.repositoryName}`,
|
|
);
|
|
|
|
const result: string = await Execute.executeCommandFile({
|
|
command: "git",
|
|
args: ["push", "-u", remoteUrl, branchName],
|
|
cwd: data.repoPath,
|
|
});
|
|
|
|
logger.debug(result);
|
|
}
|
|
|
|
@CaptureSpan()
|
|
public override async createPullRequest(data: {
|
|
baseBranchName: string;
|
|
headBranchName: string;
|
|
organizationName: string;
|
|
repositoryName: string;
|
|
title: string;
|
|
body: string;
|
|
}): Promise<PullRequest> {
|
|
const gitHubToken: string = this.authToken;
|
|
|
|
const url: URL = URL.fromString(
|
|
`https://api.github.com/repos/${data.organizationName}/${data.repositoryName}/pulls`,
|
|
);
|
|
|
|
const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.post(
|
|
{
|
|
url: url,
|
|
data: {
|
|
base: data.baseBranchName,
|
|
head: data.headBranchName,
|
|
title: data.title,
|
|
body: data.body,
|
|
},
|
|
headers: {
|
|
Authorization: `Bearer ${gitHubToken}`,
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
},
|
|
);
|
|
|
|
if (result instanceof HTTPErrorResponse) {
|
|
throw result;
|
|
}
|
|
|
|
return this.getPullRequestFromJSONObject({
|
|
pullRequest: result.data,
|
|
organizationName: data.organizationName,
|
|
repositoryName: data.repositoryName,
|
|
});
|
|
}
|
|
|
|
// GitHub App Authentication Methods
|
|
|
|
/**
|
|
* Generates a JWT for GitHub App authentication
|
|
* @returns JWT string valid for 10 minutes
|
|
*/
|
|
@CaptureSpan()
|
|
public static generateAppJWT(): string {
|
|
if (!GitHubAppId) {
|
|
throw new BadDataException(
|
|
"GITHUB_APP_ID environment variable is not set",
|
|
);
|
|
}
|
|
|
|
if (!GitHubAppPrivateKey) {
|
|
throw new BadDataException(
|
|
"GITHUB_APP_PRIVATE_KEY environment variable is not set",
|
|
);
|
|
}
|
|
|
|
const now: number = Math.floor(Date.now() / 1000);
|
|
const payload: JSONObject = {
|
|
iat: now - 60, // Issued at time (60 seconds in the past to allow for clock drift)
|
|
exp: now + 600, // Expiration time (10 minutes from now)
|
|
iss: GitHubAppId,
|
|
};
|
|
|
|
// Create JWT header
|
|
const header: JSONObject = {
|
|
alg: "RS256",
|
|
typ: "JWT",
|
|
};
|
|
|
|
const encodedHeader: string = Buffer.from(JSON.stringify(header)).toString(
|
|
"base64url",
|
|
);
|
|
const encodedPayload: string = Buffer.from(
|
|
JSON.stringify(payload),
|
|
).toString("base64url");
|
|
|
|
const signatureInput: string = `${encodedHeader}.${encodedPayload}`;
|
|
|
|
// Sign with private key
|
|
const sign: crypto.Sign = crypto.createSign("RSA-SHA256");
|
|
sign.update(signatureInput);
|
|
const signature: string = sign.sign(GitHubAppPrivateKey, "base64url");
|
|
|
|
return `${signatureInput}.${signature}`;
|
|
}
|
|
|
|
/**
|
|
* Gets an installation access token for a GitHub App installation
|
|
* @param installationId - The GitHub App installation ID
|
|
* @param options - Optional configuration for the token
|
|
* @param options.permissions - Specific permissions to request for the token
|
|
* @returns Installation token and expiration date
|
|
*/
|
|
@CaptureSpan()
|
|
public static async getInstallationAccessToken(
|
|
installationId: string,
|
|
options?: {
|
|
permissions?: {
|
|
contents?: "read" | "write";
|
|
pull_requests?: "read" | "write";
|
|
metadata?: "read";
|
|
};
|
|
},
|
|
): Promise<GitHubInstallationToken> {
|
|
const jwt: string = GitHubUtil.generateAppJWT();
|
|
|
|
const url: URL = URL.fromString(
|
|
`https://api.github.com/app/installations/${installationId}/access_tokens`,
|
|
);
|
|
|
|
// Build request data with optional permissions
|
|
const requestData: JSONObject = {};
|
|
|
|
if (options?.permissions) {
|
|
requestData["permissions"] = options.permissions;
|
|
}
|
|
|
|
const result: HTTPErrorResponse | HTTPResponse<JSONObject> = await API.post(
|
|
{
|
|
url: url,
|
|
data: requestData,
|
|
headers: {
|
|
Authorization: `Bearer ${jwt}`,
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
},
|
|
);
|
|
|
|
if (result instanceof HTTPErrorResponse) {
|
|
// Check if this is a permission error and provide helpful message
|
|
const errorMessage: string =
|
|
(result.data as JSONObject)?.["message"]?.toString() || "";
|
|
|
|
if (
|
|
errorMessage.includes("permissions") ||
|
|
result.statusCode === 403 ||
|
|
result.statusCode === 422
|
|
) {
|
|
logger.error(
|
|
`GitHub App permission error: ${errorMessage}. ` +
|
|
`Please ensure the GitHub App is configured with the required permissions ` +
|
|
`(contents: write, pull_requests: write, metadata: read) in the GitHub App settings.`,
|
|
);
|
|
}
|
|
|
|
throw result;
|
|
}
|
|
|
|
return {
|
|
token: result.data["token"] as string,
|
|
expiresAt: OneUptimeDate.fromString(result.data["expires_at"] as string),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Lists repositories accessible to a GitHub App installation
|
|
* @param installationId - The GitHub App installation ID
|
|
* @returns Array of repositories
|
|
*/
|
|
@CaptureSpan()
|
|
public static async listRepositoriesForInstallation(
|
|
installationId: string,
|
|
): Promise<Array<GitHubRepository>> {
|
|
const tokenData: GitHubInstallationToken =
|
|
await GitHubUtil.getInstallationAccessToken(installationId);
|
|
|
|
const allRepositories: Array<GitHubRepository> = [];
|
|
let page: number = 1;
|
|
let hasMore: boolean = true;
|
|
|
|
while (hasMore) {
|
|
const url: URL = URL.fromString(
|
|
`https://api.github.com/installation/repositories?per_page=100&page=${page}`,
|
|
);
|
|
|
|
const result: HTTPErrorResponse | HTTPResponse<JSONObject> =
|
|
await API.get({
|
|
url: url,
|
|
data: {},
|
|
headers: {
|
|
Authorization: `Bearer ${tokenData.token}`,
|
|
Accept: "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
},
|
|
});
|
|
|
|
if (result instanceof HTTPErrorResponse) {
|
|
throw result;
|
|
}
|
|
|
|
const repositories: JSONArray =
|
|
(result.data["repositories"] as JSONArray) || [];
|
|
|
|
for (const repo of repositories) {
|
|
const repoData: JSONObject = repo as JSONObject;
|
|
const owner: JSONObject = repoData["owner"] as JSONObject;
|
|
|
|
allRepositories.push({
|
|
id: repoData["id"] as number,
|
|
name: repoData["name"] as string,
|
|
fullName: repoData["full_name"] as string,
|
|
private: repoData["private"] as boolean,
|
|
htmlUrl: repoData["html_url"] as string,
|
|
description: (repoData["description"] as string) || null,
|
|
defaultBranch: repoData["default_branch"] as string,
|
|
ownerLogin: owner["login"] as string,
|
|
});
|
|
}
|
|
|
|
// Check if there are more pages
|
|
if (repositories.length < 100) {
|
|
hasMore = false;
|
|
} else {
|
|
page++;
|
|
}
|
|
}
|
|
|
|
return allRepositories;
|
|
}
|
|
|
|
/**
|
|
* Verifies a GitHub webhook signature
|
|
* @param payload - The raw request body
|
|
* @param signature - The X-Hub-Signature-256 header value
|
|
* @returns true if signature is valid
|
|
*/
|
|
public static verifyWebhookSignature(
|
|
payload: string,
|
|
signature: string,
|
|
): boolean {
|
|
if (!GitHubAppWebhookSecret) {
|
|
logger.warn(
|
|
"GITHUB_APP_WEBHOOK_SECRET is not set, skipping verification",
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const expectedSignature: string = `sha256=${crypto
|
|
.createHmac("sha256", GitHubAppWebhookSecret)
|
|
.update(payload)
|
|
.digest("hex")}`;
|
|
|
|
try {
|
|
return crypto.timingSafeEqual(
|
|
Buffer.from(signature) as Uint8Array,
|
|
Buffer.from(expectedSignature) as Uint8Array,
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the GitHub App installation URL for a project to install the app
|
|
* @returns The installation URL
|
|
*/
|
|
public static getAppInstallationUrl(): string {
|
|
if (!GitHubAppId) {
|
|
throw new BadDataException(
|
|
"GITHUB_APP_ID environment variable is not set",
|
|
);
|
|
}
|
|
|
|
/*
|
|
* This is the standard GitHub App installation URL format
|
|
* The app slug would typically come from another env var, but we can use the client ID approach
|
|
*/
|
|
return `https://github.com/apps`;
|
|
}
|
|
}
|