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: config event handler #18

Merged
merged 24 commits into from
Mar 6, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
89f7de7
feat: config event handler
whilefoo Feb 7, 2024
b17af14
feat: workflow dispatch
whilefoo Feb 13, 2024
10887c3
fix: add missing events
whilefoo Feb 13, 2024
084d1c1
feat: webhook type
Keyrxng Feb 14, 2024
72fdeef
Update webhook-events.ts
Keyrxng Feb 14, 2024
441ebbd
Update webhook-events.ts
Keyrxng Feb 14, 2024
7c6043a
Update webhook-events.ts
Keyrxng Feb 14, 2024
5f0e142
Update webhook-events.ts
Keyrxng Feb 14, 2024
ea1b1d7
Merge pull request #20 from Keyrxng/config
whilefoo Feb 14, 2024
0979fd7
feat: updated delegated compute inputs
whilefoo Feb 18, 2024
3c0b829
feat: remove our enum because we use octokit's types
whilefoo Feb 18, 2024
b224246
feat: pass auth token instead of installation id
whilefoo Feb 18, 2024
295284a
fix: remove toString()
whilefoo Feb 18, 2024
b02d104
feat: ref
whilefoo Feb 22, 2024
7bd5ff8
feat: plugin chain config and repo dispatch handler
whilefoo Feb 27, 2024
54c29c8
feat: added instructions to deploy to Cloudflare Workers
whilefoo Feb 29, 2024
b5fd06d
fix: add env param
whilefoo Feb 29, 2024
d769841
feat: store event payload, inputs and outputs of plugins in chain state
whilefoo Feb 29, 2024
92e48bf
feat: update instructions
whilefoo Mar 2, 2024
0d83199
feat: support using output from previous plugins
whilefoo Mar 2, 2024
387d33b
fix: refactor into smaller functions
whilefoo Mar 4, 2024
597d9ce
fix: typescript too complex expression
whilefoo Mar 4, 2024
37cb25c
feat: skip bot events
whilefoo Mar 6, 2024
c000369
Merge remote-tracking branch 'origin/development' into config
whilefoo Mar 6, 2024
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
Binary file modified bun.lockb
Binary file not shown.
5 changes: 5 additions & 0 deletions src/github/github-event-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,22 @@ import { EmitterWebhookEvent, Webhooks } from "@octokit/webhooks";
import { customOctokit } from "./github-client";
import { GitHubContext, SimplifiedContext } from "./github-context";
import { createAppAuth } from "@octokit/auth-app";
import { CloudflareKV } from "./utils/cloudflare-kv";
import { PluginChainState } from "./types/plugin-state-chain";

export type Options = {
webhookSecret: string;
appId: string | number;
privateKey: string;
pluginChainState: CloudflareKV<PluginChainState>;
};

export class GitHubEventHandler {
public webhooks: Webhooks<SimplifiedContext>;
public on: Webhooks<SimplifiedContext>["on"];
public onAny: Webhooks<SimplifiedContext>["onAny"];
public onError: Webhooks<SimplifiedContext>["onError"];
public pluginChainState: CloudflareKV<PluginChainState>;

private _webhookSecret: string;
private _privateKey: string;
Expand All @@ -23,6 +27,7 @@ export class GitHubEventHandler {
this._privateKey = options.privateKey;
this._appId = Number(options.appId);
this._webhookSecret = options.webhookSecret;
this.pluginChainState = options.pluginChainState;

this.webhooks = new Webhooks<SimplifiedContext>({
secret: this._webhookSecret,
Expand Down
33 changes: 22 additions & 11 deletions src/github/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,38 +36,48 @@ async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceTyp
return;
}

const handler = config.handlers.events[context.key];
const pluginChains = config.plugins[context.key];

if (handler.length === 0) {
if (pluginChains.length === 0) {
console.log(`No handler found for event ${event.name}`);
return;
}

for (const { workflow, settings } of handler) {
console.log(`Calling handler for event ${event.name} and workflow ${workflow}`);
for (const pluginChain of pluginChains) {
// invoke the first plugin in the chain
const { plugin, with: settings } = pluginChain.uses[0];
console.log(`Calling handler for event ${event.name}`);

const ref = workflow.ref ?? (await getDefaultBranch(context, workflow.owner, workflow.repository));
const id = crypto.randomUUID();
await eventHandler.pluginChainState.put(id, {
currentPlugin: 0,
pluginChain: pluginChain.uses,
});

const ref = plugin.ref ?? (await getDefaultBranch(context, plugin.owner, plugin.repo));
const token = await eventHandler.getToken(event.payload.installation.id);
const inputs = new DelegatedComputeInputs(context.key, event, settings, token, ref);
const inputs = new DelegatedComputeInputs(id, context.key, event, settings, token, ref);

await dispatchWorkflow(context, {
owner: workflow.owner,
repository: workflow.repository,
workflowId: workflow.workflowId,
ref: workflow.ref,
owner: plugin.owner,
repository: plugin.repo,
workflowId: plugin.workflowId,
ref: plugin.ref,
inputs: inputs.getInputs(),
});
}
}

class DelegatedComputeInputs<T extends EmitterWebhookEventName = EmitterWebhookEventName> {
public id: string;
public eventName: T;
public event: EmitterWebhookEvent<T>;
public settings: unknown;
public authToken: string;
public ref: string;

constructor(eventName: T, event: EmitterWebhookEvent<T>, settings: unknown, authToken: string, ref: string) {
constructor(id: string, eventName: T, event: EmitterWebhookEvent<T>, settings: unknown, authToken: string, ref: string) {
this.id = id;
this.eventName = eventName;
this.event = event;
this.settings = settings;
Expand All @@ -77,6 +87,7 @@ class DelegatedComputeInputs<T extends EmitterWebhookEventName = EmitterWebhookE

public getInputs() {
return {
id: this.id,
eventName: this.eventName,
event: JSON.stringify(this.event),
settings: JSON.stringify(this.settings),
Expand Down
62 changes: 60 additions & 2 deletions src/github/handlers/repository-dispatch.ts
Copy link
Member

Choose a reason for hiding this comment

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

What does the kernel need repository dispatch event handler for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I thought that plugins will use repository dispatch to return data to the kernel, which will call the next plugin in the chain or stop if it's the last one.

Copy link
Member

Choose a reason for hiding this comment

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

Cool. If that approach works then its great! I'm not sure because I never ran the stable kernel, so I never tested any plugin interfacing.

Original file line number Diff line number Diff line change
@@ -1,5 +1,63 @@
import { StaticDecode, Type } from "@sinclair/typebox";
import { GitHubContext } from "../github-context";
import { dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch";
import { Value } from "@sinclair/typebox/value";

export async function repositoryDispatch(event: GitHubContext<"repository_dispatch">) {
console.log("Repository dispatch event received", event.payload);
export async function repositoryDispatch(context: GitHubContext<"repository_dispatch">) {
console.log("Repository dispatch event received", context.payload.client_payload);

const pluginOutput = context.payload.client_payload as PluginOutput;

if (!Value.Check(pluginOutputSchema, pluginOutput)) {
const errors = [...Value.Errors(pluginOutputSchema, pluginOutput)];
console.error("Invalid environment variables", errors);
throw new Error("Invalid environment variables");
}

const state = await context.eventHandler.pluginChainState.get(pluginOutput.id);
if (!state) {
console.error("No state found for plugin chain");
return;
}

const currentPlugin = state.pluginChain[state.currentPlugin];
if (currentPlugin.plugin.owner !== context.payload.repository.owner.login || currentPlugin.plugin.repo !== context.payload.repository.name) {
console.error("Plugin chain state does not match payload");
return;
}

state.currentPlugin++;
await context.eventHandler.pluginChainState.put(pluginOutput.id, state);

const nextPlugin = state.pluginChain[state.currentPlugin];
if (!nextPlugin) {
console.log("No more plugins to call");
return;
}

console.log("Dispatching next plugin", nextPlugin);

const inputs = {
...pluginOutput.output,
id: pluginOutput.id,
settings: JSON.stringify(nextPlugin.with),
ref: nextPlugin.plugin.ref ?? (await getDefaultBranch(context, nextPlugin.plugin.owner, nextPlugin.plugin.repo)),
};

await dispatchWorkflow(context, {
owner: nextPlugin.plugin.owner,
repository: nextPlugin.plugin.repo,
ref: nextPlugin.plugin.ref,
workflowId: nextPlugin.plugin.workflowId,
inputs: inputs,
});
}

const pluginOutputSchema = Type.Object({
id: Type.String(),
owner: Type.String(),
repo: Type.String(),
output: Type.Any(),
});

type PluginOutput = StaticDecode<typeof pluginOutputSchema>;
60 changes: 43 additions & 17 deletions src/github/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,58 @@ import { Type as T } from "@sinclair/typebox";
import { StaticDecode } from "@sinclair/typebox";
import { githubWebhookEvents } from "./webhook-events";

enum Commands {
Start = "start",
Stop = "stop",
const pluginNameRegex = new RegExp("^([0-9a-zA-Z-._]+)/([0-9a-zA-Z-._]+)(?::([0-9a-zA-Z-._]+))?(?:@([0-9a-zA-Z]+))?$");

type GithubPlugin = {
owner: string;
repo: string;
workflowId: string;
ref?: string;
};

function githubPluginType() {
return T.Transform(T.String())
.Decode((value) => {
const matches = value.match(pluginNameRegex);
if (!matches) {
throw new Error(`Invalid plugin name: ${value}`);
}
return {
owner: matches[1],
repo: matches[2],
workflowId: matches[3] || "compute.yml",
Copy link
Member

Choose a reason for hiding this comment

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

We will default to action.yml in the project root of the plugin. See my conversation rewards(work in progress) as an example: https://github.com/ubiquibot/conversation-rewards/blob/development/action.yml

This is to conform to the existing approach that GitHub Actions takes, and to enable compatibility with all existing GitHub Actions.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think you can invoke workflows in the root directory because those are meant to be published to the marketplace and used in a workflow.

Copy link
Member

Choose a reason for hiding this comment

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

We can just make it a pass through action. Basically the only logic will be to invoke a workflow in the workflow folder.

Copy link
Member

Choose a reason for hiding this comment

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

I still think we should default to action.yml in the root.

@whilefoo @gentlementlegen rfc

ref: matches[4] || undefined,
} as GithubPlugin;
})
.Encode((value) => {
return `${value.owner}/${value.repo}${value.workflowId ? ":" + value.workflowId : ""}${value.ref ? "@" + value.ref : ""}`;
});
}

const pluginChainSchema = T.Array(
T.Object({
plugin: githubPluginType(),
type: T.Union([T.Literal("github")], { default: "github" }),
with: T.Optional(T.Unknown()),
}),
{ minItems: 1 }
);

export type PluginChain = StaticDecode<typeof pluginChainSchema>;

const handlerSchema = T.Array(
T.Object({
workflow: T.Object({
owner: T.String(),
repository: T.String(),
workflowId: T.String(),
ref: T.Optional(T.String()),
}),
settings: T.Optional(T.Unknown()),
name: T.Optional(T.String()),
description: T.Optional(T.String()),
command: T.Optional(T.String()),
example: T.Optional(T.String()),
uses: pluginChainSchema,
}),
{ default: [] }
);

export const configSchema = T.Object({
handlers: T.Object(
{
commands: T.Record(T.Enum(Commands), handlerSchema, { default: {} }),
events: T.Record(T.Enum(githubWebhookEvents), handlerSchema, { default: {} }),
},
{ default: {} }
),
plugins: T.Record(T.Enum(githubWebhookEvents), handlerSchema, { default: {} }),
});

export type Config = StaticDecode<typeof configSchema>;
5 changes: 4 additions & 1 deletion src/github/types/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Type as T, type Static } from "@sinclair/typebox";

export const envSchema = T.Object({ WEBHOOK_SECRET: T.String(), APP_ID: T.String(), PRIVATE_KEY: T.String() });
export type Env = Static<typeof envSchema>;

export type Env = Static<typeof envSchema> & {
PLUGIN_CHAIN_STATE: KVNamespace;
};
6 changes: 6 additions & 0 deletions src/github/types/plugin-state-chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { PluginChain } from "./config";

export type PluginChainState = {
currentPlugin: number;
pluginChain: PluginChain;
};
19 changes: 12 additions & 7 deletions src/github/types/webhook-events.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { emitterEventNames, EmitterWebhookEventName as GitHubEventClassName } from "@octokit/webhooks";

type EventName = GitHubEventClassName | "*";

type Formatted<T extends string> = T extends `${infer Prefix}.${infer Rest}` ? `${Prefix}_${Formatted<Rest>}` : T;

type GithubEventWebHookEvents = {
[K in GitHubEventClassName as Formatted<Uppercase<K>>]: K;
[K in EventName as Formatted<Uppercase<K>>]: K;
};

type Prettify<T> = {
Expand All @@ -14,9 +16,12 @@ type Prettify<T> = {
// eslint-disable-next-line @typescript-eslint/ban-types
} & {};

export const githubWebhookEvents: Prettify<GithubEventWebHookEvents> = emitterEventNames.reduce((acc: GithubEventWebHookEvents, cur) => {
const formatted = cur.replace(/\./g, "_");
const upper = formatted.toUpperCase() as Formatted<Uppercase<GitHubEventClassName>>;
acc[upper] = cur as Extract<GitHubEventClassName, Uppercase<GitHubEventClassName>>;
return acc;
}, {} as GithubEventWebHookEvents);
export const githubWebhookEvents: Prettify<GithubEventWebHookEvents> = emitterEventNames.reduce(
(acc: GithubEventWebHookEvents, cur) => {
const formatted = cur.replace(/\./g, "_");
const upper = formatted.toUpperCase() as Formatted<Uppercase<GitHubEventClassName>>;
acc[upper] = cur as Extract<GitHubEventClassName, Uppercase<GitHubEventClassName>>;
return acc;
},
{ "*": "*" } as GithubEventWebHookEvents
);
15 changes: 15 additions & 0 deletions src/github/utils/cloudflare-kv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export class CloudflareKV<T> {
private _kv: KVNamespace;

constructor(kv: KVNamespace) {
this._kv = kv;
}

get(id: string): Promise<T | null> {
return this._kv.get(id, "json");
}

put(id: string, state: T): Promise<void> {
return this._kv.put(id, JSON.stringify(state));
}
}
13 changes: 12 additions & 1 deletion src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@ import { Value } from "@sinclair/typebox/value";
import { GitHubEventHandler } from "./github/github-event-handler";
import { bindHandlers } from "./github/handlers";
import { Env, envSchema } from "./github/types/env";
import { CloudflareKV } from "./github/utils/cloudflare-kv";

export default {
async fetch(request: Request, env: Env): Promise<Response> {
try {
validateEnv(env);
const eventName = getEventName(request);
const signatureSHA256 = getSignature(request);
const id = getId(request);
const eventHandler = new GitHubEventHandler({ webhookSecret: env.WEBHOOK_SECRET, appId: env.APP_ID, privateKey: env.PRIVATE_KEY });
const eventHandler = new GitHubEventHandler({
webhookSecret: env.WEBHOOK_SECRET,
appId: env.APP_ID,
privateKey: env.PRIVATE_KEY,
pluginChainState: new CloudflareKV(env.PLUGIN_CHAIN_STATE),
});
bindHandlers(eventHandler);
await eventHandler.webhooks.verifyAndReceive({ id, name: eventName, payload: await request.text(), signature: signatureSHA256 });
return new Response("ok\n", { status: 200, headers: { "content-type": "text/plain" } });
Expand All @@ -19,6 +26,7 @@ export default {
}
},
};

function handleUncaughtError(error: unknown) {
console.error(error);
let status = 500;
Expand All @@ -38,20 +46,23 @@ function validateEnv(env: Env): void {
throw new Error("Invalid environment variables");
}
}

function getEventName(request: Request): WebhookEventName {
const eventName = request.headers.get("x-github-event");
if (!eventName || !emitterEventNames.includes(eventName as WebhookEventName)) {
throw new Error(`Unsupported or missing "x-github-event" header value: ${eventName}`);
}
return eventName as WebhookEventName;
}

function getSignature(request: Request): string {
const signatureSHA256 = request.headers.get("x-hub-signature-256");
if (!signatureSHA256) {
throw new Error(`Missing "x-hub-signature-256" header`);
}
return signatureSHA256;
}

function getId(request: Request): string {
const id = request.headers.get("x-github-delivery");
if (!id) {
Expand Down
5 changes: 5 additions & 0 deletions wrangler.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ name = "ubiquibot-worker"
main = "src/worker.ts"
compatibility_date = "2023-12-06"

[env.dev]
kv_namespaces = [
{ binding = "PLUGIN_CHAIN_STATE", id = "a50ef0a143e449c087b6be613a84e5ba" },
Copy link
Member

Choose a reason for hiding this comment

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

It is my expectation that we will handle all storage from within plugins. What is the kernel seeking to remember? Can you elaborate on your decision here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For one event type there can be multiple plugin chains, so when the plugins return data to the kernel, the kernel needs to know which plugin chain is running and which plugin in the chain is currently executing.
For example:

 issues_label.added:

    - name: "Help Menu"
      description: "This will parse the config and display the help menu for slash commands."
      command: "^\/help$"
      example: "/help"
      uses:
        - ubiquibot/plugin-A
        - ubiquibot/plugin-B

    - name: "Assistive Pricing"
      description: "Set pricing based on Time and Priority labels."
      uses:
        - ubiquibot/plugin-B
        - ubiquibot/plugin-C
        - ubiquibot/plugin-A

Another reason is because the config can change when the plugin is running so we need to save the plugin chain at the invocation of the first plugin in the chain.

It also has a nice side feature of preventing plugins from sending data to the kernel without any invocation from the kernel

]

# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
# Note: Use secrets to store sensitive data.
# Docs: https://developers.cloudflare.com/workers/platform/environment-variables
Expand Down
Loading