diff --git a/bun.lockb b/bun.lockb index 3776951..12ce21d 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 979971c..ebf3840 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "create-cloudflare": "^2.8.3", "octokit": "^3.1.2", "smee-client": "^2.0.0", - "universal-github-app-jwt": "^2.0.5" + "universal-github-app-jwt": "^2.0.5", + "yaml": "^2.3.4" }, "devDependencies": { "@cloudflare/workers-types": "^4.20240117.0", diff --git a/src/github/github-event-handler.ts b/src/github/github-event-handler.ts index fbbb2d5..fc1a1dc 100644 --- a/src/github/github-event-handler.ts +++ b/src/github/github-event-handler.ts @@ -1,4 +1,4 @@ -import { Webhooks } from "@octokit/webhooks"; +import { EmitterWebhookEvent, Webhooks } from "@octokit/webhooks"; import { customOctokit } from "./github-client"; import { GitHubContext, SimplifiedContext } from "./github-context"; @@ -25,21 +25,7 @@ export class GitHubEventHandler { this.webhooks = new Webhooks({ secret: this._webhookSecret, - transform: (event) => { - let installationId: number | undefined = undefined; - if ("installation" in event.payload) { - installationId = event.payload.installation?.id; - } - const octokit = new customOctokit({ - auth: { - appId: this._appId, - privateKey: this._privateKey, - installationId: installationId, - }, - }); - - return new GitHubContext(event, octokit); - }, + transform: this.transformEvent, }); this.on = this.webhooks.on; @@ -53,4 +39,20 @@ export class GitHubEventHandler { console.error(error); }); } + + transformEvent(event: EmitterWebhookEvent) { + let installationId: number | undefined = undefined; + if ("installation" in event.payload) { + installationId = event.payload.installation?.id; + } + const octokit = new customOctokit({ + auth: { + appId: this._appId, + privateKey: this._privateKey, + installationId: installationId, + }, + }); + + return new GitHubContext(event, octokit); + } } diff --git a/src/github/handlers/index.ts b/src/github/handlers/index.ts index f349638..34ae1a7 100644 --- a/src/github/handlers/index.ts +++ b/src/github/handlers/index.ts @@ -1,6 +1,43 @@ +import { EmitterWebhookEvent } from "@octokit/webhooks"; import { GitHubEventHandler } from "../github-event-handler"; +import { getConfig } from "../utils/config"; import { issueCommentCreated } from "./issue-comment/created"; export function bindHandlers(webhooks: GitHubEventHandler) { webhooks.on("issue_comment.created", issueCommentCreated); + webhooks.onAny( + tryCatchWrapper(async (event) => { + const context = webhooks.transformEvent(event); + + const config = await getConfig(context); + + if (!config) { + console.log("No config found"); + return; + } + + const handler = config.handlers.events[event.name]; + + if (handler.length === 0) { + console.log(`No handler found for event ${event.name}`); + return; + } + + for (const { workflow } of handler) { + console.log(`Calling handler for event ${event.name} and workflow ${workflow}`); + + // TODO: call the workflow + } + }) + ); +} + +function tryCatchWrapper(fn: (event: EmitterWebhookEvent) => unknown) { + return async (event: EmitterWebhookEvent) => { + try { + await fn(event); + } catch (error) { + console.error("Error in event handler", error); + } + }; } diff --git a/src/github/types/config.ts b/src/github/types/config.ts new file mode 100644 index 0000000..cb79043 --- /dev/null +++ b/src/github/types/config.ts @@ -0,0 +1,25 @@ +import { Type as T } from "@sinclair/typebox"; +import { StaticDecode } from "@sinclair/typebox"; +import { GitHubEvent } from "./github-events"; + +enum Commands { + Start = "start", + Stop = "stop", +} + +const handlerSchema = T.Array( + T.Object({ + workflow: T.String(), + settings: T.Unknown(), + }), + { default: [] } +); + +export const configSchema = T.Object({ + handlers: T.Object({ + commands: T.Record(T.Enum(Commands), handlerSchema, { default: {} }), + events: T.Record(T.Enum(GitHubEvent), handlerSchema, { default: {} }), + }), +}); + +export type Config = StaticDecode; diff --git a/src/github/types/github-events.ts b/src/github/types/github-events.ts new file mode 100644 index 0000000..1ddca5e --- /dev/null +++ b/src/github/types/github-events.ts @@ -0,0 +1,248 @@ +export enum GitHubEvent { + "BRANCH_PROTECTION_RULE" = "branch_protection_rule", + "BRANCH_PROTECTION_RULE_CREATED" = "branch_protection_rule.created", + "BRANCH_PROTECTION_RULE_DELETED" = "branch_protection_rule.deleted", + "BRANCH_PROTECTION_RULE_EDITED" = "branch_protection_rule.edited", + "CHECK_RUN" = "check_run", + "CHECK_RUN_COMPLETED" = "check_run.completed", + "CHECK_RUN_CREATED" = "check_run.created", + "CHECK_RUN_REQUESTED_ACTION" = "check_run.requested_action", + "CHECK_RUN_REREQUESTED" = "check_run.rerequested", + "CHECK_SUITE" = "check_suite", + "CHECK_SUITE_COMPLETED" = "check_suite.completed", + "CHECK_SUITE_REQUESTED" = "check_suite.requested", + "CHECK_SUITE_fREREQUESTED" = "check_suite.rerequested", + "CODE_SCANNING_ALERT" = "code_scanning_alert", + "CODE_SCANNING_ALERT_APPEARED_IN_BRANCH" = "code_scanning_alert.appeared_in_branch", + "CODE_SCANNING_ALERT_CLOSED_BY_USER" = "code_scanning_alert.closed_by_user", + "CODE_SCANNING_ALERT_CREATED" = "code_scanning_alert.created", + "CODE_SCANNING_ALERT_FIXED" = "code_scanning_alert.fixed", + "CODE_SCANNING_ALERT_REOPENED" = "code_scanning_alert.reopened", + "CODE_SCANNING_ALERT_REOPENED_BY_USER" = "code_scanning_alert.reopened_by_user", + "COMMIT_COMMENT" = "commit_comment", + "COMMIT_COMMENT_CREATED" = "commit_comment.created", + "CREATE" = "create", + "DELETE" = "delete", + "DEPLOY_KEY" = "deploy_key", + "DEPLOY_KEY_CREATED" = "deploy_key.created", + "DEPLOY_KEY_DELETED" = "deploy_key.deleted", + "DEPLOYMENT" = "deployment", + "DEPLOYMENT_CREATED" = "deployment.created", + "DEPLOYMENT_STATUS" = "deployment_status", + "DEPLOYMENT_STATUS_CREATED" = "deployment_status.created", + "DISCUSSION" = "discussion", + "DISCUSSION_ANSWERED" = "discussion.answered", + "DISCUSSION_CATEGORY_CHANGED" = "discussion.category_changed", + "DISCUSSION_CREATED" = "discussion.created", + "DISCUSSION_DELETED" = "discussion.deleted", + "DISCUSSION_EDITED" = "discussion.edited", + "DISCUSSION_LABELED" = "discussion.labeled", + "DISCUSSION_LOCKED" = "discussion.locked", + "DISCUSSION_PINNED" = "discussion.pinned", + "DISCUSSION_TRANSFERRED" = "discussion.transferred", + "DISCUSSION_UNANSWERED" = "discussion.unanswered", + "DISCUSSION_UNLABELED" = "discussion.unlabeled", + "DISCUSSION_UNLOCKED" = "discussion.unlocked", + "DISCUSSION_UNPINNED" = "discussion.unpinned", + "DISCUSSION_COMMENT" = "discussion_comment", + "DISCUSSION_COMMENT_CREATED" = "discussion_comment.created", + "DISCUSSION_COMMENT_DELETED" = "discussion_comment.deleted", + "DISCUSSION_COMMENT_EDITED" = "discussion_comment.edited", + "FORK" = "fork", + "GITHUB_APP_AUTHORIZATION" = "github_app_authorization", + "GITHUB_APP_AUTHORIZATION_REVOKED" = "github_app_authorization.revoked", + "GOLLUM" = "gollum", + "INSTALLATION" = "installation", + "INSTALLATION_CREATED" = "installation.created", + "INSTALLATION_DELETED" = "installation.deleted", + "INSTALLATION_NEW_PERMISSIONS_ACCEPTED" = "installation.new_permissions_accepted", + "INSTALLATION_SUSPEND" = "installation.suspend", + "INSTALLATION_UNSUSPEND" = "installation.unsuspend", + "INSTALLATION_REPOSITORIES" = "installation_repositories", + "INSTALLATION_REPOSITORIES_ADDED" = "installation_repositories.added", + "INSTALLATION_REPOSITORIES_REMOVED" = "installation_repositories.removed", + "ISSUE_COMMENT" = "issue_comment", + "ISSUE_COMMENT_CREATED" = "issue_comment.created", + "ISSUE_COMMENT_DELETED" = "issue_comment.deleted", + "ISSUE_COMMENT_EDITED" = "issue_comment.edited", + "ISSUES" = "issues", + "ISSUES_ASSIGNED" = "issues.assigned", + "ISSUES_CLOSED" = "issues.closed", + "ISSUES_DELETED" = "issues.deleted", + "ISSUES_DEMILESTONED" = "issues.demilestoned", + "ISSUES_EDITED" = "issues.edited", + "ISSUES_LABELED" = "issues.labeled", + "ISSUES_LOCKED" = "issues.locked", + "ISSUES_MILESTONED" = "issues.milestoned", + "ISSUES_OPENED" = "issues.opened", + "ISSUES_PINNED" = "issues.pinned", + "ISSUES_REOPENED" = "issues.reopened", + "ISSUES_TRANSFERRED" = "issues.transferred", + "ISSUES_UNASSIGNED" = "issues.unassigned", + "ISSUES_UNLABELED" = "issues.unlabeled", + "ISSUES_UNLOCKED" = "issues.unlocked", + "ISSUES_UNPINNED" = "issues.unpinned", + "LABEL" = "label", + "LABEL_CREATED" = "label.created", + "LABEL_DELETED" = "label.deleted", + "LABEL_EDITED" = "label.edited", + "MARKETPLACE_PURCHASE" = "marketplace_purchase", + "MARKETPLACE_PURCHASE_CANCELLED" = "marketplace_purchase.cancelled", + "MARKETPLACE_PURCHASE_CHANGED" = "marketplace_purchase.changed", + "MARKETPLACE_PURCHASE_PENDING_CHANGE" = "marketplace_purchase.pending_change", + "MARKETPLACE_PURCHASE_PENDING_CHANGE_CANCELLED" = "marketplace_purchase.pending_change_cancelled", + "MARKETPLACE_PURCHASE_PURCHASED" = "marketplace_purchase.purchased", + "MEMBER" = "member", + "MEMBER_ADDED" = "member.added", + "MEMBER_EDITED" = "member.edited", + "MEMBER_REMOVED" = "member.removed", + "MEMBERSHIP" = "membership", + "MEMBERSHIP_ADDED" = "membership.added", + "MEMBERSHIP_REMOVED" = "membership.removed", + "META" = "meta", + "META_DELETED" = "meta.deleted", + "MILESTONE" = "milestone", + "MILESTONE_CLOSED" = "milestone.closed", + "MILESTONE_CREATED" = "milestone.created", + "MILESTONE_DELETED" = "milestone.deleted", + "MILESTONE_EDITED" = "milestone.edited", + "MILESTONE_OPENED" = "milestone.opened", + "ORG_BLOCK" = "org_block", + "ORG_BLOCK_BLOCKED" = "org_block.blocked", + "ORG_BLOCK_UNBLOCKED" = "org_block.unblocked", + "ORGANIZATION" = "organization", + "ORGANIZATION_DELETED" = "organization.deleted", + "ORGANIZATION_MEMBER_ADDED" = "organization.member_added", + "ORGANIZATION_MEMBER_INVITED" = "organization.member_invited", + "ORGANIZATION_MEMBER_REMOVED" = "organization.member_removed", + "ORGANIZATION_RENAMED" = "organization.renamed", + "PACKAGE" = "package", + "PACKAGE_PUBLISHED" = "package.published", + "PACKAGE_UPDATED" = "package.updated", + "PAGE_BUILD" = "page_build", + "PING" = "ping", + "PROJECT" = "project", + "PROJECT_CLOSED" = "project.closed", + "PROJECT_CREATED" = "project.created", + "PROJECT_DELETED" = "project.deleted", + "PROJECT_EDITED" = "project.edited", + "PROJECT_REOPENED" = "project.reopened", + "PROJECT_CARD" = "project_card", + "PROJECT_CARD_CONVERTED" = "project_card.converted", + "PROJECT_CARD_CREATED" = "project_card.created", + "PROJECT_CARD_DELETED" = "project_card.deleted", + "PROJECT_CARD_EDITED" = "project_card.edited", + "PROJECT_CARD_MOVED" = "project_card.moved", + "PROJECT_COLUMN" = "project_column", + "PROJECT_COLUMN_CREATED" = "project_column.created", + "PROJECT_COLUMN_DELETED" = "project_column.deleted", + "PROJECT_COLUMN_EDITED" = "project_column.edited", + "PROJECT_COLUMN_MOVED" = "project_column.moved", + "PROJECTS_V2_ITEM" = "projects_v2_item", + "PROJECTS_V2_ITEM_ARCHIVED" = "projects_v2_item.archived", + "PROJECTS_V2_ITEM_CONVERTED" = "projects_v2_item.converted", + "PROJECTS_V2_ITEM_CREATED" = "projects_v2_item.created", + "PROJECTS_V2_ITEM_DELETED" = "projects_v2_item.deleted", + "PROJECTS_V2_ITEM_EDITED" = "projects_v2_item.edited", + "PROJECTS_V2_ITEM_REORDERED" = "projects_v2_item.reordered", + "PROJECTS_V2_ITEM_RESTORED" = "projects_v2_item.restored", + "PUBLIC" = "public", + "PULL_REQUEST" = "pull_request", + "PULL_REQUEST_ASSIGNED" = "pull_request.assigned", + "PULL_REQUEST_AUTO_MERGE_DISABLED" = "pull_request.auto_merge_disabled", + "PULL_REQUEST_AUTO_MERGE_ENABLED" = "pull_request.auto_merge_enabled", + "PULL_REQUEST_CLOSED" = "pull_request.closed", + "PULL_REQUEST_CONVERTED_TO_DRAFT" = "pull_request.converted_to_draft", + "PULL_REQUEST_EDITED" = "pull_request.edited", + "PULL_REQUEST_LABELED" = "pull_request.labeled", + "PULL_REQUEST_LOCKED" = "pull_request.locked", + "PULL_REQUEST_OPENED" = "pull_request.opened", + "PULL_REQUEST_READY_FOR_REVIEW" = "pull_request.ready_for_review", + "PULL_REQUEST_REOPENED" = "pull_request.reopened", + "PULL_REQUEST_REVIEW_REQUEST_REMOVED" = "pull_request.review_request_removed", + "PULL_REQUEST_REVIEW_REQUESTED" = "pull_request.review_requested", + "PULL_REQUEST_SYNCHRONIZE" = "pull_request.synchronize", + "PULL_REQUEST_UNASSIGNED" = "pull_request.unassigned", + "PULL_REQUEST_UNLABELED" = "pull_request.unlabeled", + "PULL_REQUEST_UNLOCKED" = "pull_request.unlocked", + "PULL_REQUEST_REVIEW" = "pull_request_review", + "PULL_REQUEST_REVIEW_DISMISSED" = "pull_request_review.dismissed", + "PULL_REQUEST_REVIEW_EDITED" = "pull_request_review.edited", + "PULL_REQUEST_REVIEW_SUBMITTED" = "pull_request_review.submitted", + "PULL_REQUEST_REVIEW_COMMENT" = "pull_request_review_comment", + "PULL_REQUEST_REVIEW_COMMENT_CREATED" = "pull_request_review_comment.created", + "PULL_REQUEST_REVIEW_COMMENT_DELETED" = "pull_request_review_comment.deleted", + "PULL_REQUEST_REVIEW_COMMENT_EDITED" = "pull_request_review_comment.edited", + "PULL_REQUEST_REVIEW_THREAD" = "pull_request_review_thread", + "PULL_REQUEST_REVIEW_THREAD_RESOLVED" = "pull_request_review_thread.resolved", + "PULL_REQUEST_REVIEW_THREAD_UNRESOLVED" = "pull_request_review_thread.unresolved", + "PUSH" = "push", + "RELEASE" = "release", + "RELEASE_CREATED" = "release.created", + "RELEASE_DELETED" = "release.deleted", + "RELEASE_EDITED" = "release.edited", + "RELEASE_PRERELEASED" = "release.prereleased", + "RELEASE_PUBLISHED" = "release.published", + "RELEASE_RELEASED" = "release.released", + "RELEASE_UNPUBLISHED" = "release.unpublished", + "REPOSITORY" = "repository", + "REPOSITORY_ARCHIVED" = "repository.archived", + "REPOSITORY_CREATED" = "repository.created", + "REPOSITORY_DELETED" = "repository.deleted", + "REPOSITORY_EDITED" = "repository.edited", + "REPOSITORY_PRIVATIZED" = "repository.privatized", + "REPOSITORY_PUBLICIZED" = "repository.publicized", + "REPOSITORY_RENAMED" = "repository.renamed", + "REPOSITORY_TRANSFERRED" = "repository.transferred", + "REPOSITORY_UNARCHIVED" = "repository.unarchived", + "REPOSITORY_DISPATCH" = "repository_dispatch", + "REPOSITORY_IMPORT" = "repository_import", + "REPOSITORY_VULNERABILITY_ALERT" = "repository_vulnerability_alert", + "REPOSITORY_VULNERABILITY_ALERT_CREATE" = "repository_vulnerability_alert.create", + "REPOSITORY_VULNERABILITY_ALERT_DISMISS" = "repository_vulnerability_alert.dismiss", + "REPOSITORY_VULNERABILITY_ALERT_REOPEN" = "repository_vulnerability_alert.reopen", + "REPOSITORY_VULNERABILITY_ALERT_RESOLVE" = "repository_vulnerability_alert.resolve", + "SECRET_SCANNING_ALERT" = "secret_scanning_alert", + "SECRET_SCANNING_ALERT_CREATED" = "secret_scanning_alert.created", + "SECRET_SCANNING_ALERT_REOPENED" = "secret_scanning_alert.reopened", + "SECRET_SCANNING_ALERT_RESOLVED" = "secret_scanning_alert.resolved", + "SECURITY_ADVISORY" = "security_advisory", + "SECURITY_ADVISORY_PERFORMED" = "security_advisory.performed", + "SECURITY_ADVISORY_PUBLISHED" = "security_advisory.published", + "SECURITY_ADVISORY_UPDATED" = "security_advisory.updated", + "SECURITY_ADVISORY_WITHDRAWN" = "security_advisory.withdrawn", + "SPONSORSHIP" = "sponsorship", + "SPONSORSHIP_CANCELLED" = "sponsorship.cancelled", + "SPONSORSHIP_CREATED" = "sponsorship.created", + "SPONSORSHIP_EDITED" = "sponsorship.edited", + "SPONSORSHIP_PENDING_CANCELLATION" = "sponsorship.pending_cancellation", + "SPONSORSHIP_PENDING_TIER_CHANGE" = "sponsorship.pending_tier_change", + "SPONSORSHIP_TIER_CHANGED" = "sponsorship.tier_changed", + "STAR" = "star", + "STAR_CREATED" = "star.created", + "STAR_DELETED" = "star.deleted", + "STATUS" = "status", + "TEAM" = "team", + "TEAM_ADDED_TO_REPOSITORY" = "team.added_to_repository", + "TEAM_CREATED" = "team.created", + "TEAM_DELETED" = "team.deleted", + "TEAM_EDITED" = "team.edited", + "TEAM_REMOVED_FROM_REPOSITORY" = "team.removed_from_repository", + "TEAM_ADD" = "team_add", + "WATCH" = "watch", + "WATCH_STARTED" = "watch.started", + "WORKFLOW_DISPATCH" = "workflow_dispatch", + "WORKFLOW_JOB" = "workflow_job", + "WORKFLOW_JOB_COMPLETED" = "workflow_job.completed", + "WORKFLOW_JOB_IN_PROGRESS" = "workflow_job.in_progress", + "WORKFLOW_JOB_QUEUED" = "workflow_job.queued", + "WORKFLOW_RUN" = "workflow_run", + "WORKFLOW_RUN_COMPLETED" = "workflow_run.completed", + "WORKFLOW_RUN_REQUESTED" = "workflow_run.requested", + "DEPENDABOT_ALERT" = "dependabot_alert", + "DEPLOYMENT_PROTECTION_RULE" = "deployment_protection_rule", + "INSTALLATION_TARGET" = "installation_target", + "MERGE_GROUP" = "merge_group", + "REGISTRY_PACKAGE" = "registry_package", + "SECRET_SCANNING_ALERT_LOCATION" = "secret_scanning_alert_location", +} diff --git a/src/github/utils/config.ts b/src/github/utils/config.ts new file mode 100644 index 0000000..4a27ac4 --- /dev/null +++ b/src/github/utils/config.ts @@ -0,0 +1,53 @@ +import { Value } from "@sinclair/typebox/value"; +import { GitHubContext } from "../github-context"; +import YAML from "yaml"; +import { Config, configSchema } from "../types/config"; + +const UBIQUIBOT_CONFIG_FULL_PATH = ".github/ubiquibot-config.yml"; + +export async function getConfig(context: GitHubContext): Promise { + const payload = context.payload; + if (!("repository" in payload) || !payload.repository) throw new Error("Repository is not defined"); + + const _repoConfig = parseYaml( + await download({ + context, + repository: payload.repository.name, + owner: payload.repository.owner.login, + }) + ); + + try { + return Value.Decode(configSchema, _repoConfig); + } catch (error) { + console.error("Error decoding config", error); + return null; + } +} + +async function download({ context, repository, owner }: { context: GitHubContext; repository: string; owner: string }): Promise { + if (!repository || !owner) throw new Error("Repo or owner is not defined"); + try { + const { data } = await context.octokit.rest.repos.getContent({ + owner, + repo: repository, + path: UBIQUIBOT_CONFIG_FULL_PATH, + mediaType: { format: "raw" }, + }); + return data as unknown as string; // this will be a string if media format is raw + } catch (err) { + return null; + } +} + +export function parseYaml(data: null | string) { + try { + if (data) { + const parsedData = YAML.parse(data); + return parsedData ?? null; + } + } catch (error) { + console.error("Error parsing YAML", error); + } + return null; +}