diff --git a/.cspell.json b/.cspell.json index 4aa417b..c5d2d42 100644 --- a/.cspell.json +++ b/.cspell.json @@ -4,7 +4,7 @@ "ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log"], "useGitignore": true, "language": "en", - "words": ["dataurl", "devpool", "fkey", "mswjs", "outdir", "servedir", "supabase", "typebox", "ubiquibot", "smee", "tomlify"], + "words": ["dataurl", "devpool", "fkey", "mswjs", "outdir", "servedir", "supabase", "typebox", "ubiquibot", "smee", "tomlify", "hono"], "dictionaries": ["typescript", "node", "software-terms"], "import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"], "ignoreRegExpList": ["[0-9a-fA-F]{6}"] diff --git a/.github/knip.ts b/.github/knip.ts index df72ff7..c7dbb21 100644 --- a/.github/knip.ts +++ b/.github/knip.ts @@ -1,11 +1,12 @@ import type { KnipConfig } from "knip"; const config: KnipConfig = { - entry: ["src/worker.ts"], + entry: ["src/worker.ts", "deploy/setup-kv-namespace.ts"], project: ["src/**/*.ts"], + ignore: ["jest.config.ts"], ignoreBinaries: ["i"], ignoreExportsUsedInFile: true, - ignoreDependencies: ["@mswjs/data", "esbuild", "eslint-config-prettier", "eslint-plugin-prettier", "msw"], + ignoreDependencies: ["@mswjs/data", "esbuild", "eslint-config-prettier", "eslint-plugin-prettier", "msw", "ts-node"], }; export default config; diff --git a/bun.lockb b/bun.lockb old mode 100644 new mode 100755 index 971b8a4..b8854d2 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/eslint.config.mjs b/eslint.config.mjs index acbd01f..dde353e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,7 @@ export default tsEslint.config({ "@typescript-eslint": tsEslint.plugin, "check-file": checkFile, }, - ignores: [".github/knip.ts", "**/.wrangler/**"], + ignores: [".github/knip.ts", "**/.wrangler/**", "jest.config.ts"], extends: [eslint.configs.recommended, ...tsEslint.configs.recommended, sonarjs.configs.recommended], languageOptions: { parser: tsEslint.parser, diff --git a/jest.config.json b/jest.config.json deleted file mode 100644 index 2796b3f..0000000 --- a/jest.config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "preset": "ts-jest", - "testEnvironment": "node", - "roots": ["./tests"], - "coveragePathIgnorePatterns": ["node_modules", "mocks"], - "collectCoverage": true, - "coverageReporters": ["json", "lcov", "text", "clover", "json-summary"], - "reporters": ["default", "jest-junit", "jest-md-dashboard"], - "coverageDirectory": "coverage", - "setupFiles": ["dotenv/config"] -} diff --git a/jest.config.ts b/jest.config.ts new file mode 100644 index 0000000..2c8ef92 --- /dev/null +++ b/jest.config.ts @@ -0,0 +1,21 @@ +import type { Config } from "jest"; + +const config: Config = { + testEnvironment: "node", + roots: ["./tests"], + coveragePathIgnorePatterns: ["node_modules", "mocks"], + collectCoverage: true, + coverageReporters: ["json", "lcov", "text", "clover", "json-summary"], + reporters: ["default", "jest-junit"], + coverageDirectory: "coverage", + verbose: true, + transformIgnorePatterns: [], + transform: { + "^.+\\.[j|t]s$": "@swc/jest", + }, + moduleNameMapper: { + "@octokit/webhooks-methods": "/node_modules/@octokit/webhooks-methods/dist-node/index.js", + }, +}; + +export default config; diff --git a/package.json b/package.json index c66de11..bb96988 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,21 @@ { - "name": "ubiquibot-kernel", - "version": "0.0.0", + "name": "@ubiquity-dao/ubiquibot-kernel", + "version": "0.0.1", "private": false, "description": "The kernel for UbiquiBot.", - "main": "src/worker.ts", + "module": "dist/esm/index.js", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js", + "import": "./dist/esm/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist" + ], "author": "Ubiquity DAO", "license": "MIT", "engines": { @@ -25,7 +37,8 @@ "knip-ci": "knip --no-exit-code --reporter json --config .github/knip.ts", "jest:test": "jest --coverage", "plugin:hello-world": "tsx tests/__mocks__/hello-world-plugin.ts", - "setup-kv": "bun --env-file=.dev.vars deploy/setup-kv-namespace.ts" + "setup-kv": "bun --env-file=.dev.vars deploy/setup-kv-namespace.ts", + "sdk:build": "tsup" }, "keywords": [ "typescript", @@ -44,22 +57,30 @@ "@octokit/types": "13.5.0", "@octokit/webhooks": "13.2.8", "@octokit/webhooks-types": "7.5.1", - "@sinclair/typebox": "0.32.34", + "@sinclair/typebox": "0.32.35", + "@ubiquity-dao/ubiquibot-logger": "1.3.0", "dotenv": "16.4.5", - "jest": "29.7.0", + "hono": "4.4.13", "smee-client": "2.0.1", + "ts-node": "^10.9.2", "typebox-validators": "0.3.5", "yaml": "2.4.5" }, "devDependencies": { - "@cloudflare/workers-types": "^4.20240117.0", + "@swc/core": "1.6.5", + "@swc/jest": "0.2.36", + "tsup": "8.1.0", + "@jest/globals": "29.7.0", + "@types/jest": "29.5.12", + "jest": "29.7.0", + "jest-junit": "16.0.0", + "@cloudflare/workers-types": "4.20240117.0", "@commitlint/cli": "19.3.0", "@commitlint/config-conventional": "19.2.2", "@cspell/dict-node": "5.0.1", "@cspell/dict-software-terms": "3.4.6", "@cspell/dict-typescript": "3.1.5", "@eslint/js": "9.7.0", - "@jest/globals": "29.7.0", "@mswjs/data": "0.16.1", "@mswjs/http-middleware": "0.10.1", "@types/node": "20.14.10", @@ -71,15 +92,12 @@ "eslint-plugin-prettier": "5.1.3", "eslint-plugin-sonarjs": "1.0.3", "husky": "9.0.11", - "jest-junit": "16.0.0", - "jest-md-dashboard": "0.8.0", "knip": "5.26.0", "lint-staged": "15.2.7", "npm-run-all": "4.1.5", "prettier": "3.3.3", "toml": "3.0.0", "tomlify-j0.4": "3.0.0", - "ts-jest": "29.2.2", "tsx": "4.16.2", "typescript": "5.5.3", "typescript-eslint": "7.16.0", diff --git a/src/github/github-client.ts b/src/github/github-client.ts index bb28289..7eea678 100644 --- a/src/github/github-client.ts +++ b/src/github/github-client.ts @@ -1,7 +1,7 @@ import { Octokit } from "@octokit/core"; import { RequestOptions } from "@octokit/types"; import { paginateRest } from "@octokit/plugin-paginate-rest"; -import { legacyRestEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; +import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; import { retry } from "@octokit/plugin-retry"; import { throttling } from "@octokit/plugin-throttling"; import { createAppAuth } from "@octokit/auth-app"; @@ -46,6 +46,6 @@ function requestLogging(octokit: Octokit) { }); } -export const customOctokit = Octokit.plugin(throttling, retry, paginateRest, legacyRestEndpointMethods, requestLogging).defaults((instanceOptions: object) => { +export const customOctokit = Octokit.plugin(throttling, retry, paginateRest, restEndpointMethods, requestLogging).defaults((instanceOptions: object) => { return Object.assign({}, defaultOptions, instanceOptions); }); diff --git a/src/github/handlers/help-command.ts b/src/github/handlers/help-command.ts index 0acf5db..f5fbeb3 100644 --- a/src/github/handlers/help-command.ts +++ b/src/github/handlers/help-command.ts @@ -27,7 +27,7 @@ export async function postHelpCommand(context: GitHubContext<"issue_comment.crea const { plugin } = pluginElement.uses[0]; commands.push(...(await parseCommandsFromManifest(context, plugin))); } - await context.octokit.issues.createComment({ + await context.octokit.rest.issues.createComment({ body: comments.concat(commands.sort()).join("\n"), issue_number: context.payload.issue.number, owner: context.payload.repository.owner.login, diff --git a/src/github/handlers/index.ts b/src/github/handlers/index.ts index 6082c06..eebf9d9 100644 --- a/src/github/handlers/index.ts +++ b/src/github/handlers/index.ts @@ -34,6 +34,7 @@ async function shouldSkipPlugin(event: EmitterWebhookEvent, context: GitHubConte if ( context.key === "issue_comment.created" && manifest && + manifest.commands && !Object.keys(manifest.commands).some( (command) => "comment" in context.payload && typeof context.payload.comment !== "string" && context.payload.comment?.body.startsWith(`/${command}`) ) diff --git a/src/github/utils/config.ts b/src/github/utils/config.ts index ba982d1..5b46241 100644 --- a/src/github/utils/config.ts +++ b/src/github/utils/config.ts @@ -5,8 +5,8 @@ import { expressionRegex } from "../types/plugin"; import { configSchema, configSchemaValidator, PluginConfiguration } from "../types/plugin-configuration"; import { getManifest } from "./plugins"; -const UBIQUIBOT_CONFIG_FULL_PATH = ".github/.ubiquibot-config.yml"; -const UBIQUIBOT_CONFIG_ORG_REPO = "ubiquibot-config"; +const CONFIG_FULL_PATH = ".github/.ubiquibot-config.yml"; +const CONFIG_ORG_REPO = "ubiquibot-config"; async function getConfigurationFromRepo(context: GitHubContext, repository: string, owner: string) { const targetRepoConfiguration: PluginConfiguration = parseYaml( @@ -60,7 +60,7 @@ export async function getConfig(context: GitHubContext): Promise> { - const installations = await context.octokit.apps.listInstallations(); + const installations = await context.octokit.rest.apps.listInstallations(); const installation = installations.data.find((inst) => inst.account?.login === owner); if (!installation) { @@ -23,7 +23,7 @@ async function getInstallationOctokitForOrg(context: GitHubContext, owner: strin export async function dispatchWorkflow(context: GitHubContext, options: WorkflowDispatchOptions) { const authenticatedOctokit = await getInstallationOctokitForOrg(context, options.owner); - return await authenticatedOctokit.actions.createWorkflowDispatch({ + return await authenticatedOctokit.rest.actions.createWorkflowDispatch({ owner: options.owner, repo: options.repository, workflow_id: options.workflowId, @@ -45,7 +45,7 @@ export async function dispatchWorker(targetUrl: string, payload?: Record { + eventName: TSupportedEvents; + payload: { + [K in TSupportedEvents]: K extends WebhookEventName ? WebhookEvent : never; + }[TSupportedEvents]["payload"]; + octokit: InstanceType; + config: TConfig; + env: TEnv; + logger: Logs; +} diff --git a/src/sdk/index.ts b/src/sdk/index.ts new file mode 100644 index 0000000..7cadb41 --- /dev/null +++ b/src/sdk/index.ts @@ -0,0 +1,2 @@ +export { createPlugin } from "./server"; +export type { Context } from "./context"; diff --git a/src/sdk/octokit.ts b/src/sdk/octokit.ts new file mode 100644 index 0000000..31ed90a --- /dev/null +++ b/src/sdk/octokit.ts @@ -0,0 +1,27 @@ +import { Octokit } from "@octokit/core"; +import { RequestOptions } from "@octokit/types"; +import { paginateRest } from "@octokit/plugin-paginate-rest"; +import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods"; +import { retry } from "@octokit/plugin-retry"; +import { throttling } from "@octokit/plugin-throttling"; + +const defaultOptions = { + throttle: { + onAbuseLimit: (retryAfter: number, options: RequestOptions, octokit: Octokit) => { + octokit.log.warn(`Abuse limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`); + return true; + }, + onRateLimit: (retryAfter: number, options: RequestOptions, octokit: Octokit) => { + octokit.log.warn(`Rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`); + return true; + }, + onSecondaryRateLimit: (retryAfter: number, options: RequestOptions, octokit: Octokit) => { + octokit.log.warn(`Secondary rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`); + return true; + }, + }, +}; + +export const customOctokit = Octokit.plugin(throttling, retry, paginateRest, restEndpointMethods).defaults((instanceOptions: object) => { + return Object.assign({}, defaultOptions, instanceOptions); +}); diff --git a/src/sdk/server.ts b/src/sdk/server.ts new file mode 100644 index 0000000..6285d1e --- /dev/null +++ b/src/sdk/server.ts @@ -0,0 +1,65 @@ +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { Context } from "./context"; +import { customOctokit } from "./octokit"; +import { EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; +import { verifySignature } from "./signature"; +import { KERNEL_PUBLIC_KEY } from "./constants"; +import { Logs, LogLevel, LOG_LEVEL } from "@ubiquity-dao/ubiquibot-logger"; +import { Manifest } from "../types/manifest"; + +interface Options { + kernelPublicKey?: string; + logLevel?: LogLevel; +} + +export async function createPlugin( + handler: (context: Context) => Promise | undefined>, + manifest: Manifest, + options?: Options +) { + const app = new Hono(); + + app.get("/manifest.json", (ctx) => { + return ctx.json(manifest); + }); + + app.post("/", async (ctx) => { + if (ctx.req.header("content-type") !== "application/json") { + throw new HTTPException(400, { message: "Content-Type must be application/json" }); + } + + const payload = await ctx.req.json(); + const signature = payload.signature; + delete payload.signature; + if (!(await verifySignature(options?.kernelPublicKey || KERNEL_PUBLIC_KEY, payload, signature))) { + throw new HTTPException(400, { message: "Invalid signature" }); + } + + try { + new customOctokit({ auth: payload.authToken }); + } catch (error) { + console.error("SDK ERROR", error); + throw new HTTPException(500, { message: "Unexpected error" }); + } + + const context: Context = { + eventName: payload.eventName, + payload: payload.payload, + octokit: new customOctokit({ auth: payload.authToken }), + config: payload.settings as TConfig, + env: ctx.env as TEnv, + logger: new Logs(options?.logLevel || LOG_LEVEL.INFO), + }; + + try { + const result = await handler(context); + return ctx.json({ stateId: payload.stateId, output: result }); + } catch (error) { + console.error(error); + throw new HTTPException(500, { message: "Unexpected error" }); + } + }); + + return app; +} diff --git a/src/sdk/signature.ts b/src/sdk/signature.ts new file mode 100644 index 0000000..8981c12 --- /dev/null +++ b/src/sdk/signature.ts @@ -0,0 +1,20 @@ +export async function verifySignature(publicKeyPem: string, payload: unknown, signature: string) { + const pemContents = publicKeyPem.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").trim(); + const binaryDer = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0)); + + const publicKey = await crypto.subtle.importKey( + "spki", + binaryDer, + { + name: "RSASSA-PKCS1-v1_5", + hash: "SHA-256", + }, + true, + ["verify"] + ); + + const signatureArray = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)); + const dataArray = new TextEncoder().encode(JSON.stringify(payload)); + + return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signatureArray, dataArray); +} diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 0c01173..6dcd92c 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -11,8 +11,8 @@ export const commandSchema = T.Object({ export const manifestSchema = T.Object({ name: T.String({ minLength: 1 }), - description: T.String({ default: "" }), - commands: T.Record(T.String(), commandSchema, { default: {} }), + description: T.Optional(T.String({ default: "" })), + commands: T.Optional(T.Record(T.String(), commandSchema, { default: {} })), "ubiquity:listeners": T.Optional(T.Array(runEvent, { default: [] })), }); diff --git a/src/worker.ts b/src/worker.ts index 6f6e4ec..6f88853 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -36,6 +36,8 @@ function handleUncaughtError(error: unknown) { const err = error.errors[0]; errorMessage = err.message ? `${err.name}: ${err.message}` : `Error: ${errorMessage}`; status = typeof err.status !== "undefined" ? err.status : 500; + } else { + errorMessage = error instanceof Error ? `${error.name}: ${error.message}` : `Error: ${error}`; } return new Response(JSON.stringify({ error: errorMessage }), { status: status, headers: { "content-type": "application/json" } }); } diff --git a/tests/__mocks__/requests/issue-comment-post.json b/tests/__mocks__/requests/issue-comment-post.json new file mode 100644 index 0000000..90a9638 --- /dev/null +++ b/tests/__mocks__/requests/issue-comment-post.json @@ -0,0 +1,249 @@ +{ + "eventName": "issue_comment.created", + "eventPayload": { + "action": "created", + "issue": { + "url": "https://api.github.com/repos/ubiquibot/assisitve-pricing/issues/1", + "repository_url": "https://api.github.com/repos/ubiquibot/bot", + "labels_url": "https://api.github.com/repos/ubiquibot/assisitve-pricing/issues/1/labels{/name}", + "comments_url": "https://api.github.com/repos/ubiquibot/assisitve-pricing/issues/1/comments", + "events_url": "https://api.github.com/repos/ubiquibot/assisitve-pricing/issues/1/events", + "html_url": "https://github.com/ubiquibot/assisitve-pricing/issues/1", + "id": 2297627819, + "node_id": "I_kwDOLy-Pv86I8wSr", + "number": 5, + "title": "New issue", + "user": { + "login": "gentlementlegen", + "id": 9807008, + "node_id": "MDQ6VXNlcjk4MDcwMDg=", + "avatar_url": "https://avatars.githubusercontent.com/u/9807008?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/gentlementlegen", + "html_url": "https://github.com/gentlementlegen", + "followers_url": "https://api.github.com/users/gentlementlegen/followers", + "following_url": "https://api.github.com/users/gentlementlegen/following{/other_user}", + "gists_url": "https://api.github.com/users/gentlementlegen/gists{/gist_id}", + "starred_url": "https://api.github.com/users/gentlementlegen/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/gentlementlegen/subscriptions", + "organizations_url": "https://api.github.com/users/gentlementlegen/orgs", + "repos_url": "https://api.github.com/users/gentlementlegen/repos", + "events_url": "https://api.github.com/users/gentlementlegen/events{/privacy}", + "received_events_url": "https://api.github.com/users/gentlementlegen/received_events", + "type": "User", + "site_admin": false + }, + "labels": [], + "state": "open", + "locked": false, + "assignee": null, + "assignees": [], + "milestone": null, + "comments": 34, + "created_at": "2024-05-15T11:22:48Z", + "updated_at": "2024-05-19T11:54:24Z", + "closed_at": null, + "author_association": "CONTRIBUTOR", + "active_lock_reason": null, + "body": "Another issue.", + "reactions": { + "url": "https://api.github.com/repos/ubiquibot/assisitve-pricing/issues/1/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "timeline_url": "https://api.github.com/repos/ubiquibot/assisitve-pricing/issues/1/timeline", + "performed_via_github_app": null, + "state_reason": null + }, + "comment": { + "url": "https://api.github.com/repos/ubiquibot/bot/issues/comments/2119208855", + "html_url": "https://github.com/ubiquibot/assisitve-pricing/issues/1#issuecomment-2119208855", + "issue_url": "https://api.github.com/repos/ubiquibot/assisitve-pricing/issues/1", + "id": 2119208855, + "node_id": "IC_kwDOLy-Pv85-UI-X", + "user": { + "login": "gentlementlegen", + "id": 9807008, + "node_id": "MDQ6VXNlcjk4MDcwMDg=", + "avatar_url": "https://avatars.githubusercontent.com/u/9807008?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/gentlementlegen", + "html_url": "https://github.com/gentlementlegen", + "followers_url": "https://api.github.com/users/gentlementlegen/followers", + "following_url": "https://api.github.com/users/gentlementlegen/following{/other_user}", + "gists_url": "https://api.github.com/users/gentlementlegen/gists{/gist_id}", + "starred_url": "https://api.github.com/users/gentlementlegen/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/gentlementlegen/subscriptions", + "organizations_url": "https://api.github.com/users/gentlementlegen/orgs", + "repos_url": "https://api.github.com/users/gentlementlegen/repos", + "events_url": "https://api.github.com/users/gentlementlegen/events{/privacy}", + "received_events_url": "https://api.github.com/users/gentlementlegen/received_events", + "type": "User", + "site_admin": false + }, + "created_at": "2024-05-19T11:54:23Z", + "updated_at": "2024-05-19T11:54:23Z", + "author_association": "CONTRIBUTOR", + "body": "/allow @gentlementlegen time", + "reactions": { + "url": "https://api.github.com/repos/ubiquibot/bot/issues/comments/2119208855/reactions", + "total_count": 0, + "+1": 0, + "-1": 0, + "laugh": 0, + "hooray": 0, + "confused": 0, + "heart": 0, + "rocket": 0, + "eyes": 0 + }, + "performed_via_github_app": null + }, + "repository": { + "id": 791646143, + "node_id": "R_kgDOLy-Pvw", + "name": "bot", + "full_name": "ubiquibot/bot", + "private": false, + "owner": { + "login": "ubiquibot", + "id": 159901852, + "node_id": "O_kgDOCYfonA", + "avatar_url": "https://avatars.githubusercontent.com/u/159901852?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/ubiquibot", + "html_url": "https://github.com/ubiquibot", + "followers_url": "https://api.github.com/users/ubiquibot/followers", + "following_url": "https://api.github.com/users/ubiquibot/following{/other_user}", + "gists_url": "https://api.github.com/users/ubiquibot/gists{/gist_id}", + "starred_url": "https://api.github.com/users/ubiquibot/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/ubiquibot/subscriptions", + "organizations_url": "https://api.github.com/users/ubiquibot/orgs", + "repos_url": "https://api.github.com/users/ubiquibot/repos", + "events_url": "https://api.github.com/users/ubiquibot/events{/privacy}", + "received_events_url": "https://api.github.com/users/ubiquibot/received_events", + "type": "Organization", + "site_admin": false + }, + "html_url": "https://github.com/ubiquibot/bot", + "description": null, + "fork": false, + "url": "https://api.github.com/repos/ubiquibot/bot", + "forks_url": "https://api.github.com/repos/ubiquibot/bot/forks", + "keys_url": "https://api.github.com/repos/ubiquibot/bot/keys{/key_id}", + "collaborators_url": "https://api.github.com/repos/ubiquibot/bot/collaborators{/collaborator}", + "teams_url": "https://api.github.com/repos/ubiquibot/bot/teams", + "hooks_url": "https://api.github.com/repos/ubiquibot/bot/hooks", + "issue_events_url": "https://api.github.com/repos/ubiquibot/bot/issues/events{/number}", + "events_url": "https://api.github.com/repos/ubiquibot/bot/events", + "assignees_url": "https://api.github.com/repos/ubiquibot/bot/assignees{/user}", + "branches_url": "https://api.github.com/repos/ubiquibot/bot/branches{/branch}", + "tags_url": "https://api.github.com/repos/ubiquibot/bot/tags", + "blobs_url": "https://api.github.com/repos/ubiquibot/bot/git/blobs{/sha}", + "git_tags_url": "https://api.github.com/repos/ubiquibot/bot/git/tags{/sha}", + "git_refs_url": "https://api.github.com/repos/ubiquibot/bot/git/refs{/sha}", + "trees_url": "https://api.github.com/repos/ubiquibot/bot/git/trees{/sha}", + "statuses_url": "https://api.github.com/repos/ubiquibot/bot/statuses/{sha}", + "languages_url": "https://api.github.com/repos/ubiquibot/bot/languages", + "stargazers_url": "https://api.github.com/repos/ubiquibot/bot/stargazers", + "contributors_url": "https://api.github.com/repos/ubiquibot/bot/contributors", + "subscribers_url": "https://api.github.com/repos/ubiquibot/bot/subscribers", + "subscription_url": "https://api.github.com/repos/ubiquibot/bot/subscription", + "commits_url": "https://api.github.com/repos/ubiquibot/bot/commits{/sha}", + "git_commits_url": "https://api.github.com/repos/ubiquibot/bot/git/commits{/sha}", + "comments_url": "https://api.github.com/repos/ubiquibot/bot/comments{/number}", + "issue_comment_url": "https://api.github.com/repos/ubiquibot/bot/issues/comments{/number}", + "contents_url": "https://api.github.com/repos/ubiquibot/bot/contents/{+path}", + "compare_url": "https://api.github.com/repos/ubiquibot/bot/compare/{base}...{head}", + "merges_url": "https://api.github.com/repos/ubiquibot/bot/merges", + "archive_url": "https://api.github.com/repos/ubiquibot/bot/{archive_format}{/ref}", + "downloads_url": "https://api.github.com/repos/ubiquibot/bot/downloads", + "issues_url": "https://api.github.com/repos/ubiquibot/bot/issues{/number}", + "pulls_url": "https://api.github.com/repos/ubiquibot/bot/pulls{/number}", + "milestones_url": "https://api.github.com/repos/ubiquibot/bot/milestones{/number}", + "notifications_url": "https://api.github.com/repos/ubiquibot/bot/notifications{?since,all,participating}", + "labels_url": "https://api.github.com/repos/ubiquibot/bot/labels{/name}", + "releases_url": "https://api.github.com/repos/ubiquibot/bot/releases{/id}", + "deployments_url": "https://api.github.com/repos/ubiquibot/bot/deployments", + "created_at": "2024-04-25T05:19:30Z", + "updated_at": "2024-05-19T09:47:02Z", + "pushed_at": "2024-05-19T09:46:59Z", + "git_url": "git://github.com/ubiquibot/bot.git", + "ssh_url": "git@github.com:ubiquibot/bot.git", + "clone_url": "https://github.com/ubiquibot/bot.git", + "svn_url": "https://github.com/ubiquibot/bot", + "homepage": null, + "size": 56, + "stargazers_count": 0, + "watchers_count": 0, + "language": null, + "has_issues": true, + "has_projects": true, + "has_downloads": true, + "has_wiki": false, + "has_pages": false, + "has_discussions": false, + "forks_count": 0, + "mirror_url": null, + "archived": false, + "disabled": false, + "open_issues_count": 1, + "license": null, + "allow_forking": true, + "is_template": false, + "web_commit_signoff_required": false, + "topics": [], + "visibility": "public", + "forks": 0, + "open_issues": 1, + "watchers": 0, + "default_branch": "main", + "custom_properties": {} + }, + "organization": { + "login": "ubiquibot", + "id": 159901852, + "node_id": "O_kgDOCYfonA", + "url": "https://api.github.com/orgs/ubiquibot", + "repos_url": "https://api.github.com/orgs/ubiquibot/repos", + "events_url": "https://api.github.com/orgs/ubiquibot/events", + "hooks_url": "https://api.github.com/orgs/ubiquibot/hooks", + "issues_url": "https://api.github.com/orgs/ubiquibot/issues", + "members_url": "https://api.github.com/orgs/ubiquibot/members{/member}", + "public_members_url": "https://api.github.com/orgs/ubiquibot/public_members{/member}", + "avatar_url": "https://avatars.githubusercontent.com/u/159901852?v=4", + "description": null + }, + "sender": { + "login": "gentlementlegen", + "id": 9807008, + "node_id": "MDQ6VXNlcjk4MDcwMDg=", + "avatar_url": "https://avatars.githubusercontent.com/u/9807008?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/gentlementlegen", + "html_url": "https://github.com/gentlementlegen", + "followers_url": "https://api.github.com/users/gentlementlegen/followers", + "following_url": "https://api.github.com/users/gentlementlegen/following{/other_user}", + "gists_url": "https://api.github.com/users/gentlementlegen/gists{/gist_id}", + "starred_url": "https://api.github.com/users/gentlementlegen/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/gentlementlegen/subscriptions", + "organizations_url": "https://api.github.com/users/gentlementlegen/orgs", + "repos_url": "https://api.github.com/users/gentlementlegen/repos", + "events_url": "https://api.github.com/users/gentlementlegen/events{/privacy}", + "received_events_url": "https://api.github.com/users/gentlementlegen/received_events", + "type": "User", + "site_admin": false + }, + "installation": { + "id": 48381972, + "node_id": "MDIzOkludGVncmF0aW9uSW5zdGFsbGF0aW9uNDgzODE5NzI=" + } + } +} diff --git a/tests/__mocks__/webhooks.ts b/tests/__mocks__/webhooks.ts index 855e2f7..195c296 100644 --- a/tests/__mocks__/webhooks.ts +++ b/tests/__mocks__/webhooks.ts @@ -1,10 +1,9 @@ /* eslint-disable @typescript-eslint/naming-convention */ +import { jest } from "@jest/globals"; -export class WebhooksMocked { +class WebhooksMocked { constructor(_: unknown) {} - verifyAndReceive(_: unknown) { - return Promise.resolve(); - } + verifyAndReceive(_: unknown) {} onAny(_: unknown) {} on(_: unknown) {} onError(_: unknown) {} @@ -13,3 +12,12 @@ export class WebhooksMocked { verify(_: unknown, __: unknown) {} receive(_: unknown) {} } + +void jest.mock("@octokit/webhooks", () => { + const originalModule = jest.requireActual("@octokit/webhooks"); + return { + __esModule: true, + ...(originalModule as object), + Webhooks: WebhooksMocked, + }; +}); diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts index 67f086a..68456ea 100644 --- a/tests/configuration.test.ts +++ b/tests/configuration.test.ts @@ -1,18 +1,13 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "@jest/globals"; import { config } from "dotenv"; import { server } from "./__mocks__/node"; -import { WebhooksMocked } from "./__mocks__/webhooks"; +import "./__mocks__/webhooks"; import { getConfig } from "../src/github/utils/config"; import { GitHubContext } from "../src/github/github-context"; import { GitHubEventHandler } from "../src/github/github-event-handler"; config({ path: ".dev.vars" }); -jest.mock("@octokit/webhooks", () => ({ - Webhooks: WebhooksMocked, - emitterEventNames: [], -})); - const issueOpened = "issues.opened"; beforeAll(() => { diff --git a/tests/events.test.ts b/tests/events.test.ts index 5a734f7..161ec18 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -6,12 +6,7 @@ import { GitHubContext } from "../src/github/github-context"; import { GitHubEventHandler } from "../src/github/github-event-handler"; import issueCommentCreated from "../src/github/handlers/issue-comment-created"; import { server } from "./__mocks__/node"; -import { WebhooksMocked } from "./__mocks__/webhooks"; - -jest.mock("@octokit/webhooks", () => ({ - Webhooks: jest.fn(() => new WebhooksMocked({})), - emitterEventNames: [], -})); +import "./__mocks__/webhooks"; jest.mock("@octokit/plugin-paginate-rest", () => ({})); jest.mock("@octokit/plugin-rest-endpoint-methods", () => ({})); @@ -62,45 +57,46 @@ describe("Event related tests", () => { id: "", key: "issue_comment.created", octokit: { - issues, rest: { + issues, repos: { - getContent() { - return { - data: ` - plugins: - - name: "Run on comment created" - uses: - - id: plugin-A - plugin: https://plugin-a.internal - - name: "Some Action plugin" - uses: - - id: plugin-B - plugin: ubiquibot/plugin-b - `, - }; + getContent(params?: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) { + if (params?.path === ".github/.ubiquibot-config.yml") { + return { + data: ` + plugins: + - name: "Run on comment created" + uses: + - id: plugin-A + plugin: https://plugin-a.internal + - name: "Some Action plugin" + uses: + - id: plugin-B + plugin: ubiquibot/plugin-b + `, + }; + } else if (params?.path === "manifest.json") { + return { + data: { + content: btoa( + JSON.stringify({ + name: "plugin", + commands: { + action: { + description: "action", + "ubiquity:example": "/action", + }, + }, + }) + ), + }, + }; + } else { + throw new Error("Not found"); + } }, }, }, - repos: { - getContent() { - return { - data: { - content: btoa( - JSON.stringify({ - name: "plugin", - commands: { - action: { - description: "action", - "ubiquity:example": "/action", - }, - }, - }) - ), - }, - }; - }, - }, }, eventHandler: {} as GitHubEventHandler, payload: { diff --git a/tests/main.test.ts b/tests/main.test.ts index c183f3f..fa5ad59 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -4,16 +4,11 @@ import { config } from "dotenv"; import { GitHubContext } from "../src/github/github-context"; import { GitHubEventHandler } from "../src/github/github-event-handler"; import { getConfig } from "../src/github/utils/config"; -import worker from "../src/worker"; import { server } from "./__mocks__/node"; -import { WebhooksMocked } from "./__mocks__/webhooks"; +import "./__mocks__/webhooks"; +import worker from "../src/worker"; // has to be imported after the mocks import { http, HttpResponse } from "msw"; -jest.mock("@octokit/webhooks", () => ({ - Webhooks: jest.fn(() => new WebhooksMocked({})), - emitterEventNames: ["issues.opened"], -})); - jest.mock("@octokit/plugin-paginate-rest", () => ({})); jest.mock("@octokit/plugin-rest-endpoint-methods", () => ({})); jest.mock("@octokit/plugin-retry", () => ({})); @@ -86,6 +81,7 @@ describe("Worker tests", () => { APP_PRIVATE_KEY: "private-key", PLUGIN_CHAIN_STATE: {} as KVNamespace, }); + expect(await res.text()).toEqual("ok\n"); expect(res.status).toEqual(200); }); diff --git a/tests/sdk.test.ts b/tests/sdk.test.ts new file mode 100644 index 0000000..5679245 --- /dev/null +++ b/tests/sdk.test.ts @@ -0,0 +1,134 @@ +import { server } from "./__mocks__/node"; +import issueCommented from "./__mocks__/requests/issue-comment-post.json"; +import { expect, describe, beforeAll, afterAll, afterEach, it } from "@jest/globals"; + +import * as crypto from "crypto"; +import { createPlugin } from "../src/sdk/server"; +import { Hono } from "hono"; +import { Context } from "../src/sdk/context"; + +const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { + modulusLength: 2048, + publicKeyEncoding: { + type: "spki", + format: "pem", + }, + privateKeyEncoding: { + type: "pkcs8", + format: "pem", + }, +}); + +let app: Hono; + +beforeAll(async () => { + app = await createPlugin( + async (context: Context<{ shouldFail: boolean }>) => { + if (context.config.shouldFail) { + throw new Error("Failed"); + } + return { + success: true, + event: context.eventName, + }; + }, + { name: "test" }, + { kernelPublicKey: publicKey } + ); + server.listen(); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +describe("SDK tests", () => { + it("Should serve manifest", async () => { + const res = await app.request("/manifest.json", { + method: "GET", + }); + expect(res.status).toEqual(200); + const result = await res.json(); + expect(result).toEqual({ name: "test" }); + }); + it("Should deny POST request with different path", async () => { + const res = await app.request("/test", { + method: "POST", + }); + expect(res.status).toEqual(404); + }); + it("Should deny POST request without content-type", async () => { + const res = await app.request("/", { + method: "POST", + }); + expect(res.status).toEqual(400); + }); + it("Should deny POST request with invalid signature", async () => { + const data = { + ...issueCommented, + stateId: "stateId", + authToken: process.env.GITHUB_TOKEN, + settings: { + shouldFail: false, + }, + ref: "", + }; + const signature = "invalid"; + const res = await app.request("/", { + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ ...data, signature }), + method: "POST", + }); + expect(res.status).toEqual(400); + }); + it("Should handle thrown errors", async () => { + const data = { + ...issueCommented, + stateId: "stateId", + authToken: process.env.GITHUB_TOKEN, + settings: { + shouldFail: true, + }, + ref: "", + }; + const sign = crypto.createSign("SHA256"); + sign.update(JSON.stringify(data)); + sign.end(); + const signature = sign.sign(privateKey, "base64"); + + const res = await app.request("/", { + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ ...data, signature }), + method: "POST", + }); + expect(res.status).toEqual(500); + }); + it("Should accept correct request", async () => { + const data = { + ...issueCommented, + stateId: "stateId", + authToken: "test", + settings: { + shouldFail: false, + }, + ref: "", + }; + const sign = crypto.createSign("SHA256"); + sign.update(JSON.stringify(data)); + sign.end(); + const signature = sign.sign(privateKey, "base64"); + + const res = await app.request("/", { + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ ...data, signature }), + method: "POST", + }); + expect(res.status).toEqual(200); + const result = await res.json(); + expect(result).toEqual({ stateId: "stateId", output: { success: true, event: "issue_comment.created" } }); + }); +}); diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..44c6c25 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/sdk/index.ts"], + format: ["cjs", "esm"], + outDir: "dist", + splitting: false, + sourcemap: false, + clean: true, + dts: true, + legacyOutput: true, +});