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 20 commits
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
2 changes: 1 addition & 1 deletion .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"constructor-super": "error",
"no-invalid-this": "off",
"@typescript-eslint/no-invalid-this": ["error"],
"no-restricted-syntax": ["error", "ForInStatement"],
"no-restricted-syntax": ["error"],
"use-isnan": "error",
"@typescript-eslint/no-unused-vars": [
"error",
Expand Down
87 changes: 73 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,90 @@ The kernel is designed to:
- Interface with plugins (GitHub Actions) for longer running processes.
- Run on Cloudflare Workers.

## Environment variables
## Environment Variables

- `PRIVATE_KEY`
You need to obtain a private key from your GitHub App settings and convert it to Public-Key Cryptography Standards #8 (PKCS#8) format. You can use the following command to perform this conversion and append the result to your `.dev.vars` file:
- **`PRIVATE_KEY`**
Obtain a private key from your GitHub App settings and convert it to the Public-Key Cryptography Standards #8 (PKCS#8) format. Use the following command to perform this conversion and append the result to your `.dev.vars` file:

```sh
echo "PRIVATE_KEY=\"$(openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in YOUR_PRIVATE_KEY.PEM | awk 'BEGIN{ORS="\\n"} 1')\"" >> .dev.vars
```
```sh
echo "PRIVATE_KEY=\"$(openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in YOUR_PRIVATE_KEY.PEM | awk 'BEGIN{ORS="\\n"} 1')\"" >> .dev.vars
```

###### Please replace `YOUR_PRIVATE_KEY.PEM` with the path to your actual PEM file when running the command.
**Note:** Replace `YOUR_PRIVATE_KEY.PEM` with the path to your actual PEM file when running the command.

- `WEBHOOK_SECRET`
This should be set in your GitHub App settings and also here.
- **`WEBHOOK_SECRET`**
Set this value in both your GitHub App settings and here.

- `APP_ID`
You can find this in your GitHub App settings.
- **`APP_ID`**
Retrieve this from your GitHub App settings.

- `WEBHOOK_PROXY_URL` (only for development)
You need to obtain a webhook URL at <https://smee.io/> and set it in the GitHub App settings.
- **`WEBHOOK_PROXY_URL` (only for development)**
Obtain a webhook URL at [smee.io](https://smee.io/) and set it in your GitHub App settings.

### Quick Start

```bash
git clone https://github.com/ubiquity/ubiquibot-kernel
cd ubiquibot-kernel
bun
bun install
bun dev
```

### Deploying to Cloudflare Workers

1. **Install Dependencies:**
- Execute `bun install` to install the required dependencies.

2. **Create a Github App:**
- Generate a Github App and configure its settings.
- Navigate to app settings and click `Permissions & events`.
- Ensure the app is subscribed to all events with the following permissions:

Repository permissions:
- Actions: Read & Write
- Contents: Read & Write
- Issues: Read & Write
- Pull Requests: Read & Write

Organization permissions:
- Members: Read only

3. **Cloudflare Account Setup:**
- If not done already, create a Cloudflare account.
- Run `npx wrangler login` to log in.

4. **Create a KV Namespace:**
- Generate a KV namespace using `npx wrangler kv:namespace create PLUGIN_CHAIN_STATE`.
- Copy the generated ID and paste it under `[env.dev]` in `wrangler.toml`.

5. **Manage Secrets:**
- Add (env) secrets using `npx wrangler secret put <KEY> --env dev`.
- For the private key, execute the following (replace `YOUR_PRIVATE_KEY.PEM` with the actual PEM file path):

```sh
echo $(openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in YOUR_PRIVATE_KEY.PEM) | npx wrangler secret put PRIVATE_KEY --env dev
```

6. **Deploy the Kernel:**
- Execute `bun run deploy-dev` to deploy the kernel.

### Plugin Input and Output

#### Input

Inputs are received within the workflow, triggered by the `workflow_dispatch` event. The plugin is designed to handle the following inputs:

- `stateId`: An identifier used to track the state of plugin chain execution in Cloudflare KV. It is crucial to pass this identifier back in the output.
- `eventName`: The complete name of the event (e.g., `issue_comment.created`).
- `eventPayload`: The payload associated with the event.
- `settings`: A string containing JSON with settings specific to your plugin. The plugin itself defines these settings.
- `authToken`: A JWT token for accessing GitHub's API to the repository where the event occurred.
- `ref`: A reference (branch, tag, commit SHA) indicating the version of the plugin to be utilized.

#### Output

Data is returned using the `repository_dispatch` event on the plugin's repository, and the output is structured within the `client_payload`.
The `event_type` must be set to `return_data_to_ubiquibot_kernel`.

- `state_id`: The state ID passed in the inputs must be included here.
- `output`: A string containing JSON with custom output, defined by the plugin itself.
Binary file modified bun.lockb
Binary file not shown.
11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
"format:prettier": "prettier --write .",
"format:cspell": "cspell **/*",
"prepare": "husky install",
"deploy": "wrangler deploy",
"worker": "wrangler dev --port 8787",
"deploy-dev": "wrangler deploy --env dev",
"deploy-production": "wrangler deploy --env production",
"worker": "wrangler dev --env dev --port 8787",
"proxy": "tsx src/proxy.ts"
},
"keywords": [
Expand All @@ -29,13 +30,15 @@
"open-source"
],
"dependencies": {
"@octokit/auth-app": "^6.0.3",
"@octokit/webhooks": "^12.0.10",
"@octokit/webhooks-types": "^7.3.1",
"@sinclair/typebox": "^0.32.5",
"create-cloudflare": "^2.8.3",
"octokit": "^3.1.2",
"smee-client": "^2.0.0",
"universal-github-app-jwt": "^2.0.5"
"universal-github-app-jwt": "^2.0.5",
"yaml": "^2.3.4"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20240117.0",
Expand All @@ -60,7 +63,7 @@
"prettier": "^3.2.4",
"tsx": "^4.6.2",
"typescript": "^5.0.4",
"wrangler": "^3.23.0"
"wrangler": "^3.30.1"
},
"lint-staged": {
"*.ts": [
Expand Down
5 changes: 4 additions & 1 deletion src/github/github-context.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { customOctokit } from "./github-client";
import { GitHubEventHandler } from "./github-event-handler";

export class GitHubContext<T extends WebhookEventName = WebhookEventName> {
public key: WebhookEventName;
public name: WebhookEventName;
public id: string;
public payload: WebhookEvent<T>["payload"];
public octokit: InstanceType<typeof customOctokit>;
public eventHandler: InstanceType<typeof GitHubEventHandler>;

constructor(event: WebhookEvent<T>, octokit: InstanceType<typeof customOctokit>) {
constructor(eventHandler: InstanceType<typeof GitHubEventHandler>, event: WebhookEvent<T>, octokit: InstanceType<typeof customOctokit>) {
this.eventHandler = eventHandler;
this.name = event.name;
this.id = event.id;
this.payload = event.payload;
Expand Down
62 changes: 46 additions & 16 deletions src/github/github-event-handler.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { Webhooks } from "@octokit/webhooks";
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";

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 @@ -22,24 +27,11 @@ 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,
transform: (event) => {
let installationId: number | undefined = undefined;
if ("installation" in event.payload) {
installationId = event.payload.installation?.id;
}
const octokit = new customOctokit({
auth: {
appId: this._appId,
privateKey: this._privateKey,
installationId: installationId,
},
});

return new GitHubContext(event, octokit);
},
transform: (event) => this.transformEvent(event), // it is important to use an arrow function here to keep the context of `this`
});

this.on = this.webhooks.on;
Expand All @@ -53,4 +45,42 @@ export class GitHubEventHandler {
console.error(error);
});
}

transformEvent(event: EmitterWebhookEvent) {
if ("installation" in event.payload && event.payload.installation?.id !== undefined) {
const octokit = this.getAuthenticatedOctokit(event.payload.installation.id);
return new GitHubContext(this, event, octokit);
} else {
const octokit = this.getUnauthenticatedOctokit();
return new GitHubContext(this, event, octokit);
}
}

getAuthenticatedOctokit(installationId: number) {
return new customOctokit({
auth: {
appId: this._appId,
privateKey: this._privateKey,
installationId: installationId,
},
});
}

getUnauthenticatedOctokit() {
return new customOctokit({
auth: {
appId: this._appId,
privateKey: this._privateKey,
},
});
}

async getToken(installationId: number) {
const auth = createAppAuth({
appId: this._appId,
privateKey: this._privateKey,
});
const token = await auth({ type: "installation", installationId });
return token.token;
}
}
Comment on lines +78 to +85
Copy link
Member

Choose a reason for hiding this comment

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

So I assume that this is the kernel logic to generate the token with the intent to pass it along to the plugin.

77 changes: 75 additions & 2 deletions src/github/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,79 @@
import { EmitterWebhookEvent } from "@octokit/webhooks";
import { GitHubEventHandler } from "../github-event-handler";
import { getConfig } from "../utils/config";
import { issueCommentCreated } from "./issue-comment/created";
import { repositoryDispatch } from "./repository-dispatch";
import { dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch";
import { DelegatedComputeInputs } from "../types/plugin";

export function bindHandlers(webhooks: GitHubEventHandler) {
webhooks.on("issue_comment.created", issueCommentCreated);
function tryCatchWrapper(fn: (event: EmitterWebhookEvent) => unknown) {
return async (event: EmitterWebhookEvent) => {
try {
await fn(event);
} catch (error) {
console.error("Error in event handler", error);
}
};
}

export function bindHandlers(eventHandler: GitHubEventHandler) {
eventHandler.on("issue_comment.created", issueCommentCreated);
eventHandler.on("repository_dispatch", repositoryDispatch);
eventHandler.onAny(tryCatchWrapper((event) => handleEvent(event, eventHandler))); // onAny should also receive GithubContext but the types in octokit/webhooks are weird
}

async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceType<typeof GitHubEventHandler>) {
const context = eventHandler.transformEvent(event);

const config = await getConfig(context);

if (!config) {
console.log("No config found");
return;
}

if (!("installation" in event.payload) || event.payload.installation?.id === undefined) {
console.log("No installation found");
return;
}

const pluginChains = config.plugins[context.key].concat(config.plugins["*"]);

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

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 stateId = crypto.randomUUID();

const state = {
eventId: context.id,
eventName: context.key,
eventPayload: event.payload,
currentPlugin: 0,
pluginChain: pluginChain.uses,
outputs: new Array(pluginChain.uses.length),
inputs: new Array(pluginChain.uses.length),
};

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

state.inputs[0] = inputs;
await eventHandler.pluginChainState.put(stateId, state);

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