Skip to content

Commit

Permalink
Merge pull request #64 from ubiquity/sdk
Browse files Browse the repository at this point in the history
SDK
  • Loading branch information
whilefoo authored Jul 30, 2024
2 parents d0f975a + b22d978 commit 3257410
Show file tree
Hide file tree
Showing 28 changed files with 648 additions and 97 deletions.
2 changes: 1 addition & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}"]
Expand Down
5 changes: 3 additions & 2 deletions .github/knip.ts
Original file line number Diff line number Diff line change
@@ -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;
Binary file modified bun.lockb
100644 → 100755
Binary file not shown.
2 changes: 1 addition & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 0 additions & 11 deletions jest.config.json

This file was deleted.

21 changes: 21 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -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": "<rootDir>/node_modules/@octokit/webhooks-methods/dist-node/index.js",
},
};

export default config;
40 changes: 29 additions & 11 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions src/github/github-client.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
});
2 changes: 1 addition & 1 deletion src/github/handlers/help-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/github/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
)
Expand Down
8 changes: 4 additions & 4 deletions src/github/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -60,7 +60,7 @@ export async function getConfig(context: GitHubContext): Promise<PluginConfigura
let mergedConfiguration: PluginConfiguration = defaultConfiguration;

const configurations = await Promise.all([
getConfigurationFromRepo(context, UBIQUIBOT_CONFIG_ORG_REPO, payload.repository.owner.login),
getConfigurationFromRepo(context, CONFIG_ORG_REPO, payload.repository.owner.login),
getConfigurationFromRepo(context, payload.repository.name, payload.repository.owner.login),
]);

Expand Down Expand Up @@ -143,7 +143,7 @@ async function download({ context, repository, owner }: { context: GitHubContext
const { data } = await context.octokit.rest.repos.getContent({
owner,
repo: repository,
path: UBIQUIBOT_CONFIG_FULL_PATH,
path: CONFIG_FULL_PATH,
mediaType: { format: "raw" },
});
return data as unknown as string; // this will be a string if media format is raw
Expand Down
2 changes: 1 addition & 1 deletion src/github/utils/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async function fetchActionManifest(context: GitHubContext<"issue_comment.created
return _manifestCache[manifestKey];
}
try {
const { data } = await context.octokit.repos.getContent({
const { data } = await context.octokit.rest.repos.getContent({
owner,
repo,
path: "manifest.json",
Expand Down
6 changes: 3 additions & 3 deletions src/github/utils/workflow-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ interface WorkflowDispatchOptions {
}

async function getInstallationOctokitForOrg(context: GitHubContext, owner: string): Promise<InstanceType<typeof customOctokit>> {
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) {
Expand All @@ -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,
Expand All @@ -45,7 +45,7 @@ export async function dispatchWorker(targetUrl: string, payload?: Record<string,

export async function getDefaultBranch(context: GitHubContext, owner: string, repository: string) {
const octokit = await getInstallationOctokitForOrg(context, owner); // we cannot access other repos with the context's octokit
const repo = await octokit.repos.get({
const repo = await octokit.rest.repos.get({
owner: owner,
repo: repository,
});
Expand Down
1 change: 1 addition & 0 deletions src/sdk/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const KERNEL_PUBLIC_KEY = "";
14 changes: 14 additions & 0 deletions src/sdk/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { customOctokit } from "./octokit";
import { Logs } from "@ubiquity-dao/ubiquibot-logger";

export interface Context<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName> {
eventName: TSupportedEvents;
payload: {
[K in TSupportedEvents]: K extends WebhookEventName ? WebhookEvent<K> : never;
}[TSupportedEvents]["payload"];
octokit: InstanceType<typeof customOctokit>;
config: TConfig;
env: TEnv;
logger: Logs;
}
2 changes: 2 additions & 0 deletions src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createPlugin } from "./server";
export type { Context } from "./context";
27 changes: 27 additions & 0 deletions src/sdk/octokit.ts
Original file line number Diff line number Diff line change
@@ -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);
});
65 changes: 65 additions & 0 deletions src/sdk/server.ts
Original file line number Diff line number Diff line change
@@ -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<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | 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<TConfig, TEnv, TSupportedEvents> = {
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;
}
20 changes: 20 additions & 0 deletions src/sdk/signature.ts
Original file line number Diff line number Diff line change
@@ -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);
}
4 changes: 2 additions & 2 deletions src/types/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [] })),
});

Expand Down
Loading

0 comments on commit 3257410

Please sign in to comment.