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 6 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));
}
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 "./proxy-callbacks";
export { postComment } from "./comment";
export type { Context } from "./context";
export * from "./constants";
Expand Down
104 changes: 104 additions & 0 deletions src/sdk/proxy-callbacks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { Context, SupportedEventsU } from "../sdk/context";
import { CallbackFunction, ProxyCallbacks } 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: ProxyCallbacks = {} as ProxyCallbacks;

/**
* 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;
}

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

export function proxyCallbacks(context: Context, callbackBuilder: CallbackBuilder): ProxyCallbacks {
return new Proxy(callbackBuilder.build(), {
Keyrxng marked this conversation as resolved.
Show resolved Hide resolved
get(target, prop: SupportedEventsU) {
if (!target[prop]) {
context.logger.info(`No callbacks found for event ${prop}`);
return { status: 204, reason: "skipped" };
}
return (async () => {
try {
const res = await Promise.all(target[prop].map((callback) => handleCallback(callback, context)));
context.logger.info(`${prop} callbacks completed`, { res });
let hasFailed = false;
for (const r of res) {
if (r.status === 500) {
/**
* Once https://github.com/ubiquity-os/ubiquity-os-kernel/pull/169 is merged,
* we'll be able to detect easily if it's a worker or an action using the new context var
* `pluginDeploymentDetails` which is just `inputs.ref` essentially.
*/
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 ${prop}` };
}
return { status: 200, reason: "success" };
} catch (er) {
console.log("Error in proxyCallbacks", er);
await postWorkerErrorComment(context, context.logger.fatal("Error in proxyCallbacks", { er }));
return { status: 500, reason: "error", content: String(er) };
}
})();
},
});
}

/**
* Helper for awaiting proxyCallbacks
*/
export async function handleProxyCallbacks(proxyCallbacks: ProxyCallbacks, context: Context) {
return proxyCallbacks[context.eventName];
}

/**
* 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, handleProxyCallbacks, proxyCallbacks } from "./proxy-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 handleProxyCallbacks(proxyCallbacks(context, callbackBuilder), context);
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
27 changes: 27 additions & 0 deletions src/types/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Context } from "../sdk";
import { SupportedEventsU } from "../sdk/context";

export type CallbackResult = { status: 200 | 201 | 204 | 404 | 500; reason: string; content?: string | Record<string, unknown> };
export type CallbackFunction<TEvent extends SupportedEventsU, TConfig = Record<string, unknown>, TEnv = Record<string, unknown>> = (
context: Context<TConfig, TEnv, TEvent>
) => Promise<CallbackResult>;
/**
* The `Context` type is a generic type defined as `Context<TEvent, TPayload>`,
* where `TEvent` is a string representing the event name (e.g., "issues.labeled")
* and `TPayload` is the webhook payload type for that event, derived from
* the `SupportedEvents` type map.
*
* The `ProxyCallbacks` object is cast to allow optional callbacks
* for each event type. This is useful because not all events may have associated callbacks.
* As opposed to Partial<ProxyCallbacks> which could mean an undefined object.
*
* The expected function signature for callbacks looks like this:
*
* ```typescript
* fn(context: Context<"issues.labeled", SupportedEvents["issues.labeled"]>): Promise<Result>
* ```
*/

export type ProxyCallbacks = {
[K in SupportedEventsU]: Array<CallbackFunction<K>>;
};
3 changes: 3 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Manifest as M } from "./manifest";
export type { M as Manifest };
export type { CallbackFunction, CallbackResult } from "./helpers";
Loading
Loading