Skip to content

Commit

Permalink
Merge pull request #74 from gentlementlegen/feat/runs-on
Browse files Browse the repository at this point in the history
feat: plugins can describe their own event listeneners
  • Loading branch information
gentlementlegen authored Jul 24, 2024
2 parents 056989f + 950ee42 commit d0f975a
Show file tree
Hide file tree
Showing 14 changed files with 331 additions and 250 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,4 @@ keys
junit.xml

*.pem
test-dashboard.md
25 changes: 10 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,9 @@ bun dev

- Execute `bun install` to install the required dependencies.

2. **Create a Github App:**
2. **Create a GitHub App:**

- Generate a Github App and configure its settings.
- 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:

Expand Down Expand Up @@ -137,7 +137,7 @@ const output: PluginOutput = {
The kernel supports 2 types of plugins:
1. Github actions ([wiki](https://github.com/ubiquity/ubiquibot-kernel/wiki/How-it-works))
1. GitHub actions ([wiki](https://github.com/ubiquity/ubiquibot-kernel/wiki/How-it-works))
2. Cloudflare Workers (which are simple backend servers with a single API route)
How to run a "hello-world" plugin the Cloudflare way:
Expand All @@ -146,18 +146,13 @@ How to run a "hello-world" plugin the Cloudflare way:
2. Run `bun plugin:hello-world` to spin up a local server for the "hello-world" plugin
3. Update the bot's config file in the repository where you use the bot (`OWNER/REPOSITORY/.github/.ubiquibot-config.yml`):
```
```yml
plugins:
'issue_comment.created':
- name: "hello-world-plugin name"
description: "hello-world-plugin description"
command: "/hello"
example: "/hello example"
skipBotEvents: true
uses:
# hello-world-plugin
- skipBotEvents: true
uses:
# hello-world-plugin
- plugin: http://127.0.0.1:9090
type: github
runsOn: [ "issue_comment.created" ]
with:
response: world
```
Expand All @@ -169,8 +164,8 @@ How it works:
1. When you post the `/hello` command the kernel receives the `issue_comment.created` event
2. The kernel matches the `/hello` command to the plugin that should be executed (i.e. the API method that should be called)
3. The kernel passes github event payload, bot's access token and plugin settings (from `.ubiquibot-config.yml`) to the plugin endpoint
4. The plugin performs all of the required actions and returns the result
3. The kernel passes GitHub event payload, bot's access token and plugin settings (from `.ubiquibot-config.yml`) to the plugin endpoint
4. The plugin performs all the required actions and returns the result
## Testing
Expand Down
61 changes: 9 additions & 52 deletions src/github/handlers/help-command.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,14 @@
import { getConfig } from "../utils/config";
import { GithubPlugin, isGithubPlugin } from "../types/plugin-configuration";
import { GithubPlugin } from "../types/plugin-configuration";
import { GitHubContext } from "../github-context";
import { Manifest, manifestSchema, manifestValidator } from "../../types/manifest";
import { Value } from "@sinclair/typebox/value";
import { Buffer } from "node:buffer";
import { getManifest } from "../utils/plugins";

async function parseCommandsFromManifest(context: GitHubContext<"issue_comment.created">, plugin: string | GithubPlugin) {
const commands: string[] = [];
const manifest = await (isGithubPlugin(plugin) ? fetchActionManifest(context, plugin) : fetchWorkerManifest(plugin));
if (manifest) {
Value.Default(manifestSchema, manifest);
const errors = manifestValidator.testReturningErrors(manifest);
if (errors !== null) {
console.error(`Failed to load the manifest for ${JSON.stringify(plugin)}`);
for (const error of errors) {
console.error(error);
}
} else {
if (manifest?.commands) {
for (const [key, value] of Object.entries(manifest.commands)) {
commands.push(`| \`/${getContent(key)}\` | ${getContent(value.description)} | \`${getContent(value["ubiquity:example"])}\` |`);
}
}
const manifest = await getManifest(context, plugin);
if (manifest?.commands) {
for (const [key, value] of Object.entries(manifest.commands)) {
commands.push(`| \`/${getContent(key)}\` | ${getContent(value.description)} | \`${getContent(value["ubiquity:example"])}\` |`);
}
}
return commands;
Expand All @@ -36,11 +23,9 @@ export async function postHelpCommand(context: GitHubContext<"issue_comment.crea
];
const commands: string[] = [];
const configuration = await getConfig(context);
for (const pluginArray of Object.values(configuration.plugins)) {
for (const pluginElement of pluginArray) {
const { plugin } = pluginElement.uses[0];
commands.push(...(await parseCommandsFromManifest(context, plugin)));
}
for (const pluginElement of configuration.plugins) {
const { plugin } = pluginElement.uses[0];
commands.push(...(await parseCommandsFromManifest(context, plugin)));
}
await context.octokit.issues.createComment({
body: comments.concat(commands.sort()).join("\n"),
Expand All @@ -56,31 +41,3 @@ export async function postHelpCommand(context: GitHubContext<"issue_comment.crea
function getContent(content: string | undefined) {
return content ? content.replace("|", "\\|") : "-";
}

async function fetchActionManifest(context: GitHubContext<"issue_comment.created">, { owner, repo }: GithubPlugin): Promise<Manifest | null> {
try {
const { data } = await context.octokit.repos.getContent({
owner,
repo,
path: "manifest.json",
});
if ("content" in data) {
const content = Buffer.from(data.content, "base64").toString();
return JSON.parse(content);
}
} catch (e) {
console.warn(`Could not find a manifest for ${owner}/${repo}: ${e}`);
}
return null;
}

async function fetchWorkerManifest(url: string): Promise<Manifest | null> {
const manifestUrl = `${url}/manifest.json`;
try {
const result = await fetch(manifestUrl);
return (await result.json()) as Manifest;
} catch (e) {
console.warn(`Could not find a manifest for ${manifestUrl}: ${e}`);
}
return null;
}
20 changes: 11 additions & 9 deletions src/github/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { repositoryDispatch } from "./repository-dispatch";
import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch";
import { PluginInput } from "../types/plugin";
import { isGithubPlugin, PluginConfiguration } from "../types/plugin-configuration";
import { getManifest, getPluginsForEvent } from "../utils/plugins";

function tryCatchWrapper(fn: (event: EmitterWebhookEvent) => unknown) {
return async (event: EmitterWebhookEvent) => {
Expand All @@ -24,19 +25,20 @@ export function bindHandlers(eventHandler: GitHubEventHandler) {
eventHandler.onAny(tryCatchWrapper((event) => handleEvent(event, eventHandler))); // onAny should also receive GithubContext but the types in octokit/webhooks are weird
}

function shouldSkipPlugin(event: EmitterWebhookEvent, context: GitHubContext, pluginChain: PluginConfiguration["plugins"]["*"][0]) {
async function shouldSkipPlugin(event: EmitterWebhookEvent, context: GitHubContext, pluginChain: PluginConfiguration["plugins"][0]) {
if (pluginChain.skipBotEvents && "sender" in event.payload && event.payload.sender?.type === "Bot") {
console.log("Skipping plugin chain because sender is a bot");
return true;
}
const manifest = await getManifest(context, pluginChain.uses[0].plugin);
if (
context.key === "issue_comment.created" &&
pluginChain.command &&
"comment" in context.payload &&
typeof context.payload.comment !== "string" &&
!context.payload.comment?.body.startsWith(pluginChain.command)
manifest &&
!Object.keys(manifest.commands).some(
(command) => "comment" in context.payload && typeof context.payload.comment !== "string" && context.payload.comment?.body.startsWith(`/${command}`)
)
) {
console.log("Skipping plugin chain because command does not match");
console.log(`Skipping plugin chain ${manifest.name} because command does not match`);
return true;
}
return false;
Expand All @@ -57,15 +59,15 @@ async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceTyp
return;
}

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

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

for (const pluginChain of pluginChains) {
if (shouldSkipPlugin(event, context, pluginChain)) {
if (await shouldSkipPlugin(event, context, pluginChain)) {
continue;
}

Expand All @@ -86,7 +88,7 @@ async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceTyp
inputs: new Array(pluginChain.uses.length),
};

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

Expand Down
20 changes: 13 additions & 7 deletions src/github/types/plugin-configuration.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Type as T } from "@sinclair/typebox";
import { StaticDecode } from "@sinclair/typebox";
import { StaticDecode, TLiteral, Type as T, Union } from "@sinclair/typebox";
import { StandardValidator } from "typebox-validators";
import { githubWebhookEvents } from "./webhook-events";
import { emitterEventNames } from "@octokit/webhooks";

const pluginNameRegex = new RegExp("^([0-9a-zA-Z-._]+)\\/([0-9a-zA-Z-._]+)(?::([0-9a-zA-Z-._]+))?(?:@([0-9a-zA-Z-._]+(?:\\/[0-9a-zA-Z-._]+)?))?$");

Expand Down Expand Up @@ -46,11 +45,21 @@ function githubPluginType() {
});
}

type IntoStringLiteralUnion<T> = { [K in keyof T]: T[K] extends string ? TLiteral<T[K]> : never };

export function stringLiteralUnion<T extends string[]>(values: readonly [...T]): Union<IntoStringLiteralUnion<T>> {
const literals = values.map((value) => T.Literal(value));
return T.Union(literals as never);
}

const emitterType = stringLiteralUnion(emitterEventNames);

const pluginChainSchema = T.Array(
T.Object({
id: T.Optional(T.String()),
plugin: githubPluginType(),
with: T.Record(T.String(), T.Unknown(), { default: {} }),
runsOn: T.Array(emitterType, { default: [] }),
}),
{ minItems: 1, default: [] }
);
Expand All @@ -60,17 +69,14 @@ export type PluginChain = StaticDecode<typeof pluginChainSchema>;
const handlerSchema = T.Array(
T.Object({
name: T.Optional(T.String()),
description: T.Optional(T.String()),
command: T.Optional(T.String()),
example: T.Optional(T.String()),
uses: pluginChainSchema,
skipBotEvents: T.Boolean({ default: true }),
}),
{ default: [] }
);

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

export const configSchemaValidator = new StandardValidator(configSchema);
Expand Down
28 changes: 0 additions & 28 deletions src/github/types/webhook-events.ts

This file was deleted.

30 changes: 16 additions & 14 deletions src/github/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import YAML from "yaml";
import { GitHubContext } from "../github-context";
import { expressionRegex } from "../types/plugin";
import { configSchema, configSchemaValidator, PluginConfiguration } from "../types/plugin-configuration";
import { eventNames } from "../types/webhook-events";
import { getManifest } from "./plugins";

const UBIQUIBOT_CONFIG_FULL_PATH = ".github/.ubiquibot-config.yml";
const UBIQUIBOT_CONFIG_ORG_REPO = "ubiquibot-config";
Expand Down Expand Up @@ -39,11 +39,8 @@ async function getConfigurationFromRepo(context: GitHubContext, repository: stri
*/
function mergeConfigurations(configuration1: PluginConfiguration, configuration2: PluginConfiguration): PluginConfiguration {
const mergedConfiguration = { ...configuration1 };
for (const key of Object.keys(configuration2.plugins)) {
const pluginKey = key as keyof PluginConfiguration["plugins"];
if (configuration2.plugins[pluginKey]?.length) {
mergedConfiguration.plugins[pluginKey] = configuration2.plugins[pluginKey];
}
if (configuration2.plugins?.length) {
mergedConfiguration.plugins = configuration2.plugins;
}
return mergedConfiguration;
}
Expand Down Expand Up @@ -75,20 +72,25 @@ export async function getConfig(context: GitHubContext): Promise<PluginConfigura

checkPluginChains(mergedConfiguration);

for (const plugin of mergedConfiguration.plugins) {
if (plugin.uses.length && !plugin.uses[0].runsOn?.length) {
const manifest = await getManifest(context, plugin.uses[0].plugin);
if (manifest) {
plugin.uses[0].runsOn = manifest["ubiquity:listeners"] || [];
}
}
}
return mergedConfiguration;
}

function checkPluginChains(config: PluginConfiguration) {
for (const eventName of eventNames) {
const plugins = config.plugins[eventName];
for (const plugin of plugins) {
const allIds = checkPluginChainUniqueIds(plugin);
checkPluginChainExpressions(plugin, allIds);
}
for (const plugin of config.plugins) {
const allIds = checkPluginChainUniqueIds(plugin);
checkPluginChainExpressions(plugin, allIds);
}
}

function checkPluginChainUniqueIds(plugin: PluginConfiguration["plugins"]["*"][0]) {
function checkPluginChainUniqueIds(plugin: PluginConfiguration["plugins"][0]) {
const allIds = new Set<string>();
for (const use of plugin.uses) {
if (!use.id) continue;
Expand All @@ -101,7 +103,7 @@ function checkPluginChainUniqueIds(plugin: PluginConfiguration["plugins"]["*"][0
return allIds;
}

function checkPluginChainExpressions(plugin: PluginConfiguration["plugins"]["*"][0], allIds: Set<string>) {
function checkPluginChainExpressions(plugin: PluginConfiguration["plugins"][0], allIds: Set<string>) {
const calledIds = new Set<string>();
for (const use of plugin.uses) {
if (!use.id) continue;
Expand Down
Loading

0 comments on commit d0f975a

Please sign in to comment.