diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index effd56a..371db45 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -20,22 +20,5 @@ jobs: - uses: googleapis/release-please-action@v4 id: release with: - release-type: node + release-type: simple target-branch: main - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: "20.10.0" - registry-url: https://registry.npmjs.org/ - - - uses: oven-sh/setup-bun@v1 - - - name: Build - run: | - bun install --frozen-lockfile - bun sdk:build - if: ${{ steps.release.outputs.release_created }} - - run: bun publish --access public - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - if: ${{ steps.release.outputs.release_created }} diff --git a/.github/workflows/worker-deploy.yml b/.github/workflows/worker-deploy.yml index a5631a1..1616e5b 100644 --- a/.github/workflows/worker-deploy.yml +++ b/.github/workflows/worker-deploy.yml @@ -48,7 +48,7 @@ jobs: - uses: cloudflare/wrangler-action@v3 id: wrangler_deploy with: - wranglerVersion: "3.79.0" + wranglerVersion: "3.86.0" apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} secrets: | diff --git a/bun.lockb b/bun.lockb index 98c3ed0..6adfd27 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 6bfb658..aa1e18f 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,6 @@ "module": "dist/index.mjs", "main": "dist/index.js", "typings": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "require": "./dist/index.js", - "import": "./dist/index.mjs" - } - }, "files": [ "dist" ], @@ -37,8 +30,7 @@ "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", - "sdk:build": "tsup" + "setup-kv": "bun --env-file=.dev.vars deploy/setup-kv-namespace.ts" }, "keywords": [ "typescript", @@ -48,12 +40,9 @@ "open-source" ], "dependencies": { - "@actions/core": "1.10.1", - "@actions/github": "6.0.0", "@cfworker/json-schema": "2.0.1", "@octokit/auth-app": "7.1.0", "@octokit/core": "6.1.2", - "@octokit/plugin-paginate-graphql": "^5.2.4", "@octokit/plugin-paginate-rest": "11.3.3", "@octokit/plugin-rest-endpoint-methods": "13.2.4", "@octokit/plugin-retry": "7.1.1", @@ -62,12 +51,9 @@ "@octokit/types": "^13.5.0", "@octokit/webhooks": "13.3.0", "@octokit/webhooks-types": "7.5.1", - "@sinclair/typebox": "^0.33.17", - "@ubiquity-os/ubiquity-os-logger": "^1.3.2", + "@sinclair/typebox": "^0.33.20", + "@ubiquity-os/plugin-sdk": "^1.0.11", "dotenv": "16.4.5", - "hono": "4.4.13", - "smee-client": "2.0.1", - "ts-node": "^10.9.2", "typebox-validators": "0.3.5", "yaml": "2.4.5" }, @@ -100,13 +86,14 @@ "lint-staged": "15.2.7", "npm-run-all": "4.1.5", "prettier": "3.3.3", + "smee-client": "^2.0.4", "toml": "3.0.0", "tomlify-j0.4": "3.0.0", - "tsup": "8.1.0", + "ts-node": "^10.9.2", "tsx": "4.16.2", - "typescript": "5.6.2", + "typescript": "5.6.3", "typescript-eslint": "7.16.0", - "wrangler": "3.79.0" + "wrangler": "^3.86.0" }, "lint-staged": { "*.ts": [ diff --git a/src/github/github-event-handler.ts b/src/github/github-event-handler.ts index b807b55..38072fa 100644 --- a/src/github/github-event-handler.ts +++ b/src/github/github-event-handler.ts @@ -4,6 +4,7 @@ import { GitHubContext, SimplifiedContext } from "./github-context"; import { createAppAuth } from "@octokit/auth-app"; import { KvStore } from "./utils/kv-store"; import { PluginChainState } from "./types/plugin"; +import { signPayload } from "@ubiquity-os/plugin-sdk/signature"; export type Options = { environment: "production" | "development"; @@ -49,27 +50,8 @@ export class GitHubEventHandler { }); } - async importRsaPrivateKey(pem: string) { - const pemContents = pem.replace("-----BEGIN PRIVATE KEY-----", "").replace("-----END PRIVATE KEY-----", "").trim(); - const binaryDer = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0)); - - return await crypto.subtle.importKey( - "pkcs8", - binaryDer.buffer as ArrayBuffer, - { - name: "RSASSA-PKCS1-v1_5", - hash: "SHA-256", - }, - true, - ["sign"] - ); - } - async signPayload(payload: string) { - const data = new TextEncoder().encode(payload); - const privateKey = await this.importRsaPrivateKey(this._privateKey); - const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", privateKey, data); - return btoa(String.fromCharCode(...new Uint8Array(signature))); + return signPayload(payload, this._privateKey); } transformEvent(event: EmitterWebhookEvent) { diff --git a/src/github/utils/plugins.ts b/src/github/utils/plugins.ts index bc094c5..945296f 100644 --- a/src/github/utils/plugins.ts +++ b/src/github/utils/plugins.ts @@ -1,7 +1,7 @@ import { GithubPlugin, isGithubPlugin, PluginConfiguration } from "../types/plugin-configuration"; import { EmitterWebhookEventName } from "@octokit/webhooks"; import { GitHubContext } from "../github-context"; -import { Manifest, manifestSchema, manifestValidator } from "../../types/manifest"; +import { Manifest, manifestSchema } from "@ubiquity-os/plugin-sdk/manifest"; import { Buffer } from "node:buffer"; import { Value } from "@sinclair/typebox/value"; @@ -58,13 +58,13 @@ async function fetchWorkerManifest(url: string): Promise { } function decodeManifest(manifest: unknown) { - const defaultManifest = Value.Default(manifestSchema, manifest); - const errors = manifestValidator.testReturningErrors(manifest as Readonly); - if (errors !== null) { + const errors = [...Value.Errors(manifestSchema, manifest)]; + if (errors.length) { for (const error of errors) { - console.error(error); + console.dir(error, { depth: null }); } throw new Error("Manifest is invalid."); } + const defaultManifest = Value.Default(manifestSchema, manifest); return defaultManifest as Manifest; } diff --git a/src/sdk/actions.ts b/src/sdk/actions.ts deleted file mode 100644 index fdd53b9..0000000 --- a/src/sdk/actions.ts +++ /dev/null @@ -1,145 +0,0 @@ -import * as core from "@actions/core"; -import * as github from "@actions/github"; -import { EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; -import { TAnySchema, Type as T } from "@sinclair/typebox"; -import { Value } from "@sinclair/typebox/value"; -import { LOG_LEVEL, LogLevel, LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger"; -import { config } from "dotenv"; -import { Context } from "./context"; -import { customOctokit } from "./octokit"; -import { sanitizeMetadata } from "./util"; -import { verifySignature } from "./signature"; -import { KERNEL_PUBLIC_KEY } from "./constants"; - -config(); - -interface Options { - logLevel?: LogLevel; - postCommentOnError?: boolean; - settingsSchema?: TAnySchema; - envSchema?: TAnySchema; - kernelPublicKey?: string; -} - -const inputSchema = T.Object({ - stateId: T.String(), - eventName: T.String(), - eventPayload: T.String(), - authToken: T.String(), - settings: T.String(), - ref: T.String(), - signature: T.String(), -}); - -export async function createActionsPlugin( - handler: (context: Context) => Promise | undefined>, - options?: Options -) { - const pluginOptions = { - logLevel: options?.logLevel || LOG_LEVEL.INFO, - postCommentOnError: options?.postCommentOnError || true, - settingsSchema: options?.settingsSchema, - envSchema: options?.envSchema, - kernelPublicKey: options?.kernelPublicKey || KERNEL_PUBLIC_KEY, - }; - - const pluginGithubToken = process.env.PLUGIN_GITHUB_TOKEN; - if (!pluginGithubToken) { - core.setFailed("Error: PLUGIN_GITHUB_TOKEN env is not set"); - return; - } - - const inputs = Value.Decode(inputSchema, github.context.payload.inputs); - const signature = inputs.signature; - if (!(await verifySignature(pluginOptions.kernelPublicKey, inputs, signature))) { - core.setFailed(`Error: Invalid signature`); - return; - } - - let config: TConfig; - if (pluginOptions.settingsSchema) { - try { - config = Value.Decode(pluginOptions.settingsSchema, Value.Default(pluginOptions.settingsSchema, JSON.parse(inputs.settings))); - } catch (e) { - console.dir(...Value.Errors(pluginOptions.settingsSchema, JSON.parse(inputs.settings)), { depth: null }); - throw e; - } - } else { - config = JSON.parse(inputs.settings) as TConfig; - } - - let env: TEnv; - if (pluginOptions.envSchema) { - try { - env = Value.Decode(pluginOptions.envSchema, Value.Default(pluginOptions.envSchema, process.env)); - } catch (e) { - console.dir(...Value.Errors(pluginOptions.envSchema, process.env), { depth: null }); - throw e; - } - } else { - env = process.env as TEnv; - } - - const context: Context = { - eventName: inputs.eventName as TSupportedEvents, - payload: JSON.parse(inputs.eventPayload), - octokit: new customOctokit({ auth: inputs.authToken }), - config: config, - env: env, - logger: new Logs(pluginOptions.logLevel), - }; - - try { - const result = await handler(context); - core.setOutput("result", result); - await returnDataToKernel(pluginGithubToken, inputs.stateId, result); - } catch (error) { - console.error(error); - - let loggerError: LogReturn | null; - if (error instanceof Error) { - core.setFailed(error); - loggerError = context.logger.error(`Error: ${error}`, { error: error }); - } else if (error instanceof LogReturn) { - core.setFailed(error.logMessage.raw); - loggerError = error; - } else { - core.setFailed(`Error: ${error}`); - loggerError = context.logger.error(`Error: ${error}`); - } - - if (pluginOptions.postCommentOnError && loggerError) { - await postErrorComment(context, loggerError); - } - } -} - -async function postErrorComment(context: Context, error: LogReturn) { - if ("issue" in context.payload && context.payload.repository?.owner?.login) { - await context.octokit.rest.issues.createComment({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - issue_number: context.payload.issue.number, - body: `${error.logMessage.diff}\n`, - }); - } else { - context.logger.info("Cannot post error comment because issue is not found in the payload"); - } -} - -function getGithubWorkflowRunUrl() { - return `${github.context.payload.repository?.html_url}/actions/runs/${github.context.runId}`; -} - -async function returnDataToKernel(repoToken: string, stateId: string, output: object | undefined) { - const octokit = new customOctokit({ auth: repoToken }); - await octokit.rest.repos.createDispatchEvent({ - owner: github.context.repo.owner, - repo: github.context.repo.repo, - event_type: "return-data-to-ubiquity-os-kernel", - client_payload: { - state_id: stateId, - output: output ? JSON.stringify(output) : null, - }, - }); -} diff --git a/src/sdk/comment.ts b/src/sdk/comment.ts deleted file mode 100644 index cf87399..0000000 --- a/src/sdk/comment.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Context } from "./context"; -import { LogReturn } from "@ubiquity-os/ubiquity-os-logger"; -import { sanitizeMetadata } from "./util"; - -const HEADER_NAME = "Ubiquity"; - -/** - * Posts a comment on a GitHub issue if the issue exists in the context payload, embedding structured metadata to it. - */ -export async function postComment(context: Context, message: LogReturn) { - if ("issue" in context.payload && context.payload.repository?.owner?.login) { - const metadata = createStructuredMetadata(message.metadata?.name, message); - await context.octokit.rest.issues.createComment({ - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - issue_number: context.payload.issue.number, - body: [message.logMessage.diff, metadata].join("\n"), - }); - } else { - context.logger.info("Cannot post comment because issue is not found in the payload"); - } -} - -function createStructuredMetadata(className: string | undefined, logReturn: LogReturn) { - const logMessage = logReturn.logMessage; - const metadata = logReturn.metadata; - - const jsonPretty = sanitizeMetadata(metadata); - const stack = logReturn.metadata?.stack; - const stackLine = (Array.isArray(stack) ? stack.join("\n") : stack)?.split("\n")[2] ?? ""; - const caller = stackLine.match(/at (\S+)/)?.[1] ?? ""; - const ubiquityMetadataHeader = `"].join("\n"); - - if (logMessage?.type === "fatal") { - // if the log message is fatal, then we want to show the metadata - metadataSerialized = [metadataSerializedVisible, metadataSerializedHidden].join("\n"); - } else { - // otherwise we want to hide it - metadataSerialized = metadataSerializedHidden; - } - - // Add carriage returns to avoid any formatting issue - return `\n${metadataSerialized}\n`; -} diff --git a/src/sdk/constants.ts b/src/sdk/constants.ts deleted file mode 100644 index 4978657..0000000 --- a/src/sdk/constants.ts +++ /dev/null @@ -1,13 +0,0 @@ -export { DEV_CONFIG_FULL_PATH, CONFIG_FULL_PATH, CONFIG_ORG_REPO } from "../github/utils/config"; -export const KERNEL_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs96DOU+JqM8SyNXOB6u3 -uBKIFiyrcST/LZTYN6y7LeJlyCuGPqSDrWCfjU9Ph5PLf9TWiNmeM8DGaOpwEFC7 -U3NRxOSglo4plnQ5zRwIHHXvxyK400sQP2oISXymISuBQWjEIqkC9DybQrKwNzf+ -I0JHWPqmwMIw26UvVOtXGOOWBqTkk+N2+/9f8eDIJP5QQVwwszc8s1rXOsLMlVIf -wShw7GO4E2jyK8TSJKpyjV8eb1JJMDwFhPiRrtZfQJUtDf2mV/67shQww61BH2Y/ -Plnalo58kWIbkqZoq1yJrL5sFb73osM5+vADTXVn79bkvea7W19nSkdMiarYt4Hq -JQIDAQAB ------END PUBLIC KEY----- -`; -export const KERNEL_APP_ID = 975031; -export const BOT_USER_ID = 178941584; diff --git a/src/sdk/context.ts b/src/sdk/context.ts deleted file mode 100644 index 8502b0d..0000000 --- a/src/sdk/context.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; -import { Logs } from "@ubiquity-os/ubiquity-os-logger"; -import { customOctokit } from "./octokit"; - -export interface Context { - 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 deleted file mode 100644 index 4e05ca0..0000000 --- a/src/sdk/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { createPlugin } from "./server"; -export { createActionsPlugin } from "./actions"; -export { postComment } from "./comment"; -export type { Context } from "./context"; -export * from "./constants"; -export type { Manifest } from "../types/manifest"; diff --git a/src/sdk/octokit.ts b/src/sdk/octokit.ts deleted file mode 100644 index 0b7f8c5..0000000 --- a/src/sdk/octokit.ts +++ /dev/null @@ -1,28 +0,0 @@ -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"; -import { paginateGraphQL } from "@octokit/plugin-paginate-graphql"; - -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, paginateGraphQL).defaults((instanceOptions: object) => { - return Object.assign({}, defaultOptions, instanceOptions); -}); diff --git a/src/sdk/server.ts b/src/sdk/server.ts deleted file mode 100644 index bf069cf..0000000 --- a/src/sdk/server.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; -import { TAnySchema } from "@sinclair/typebox"; -import { Value } from "@sinclair/typebox/value"; -import { LOG_LEVEL, LogLevel, LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger"; -import { Hono } from "hono"; -import { HTTPException } from "hono/http-exception"; -import { Manifest } from "../types/manifest"; -import { KERNEL_PUBLIC_KEY } from "./constants"; -import { Context } from "./context"; -import { customOctokit } from "./octokit"; -import { verifySignature } from "./signature"; -import { env as honoEnv } from "hono/adapter"; -import { postComment } from "./comment"; -import { Type as T } from "@sinclair/typebox"; - -interface Options { - kernelPublicKey?: string; - logLevel?: LogLevel; - postCommentOnError?: boolean; - settingsSchema?: TAnySchema; - envSchema?: TAnySchema; -} - -const inputSchema = T.Object({ - stateId: T.String(), - eventName: T.String(), - eventPayload: T.Record(T.String(), T.Any()), - authToken: T.String(), - settings: T.Record(T.String(), T.Any()), - ref: T.String(), - signature: T.String(), -}); - -export function createPlugin( - handler: (context: Context) => Promise | undefined>, - manifest: Manifest, - options?: Options -) { - const pluginOptions = { - kernelPublicKey: options?.kernelPublicKey || KERNEL_PUBLIC_KEY, - logLevel: options?.logLevel || LOG_LEVEL.INFO, - postCommentOnError: options?.postCommentOnError || true, - settingsSchema: options?.settingsSchema, - envSchema: options?.envSchema, - }; - - 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 body = await ctx.req.json(); - const inputSchemaErrors = [...Value.Errors(inputSchema, body)]; - if (inputSchemaErrors.length) { - console.dir(inputSchemaErrors, { depth: null }); - throw new HTTPException(400, { message: "Invalid body" }); - } - const inputs = Value.Decode(inputSchema, body); - const signature = inputs.signature; - if (!(await verifySignature(pluginOptions.kernelPublicKey, inputs, signature))) { - throw new HTTPException(400, { message: "Invalid signature" }); - } - - let config: TConfig; - if (pluginOptions.settingsSchema) { - try { - config = Value.Decode(pluginOptions.settingsSchema, Value.Default(pluginOptions.settingsSchema, inputs.settings)); - } catch (e) { - console.dir(...Value.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null }); - throw e; - } - } else { - config = inputs.settings as TConfig; - } - - let env: TEnv; - const honoEnvironment = honoEnv(ctx); - if (pluginOptions.envSchema) { - try { - env = Value.Decode(pluginOptions.envSchema, Value.Default(pluginOptions.envSchema, honoEnvironment)); - } catch (e) { - console.dir(...Value.Errors(pluginOptions.envSchema, honoEnvironment), { depth: null }); - throw e; - } - } else { - env = ctx.env as TEnv; - } - - const context: Context = { - eventName: inputs.eventName as TSupportedEvents, - payload: inputs.eventPayload, - octokit: new customOctokit({ auth: inputs.authToken }), - config: config, - env: env, - logger: new Logs(pluginOptions.logLevel), - }; - - try { - const result = await handler(context); - return ctx.json({ stateId: inputs.stateId, output: result }); - } catch (error) { - console.error(error); - - let loggerError: LogReturn | null; - if (error instanceof Error) { - loggerError = context.logger.error(`Error: ${error}`, { error: error }); - } else if (error instanceof LogReturn) { - loggerError = error; - } else { - loggerError = context.logger.error(`Error: ${error}`); - } - - if (pluginOptions.postCommentOnError && loggerError) { - await postComment(context, loggerError); - } - - throw new HTTPException(500, { message: "Unexpected error" }); - } - }); - - return app; -} diff --git a/src/sdk/signature.ts b/src/sdk/signature.ts deleted file mode 100644 index b23f68b..0000000 --- a/src/sdk/signature.ts +++ /dev/null @@ -1,43 +0,0 @@ -interface Inputs { - stateId: unknown; - eventName: unknown; - eventPayload: unknown; - authToken: unknown; - settings: unknown; - ref: unknown; -} - -export async function verifySignature(publicKeyPem: string, inputs: Inputs, signature: string) { - try { - const inputsOrdered = { - stateId: inputs.stateId, - eventName: inputs.eventName, - eventPayload: inputs.eventPayload, - settings: inputs.settings, - authToken: inputs.authToken, - ref: inputs.ref, - }; - console.log(JSON.stringify(inputs)); - 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(inputsOrdered)); - - return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signatureArray, dataArray); - } catch (error) { - console.error(error); - return false; - } -} diff --git a/src/sdk/util.ts b/src/sdk/util.ts deleted file mode 100644 index 8854f8f..0000000 --- a/src/sdk/util.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { LogReturn } from "@ubiquity-os/ubiquity-os-logger"; - -export function sanitizeMetadata(obj: LogReturn["metadata"]): string { - return JSON.stringify(obj, null, 2).replace(//g, ">").replace(/--/g, "--"); -} diff --git a/src/types/manifest.ts b/src/types/manifest.ts deleted file mode 100644 index 28ad2e9..0000000 --- a/src/types/manifest.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { type Static, Type as T } from "@sinclair/typebox"; -import { StandardValidator } from "typebox-validators"; -import { emitterEventNames } from "@octokit/webhooks"; - -export const runEvent = T.Union(emitterEventNames.map((o) => T.Literal(o))); - -export const commandSchema = T.Object({ - description: T.String({ minLength: 1 }), - "ubiquity:example": T.String({ minLength: 1 }), -}); - -export const manifestSchema = T.Object({ - name: T.String({ minLength: 1 }), - description: T.Optional(T.String({ default: "" })), - commands: T.Optional(T.Record(T.String(), commandSchema, { default: {} })), - "ubiquity:listeners": T.Optional(T.Array(runEvent, { default: [] })), - configuration: T.Optional(T.Record(T.String(), T.Any(), { default: {} })), -}); - -export const manifestValidator = new StandardValidator(manifestSchema); - -export type Manifest = Static; diff --git a/tests/sdk.test.ts b/tests/sdk.test.ts deleted file mode 100644 index 43e7357..0000000 --- a/tests/sdk.test.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { server } from "./__mocks__/node"; -import issueCommented from "./__mocks__/requests/issue-comment-post.json"; -import { expect, describe, beforeAll, afterAll, afterEach, it, jest } from "@jest/globals"; - -import * as crypto from "crypto"; -import { createPlugin } from "../src/sdk/server"; -import { Context } from "../src/sdk/context"; -import { GitHubEventHandler } from "../src/github/github-event-handler"; -import { EmptyStore } from "../src/github/utils/kv-store"; -import { PluginChainState, PluginInput } from "../src/github/types/plugin"; -import { EmitterWebhookEventName } from "@octokit/webhooks"; - -const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", { - modulusLength: 2048, - publicKeyEncoding: { - type: "spki", - format: "pem", - }, - privateKeyEncoding: { - type: "pkcs8", - format: "pem", - }, -}); - -const issueCommentedEvent = { - eventName: issueCommented.eventName as EmitterWebhookEventName, - eventPayload: issueCommented.eventPayload, -}; - -const sdkOctokitImportPath = "../src/sdk/octokit"; -const githubActionImportPath = "@actions/github"; -const githubCoreImportPath = "@actions/core"; - -const eventHandler = new GitHubEventHandler({ - environment: "production", - webhookSecret: "test", - appId: "1", - privateKey: privateKey, - pluginChainState: new EmptyStore(), -}); - -const app = createPlugin( - async (context: Context<{ shouldFail: boolean }>) => { - if (context.config.shouldFail) { - throw context.logger.error("test error"); - } - return { - success: true, - event: context.eventName, - }; - }, - { name: "test" }, - { kernelPublicKey: publicKey } -); - -beforeAll(async () => { - server.listen(); -}); - -afterEach(() => { - server.resetHandlers(); - jest.resetModules(); - jest.restoreAllMocks(); -}); - -afterAll(() => server.close()); - -describe("SDK worker 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 inputs = new PluginInput(eventHandler, "stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, { shouldFail: false }, "test", ""); - - const res = await app.request("/", { - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ ...(await inputs.getWorkerInputs()), signature: "invalid_signature" }), - method: "POST", - }); - expect(res.status).toEqual(400); - }); - it("Should handle thrown errors", async () => { - const createComment = jest.fn(); - jest.mock(sdkOctokitImportPath, () => ({ - customOctokit: class MockOctokit { - constructor() { - return { - rest: { - issues: { - createComment, - }, - }, - }; - } - }, - })); - - const { createPlugin } = await import("../src/sdk/server"); - const app = createPlugin( - async (context: Context<{ shouldFail: boolean }>) => { - if (context.config.shouldFail) { - throw context.logger.error("test error"); - } - return { - success: true, - event: context.eventName, - }; - }, - { name: "test" }, - { kernelPublicKey: publicKey } - ); - - const inputs = new PluginInput(eventHandler, "stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, { shouldFail: true }, "test", ""); - - const res = await app.request("/", { - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(await inputs.getWorkerInputs()), - method: "POST", - }); - expect(res.status).toEqual(500); - expect(createComment).toHaveBeenCalledWith({ - issue_number: 5, - owner: "ubiquity-os", - repo: "bot", - body: `\`\`\`diff -! test error -\`\`\` - - -`, - }); - }); - it("Should accept correct request", async () => { - const inputs = new PluginInput(eventHandler, "stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, { shouldFail: false }, "test", ""); - - const res = await app.request("/", { - headers: { - "content-type": "application/json", - }, - body: JSON.stringify(await inputs.getWorkerInputs()), - method: "POST", - }); - expect(res.status).toEqual(200); - const result = await res.json(); - expect(result).toEqual({ stateId: "stateId", output: { success: true, event: issueCommented.eventName } }); - }); -}); - -describe("SDK actions tests", () => { - process.env.PLUGIN_GITHUB_TOKEN = "token"; - const repo = { - owner: "ubiquity", - repo: "ubiquity-os-kernel", - }; - - it("Should accept correct request", async () => { - const inputs = new PluginInput(eventHandler, "stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, {}, "test_token", ""); - const githubInputs = await inputs.getWorkflowInputs(); - jest.mock(githubActionImportPath, () => ({ - context: { - runId: "1", - payload: { - inputs: githubInputs, - }, - repo: repo, - }, - })); - const setOutput = jest.fn(); - const setFailed = jest.fn(); - jest.mock(githubCoreImportPath, () => ({ - setOutput, - setFailed, - })); - const createDispatchEvent = jest.fn(); - jest.mock("../src/sdk/octokit", () => ({ - customOctokit: class MockOctokit { - constructor() { - return { - rest: { - repos: { - createDispatchEvent: createDispatchEvent, - }, - }, - }; - } - }, - })); - const { createActionsPlugin } = await import("../src/sdk/actions"); - - await createActionsPlugin( - async (context: Context) => { - return { - event: context.eventName, - }; - }, - { - kernelPublicKey: publicKey, - } - ); - expect(setFailed).not.toHaveBeenCalled(); - expect(setOutput).toHaveBeenCalledWith("result", { event: issueCommented.eventName }); - expect(createDispatchEvent).toHaveBeenCalledWith({ - event_type: "return-data-to-ubiquity-os-kernel", - owner: repo.owner, - repo: repo.repo, - client_payload: { - state_id: "stateId", - output: JSON.stringify({ event: issueCommented.eventName }), - }, - }); - }); - it("Should deny invalid signature", async () => { - const inputs = new PluginInput(eventHandler, "stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, {}, "test_token", ""); - const githubInputs = await inputs.getWorkflowInputs(); - - jest.mock("@actions/github", () => ({ - context: { - runId: "1", - payload: { - inputs: { - ...githubInputs, - signature: "invalid_signature", - }, - }, - repo: repo, - }, - })); - const setOutput = jest.fn(); - const setFailed = jest.fn(); - jest.mock(githubCoreImportPath, () => ({ - setOutput, - setFailed, - })); - const { createActionsPlugin } = await import("../src/sdk/actions"); - - await createActionsPlugin( - async (context: Context) => { - return { - event: context.eventName, - }; - }, - { - kernelPublicKey: publicKey, - } - ); - expect(setFailed).toHaveBeenCalledWith("Error: Invalid signature"); - expect(setOutput).not.toHaveBeenCalled(); - }); - it("Should accept inputs in different order", async () => { - const inputs = new PluginInput(eventHandler, "stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, {}, "test_token", ""); - const githubInputs = await inputs.getWorkflowInputs(); - - jest.mock(githubActionImportPath, () => ({ - context: { - runId: "1", - payload: { - inputs: { - // different order - signature: githubInputs.signature, - eventName: githubInputs.eventName, - settings: githubInputs.settings, - ref: githubInputs.ref, - authToken: githubInputs.authToken, - stateId: githubInputs.stateId, - eventPayload: githubInputs.eventPayload, - }, - }, - repo: repo, - }, - })); - const setOutput = jest.fn(); - const setFailed = jest.fn(); - jest.mock(githubCoreImportPath, () => ({ - setOutput, - setFailed, - })); - const createDispatchEventFn = jest.fn(); - jest.mock(sdkOctokitImportPath, () => ({ - customOctokit: class MockOctokit { - constructor() { - return { - rest: { - repos: { - createDispatchEvent: createDispatchEventFn, - }, - }, - }; - } - }, - })); - const { createActionsPlugin } = await import("../src/sdk/actions"); - - await createActionsPlugin( - async (context: Context) => { - return { - event: context.eventName, - }; - }, - { - kernelPublicKey: publicKey, - } - ); - expect(setFailed).not.toHaveBeenCalled(); - expect(setOutput).toHaveBeenCalledWith("result", { event: issueCommentedEvent.eventName }); - expect(createDispatchEventFn).toHaveBeenCalledWith({ - event_type: "return-data-to-ubiquity-os-kernel", - owner: repo.owner, - repo: repo.repo, - client_payload: { - state_id: "stateId", - output: JSON.stringify({ event: issueCommentedEvent.eventName }), - }, - }); - }); -}); diff --git a/tsup.config.ts b/tsup.config.ts deleted file mode 100644 index b183352..0000000 --- a/tsup.config.ts +++ /dev/null @@ -1,12 +0,0 @@ -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: false, -});