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: plugins can describe their own event listeneners #74

Merged
merged 28 commits into from
Jul 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
af78d17
chore: update plugin schema logic (WIP)
gentlementlegen Jul 12, 2024
76af3a0
feat: manifest is now read and cached from the target repo
gentlementlegen Jul 12, 2024
41dd0ce
chore: fix type check
gentlementlegen Jul 12, 2024
8313e33
chore: fix type check
gentlementlegen Jul 12, 2024
9b0183d
chore: fix types for tests
gentlementlegen Jul 12, 2024
630d94d
Merge branch 'refs/heads/feat/help-command-manifest' into meniole-main
gentlementlegen Jul 15, 2024
c997039
Merge branch 'refs/heads/feat/help-command-manifest' into meniole-main
gentlementlegen Jul 15, 2024
2d6f3fc
chore: fixed logic
gentlementlegen Jul 15, 2024
c0e52a3
chore: fixed types
gentlementlegen Jul 15, 2024
3f592bd
chore: fixed unused exports
gentlementlegen Jul 15, 2024
b8c8f51
chore: fixed tests
gentlementlegen Jul 15, 2024
07a3305
chore: fixed tests
gentlementlegen Jul 15, 2024
0827a30
chore: renamed ubiquibot key to ubiquity
gentlementlegen Jul 16, 2024
bd9c210
fix: import buffer from node namespace
gentlementlegen Jul 16, 2024
41e4b96
fix: enabled nodejs compat
gentlementlegen Jul 16, 2024
7b3e111
feat: multiple commands can be handled for skip
gentlementlegen Jul 17, 2024
c63d48f
chore: loop through plugins instead of event names
gentlementlegen Jul 18, 2024
9122c2e
chore: removed optional for runsOn
gentlementlegen Jul 18, 2024
9dc9ad9
chore: updated README.md
gentlementlegen Jul 18, 2024
6136da5
chore: deleted unused file webhook-events.ts
gentlementlegen Jul 18, 2024
bc05c58
fix: enabled nodejs compat
gentlementlegen Jul 16, 2024
9c66d70
feat: manifest is now read and cached from the target repo
gentlementlegen Jul 12, 2024
9861495
Merge branch 'refs/heads/development' into feat/runs-on
gentlementlegen Jul 19, 2024
92b8bf2
chore: merge develop
gentlementlegen Jul 19, 2024
7b21ae4
Merge branch 'refs/heads/fix/multiple-commands-per-plugin' into feat/…
gentlementlegen Jul 21, 2024
d93b5c3
feat: added manifest to hello-world-plugin.ts
gentlementlegen Jul 21, 2024
63ee503
chore: fixed compile issue
gentlementlegen Jul 21, 2024
950ee42
chore: fixed tests
gentlementlegen Jul 21, 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
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