Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

SDK #64

Merged
merged 23 commits into from
Jul 30, 2024
Merged

SDK #64

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
],
whilefoo marked this conversation as resolved.
Show resolved Hide resolved
"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();

whilefoo marked this conversation as resolved.
Show resolved Hide resolved
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));
whilefoo marked this conversation as resolved.
Show resolved Hide resolved
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: {} })),
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
"ubiquity:listeners": T.Optional(T.Array(runEvent, { default: [] })),
});

Expand Down
Loading
Loading