diff --git a/.github/workflows/bun-testing.yml b/.github/workflows/bun-testing.yml index b3a27e3..0394e4d 100644 --- a/.github/workflows/bun-testing.yml +++ b/.github/workflows/bun-testing.yml @@ -25,4 +25,4 @@ jobs: - name: Build & Run test suite run: | bun i - bun test --coverage + bun jest:test diff --git a/bun.lockb b/bun.lockb old mode 100755 new mode 100644 index 506158a..971b8a4 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/jest.config.json b/jest.config.json new file mode 100644 index 0000000..2796b3f --- /dev/null +++ b/jest.config.json @@ -0,0 +1,11 @@ +{ + "preset": "ts-jest", + "testEnvironment": "node", + "roots": ["./tests"], + "coveragePathIgnorePatterns": ["node_modules", "mocks"], + "collectCoverage": true, + "coverageReporters": ["json", "lcov", "text", "clover", "json-summary"], + "reporters": ["default", "jest-junit", "jest-md-dashboard"], + "coverageDirectory": "coverage", + "setupFiles": ["dotenv/config"] +} diff --git a/package.json b/package.json index 5a4c0d7..c66de11 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "proxy": "tsx src/proxy.ts", "knip": "knip --config .github/knip.ts", "knip-ci": "knip --no-exit-code --reporter json --config .github/knip.ts", - "test": "bun test", + "jest:test": "jest --coverage", "plugin:hello-world": "tsx tests/__mocks__/hello-world-plugin.ts", "setup-kv": "bun --env-file=.dev.vars deploy/setup-kv-namespace.ts" }, @@ -37,15 +37,16 @@ "dependencies": { "@octokit/auth-app": "7.1.0", "@octokit/core": "6.1.2", - "@octokit/plugin-paginate-rest": "11.3.0", - "@octokit/plugin-rest-endpoint-methods": "13.2.1", + "@octokit/plugin-paginate-rest": "11.3.3", + "@octokit/plugin-rest-endpoint-methods": "13.2.4", "@octokit/plugin-retry": "7.1.1", - "@octokit/plugin-throttling": "9.3.0", + "@octokit/plugin-throttling": "9.3.1", "@octokit/types": "13.5.0", - "@octokit/webhooks": "13.2.7", + "@octokit/webhooks": "13.2.8", "@octokit/webhooks-types": "7.5.1", - "@sinclair/typebox": "0.32.33", + "@sinclair/typebox": "0.32.34", "dotenv": "16.4.5", + "jest": "29.7.0", "smee-client": "2.0.1", "typebox-validators": "0.3.5", "yaml": "2.4.5" @@ -57,29 +58,32 @@ "@cspell/dict-node": "5.0.1", "@cspell/dict-software-terms": "3.4.6", "@cspell/dict-typescript": "3.1.5", - "@eslint/js": "9.5.0", + "@eslint/js": "9.7.0", + "@jest/globals": "29.7.0", "@mswjs/data": "0.16.1", "@mswjs/http-middleware": "0.10.1", - "@types/bun": "1.1.5", - "@types/node": "20.14.6", + "@types/node": "20.14.10", "cspell": "8.9.0", - "esbuild": "0.21.5", - "eslint": "9.5.0", + "esbuild": "0.23.0", + "eslint": "9.7.0", "eslint-config-prettier": "9.1.0", "eslint-plugin-check-file": "2.8.0", "eslint-plugin-prettier": "5.1.3", "eslint-plugin-sonarjs": "1.0.3", "husky": "9.0.11", - "knip": "5.22.0", + "jest-junit": "16.0.0", + "jest-md-dashboard": "0.8.0", + "knip": "5.26.0", "lint-staged": "15.2.7", "npm-run-all": "4.1.5", - "prettier": "3.3.2", + "prettier": "3.3.3", "toml": "3.0.0", "tomlify-j0.4": "3.0.0", - "tsx": "4.15.6", - "typescript": "5.4.5", - "typescript-eslint": "7.13.1", - "wrangler": "3.61.0" + "ts-jest": "29.2.2", + "tsx": "4.16.2", + "typescript": "5.5.3", + "typescript-eslint": "7.16.0", + "wrangler": "3.64.0" }, "lint-staged": { "*.ts": [ @@ -94,6 +98,5 @@ "extends": [ "@commitlint/config-conventional" ] - }, - "packageManager": "bun@1.1.15" + } } diff --git a/src/github/handlers/help-command.ts b/src/github/handlers/help-command.ts new file mode 100644 index 0000000..11e7733 --- /dev/null +++ b/src/github/handlers/help-command.ts @@ -0,0 +1,86 @@ +import { getConfig } from "../utils/config"; +import { GithubPlugin, isGithubPlugin } 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"; + +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"])}\` |`); + } + } + } + } + return commands; +} + +export async function postHelpCommand(context: GitHubContext<"issue_comment.created">) { + const comments = [ + "### Available Commands\n\n", + "| Command | Description | Example |", + "|---|---|---|", + "| `/help` | List all available commands. | `/help` |", + ]; + 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))); + } + } + await context.octokit.issues.createComment({ + body: comments.concat(commands.sort()).join("\n"), + issue_number: context.payload.issue.number, + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + }); +} + +/** + * Ensures that passed content does not break MD display within the table. + */ +function getContent(content: string | undefined) { + return content ? content.replace("|", "\\|") : "-"; +} + +async function fetchActionManifest(context: GitHubContext<"issue_comment.created">, { owner, repo }: GithubPlugin): Promise { + 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 { + 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; +} diff --git a/src/github/handlers/issue-comment-created.ts b/src/github/handlers/issue-comment-created.ts index 45d4a52..e01c493 100644 --- a/src/github/handlers/issue-comment-created.ts +++ b/src/github/handlers/issue-comment-created.ts @@ -1,36 +1,9 @@ import { GitHubContext } from "../github-context"; -import { getConfig } from "../utils/config"; +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 comments = [ - "### Available Commands\n\n", - "| Command | Description | Example |", - "|---|---|---|", - "| `/help` | List all available commands. | `/help` |", - ]; - const configuration = await getConfig(context); - for (const pluginArray of Object.values(configuration.plugins)) { - for (const plugin of pluginArray) { - // Only show plugins that have commands available for the user - if (plugin.command) { - comments.push(`| \`${getContent(plugin.command)}\` | ${getContent(plugin.description)} | \`${getContent(plugin.example)}\` |`); - } - } - } - await context.octokit.issues.createComment({ - body: comments.join("\n"), - issue_number: context.payload.issue.number, - owner: context.payload.repository.owner.login, - repo: context.payload.repository.name, - }); + await postHelpCommand(context); } } - -/** - * Ensures that passed content does not break MD display within the table. - */ -function getContent(content: string | undefined) { - return content ? content.replace("|", "\\|") : "-"; -} diff --git a/src/github/types/plugin-configuration.ts b/src/github/types/plugin-configuration.ts index fb43963..cebccec 100644 --- a/src/github/types/plugin-configuration.ts +++ b/src/github/types/plugin-configuration.ts @@ -5,7 +5,7 @@ import { githubWebhookEvents } from "./webhook-events"; const pluginNameRegex = new RegExp("^([0-9a-zA-Z-._]+)\\/([0-9a-zA-Z-._]+)(?::([0-9a-zA-Z-._]+))?(?:@([0-9a-zA-Z-._]+(?:\\/[0-9a-zA-Z-._]+)?))?$"); -type GithubPlugin = { +export type GithubPlugin = { owner: string; repo: string; workflowId: string; diff --git a/src/types/manifest.ts b/src/types/manifest.ts new file mode 100644 index 0000000..cbb260c --- /dev/null +++ b/src/types/manifest.ts @@ -0,0 +1,17 @@ +import { type Static, Type as T } from "@sinclair/typebox"; +import { StandardValidator } from "typebox-validators"; + +export const commandSchema = T.Object({ + description: T.String({ minLength: 1 }), + "ubiquity:example": T.String({ minLength: 1 }), +}); + +export const manifestSchema = T.Object({ + name: T.String({ minLength: 1 }), + description: T.String({ minLength: 1 }), + commands: T.Record(T.String(), commandSchema), +}); + +export const manifestValidator = new StandardValidator(manifestSchema); + +export type Manifest = Static; diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts index 3cd03fb..71ada1b 100644 --- a/tests/configuration.test.ts +++ b/tests/configuration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals"; import { config } from "dotenv"; import { server } from "./__mocks__/node"; import { WebhooksMocked } from "./__mocks__/webhooks"; @@ -8,8 +8,9 @@ import { GitHubEventHandler } from "../src/github/github-event-handler"; config({ path: ".dev.vars" }); -void mock.module("@octokit/webhooks", () => ({ +jest.mock("@octokit/webhooks", () => ({ Webhooks: WebhooksMocked, + emitterEventNames: [], })); const issueOpened = "issues.opened"; diff --git a/tests/events.test.ts b/tests/events.test.ts index 5180559..1b93753 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -1,13 +1,14 @@ import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; -import { afterAll, afterEach, beforeAll, describe, expect, it, mock, spyOn } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, jest, beforeEach } from "@jest/globals"; 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 { server } from "./__mocks__/node"; import { WebhooksMocked } from "./__mocks__/webhooks"; -void mock.module("@octokit/webhooks", () => ({ +jest.mock("@octokit/webhooks", () => ({ Webhooks: WebhooksMocked, })); @@ -24,13 +25,33 @@ afterAll(() => { }); describe("Event related tests", () => { + beforeEach(() => { + server.use( + http.get("https://plugin-a.internal/manifest.json", () => + HttpResponse.json({ + commands: { + foo: { + command: "/foo", + description: "foo command", + example: "/foo bar", + }, + bar: { + command: "/bar", + description: "bar command", + 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 = spyOn(issues, "createComment"); + const spy = jest.spyOn(issues, "createComment"); await issueCommentCreated({ id: "", key: "issue_comment.created", @@ -44,17 +65,37 @@ describe("Event related tests", () => { plugins: issue_comment.created: - name: "Run on comment created" - description: "Plugin A" - example: /command [foo | bar] - command: /command uses: - id: plugin-A plugin: https://plugin-a.internal + - name: "Some Action plugin" + uses: + - id: plugin-B + plugin: ubiquibot/plugin-b `, }; }, }, }, + repos: { + getContent() { + return { + data: { + content: btoa( + JSON.stringify({ + commands: [ + { + command: "/action", + description: "action", + example: "/action", + }, + ], + }) + ), + }, + }; + }, + }, }, eventHandler: {} as GitHubEventHandler, payload: { @@ -74,7 +115,7 @@ describe("Event related tests", () => { { body: "### Available Commands\n\n\n| Command | Description | Example |\n|---|---|---|\n| `/help` | List" + - " all available commands. | `/help` |\n| `/command` | Plugin A | `/command [foo \\| bar]` |", + " 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: "ubiquibot-kernel", diff --git a/tests/main.test.ts b/tests/main.test.ts index b49a6e0..3f1124f 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -1,5 +1,5 @@ import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; -import { afterAll, afterEach, beforeAll, describe, expect, it, jest, mock, spyOn } from "bun:test"; +import { afterAll, afterEach, beforeAll, describe, expect, it, jest } from "@jest/globals"; import { config } from "dotenv"; import { GitHubContext } from "../src/github/github-context"; import { GitHubEventHandler } from "../src/github/github-event-handler"; @@ -8,7 +8,7 @@ import worker from "../src/worker"; import { server } from "./__mocks__/node"; import { WebhooksMocked } from "./__mocks__/webhooks"; -void mock.module("@octokit/webhooks", () => ({ +jest.mock("@octokit/webhooks", () => ({ Webhooks: WebhooksMocked, })); @@ -29,7 +29,7 @@ afterAll(() => { describe("Worker tests", () => { it("Should fail on missing env variables", async () => { const req = new Request("http://localhost:8080"); - const consoleSpy = spyOn(console, "error").mockImplementation(() => jest.fn()); + const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => jest.fn()); const res = await worker.fetch(req, { WEBHOOK_SECRET: "", APP_ID: "", @@ -130,7 +130,7 @@ describe("Worker tests", () => { const pluginChain = cfg.plugins["issue_comment.created"]; expect(pluginChain.length).toBe(1); expect(pluginChain[0].uses.length).toBe(1); - expect(pluginChain[0].skipBotEvents).toBeTrue(); + expect(pluginChain[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({}); diff --git a/tsconfig.json b/tsconfig.json index 76d1e39..e128ce7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,14 +24,14 @@ // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ /* Modules */ - "module": "es2022" /* Specify what module code is generated. */, + "module": "commonjs" /* Specify what module code is generated. */, // "rootDir": "./", /* Specify the root folder within your source files. */ "moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */, // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ - "types": ["@cloudflare/workers-types/2023-07-01", "bun"] /* Specify type package names to be included without being referenced in a source file. */, + "types": ["@cloudflare/workers-types/2023-07-01"] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ "resolveJsonModule": true /* Enable importing .json files */, // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ diff --git a/wrangler.toml b/wrangler.toml index 6d0e84c..2deae05 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,6 +1,7 @@ name = "ubiquibot-worker" main = "src/worker.ts" compatibility_date = "2023-12-06" +compatibility_flags = [ "nodejs_compat" ] # Prefer this syntax due to a bug in Wrangler: https://github.com/cloudflare/workers-sdk/issues/5634 [env]