diff --git a/.dev.vars.example b/.dev.vars.example index 4278157..3f08e1e 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -2,3 +2,4 @@ WEBHOOK_PROXY_URL=https://smee.io/new APP_WEBHOOK_SECRET=xxxxxx APP_ID=123456 ENVIRONMENT=development | production +OPENAI_API_KEY= diff --git a/bun.lockb b/bun.lockb index 812bfba..044990f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 3b33b6e..86a7d0c 100644 --- a/package.json +++ b/package.json @@ -53,9 +53,10 @@ "@octokit/types": "^13.5.0", "@octokit/webhooks": "13.3.0", "@octokit/webhooks-types": "7.5.1", - "@sinclair/typebox": "^0.33.20", - "@ubiquity-os/plugin-sdk": "^1.0.11", + "@sinclair/typebox": "0.34.3", + "@ubiquity-os/plugin-sdk": "^1.1.0", "dotenv": "16.4.5", + "openai": "^4.70.2", "typebox-validators": "0.3.5", "yaml": "2.4.5" }, diff --git a/src/github/github-context.ts b/src/github/github-context.ts index b71d6db..c5faa25 100644 --- a/src/github/github-context.ts +++ b/src/github/github-context.ts @@ -1,6 +1,7 @@ import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; import { customOctokit } from "./github-client"; import { GitHubEventHandler } from "./github-event-handler"; +import OpenAI from "openai"; export class GitHubContext { public key: WebhookEventName; @@ -11,8 +12,14 @@ export class GitHubContext; public eventHandler: InstanceType; + public openAi: OpenAI; - constructor(eventHandler: InstanceType, event: WebhookEvent, octokit: InstanceType) { + constructor( + eventHandler: InstanceType, + event: WebhookEvent, + octokit: InstanceType, + openAi: OpenAI + ) { this.eventHandler = eventHandler; this.name = event.name; this.id = event.id; @@ -23,6 +30,7 @@ export class GitHubContext; + openAiClient: OpenAI; }; export class GitHubEventHandler { @@ -25,6 +28,7 @@ export class GitHubEventHandler { private readonly _webhookSecret: string; private readonly _privateKey: string; private readonly _appId: number; + private readonly _openAiClient: OpenAI; constructor(options: Options) { this.environment = options.environment; @@ -32,6 +36,7 @@ export class GitHubEventHandler { this._appId = Number(options.appId); this._webhookSecret = options.webhookSecret; this.pluginChainState = options.pluginChainState; + this._openAiClient = options.openAiClient; this.webhooks = new Webhooks({ secret: this._webhookSecret, @@ -57,10 +62,10 @@ export class GitHubEventHandler { 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); + return new GitHubContext(this, event, octokit, this._openAiClient); } else { const octokit = this.getUnauthenticatedOctokit(); - return new GitHubContext(this, event, octokit); + return new GitHubContext(this, event, octokit, this._openAiClient); } } diff --git a/src/github/handlers/help-command.ts b/src/github/handlers/help-command.ts index cc35b03..86e2e73 100644 --- a/src/github/handlers/help-command.ts +++ b/src/github/handlers/help-command.ts @@ -7,8 +7,8 @@ async function parseCommandsFromManifest(context: GitHubContext<"issue_comment.c const commands: string[] = []; 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"])}\` |`); + for (const [name, command] of Object.entries(manifest.commands)) { + commands.push(`| \`/${getContent(name)}\` | ${getContent(command.description)} | \`${getContent(command["ubiquity:example"])}\` |`); } } return commands; diff --git a/src/github/handlers/index.ts b/src/github/handlers/index.ts index 3905d0e..604d821 100644 --- a/src/github/handlers/index.ts +++ b/src/github/handlers/index.ts @@ -28,7 +28,7 @@ export function bindHandlers(eventHandler: GitHubEventHandler) { } export async function shouldSkipPlugin(context: GitHubContext, pluginChain: PluginConfiguration["plugins"][0]) { - if (pluginChain.skipBotEvents && "sender" in context.payload && context.payload.sender?.type === "Bot") { + if (pluginChain.uses[0].skipBotEvents && "sender" in context.payload && context.payload.sender?.type === "Bot") { console.log("Skipping plugin chain because sender is a bot"); return true; } @@ -93,7 +93,7 @@ async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceTyp 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); + const inputs = new PluginInput(context.eventHandler, stateId, context.key, event.payload, settings, token, ref, null); state.inputs[0] = inputs; await eventHandler.pluginChainState.put(stateId, state); diff --git a/src/github/handlers/issue-comment-created.ts b/src/github/handlers/issue-comment-created.ts index e01c493..4f3ff0f 100644 --- a/src/github/handlers/issue-comment-created.ts +++ b/src/github/handlers/issue-comment-created.ts @@ -1,9 +1,212 @@ +import { Manifest } from "@ubiquity-os/plugin-sdk/manifest"; import { GitHubContext } from "../github-context"; +import { PluginInput } from "../types/plugin"; +import { isGithubPlugin, PluginConfiguration } from "../types/plugin-configuration"; +import { getConfig } from "../utils/config"; +import { getManifest } from "../utils/plugins"; +import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch"; import { postHelpCommand } from "./help-command"; export default async function issueCommentCreated(context: GitHubContext<"issue_comment.created">) { - const body = context.payload.comment.body.trim(); - if (/^\/help$/.test(body)) { + const body = context.payload.comment.body.trim().toLowerCase(); + if (body.startsWith(`@ubiquityos`)) { + await commandRouter(context); + } + if (body.startsWith(`/help`)) { await postHelpCommand(context); } } + +interface OpenAiFunction { + type: "function"; + function: { + name: string; + description?: string; + parameters?: Record; + strict?: boolean | null; + }; +} + +const embeddedCommands: Array = [ + { + type: "function", + function: { + name: "help", + description: "Shows all available commands and their examples", + strict: false, + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + }, +]; + +async function commandRouter(context: GitHubContext<"issue_comment.created">) { + if (!("installation" in context.payload) || context.payload.installation?.id === undefined) { + console.log(`No installation found, cannot invoke command`); + return; + } + + const commands = [...embeddedCommands]; + const config = await getConfig(context); + const pluginsWithManifest: { plugin: PluginConfiguration["plugins"][0]["uses"][0]; manifest: Manifest }[] = []; + for (let i = 0; i < config.plugins.length; ++i) { + const plugin = config.plugins[i].uses[0]; + + const manifest = await getManifest(context, plugin.plugin); + if (!manifest?.commands) { + continue; + } + pluginsWithManifest.push({ + plugin: plugin, + manifest, + }); + for (const [name, command] of Object.entries(manifest.commands)) { + commands.push({ + type: "function", + function: { + name: name, + parameters: command.parameters + ? { + ...command.parameters, + required: Object.keys(command.parameters.properties), + additionalProperties: false, + } + : undefined, + strict: true, + }, + }); + } + } + + const response = await context.openAi.chat.completions.create({ + model: "gpt-4o-mini", + messages: [ + { + role: "system", + content: [ + { + text: ` +You are a GitHub bot named **UbiquityOS**. Your role is to interpret and execute commands based on user comments provided in structured JSON format. + +### JSON Structure: +The input will include the following fields: +- repositoryOwner: The username of the repository owner. +- repositoryName: The name of the repository where the comment was made. +- issueNumber: The issue or pull request number where the comment appears. +- author: The username of the user who posted the comment. +- comment: The comment text directed at UbiquityOS. + +### Example JSON: +{ + "repositoryOwner": "repoOwnerUsername", + "repositoryName": "example-repo", + "issueNumber": 42, + "author": "user1", + "comment": "@UbiquityOS please allow @user2 to change priority and time labels." +} + +### Instructions: +- **Interpretation Mode**: + - **Tagged Natural Language**: Interpret the "comment" field provided in JSON. Users will mention you with "@UbiquityOS", followed by their request. Infer the intended command and parameters based on the "comment" content. + +- **Action**: Map the user's intent to one of your available functions. When responding, use the "author", "repositoryOwner", "repositoryName", and "issueNumber" fields as context if relevant. +`, + type: "text", + }, + ], + }, + { + role: "user", + content: [ + { + text: JSON.stringify({ + repositoryOwner: context.payload.repository.owner.login, + repositoryName: context.payload.repository.name, + issueNumber: context.payload.issue.number, + author: context.payload.comment.user?.login, + comment: context.payload.comment.body, + }), + type: "text", + }, + ], + }, + ], + temperature: 1, + max_tokens: 2048, + top_p: 1, + frequency_penalty: 0, + presence_penalty: 0, + tools: commands, + parallel_tool_calls: false, + response_format: { + type: "text", + }, + }); + + if (response.choices.length === 0) { + return; + } + + const toolCalls = response.choices[0].message.tool_calls; + if (!toolCalls?.length) { + const message = response.choices[0].message.content || "I cannot help you with that."; + await context.octokit.rest.issues.createComment({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: context.payload.issue.number, + body: message, + }); + return; + } + + const toolCall = toolCalls[0]; + if (!toolCall) { + console.log("No tool call"); + return; + } + + const command = { + name: toolCall.function.name, + parameters: toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : null, + }; + + if (command.name === "help") { + await postHelpCommand(context); + return; + } + + const pluginWithManifest = pluginsWithManifest.find((o) => o.manifest?.commands?.[command.name] !== undefined); + if (!pluginWithManifest) { + console.log(`No plugin found for command '${command.name}'`); + return; + } + const { + plugin: { plugin, with: settings }, + } = pluginWithManifest; + + // call plugin + const isGithubPluginObject = isGithubPlugin(plugin); + const stateId = crypto.randomUUID(); + const ref = isGithubPluginObject ? (plugin.ref ?? (await getDefaultBranch(context, plugin.owner, plugin.repo))) : plugin; + const token = await context.eventHandler.getToken(context.payload.installation.id); + const inputs = new PluginInput(context.eventHandler, stateId, context.key, context.payload, settings, token, ref, command); + + try { + if (!isGithubPluginObject) { + await dispatchWorker(plugin, await inputs.getWorkerInputs()); + } else { + await dispatchWorkflow(context, { + owner: plugin.owner, + repository: plugin.repo, + workflowId: plugin.workflowId, + ref: ref, + inputs: await inputs.getWorkflowInputs(), + }); + } + } catch (e) { + console.error(`An error occurred while processing the plugin chain, will skip plugin ${JSON.stringify(plugin)}`, e); + } +} diff --git a/src/github/handlers/repository-dispatch.ts b/src/github/handlers/repository-dispatch.ts index 510a8c1..ccd82af 100644 --- a/src/github/handlers/repository-dispatch.ts +++ b/src/github/handlers/repository-dispatch.ts @@ -61,7 +61,7 @@ export async function repositoryDispatch(context: GitHubContext<"repository_disp } else { ref = nextPlugin.plugin; } - const inputs = new PluginInput(context.eventHandler, pluginOutput.state_id, state.eventName, state.eventPayload, settings, token, ref); + const inputs = new PluginInput(context.eventHandler, pluginOutput.state_id, state.eventName, state.eventPayload, settings, token, ref, null); state.currentPlugin++; state.inputs[state.currentPlugin] = inputs; diff --git a/src/github/types/env.ts b/src/github/types/env.ts index 3f73306..dce3b21 100644 --- a/src/github/types/env.ts +++ b/src/github/types/env.ts @@ -5,6 +5,7 @@ export const envSchema = T.Object({ APP_WEBHOOK_SECRET: T.String({ minLength: 1 }), APP_ID: T.String({ minLength: 1 }), APP_PRIVATE_KEY: T.String({ minLength: 1 }), + OPENAI_API_KEY: T.String({ minLength: 1 }), }); export type Env = Static & { @@ -18,6 +19,7 @@ declare global { APP_ID: string; APP_WEBHOOK_SECRET: string; APP_PRIVATE_KEY: string; + OPENAI_API_KEY: string; } } } diff --git a/src/github/types/plugin-configuration.ts b/src/github/types/plugin-configuration.ts index 0bd2b2d..7627b6f 100644 --- a/src/github/types/plugin-configuration.ts +++ b/src/github/types/plugin-configuration.ts @@ -60,6 +60,7 @@ const pluginChainSchema = T.Array( plugin: githubPluginType(), with: T.Record(T.String(), T.Unknown(), { default: {} }), runsOn: T.Array(emitterType, { default: [] }), + skipBotEvents: T.Boolean({ default: true }), }), { minItems: 1, default: [] } ); @@ -70,7 +71,6 @@ const handlerSchema = T.Array( T.Object({ name: T.Optional(T.String()), uses: pluginChainSchema, - skipBotEvents: T.Boolean({ default: true }), }), { default: [] } ); diff --git a/src/github/types/plugin.ts b/src/github/types/plugin.ts index cac7f2a..ee00902 100644 --- a/src/github/types/plugin.ts +++ b/src/github/types/plugin.ts @@ -2,18 +2,14 @@ import { EmitterWebhookEvent, EmitterWebhookEventName } from "@octokit/webhooks" import { StaticDecode, Type } from "@sinclair/typebox"; import { PluginChain } from "./plugin-configuration"; import { GitHubEventHandler } from "../github-event-handler"; +import { CommandCall } from "../../types/command"; +import { jsonType } from "../../types/util"; export const expressionRegex = /^\s*\${{\s*(\S+)\s*}}\s*$/; -function jsonString() { - return Type.Transform(Type.String()) - .Decode((value) => JSON.parse(value) as Record) - .Encode((value) => JSON.stringify(value)); -} - export const pluginOutputSchema = Type.Object({ state_id: Type.String(), // GitHub forces snake_case - output: jsonString(), + output: jsonType(Type.Record(Type.String(), Type.Unknown())), }); export type PluginOutput = StaticDecode; @@ -26,6 +22,7 @@ export class PluginInput["payload"], settings: unknown, authToken: string, - ref: string + ref: string, + command: CommandCall ) { this.eventHandler = eventHandler; this.stateId = stateId; @@ -43,6 +41,7 @@ export class PluginInput; diff --git a/src/types/util.ts b/src/types/util.ts new file mode 100644 index 0000000..8aa6275 --- /dev/null +++ b/src/types/util.ts @@ -0,0 +1,11 @@ +import { Type, TAnySchema } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; + +export function jsonType(type: TSchema) { + return Type.Transform(Type.String()) + .Decode((value) => { + const parsed = JSON.parse(value); + return Value.Decode(type, Value.Default(type, parsed)); + }) + .Encode((value) => JSON.stringify(value)); +} diff --git a/src/worker.ts b/src/worker.ts index 00e081c..de266d6 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -5,6 +5,7 @@ import { bindHandlers } from "./github/handlers"; import { Env, envSchema } from "./github/types/env"; import { EmptyStore } from "./github/utils/kv-store"; import { WebhookEventName } from "@octokit/webhooks-types"; +import OpenAI from "openai"; export default { async fetch(request: Request, env: Env): Promise { @@ -13,12 +14,16 @@ export default { const eventName = getEventName(request); const signatureSha256 = getSignature(request); const id = getId(request); + const openAiClient = new OpenAI({ + apiKey: env.OPENAI_API_KEY, + }); const eventHandler = new GitHubEventHandler({ environment: env.ENVIRONMENT, webhookSecret: env.APP_WEBHOOK_SECRET, appId: env.APP_ID, privateKey: env.APP_PRIVATE_KEY, pluginChainState: new EmptyStore(), + openAiClient, }); bindHandlers(eventHandler); await eventHandler.webhooks.verifyAndReceive({ id, name: eventName, payload: await request.text(), signature: signatureSha256 }); diff --git a/tests/commands.test.ts b/tests/commands.test.ts new file mode 100644 index 0000000..3f4dec4 --- /dev/null +++ b/tests/commands.test.ts @@ -0,0 +1,370 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; +import { config } from "dotenv"; +import { http, HttpResponse } from "msw"; +import { GitHubContext } from "../src/github/github-context"; +import { GitHubEventHandler } from "../src/github/github-event-handler"; +import { CONFIG_FULL_PATH } from "../src/github/utils/config"; +import { server } from "./__mocks__/node"; +import "./__mocks__/webhooks"; + +jest.mock("@octokit/plugin-paginate-rest", () => ({})); +jest.mock("@octokit/plugin-rest-endpoint-methods", () => ({})); +jest.mock("@octokit/plugin-retry", () => ({})); +jest.mock("@octokit/plugin-throttling", () => ({})); +jest.mock("@octokit/auth-app", () => ({})); + +config({ path: ".dev.vars" }); + +const name = "ubiquity-os-kernel"; +const eventName = "issue_comment.created"; + +beforeAll(() => { + server.listen(); +}); +afterEach(() => { + server.resetHandlers(); + jest.clearAllMocks(); + jest.resetAllMocks(); + jest.resetModules(); +}); +afterAll(() => { + server.close(); +}); + +const eventHandler = { + environment: "production", + getToken: jest.fn().mockReturnValue("1234"), + signPayload: jest.fn().mockReturnValue("sha256=1234"), +} as unknown as GitHubEventHandler; + +function getContent(params?: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) { + if (params?.path === CONFIG_FULL_PATH) { + return { + data: ` + plugins: + - name: "Run on comment created" + uses: + - id: plugin-A + plugin: https://plugin-a.internal + - name: "Some Action plugin" + uses: + - id: plugin-B + plugin: ubiquity-os/plugin-b + `, + }; + } else if (params?.path === "manifest.json") { + return { + data: { + content: btoa( + JSON.stringify({ + name: "plugin-B", + commands: { + hello: { + description: "This command says hello to the username provided in the parameters.", + "ubiquity:example": "/hello @pavlovcik", + parameters: { + type: "object", + properties: { + username: { + type: "string", + description: "the user to say hello to", + }, + }, + }, + }, + }, + }) + ), + }, + }; + } else { + throw new Error("Not found"); + } +} + +const payload = { + repository: { + owner: { login: "ubiquity" }, + name, + }, + issue: { number: 1 }, + installation: { + id: 1, + }, +}; + +describe("Event related tests", () => { + beforeEach(() => { + server.use( + http.get("https://plugin-a.internal/manifest.json", () => + HttpResponse.json({ + name: "plugin-A", + commands: { + foo: { + description: "foo command", + "ubiquity:example": "/foo bar", + }, + bar: { + description: "bar command", + "ubiquity:example": "/bar foo", + }, + }, + }) + ) + ); + }); + + it("Should post the help menu", async () => { + const issues = { + createComment(params?: RestEndpointMethodTypes["issues"]["createComment"]["parameters"]) { + return params; + }, + }; + const spy = jest.spyOn(issues, "createComment"); + + const issueCommentCreated = (await import("../src/github/handlers/issue-comment-created")).default; + await issueCommentCreated({ + id: "", + key: eventName, + octokit: { + rest: { + issues, + repos: { + getContent: jest.fn(getContent), + }, + }, + }, + openAi: { + chat: { + completions: { + create: function () { + return { + choices: [ + { + message: { + tool_calls: [ + { + type: "function", + function: { + name: "help", + arguments: "", + }, + }, + ], + }, + }, + ], + }; + }, + }, + }, + }, + eventHandler: eventHandler, + payload: { + ...payload, + comment: { + body: "@UbiquityOS can you tell me all available commands", + }, + } as unknown as GitHubContext<"issue_comment.created">["payload"], + } as unknown as GitHubContext); + expect(spy).toBeCalledTimes(1); + expect(spy.mock.calls).toEqual([ + [ + { + body: + "### Available Commands\n\n\n| Command | Description | Example |\n|---|---|---|\n| `/help` | List" + + " all available commands. | `/help` |\n| `/bar` | bar command | `/bar foo` |\n| `/foo` | foo command | `/foo bar` |\n| `/hello` | This command says hello to the username provided in the parameters. | `/hello @pavlovcik` |", + issue_number: 1, + owner: "ubiquity", + repo: name, + }, + ], + ]); + }); + + it("Should call appropriate plugin", async () => { + const dispatchWorkflow = jest.fn(); + jest.mock("../src/github/utils/workflow-dispatch", () => ({ + getDefaultBranch: jest.fn().mockImplementation(() => Promise.resolve("main")), + dispatchWorkflow: dispatchWorkflow, + })); + + const issues = { + createComment(params?: RestEndpointMethodTypes["issues"]["createComment"]["parameters"]) { + return params; + }, + }; + const spy = jest.spyOn(issues, "createComment"); + + const issueCommentCreated = (await import("../src/github/handlers/issue-comment-created")).default; + await issueCommentCreated({ + id: "", + key: eventName, + octokit: { + rest: { + issues, + repos: { + getContent: jest.fn(getContent), + }, + }, + }, + openAi: { + chat: { + completions: { + create: function () { + return { + choices: [ + { + message: { + tool_calls: [ + { + type: "function", + function: { + name: "hello", + arguments: JSON.stringify({ username: "pavlovcik" }), + }, + }, + ], + }, + }, + ], + }; + }, + }, + }, + }, + eventHandler: eventHandler, + payload: { + ...payload, + comment: { + body: "@UbiquityOS can you say hello to @pavlovcik", + }, + } as unknown as GitHubContext<"issue_comment.created">["payload"], + } as unknown as GitHubContext); + expect(spy).toBeCalledTimes(0); + expect(dispatchWorkflow.mock.calls.length).toEqual(1); + expect(dispatchWorkflow.mock.calls[0][1]).toMatchObject({ + owner: "ubiquity-os", + repository: "plugin-b", + ref: "main", + workflowId: "compute.yml", + inputs: { + command: JSON.stringify({ name: "hello", parameters: { username: "pavlovcik" } }), + }, + }); + }); + + it("Should tell the user it cannot help with arbitrary requests", async () => { + const issues = { + createComment(params?: RestEndpointMethodTypes["issues"]["createComment"]["parameters"]) { + return params; + }, + }; + const spy = jest.spyOn(issues, "createComment"); + + const issueCommentCreated = (await import("../src/github/handlers/issue-comment-created")).default; + await issueCommentCreated({ + id: "", + key: eventName, + octokit: { + rest: { + issues, + repos: { + getContent: jest.fn(getContent), + }, + }, + }, + openAi: { + chat: { + completions: { + create: function () { + return { + choices: [ + { + message: { + content: "Sorry, but I can't help with that.", + }, + }, + ], + }; + }, + }, + }, + }, + eventHandler: eventHandler, + payload: { + ...payload, + comment: { + body: "@UbiquityOS who is the creator of the universe", + }, + } as unknown as GitHubContext<"issue_comment.created">["payload"], + } as unknown as GitHubContext); + expect(spy).toBeCalledTimes(1); + expect(spy.mock.calls).toEqual([ + [ + { + body: "Sorry, but I can't help with that.", + issue_number: 1, + owner: "ubiquity", + repo: name, + }, + ], + ]); + }); + + it("Should not post the help menu when /help command if there is no available command", async () => { + const issues = { + createComment(params?: RestEndpointMethodTypes["issues"]["createComment"]["parameters"]) { + return params; + }, + }; + const spy = jest.spyOn(issues, "createComment"); + const getContent = jest.fn((params?: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) => { + if (params?.path === CONFIG_FULL_PATH) { + return { + data: ` + plugins: + - name: "Some Action plugin" + uses: + - id: plugin-B + plugin: ubiquity-os/plugin-b + `, + }; + } else if (params?.path === "manifest.json") { + return { + data: { + content: btoa( + JSON.stringify({ + name: "plugin", + }) + ), + }, + }; + } else { + throw new Error("Not found"); + } + }); + const issueCommentCreated = (await import("../src/github/handlers/issue-comment-created")).default; + await issueCommentCreated({ + id: "", + key: eventName, + octokit: { + rest: { + issues, + repos: { + getContent: getContent, + }, + }, + }, + eventHandler: eventHandler, + payload: { + ...payload, + comment: { + body: "/help", + }, + } as unknown as GitHubContext<"issue_comment.created">["payload"], + } as unknown as GitHubContext); + expect(spy).not.toBeCalled(); + }); +}); diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts index c8666dc..796dcf3 100644 --- a/tests/configuration.test.ts +++ b/tests/configuration.test.ts @@ -43,9 +43,10 @@ describe("Configuration tests", () => { "commands": { "command": { "description": "description", - "ubiquity:example": "example" + "ubiquity:example": "/command" } - } + }, + "skipBotEvents": false } `; } else if (args.path === CONFIG_FULL_PATH) { @@ -54,8 +55,7 @@ describe("Configuration tests", () => { - uses: - plugin: ubiquity/user-activity-watcher:compute.yml@fork/pull/1 with: - settings1: 'enabled' - skipBotEvents: false`; + settings1: 'enabled'`; } else { throw new Error("Not Found"); } @@ -97,12 +97,12 @@ describe("Configuration tests", () => { ref: "fork/pull/1", }, runsOn: [], + skipBotEvents: false, with: { settings1: "enabled", }, }, ], - skipBotEvents: false, }); }); it("Should retrieve the configuration manifest from the proper branch if specified", async () => { @@ -122,6 +122,7 @@ describe("Configuration tests", () => { configuration: {}, description: "", "ubiquity:listeners": [], + skipBotEvents: true, }, withoutRef: { name: "plugin-no-ref", @@ -134,6 +135,7 @@ describe("Configuration tests", () => { configuration: {}, description: "", "ubiquity:listeners": [], + skipBotEvents: true, }, }; function getContent({ ref }: Record) { @@ -182,9 +184,10 @@ describe("Configuration tests", () => { "commands": { "command": { "description": "description", - "ubiquity:example": "example" + "ubiquity:example": "/command" } - } + }, + "skipBotEvents": false } `; } else if (args.path === CONFIG_FULL_PATH) { @@ -193,8 +196,7 @@ describe("Configuration tests", () => { - uses: - plugin: ubiquity/test-plugin with: - settings1: 'enabled' - skipBotEvents: false`; + settings1: 'enabled'`; } else { throw new Error("Not Found"); } @@ -231,7 +233,7 @@ describe("Configuration tests", () => { } as unknown as GitHubContext; const cfg = await getConfig(context); - expect(cfg.plugins[0].skipBotEvents).toEqual(false); + expect(cfg.plugins[0].uses[0].skipBotEvents).toEqual(false); await expect(shouldSkipPlugin(context, cfg.plugins[0])).resolves.toEqual(false); }); it("should return dev config if environment is not production", async () => { @@ -244,7 +246,7 @@ describe("Configuration tests", () => { "commands": { "command": { "description": "description", - "ubiquity:example": "example" + "ubiquity:example": "/command" } } } diff --git a/tests/dispatch.test.ts b/tests/dispatch.test.ts index 38de8fc..d6e4b6b 100644 --- a/tests/dispatch.test.ts +++ b/tests/dispatch.test.ts @@ -38,6 +38,7 @@ jest.mock("../src/github/types/plugin", () => { authToken: this.authToken, ref: this.ref, signature: "", + command: this.command, }; } }, @@ -159,6 +160,7 @@ describe("handleEvent", () => { APP_ID: "1", APP_PRIVATE_KEY: "1234", PLUGIN_CHAIN_STATE: {} as KVNamespace, + OPENAI_API_KEY: "token", }); expect(res).toBeTruthy(); diff --git a/tests/events.test.ts b/tests/events.test.ts deleted file mode 100644 index 39ecffb..0000000 --- a/tests/events.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; -import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; -import { config } from "dotenv"; -import { http, HttpResponse } from "msw"; -import { GitHubContext } from "../src/github/github-context"; -import { GitHubEventHandler } from "../src/github/github-event-handler"; -import issueCommentCreated from "../src/github/handlers/issue-comment-created"; -import { CONFIG_FULL_PATH } from "../src/github/utils/config"; -import { server } from "./__mocks__/node"; -import "./__mocks__/webhooks"; - -jest.mock("@octokit/plugin-paginate-rest", () => ({})); -jest.mock("@octokit/plugin-rest-endpoint-methods", () => ({})); -jest.mock("@octokit/plugin-retry", () => ({})); -jest.mock("@octokit/plugin-throttling", () => ({})); -jest.mock("@octokit/auth-app", () => ({})); - -config({ path: ".dev.vars" }); - -const name = "ubiquity-os-kernel"; - -beforeAll(() => { - server.listen(); -}); -afterEach(() => { - server.resetHandlers(); - jest.clearAllMocks(); - jest.resetAllMocks(); -}); -afterAll(() => { - server.close(); -}); - -const eventHandler = { - environment: "production", -} as GitHubEventHandler; - -describe("Event related tests", () => { - beforeEach(() => { - server.use( - http.get("https://plugin-a.internal/manifest.json", () => - HttpResponse.json({ - name: "plugin", - commands: { - foo: { - description: "foo command", - "ubiquity:example": "/foo bar", - }, - bar: { - description: "bar command", - "ubiquity:example": "/bar foo", - }, - }, - }) - ) - ); - }); - it("Should post the help menu when /help command is invoked", async () => { - const issues = { - createComment(params?: RestEndpointMethodTypes["issues"]["createComment"]["parameters"]) { - return params; - }, - }; - const spy = jest.spyOn(issues, "createComment"); - const getContent = jest.fn((params?: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) => { - if (params?.path === CONFIG_FULL_PATH) { - return { - data: ` - plugins: - - name: "Run on comment created" - uses: - - id: plugin-A - plugin: https://plugin-a.internal - - name: "Some Action plugin" - uses: - - id: plugin-B - plugin: ubiquity-os/plugin-b - `, - }; - } else if (params?.path === "manifest.json") { - return { - data: { - content: btoa( - JSON.stringify({ - name: "plugin A", - commands: { - action: { - description: "action", - "ubiquity:example": "/action", - }, - }, - }) - ), - }, - }; - } else { - throw new Error("Not found"); - } - }); - await issueCommentCreated({ - id: "", - key: "issue_comment.created", - octokit: { - rest: { - issues, - repos: { - getContent: getContent, - }, - }, - }, - eventHandler: eventHandler, - payload: { - repository: { - owner: { login: "ubiquity" }, - name, - }, - issue: { number: 1 }, - comment: { - body: "/help", - }, - } as unknown as GitHubContext<"issue_comment.created">["payload"], - } as unknown as GitHubContext); - expect(spy).toBeCalledTimes(1); - expect(spy.mock.calls).toEqual([ - [ - { - body: - "### Available Commands\n\n\n| Command | Description | Example |\n|---|---|---|\n| `/help` | List" + - " all available commands. | `/help` |\n| `/action` | action | `/action` |\n| `/bar` | bar command | `/bar foo` |\n| `/foo` | foo command | `/foo bar` |", - issue_number: 1, - owner: "ubiquity", - repo: name, - }, - ], - ]); - }); - it("Should not post the help menu when /help command if there is no available command", async () => { - const issues = { - createComment(params?: RestEndpointMethodTypes["issues"]["createComment"]["parameters"]) { - return params; - }, - }; - const spy = jest.spyOn(issues, "createComment"); - const getContent = jest.fn((params?: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) => { - if (params?.path === CONFIG_FULL_PATH) { - return { - data: ` - plugins: - - name: "Some Action plugin" - uses: - - id: plugin-c - plugin: ubiquity-os/plugin-c - `, - }; - } else if (params?.path === "manifest.json") { - return { - data: { - content: btoa( - JSON.stringify({ - name: "plugin c", - }) - ), - }, - }; - } else { - throw new Error("Not found"); - } - }); - await issueCommentCreated({ - id: "", - key: "issue_comment.created", - octokit: { - rest: { - issues, - repos: { - getContent: getContent, - }, - }, - }, - eventHandler: eventHandler, - payload: { - repository: { - owner: { login: "ubiquity" }, - name, - }, - issue: { number: 1 }, - comment: { - body: "/help", - }, - } as unknown as GitHubContext<"issue_comment.created">["payload"], - } as unknown as GitHubContext); - expect(spy).not.toBeCalled(); - }); -}); diff --git a/tests/main.test.ts b/tests/main.test.ts index 2f8e94a..f67a759 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -67,6 +67,7 @@ describe("Worker tests", () => { APP_ID: "", APP_PRIVATE_KEY: "", PLUGIN_CHAIN_STATE: {} as KVNamespace, + OPENAI_API_KEY: "token", }); expect(res.status).toEqual(500); consoleSpy.mockReset(); @@ -144,7 +145,7 @@ describe("Worker tests", () => { const pluginChain = cfg.plugins; expect(pluginChain.length).toBe(1); expect(pluginChain[0].uses.length).toBe(1); - expect(pluginChain[0].skipBotEvents).toBeTruthy(); + expect(pluginChain[0].uses[0].skipBotEvents).toBeTruthy(); expect(pluginChain[0].uses[0].id).toBe("plugin-A"); expect(pluginChain[0].uses[0].plugin).toBe("https://plugin-a.internal"); expect(pluginChain[0].uses[0].with).toEqual({}); @@ -160,7 +161,7 @@ describe("Worker tests", () => { "commands": { "command": { "description": "description", - "ubiquity:example": "example" + "ubiquity:example": "/command" } } } @@ -232,16 +233,15 @@ describe("Worker tests", () => { workflowId, }, runsOn: [], + skipBotEvents: true, with: { setting1: false, }, }, ], - skipBotEvents: true, }); expect(cfg.plugins.slice(1)).toEqual([ { - skipBotEvents: true, uses: [ { plugin: { @@ -250,6 +250,7 @@ describe("Worker tests", () => { ref: undefined, workflowId: "compute.yml", }, + skipBotEvents: true, runsOn: [], with: { setting2: true,