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

Feat/proxy callbacks #191

Closed
Closed
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
31 changes: 29 additions & 2 deletions .cspell.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,37 @@
{
"$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json",
"version": "0.2",
"ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log"],
"ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log", "bun.lockb", "eslint.config.mjs"],
"useGitignore": true,
"language": "en",
"words": ["dataurl", "devpool", "fkey", "mswjs", "outdir", "servedir", "supabase", "typebox", "ubiquity-os", "smee", "tomlify", "hono", "cfworker"],
"words": [
"dataurl",
"devpool",
"fkey",
"mswjs",
"outdir",
"servedir",
"supabase",
"typebox",
"ubiquity-os",
"smee",
"tomlify",
"hono",
"cfworker",
"findstr",
"taskkill",
"topk",
"outform",
"nocrypt",
"tokp",
"XVCJ",
"tsup",
"Jsons",
"knip",
"whilefoo",
"miniflare",
"screencast"
],
"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
6 changes: 2 additions & 4 deletions .github/workflows/sync-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: Sync branch to template
on:
workflow_dispatch:
schedule:
- cron: '14 0 1 * *'
- cron: "14 0 1 * *"

jobs:
sync:
Expand All @@ -23,7 +23,7 @@ jobs:
with:
app_id: ${{ secrets.APP_ID }}
private_key: ${{ secrets.APP_PRIVATE_KEY }}

- name: Sync branch to template
env:
GH_TOKEN: ${{ steps.get_installation_token.outputs.token }}
Expand All @@ -45,5 +45,3 @@ jobs:
git commit -m "chore: sync template"
git push "$original_remote" "$pr_branch"
gh pr create --title "Sync branch to template" --body "This pull request merges changes from the template repository." --head "$pr_branch" --base "$branch_name"


358 changes: 173 additions & 185 deletions CHANGELOG.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ bun dev
```

6. **Deploy the Kernel:**

- Execute `bun run deploy-dev` to deploy the kernel.

7. **Setup database (optional)**
Expand Down
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/**", "jest.config.ts", ".husky/**"],
ignores: [".github/knip.ts", "**/.wrangler/**", "jest.config.ts", ".husky/**", "dist/**", ".wrangler/**/*"],
extends: [eslint.configs.recommended, ...tsEslint.configs.recommended, sonarjs.configs.recommended],
languageOptions: {
parser: tsEslint.parser,
Expand Down
80 changes: 80 additions & 0 deletions sdk-build-script.ts
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed since we moved the SDK out?

Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import PACKAGE from "./package.json";
import fs from "fs/promises";

type Exports = Record<string, { import?: string; require?: string; types: string }>;

interface PackageJson {
name: string;
version: string;
description: string;
main: string;
module: string;
types: string;
author: string;
license: string;
keywords: string[];
exports: Exports;
}

export async function createCleanPackageJson(dirName: string, base = false, dirs?: string[]) {
console.log(`Creating package.json for ${!dirName ? "index" : dirName}...`);
let exports: Exports;

const packageJson = {
name: PACKAGE.name,
version: PACKAGE.version,
description: PACKAGE.description,
main: "./",
module: "./",
types: "./",
author: PACKAGE.author,
license: PACKAGE.license,
keywords: PACKAGE.keywords,
exports: {} as Exports,
} as unknown as PackageJson;

if (base && dirs) {
exports = {
".": {
types: `./sdk/index.d.ts`,
import: `./sdk/index.mjs`,
require: `./sdk/index.js`,
},
};

for (const dir of dirs) {
exports[`./${dir}`] = {
import: `./${dir}/index.mjs`,
require: `./${dir}/index.js`,
types: `./${dir}/index.d.ts`,
};
}

packageJson.exports = exports;
packageJson.types = `./sdk/index.d.ts`;
packageJson.main = `./sdk/index.js`;
packageJson.module = `./sdk/index.mjs`;

await writeDirPackageJson("dist", packageJson);
} else {
exports = {
[`.`]: {
import: `./index.mjs`,
require: `./index.js`,
types: `./index.d.ts`,
},
};

packageJson.exports = exports;
packageJson.types = `./index.d.ts`;
packageJson.main = `./index.js`;
packageJson.module = `./index.mjs`;

await writeDirPackageJson(dirName, packageJson);
}
}

async function writeDirPackageJson(dirName: string, packageJson: PackageJson) {
const path = dirName === "dist" ? "./dist/package.json" : `./dist/${dirName}/package.json`;
await fs.writeFile(path, JSON.stringify(packageJson, null, 2));
}
5 changes: 3 additions & 2 deletions src/sdk/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { customOctokit } from "./octokit";
import { sanitizeMetadata } from "./util";
import { verifySignature } from "./signature";
import { KERNEL_PUBLIC_KEY } from "./constants";
import { CallbackBuilder, handlePluginCallbacks } from "./plugin-callbacks";

config();

Expand All @@ -32,7 +33,7 @@ const inputSchema = T.Object({
});

export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
callbackBuilder: CallbackBuilder,
options?: Options
) {
const pluginOptions = {
Expand Down Expand Up @@ -90,7 +91,7 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TSu
};

try {
const result = await handler(context);
const result = await handlePluginCallbacks(context, callbackBuilder);
core.setOutput("result", result);
await returnDataToKernel(pluginGithubToken, inputs.stateId, result);
} catch (error) {
Expand Down
11 changes: 7 additions & 4 deletions src/sdk/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as Webhook
import { Logs } from "@ubiquity-os/ubiquity-os-logger";
import { customOctokit } from "./octokit";

export interface Context<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName> {
export type SupportedEventsU = WebhookEventName;
export type SupportedEvents = {
[K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent<K> : never;
};

export interface Context<TConfig = unknown, TEnv = unknown, TSupportedEvents extends SupportedEventsU = WebhookEventName> {
eventName: TSupportedEvents;
payload: {
[K in TSupportedEvents]: K extends WebhookEventName ? WebhookEvent<K> : never;
}[TSupportedEvents]["payload"];
payload: SupportedEvents[TSupportedEvents]["payload"];
octokit: InstanceType<typeof customOctokit>;
config: TConfig;
env: TEnv;
Expand Down
16 changes: 16 additions & 0 deletions src/sdk/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { LogReturn } from "@ubiquity-os/ubiquity-os-logger";
import { Context } from "./context";
import { sanitizeMetadata } from "./util";

export async function postWorkerErrorComment(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<!--\n${sanitizeMetadata(error.metadata)}\n-->`,
});
} else {
context.logger.info("Cannot post comment because issue is not found in the payload");
}
}
1 change: 1 addition & 0 deletions src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { createPlugin } from "./server";
export { createActionsPlugin } from "./actions";
export { CallbackBuilder } from "./plugin-callbacks";
export { postComment } from "./comment";
export type { Context } from "./context";
export * from "./constants";
Expand Down
101 changes: 101 additions & 0 deletions src/sdk/plugin-callbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Context, SupportedEventsU } from "./context";
import { CallbackFunction, PluginCallbacks } from "../types/helpers";
import { postWorkerErrorComment } from "./errors";

/**
* Build your callbacks first and pass `CallbackBuilder` directly to `createPlugin`.
*
* @example

```ts
const builder = new CallbackBuilder()
.addCallback("issue_comment.created", <CallbackFunction<"issue_comment.created">>helloWorld)
.addCallback("issue_comment.deleted", <CallbackFunction<"issue_comment.deleted">>goodbyeCruelWorld);
```
*/
export class CallbackBuilder {
private _callbacks: PluginCallbacks = {} as PluginCallbacks;

/**
* Add a callback for the given event.
*
* @param event The event to add a callback for.
* @param callback The callback to add.
*/
addCallback<TEvent extends SupportedEventsU>(event: TEvent, callback: CallbackFunction<TEvent>) {
this._callbacks[event] ??= [];
this._callbacks[event].push(callback);
return this;
}

/**
* Add multiple callbacks for the given event.
*
* @param event The event to add callbacks for.
* @param callbacks The callbacks to add.
*/
addCallbacks<TEvent extends SupportedEventsU>(event: TEvent, callbacks: CallbackFunction<TEvent>[]) {
this._callbacks[event] ??= [];
this._callbacks[event].push(...callbacks);
return this;
}

/**
* This simply returns the callbacks object.
*/
build() {
return this._callbacks;
}
}

export async function handlePluginCallbacks(context: Context, callbackBuilder: CallbackBuilder) {
const { eventName } = context;
const callbacks = callbackBuilder.build()[eventName];

if (!callbacks || !callbacks.length) {
context.logger.info(`No callbacks found for event ${eventName}`);
return { status: 204, reason: "skipped" };
}

try {
const res = await Promise.all(callbacks.map((callback) => handleCallback(callback, context)));
context.logger.info(`${eventName} callbacks completed`, { res });
let hasFailed = false;
for (const r of res) {
if (r.status === 500) {
await postWorkerErrorComment(context, context.logger.error(r.reason, { content: r.content }));
hasFailed = true;
} else if (r.status === 404) {
context.logger.error(r.reason, { content: r.content });
} else if (r.status === 204) {
context.logger.info(r.reason, { content: r.content });
} else {
context.logger.ok(r.reason, { content: r.content });
}
}

if (hasFailed) {
return { status: 500, reason: `One or more callbacks failed for event ${eventName}` };
}
return { status: 200, reason: "success" };
} catch (er) {
await postWorkerErrorComment(context, context.logger.fatal("Error in handlePluginCallbacks", { er }));
return { status: 500, reason: "error", content: String(er) };
}
}

/**
* Why do we need this wrapper function?
*
* By using a generic `Function` type for the callback parameter, we bypass strict type
* checking temporarily. This allows us to pass a standard `Context` object, which we know
* contains the correct event and payload types, to the callback safely.
*
* We can trust that the `ProxyCallbacks` type has already ensured that each callback function
* matches the expected event and payload types, so this function provides a safe and
* flexible way to handle callbacks without introducing type or logic errors.
*/
// eslint-disable-next-line @typescript-eslint/ban-types
function handleCallback(callback: Function, context: Context) {
return callback(context);
}
12 changes: 8 additions & 4 deletions src/sdk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { verifySignature } from "./signature";
import { env as honoEnv } from "hono/adapter";
import { postComment } from "./comment";
import { Type as T } from "@sinclair/typebox";
import { CallbackBuilder, handlePluginCallbacks } from "./plugin-callbacks";
import { postWorkerErrorComment } from "./errors";

interface Options {
kernelPublicKey?: string;
Expand All @@ -32,7 +34,7 @@ const inputSchema = T.Object({
});

export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
callbackBuilder: CallbackBuilder,
manifest: Manifest,
options?: Options
) {
Expand Down Expand Up @@ -92,8 +94,10 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
env = ctx.env as TEnv;
}

const eventName = inputs.eventName as TSupportedEvents;

const context: Context<TConfig, TEnv, TSupportedEvents> = {
eventName: inputs.eventName as TSupportedEvents,
eventName,
payload: inputs.eventPayload,
octokit: new customOctokit({ auth: inputs.authToken }),
config: config,
Expand All @@ -102,7 +106,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
};

try {
const result = await handler(context);
const result = await handlePluginCallbacks(context, callbackBuilder);
return ctx.json({ stateId: inputs.stateId, output: result });
} catch (error) {
console.error(error);
Expand All @@ -117,7 +121,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TSupportedEvents
}

if (pluginOptions.postCommentOnError && loggerError) {
await postComment(context, loggerError);
await postWorkerErrorComment(context, loggerError);
}

throw new HTTPException(500, { message: "Unexpected error" });
Expand Down
Loading
Loading