diff --git a/bun.lockb b/bun.lockb index 1cca6fa..831c287 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 452469d..9f8e335 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "@octokit/plugin-rest-endpoint-methods": "13.2.4", "@octokit/plugin-retry": "7.1.1", "@octokit/plugin-throttling": "9.3.1", + "@octokit/rest": "^21.0.2", "@octokit/types": "13.5.0", "@octokit/webhooks": "13.2.8", "@octokit/webhooks-types": "7.5.1", diff --git a/src/github/handlers/index.ts b/src/github/handlers/index.ts index 0c7344e..3905d0e 100644 --- a/src/github/handlers/index.ts +++ b/src/github/handlers/index.ts @@ -98,16 +98,21 @@ async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceTyp state.inputs[0] = inputs; await eventHandler.pluginChainState.put(stateId, state); - if (!isGithubPluginObject) { - await dispatchWorker(plugin, await inputs.getWorkerInputs()); - } else { - await dispatchWorkflow(context, { - owner: plugin.owner, - repository: plugin.repo, - workflowId: plugin.workflowId, - ref: plugin.ref, - inputs: await inputs.getWorkflowInputs(), - }); + // We wrap the dispatch so a failing plugin doesn't break the whole execution + try { + if (!isGithubPluginObject) { + await dispatchWorker(plugin, await inputs.getWorkerInputs()); + } else { + await dispatchWorkflow(context, { + owner: plugin.owner, + repository: plugin.repo, + workflowId: plugin.workflowId, + ref: plugin.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/push-event.ts b/src/github/handlers/push-event.ts index 5be0c26..2cf8f77 100644 --- a/src/github/handlers/push-event.ts +++ b/src/github/handlers/push-event.ts @@ -125,18 +125,8 @@ async function checkPluginConfigurations(context: GitHubContext<"push">, config: export default async function handlePushEvent(context: GitHubContext<"push">) { const { payload } = context; const { repository, commits, after } = payload; - const configPaths = [CONFIG_FULL_PATH, DEV_CONFIG_FULL_PATH]; - const didConfigurationFileChange = commits.some((commit) => - configPaths.some((path) => { - if (commit.modified?.includes(path) || commit.added?.includes(path)) { - // Keeps only the config that matched the modified elements - configPaths.length = 0; - configPaths.push(path); - return true; - } - return false; - }) - ); + const configPath = context.eventHandler.environment === "production" ? CONFIG_FULL_PATH : DEV_CONFIG_FULL_PATH; + const didConfigurationFileChange = commits.some((commit) => commit.modified?.includes(configPath) || commit.added?.includes(configPath)); if (!didConfigurationFileChange || !repository.owner) { return; @@ -154,7 +144,7 @@ export default async function handlePushEvent(context: GitHubContext<"push">) { try { if (errors.length) { const body = []; - body.push(...constructErrorBody(errors, rawData, repository, after, configPaths[0])); + body.push(...constructErrorBody(errors, rawData, repository, after, configPath)); await createCommitComment( context, { diff --git a/tests/dispatch.test.ts b/tests/dispatch.test.ts new file mode 100644 index 0000000..afb2b9c --- /dev/null +++ b/tests/dispatch.test.ts @@ -0,0 +1,165 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, jest } from "@jest/globals"; +import crypto from "crypto"; +import { server } from "./__mocks__/node"; +import { http, HttpResponse } from "msw"; +import { Octokit } from "@octokit/rest"; + +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", () => ({ + createAppAuth: jest.fn(() => () => jest.fn(() => "1234")), +})); + +jest.mock("../src/github/utils/cloudflare-kv", () => ({ + CloudflareKv: jest.fn().mockImplementation(() => ({ + get: jest.fn(), + put: jest.fn(), + })), +})); + +jest.mock("../src/github/types/plugin", () => { + const originalModule: typeof import("../src/github/types/plugin") = jest.requireActual("../src/github/types/plugin"); + + return { + ...originalModule, + PluginInput: class extends originalModule.PluginInput { + async getWorkerInputs() { + return { + stateId: this.stateId, + eventName: this.eventName, + eventPayload: this.eventPayload, + settings: this.settings, + authToken: this.authToken, + ref: this.ref, + signature: "", + }; + } + }, + }; +}); + +function calculateSignature(payload: string, secret: string) { + return `sha256=${crypto.createHmac("sha256", secret).update(payload).digest("hex")}`; +} + +beforeAll(() => { + server.listen(); +}); +afterEach(() => { + server.resetHandlers(); +}); +afterAll(() => { + server.close(); +}); + +describe("handleEvent", () => { + beforeEach(() => { + server.use( + http.get("https://plugin-a.internal/manifest.json", () => + HttpResponse.json({ + name: "plugin", + "ubiquity:listeners": ["issue_comment.created"], + commands: { + foo: { + description: "foo command", + "ubiquity:example": "/foo bar", + }, + bar: { + description: "bar command", + "ubiquity:example": "/bar foo", + }, + }, + }) + ), + http.get("https://api.github.com/repos/test-user/.ubiquity-os/contents/.github%2F.ubiquity-os.config.yml", (req) => { + const acceptHeader = req.request.headers.get("accept"); + const yamlContent = `plugins:\n - uses:\n - plugin: "https://plugin-a.internal"\n - uses:\n - plugin: "https://plugin-a.internal"`; + if (acceptHeader === "application/vnd.github.v3.raw") { + return HttpResponse.text(yamlContent); + } else { + return HttpResponse.json({ + type: "file", + encoding: "base64", + size: 62, + name: ".ubiquity-os.config.yml", + path: ".github/.ubiquity-os.config.yml", + content: Buffer.from(yamlContent).toString("base64"), + sha: "3ffce0fe837a21b1237acd38f7b1c3d2f7d73656", + url: "https://api.github.com/repos/test-user/.ubiquity-os/contents/.github%2F.ubiquity-os.config.yml", + git_url: "https://api.github.com/repos/test-user/.ubiquity-os/git/blobs/3ffce0fe837a21b1237acd38f7b1c3d2f7d73656", + html_url: "https://github.com/test-user/.ubiquity-os/blob/main/.github/.ubiquity-os.config.yml", + download_url: "https://raw.githubusercontent.com/test-user/.ubiquity-os/main/.github/.ubiquity-os.config.yml", + }); + } + }) + ); + }); + + it("should not stop the plugin chain if dispatch throws an error", async () => { + jest.mock("../src/github/github-client", () => { + return { + customOctokit: jest.fn().mockReturnValue(new Octokit()), + }; + }); + const dispatchWorker = jest + .fn() + .mockImplementationOnce(() => { + throw new Error("Test induced first call failure"); + }) + .mockImplementationOnce(() => Promise.resolve("success")); + jest.mock("../src/github/utils/workflow-dispatch", () => ({ + ...(jest.requireActual("../src/github/utils/workflow-dispatch") as object), + dispatchWorker: dispatchWorker, + })); + const payload = { + installation: { + id: 1, + }, + sender: { + type: "User", + }, + comment: { + body: "/foo", + }, + repository: { + id: 123456, + name: ".ubiquity-os", + full_name: "test-user/.ubiquity-os", + owner: { + login: "test-user", + id: 654321, + }, + }, + }; + const secret = "1234"; + const payloadString = JSON.stringify(payload); + const signature = calculateSignature(payloadString, secret); + + const req = new Request("http://localhost:8080", { + method: "POST", + headers: { + "x-github-event": "issue_comment.created", + "x-hub-signature-256": signature, + "x-github-delivery": "mocked_delivery_id", + "content-type": "application/json", + }, + body: payloadString, + }); + + const worker = (await import("../src/worker")).default; + const res = await worker.fetch(req, { + ENVIRONMENT: "production", + APP_WEBHOOK_SECRET: secret, + APP_ID: "1", + APP_PRIVATE_KEY: "1234", + PLUGIN_CHAIN_STATE: {} as KVNamespace, + }); + + expect(res).toBeTruthy(); + // 2 calls means the execution didn't break + expect(dispatchWorker).toHaveBeenCalledTimes(2); + dispatchWorker.mockReset(); + }); +}); diff --git a/tests/events.test.ts b/tests/events.test.ts index 49f1eb3..e8fdf6c 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -17,11 +17,15 @@ 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(); @@ -58,17 +62,10 @@ describe("Event related tests", () => { }, }; const spy = jest.spyOn(issues, "createComment"); - await issueCommentCreated({ - id: "", - key: "issue_comment.created", - octokit: { - rest: { - issues, - repos: { - getContent(params?: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) { - if (params?.path === CONFIG_FULL_PATH) { - return { - data: ` + const getContent = jest.fn((params?: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) => { + if (params?.path === CONFIG_FULL_PATH) { + return { + data: ` plugins: - name: "Run on comment created" uses: @@ -79,27 +76,35 @@ describe("Event related tests", () => { - id: plugin-B plugin: ubiquity-os/plugin-b `, - }; - } else if (params?.path === "manifest.json") { - return { - data: { - content: btoa( - JSON.stringify({ - name: "plugin", - commands: { - action: { - description: "action", - "ubiquity:example": "/action", - }, - }, - }) - ), + }; + } else if (params?.path === "manifest.json") { + return { + data: { + content: btoa( + JSON.stringify({ + name: "plugin", + commands: { + action: { + description: "action", + "ubiquity:example": "/action", }, - }; - } else { - throw new Error("Not found"); - } - }, + }, + }) + ), + }, + }; + } else { + throw new Error("Not found"); + } + }); + await issueCommentCreated({ + id: "", + key: "issue_comment.created", + octokit: { + rest: { + issues, + repos: { + getContent: getContent, }, }, }, @@ -107,7 +112,7 @@ describe("Event related tests", () => { payload: { repository: { owner: { login: "ubiquity" }, - name: "ubiquity-os-kernel", + name, }, issue: { number: 1 }, comment: { @@ -124,7 +129,7 @@ describe("Event related tests", () => { " 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: "ubiquity-os-kernel", + repo: name, }, ], ]); diff --git a/tests/push.test.ts b/tests/push.test.ts new file mode 100644 index 0000000..808cf6e --- /dev/null +++ b/tests/push.test.ts @@ -0,0 +1,140 @@ +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 handlePushEvent from "../src/github/handlers/push-event"; +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("Push 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 handle push event correctly", async () => { + const issues = { + createComment(params?: RestEndpointMethodTypes["issues"]["createComment"]["parameters"]) { + return params; + }, + }; + const createCommitComment = jest.fn(); + const context = { + id: "", + key: "issue_comment.created", + octokit: { + rest: { + issues, + repos: { + listCommentsForCommit: jest.fn(() => ({ data: [] })), + createCommitComment: createCommitComment, + 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 + with: + arg: "true" + - 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", + commands: { + action: { + description: "action", + "ubiquity:example": "/action", + }, + }, + configuration: { + default: {}, + type: "object", + properties: { + arg: { + type: "number", + }, + }, + required: ["arg"], + }, + }) + ), + }, + }; + } else { + throw new Error("Not found"); + } + }, + }, + }, + }, + eventHandler: eventHandler, + payload: { + repository: { + owner: { login: "ubiquity" }, + name, + }, + issue: { number: 1 }, + comment: { + body: "/help", + }, + commits: [{ modified: [CONFIG_FULL_PATH] }], + } as unknown as GitHubContext<"issue_comment.created">["payload"], + } as unknown as GitHubContext; + await expect(handlePushEvent(context)).resolves.not.toThrow(); + expect(createCommitComment).toBeCalledTimes(1); + }); +});