diff --git a/.github/workflows/sync-template.yml b/.github/workflows/sync-template.yml new file mode 100644 index 0000000..dc92ad5 --- /dev/null +++ b/.github/workflows/sync-template.yml @@ -0,0 +1,49 @@ +name: Sync branch to template + +on: + workflow_dispatch: + schedule: + - cron: '14 0 1 * *' + +jobs: + sync: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get GitHub App token + uses: tibdex/github-app-token@v1.7.0 + id: get_installation_token + with: + app_id: ${{ secrets.APP_ID }} + private_key: ${{ secrets.APP_PRIVATE_KEY }} + + - name: Sync branch to template + env: + GH_TOKEN: ${{ steps.get_installation_token.outputs.token }} + IGNORE_FILES: "README.md another-file.txt" + run: | + branch_name=$(git rev-parse --abbrev-ref HEAD) + original_remote=$(git remote get-url origin) + pr_branch="sync-template/${branch_name}" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git checkout -b "$pr_branch" + git clone https://github.com/ubiquity/ts-template + for file in $IGNORE_FILES; do + rm -rf "ts-template/$file" + done + cp -rf ts-template/* . + rm -rf ts-template/ + git add . + git commit -m "chore: sync template" + git push "$original_remote" "$pr_branch" + gh pr create --title "Sync branch to template" --body "This pull request merges changes from the template repository." --head "$pr_branch" --base "$branch_name" + + diff --git a/bun.lockb b/bun.lockb index a35e00b..0193fd3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index bb96988..4b94720 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,8 @@ "open-source" ], "dependencies": { + "@actions/core": "1.10.1", + "@actions/github": "6.0.0", "@octokit/auth-app": "7.1.0", "@octokit/core": "6.1.2", "@octokit/plugin-paginate-rest": "11.3.3", @@ -58,7 +60,7 @@ "@octokit/webhooks": "13.2.8", "@octokit/webhooks-types": "7.5.1", "@sinclair/typebox": "0.32.35", - "@ubiquity-dao/ubiquibot-logger": "1.3.0", + "@ubiquity-dao/ubiquibot-logger": "^1.3.1", "dotenv": "16.4.5", "hono": "4.4.13", "smee-client": "2.0.1", diff --git a/src/github/handlers/index.ts b/src/github/handlers/index.ts index 67eeb47..873a244 100644 --- a/src/github/handlers/index.ts +++ b/src/github/handlers/index.ts @@ -25,8 +25,8 @@ 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 } -async function shouldSkipPlugin(event: EmitterWebhookEvent, context: GitHubContext, pluginChain: PluginConfiguration["plugins"][0]) { - if (pluginChain.skipBotEvents && "sender" in event.payload && event.payload.sender?.type === "Bot") { +export async function shouldSkipPlugin(context: GitHubContext, pluginChain: PluginConfiguration["plugins"][0]) { + if (pluginChain.skipBotEvents && "sender" in context.payload && context.payload.sender?.type === "Bot") { console.log("Skipping plugin chain because sender is a bot"); return true; } @@ -68,7 +68,7 @@ async function handleEvent(event: EmitterWebhookEvent, eventHandler: InstanceTyp } for (const pluginChain of pluginChains) { - if (await shouldSkipPlugin(event, context, pluginChain)) { + if (await shouldSkipPlugin(context, pluginChain)) { continue; } diff --git a/src/sdk/actions.ts b/src/sdk/actions.ts new file mode 100644 index 0000000..9479c4a --- /dev/null +++ b/src/sdk/actions.ts @@ -0,0 +1,119 @@ +import * as core from "@actions/core"; +import * as github from "@actions/github"; +import { Context } from "./context"; +import { customOctokit } from "./octokit"; +import { EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; +import { Logs, LogLevel, LOG_LEVEL, LogReturn } from "@ubiquity-dao/ubiquibot-logger"; +import { config } from "dotenv"; +import { Type as T, TAnySchema } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; +import { sanitizeMetadata } from "./util"; + +config(); + +interface Options { + logLevel?: LogLevel; + postCommentOnError?: boolean; + settingsSchema?: TAnySchema; + envSchema?: TAnySchema; +} + +const inputSchema = T.Object({ + stateId: T.String(), + eventName: T.String(), + eventPayload: T.String(), + authToken: T.String(), + settings: T.String(), + ref: T.String(), +}); + +export async function createActionsPlugin( + handler: (context: Context) => Promise | undefined>, + options?: Options +) { + const pluginOptions = { + logLevel: options?.logLevel || LOG_LEVEL.INFO, + postCommentOnError: options?.postCommentOnError || true, + settingsSchema: options?.settingsSchema, + envSchema: options?.envSchema, + }; + + const inputs = Value.Decode(inputSchema, github.context.payload.inputs); + + let config: TConfig; + if (pluginOptions.settingsSchema) { + config = Value.Decode(pluginOptions.settingsSchema, JSON.parse(inputs.settings)); + } else { + config = JSON.parse(inputs.settings) as TConfig; + } + + let env: TEnv; + if (pluginOptions.envSchema) { + env = Value.Decode(pluginOptions.envSchema, process.env); + } else { + env = process.env as TEnv; + } + + const context: Context = { + eventName: inputs.eventName as TSupportedEvents, + payload: JSON.parse(inputs.eventPayload), + octokit: new customOctokit({ auth: inputs.authToken }), + config: config, + env: env, + logger: new Logs(pluginOptions.logLevel), + }; + + try { + const result = await handler(context); + core.setOutput("result", result); + await returnDataToKernel(inputs.authToken, inputs.stateId, result); + } catch (error) { + console.error(error); + + let loggerError: LogReturn | null; + if (error instanceof Error) { + core.setFailed(error); + loggerError = context.logger.error(`Error: ${error}`, { error: error }); + } else if (error instanceof LogReturn) { + core.setFailed(error.logMessage.raw); + loggerError = error; + } else { + core.setFailed(`Error: ${error}`); + loggerError = context.logger.error(`Error: ${error}`); + } + + if (pluginOptions.postCommentOnError && loggerError) { + await postComment(context, loggerError); + } + } +} + +async function postComment(context: Context, error: LogReturn) { + if ("issue" in context.payload && context.payload.repository?.owner?.login) { + await context.octokit.rest.issues.createComment({ + owner: context.payload.repository.owner.login, + repo: context.payload.repository.name, + issue_number: context.payload.issue.number, + body: `${error.logMessage.diff}\n`, + }); + } else { + context.logger.info("Cannot post comment because issue is not found in the payload"); + } +} + +function getGithubWorkflowRunUrl() { + return `${github.context.payload.repository?.html_url}/actions/runs/${github.context.runId}`; +} + +async function returnDataToKernel(repoToken: string, stateId: string, output: object | undefined) { + const octokit = new customOctokit({ auth: repoToken }); + await octokit.rest.repos.createDispatchEvent({ + owner: github.context.repo.owner, + repo: github.context.repo.repo, + event_type: "return_data_to_ubiquibot_kernel", + client_payload: { + state_id: stateId, + output: output ? JSON.stringify(output) : null, + }, + }); +} diff --git a/src/sdk/constants.ts b/src/sdk/constants.ts index bd46b8d..f394a86 100644 --- a/src/sdk/constants.ts +++ b/src/sdk/constants.ts @@ -8,3 +8,5 @@ dkRj2Je2kag9b3FMxskv1npNSrPVcSc5lGNYlnZnfxIAnCknOC118JjitlrpT6wd 8wIDAQAB -----END PUBLIC KEY----- `; +export const KERNEL_APP_ID = 975031; +export const BOT_USER_ID = 178941584; diff --git a/src/sdk/index.ts b/src/sdk/index.ts index 7cadb41..4339ddf 100644 --- a/src/sdk/index.ts +++ b/src/sdk/index.ts @@ -1,2 +1,4 @@ export { createPlugin } from "./server"; +export { createActionsPlugin } from "./actions"; export type { Context } from "./context"; +export * from "./constants"; diff --git a/src/sdk/server.ts b/src/sdk/server.ts index 6285d1e..5e17035 100644 --- a/src/sdk/server.ts +++ b/src/sdk/server.ts @@ -5,12 +5,18 @@ import { customOctokit } from "./octokit"; import { EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; import { verifySignature } from "./signature"; import { KERNEL_PUBLIC_KEY } from "./constants"; -import { Logs, LogLevel, LOG_LEVEL } from "@ubiquity-dao/ubiquibot-logger"; +import { Logs, LogLevel, LOG_LEVEL, LogReturn } from "@ubiquity-dao/ubiquibot-logger"; import { Manifest } from "../types/manifest"; +import { TAnySchema } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value"; +import { sanitizeMetadata } from "./util"; interface Options { kernelPublicKey?: string; logLevel?: LogLevel; + postCommentOnError?: boolean; + settingsSchema?: TAnySchema; + envSchema?: TAnySchema; } export async function createPlugin( @@ -18,6 +24,14 @@ export async function createPlugin { @@ -32,24 +46,31 @@ export async function createPlugin = { eventName: payload.eventName, - payload: payload.payload, + payload: payload.eventPayload, octokit: new customOctokit({ auth: payload.authToken }), - config: payload.settings as TConfig, - env: ctx.env as TEnv, - logger: new Logs(options?.logLevel || LOG_LEVEL.INFO), + config: config, + env: env, + logger: new Logs(pluginOptions.logLevel), }; try { @@ -57,9 +78,36 @@ export async function createPlugin`, + }); + } else { + context.logger.info("Cannot post comment because issue is not found in the payload"); + } +} diff --git a/src/sdk/util.ts b/src/sdk/util.ts new file mode 100644 index 0000000..d518b91 --- /dev/null +++ b/src/sdk/util.ts @@ -0,0 +1,5 @@ +import { LogReturn } from "@ubiquity-dao/ubiquibot-logger"; + +export function sanitizeMetadata(obj: LogReturn["metadata"]): string { + return JSON.stringify(obj, null, 2).replace(//g, ">").replace(/--/g, "--"); +} diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts index 582872b..a5eaeed 100644 --- a/tests/configuration.test.ts +++ b/tests/configuration.test.ts @@ -6,6 +6,8 @@ import { getConfig } from "../src/github/utils/config"; import { GitHubContext } from "../src/github/github-context"; import { GitHubEventHandler } from "../src/github/github-event-handler"; import { getManifest } from "../src/github/utils/plugins"; +import { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods"; +import { shouldSkipPlugin } from "../src/github/handlers"; config({ path: ".dev.vars" }); @@ -23,16 +25,41 @@ afterAll(() => { describe("Configuration tests", () => { it("Should properly parse the Action path if a branch and workflow are specified", async () => { - function getContent() { - return { - data: ` -plugins: - - uses: - - plugin: ubiquity/user-activity-watcher:compute.yml@fork/pull/1 - with: - settings1: 'enabled'`, - }; + function getContent(args: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) { + let data: string; + if (args.path === "manifest.json") { + data = ` + { + "name": "plugin", + "commands": { + "command": { + "description": "description", + "ubiquity:example": "example" + } + } + } + `; + } else { + data = ` + plugins: + - uses: + - plugin: ubiquity/user-activity-watcher:compute.yml@fork/pull/1 + with: + settings1: 'enabled' + skipBotEvents: false`; + } + + if (args.mediaType === undefined || args.mediaType?.format === "base64") { + return { + data: { + content: Buffer.from(data).toString("base64"), + }, + }; + } else if (args.mediaType?.format === "raw") { + return { data }; + } } + const cfg = await getConfig({ key: issueOpened, name: issueOpened, @@ -44,25 +71,6 @@ plugins: }, } as unknown as GitHubContext<"issues.closed">["payload"], octokit: { - repos: { - getContent() { - return { - data: { - content: Buffer.from( - JSON.stringify({ - name: "plugin", - commands: { - command: { - description: "description", - "ubiquity:example": "example", - }, - }, - }) - ).toString("base64"), - }, - }; - }, - }, rest: { repos: { getContent, @@ -86,7 +94,7 @@ plugins: }, }, ], - skipBotEvents: true, + skipBotEvents: false, }); }); it("Should retrieve the configuration manifest from the proper branch if specified", async () => { @@ -154,4 +162,67 @@ plugins: ); expect(manifest).toEqual(content["withoutRef"]); }); + it("should not skip bot event if skipBotEvents is set to false", async () => { + function getContent(args: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) { + let data: string; + if (args.path === "manifest.json") { + data = ` + { + "name": "plugin", + "commands": { + "command": { + "description": "description", + "ubiquity:example": "example" + } + } + } + `; + } else { + data = ` + plugins: + - uses: + - plugin: ubiquity/test-plugin + with: + settings1: 'enabled' + skipBotEvents: false`; + } + + if (args.mediaType === undefined || args.mediaType?.format === "base64") { + return { + data: { + content: Buffer.from(data).toString("base64"), + }, + }; + } else if (args.mediaType?.format === "raw") { + return { data }; + } + } + + const context = { + key: issueOpened, + name: issueOpened, + id: "", + payload: { + repository: { + owner: { login: "ubiquity" }, + name: "conversation-rewards", + }, + sender: { + type: "Bot", + }, + } as unknown as GitHubContext<"issues.closed">["payload"], + octokit: { + rest: { + repos: { + getContent, + }, + }, + }, + eventHandler: {} as GitHubEventHandler, + } as unknown as GitHubContext; + + const cfg = await getConfig(context); + expect(cfg.plugins[0].skipBotEvents).toEqual(false); + await expect(shouldSkipPlugin(context, cfg.plugins[0])).resolves.toEqual(false); + }); }); diff --git a/tests/main.test.ts b/tests/main.test.ts index fa5ad59..85ab398 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -165,36 +165,56 @@ describe("Worker tests", () => { it("Should merge organization and repository configuration", async () => { const workflowId = "compute.yml"; function getContent(args: RestEndpointMethodTypes["repos"]["getContent"]["parameters"]) { - if (args.repo !== "ubiquibot-config") { + let data: string; + if (args.path === "manifest.json") { + data = ` + { + "name": "plugin", + "commands": { + "command": { + "description": "description", + "ubiquity:example": "example" + } + } + } + `; + } else if (args.repo !== "ubiquibot-config") { + data = ` + plugins: + - uses: + - plugin: repo-3/plugin-3 + with: + setting1: false + - uses: + - plugin: repo-1/plugin-1 + with: + setting2: true`; + } else { + data = ` + plugins: + - uses: + - plugin: uses-1/plugin-1 + with: + settings1: 'enabled' + - uses: + - plugin: repo-1/plugin-1 + with: + setting1: false + - uses: + - plugin: repo-2/plugin-2 + with: + setting2: true`; + } + + if (args.mediaType === undefined || args.mediaType?.format === "base64") { return { - data: ` -plugins: - - uses: - - plugin: repo-3/plugin-3 - with: - setting1: false - - uses: - - plugin: repo-1/plugin-1 - with: - setting2: true`, + data: { + content: Buffer.from(data).toString("base64"), + }, }; + } else if (args.mediaType?.format === "raw") { + return { data }; } - return { - data: ` -plugins: - - uses: - - plugin: uses-1/plugin-1 - with: - settings1: 'enabled' - - uses: - - plugin: repo-1/plugin-1 - with: - setting1: false - - uses: - - plugin: repo-2/plugin-2 - with: - setting2: true`, - }; } const cfg = await getConfig({ key: issueOpened, @@ -207,25 +227,6 @@ plugins: }, } as unknown as GitHubContext<"issues.closed">["payload"], octokit: { - repos: { - getContent() { - return { - data: { - content: Buffer.from( - JSON.stringify({ - name: "plugin", - commands: { - command: { - description: "description", - "ubiquity:example": "example", - }, - }, - }) - ).toString("base64"), - }, - }; - }, - }, rest: { repos: { getContent, diff --git a/tests/sdk.test.ts b/tests/sdk.test.ts index 5679245..8cc7df5 100644 --- a/tests/sdk.test.ts +++ b/tests/sdk.test.ts @@ -1,6 +1,6 @@ import { server } from "./__mocks__/node"; import issueCommented from "./__mocks__/requests/issue-comment-post.json"; -import { expect, describe, beforeAll, afterAll, afterEach, it } from "@jest/globals"; +import { expect, describe, beforeAll, afterAll, afterEach, it, jest } from "@jest/globals"; import * as crypto from "crypto"; import { createPlugin } from "../src/sdk/server"; @@ -37,10 +37,14 @@ beforeAll(async () => { ); server.listen(); }); -afterEach(() => server.resetHandlers()); +afterEach(() => { + server.resetHandlers(); + jest.resetModules(); + jest.restoreAllMocks(); +}); afterAll(() => server.close()); -describe("SDK tests", () => { +describe("SDK worker tests", () => { it("Should serve manifest", async () => { const res = await app.request("/manifest.json", { method: "GET", @@ -82,6 +86,36 @@ describe("SDK tests", () => { expect(res.status).toEqual(400); }); it("Should handle thrown errors", async () => { + const createComment = jest.fn(); + jest.mock("../src/sdk/octokit", () => ({ + customOctokit: class MockOctokit { + constructor() { + return { + rest: { + issues: { + createComment, + }, + }, + }; + } + }, + })); + + const { createPlugin } = await import("../src/sdk/server"); + const app = await createPlugin( + async (context: Context<{ shouldFail: boolean }>) => { + if (context.config.shouldFail) { + throw context.logger.error("test error"); + } + return { + success: true, + event: context.eventName, + }; + }, + { name: "test" }, + { kernelPublicKey: publicKey } + ); + const data = { ...issueCommented, stateId: "stateId", @@ -104,6 +138,19 @@ describe("SDK tests", () => { method: "POST", }); expect(res.status).toEqual(500); + expect(createComment).toHaveBeenCalledWith({ + issue_number: 5, + owner: "ubiquibot", + repo: "bot", + body: `\`\`\`diff +! test error +\`\`\` +`, + }); }); it("Should accept correct request", async () => { const data = { @@ -129,6 +176,68 @@ describe("SDK tests", () => { }); expect(res.status).toEqual(200); const result = await res.json(); - expect(result).toEqual({ stateId: "stateId", output: { success: true, event: "issue_comment.created" } }); + expect(result).toEqual({ stateId: "stateId", output: { success: true, event: issueCommented.eventName } }); + }); +}); + +describe("SDK actions tests", () => { + it("Should accept correct request", async () => { + jest.mock("@actions/github", () => ({ + context: { + runId: "1", + payload: { + inputs: { + stateId: "stateId", + eventName: issueCommented.eventName, + settings: "{}", + eventPayload: JSON.stringify(issueCommented.eventPayload), + authToken: "test", + ref: "", + }, + }, + repo: { + owner: "ubiquity", + repo: "ubiquibot-kernel", + }, + }, + })); + const setOutput = jest.fn(); + const setFailed = jest.fn(); + jest.mock("@actions/core", () => ({ + setOutput, + setFailed, + })); + const createDispatchEvent = jest.fn(); + jest.mock("../src/sdk/octokit", () => ({ + customOctokit: class MockOctokit { + constructor() { + return { + rest: { + repos: { + createDispatchEvent: createDispatchEvent, + }, + }, + }; + } + }, + })); + const { createActionsPlugin } = await import("../src/sdk/actions"); + + await createActionsPlugin(async (context: Context) => { + return { + event: context.eventName, + }; + }); + expect(setOutput).toHaveBeenCalledWith("result", { event: issueCommented.eventName }); + expect(setFailed).not.toHaveBeenCalled(); + expect(createDispatchEvent).toHaveBeenCalledWith({ + event_type: "return_data_to_ubiquibot_kernel", + owner: "ubiquity", + repo: "ubiquibot-kernel", + client_payload: { + state_id: "stateId", + output: JSON.stringify({ event: issueCommented.eventName }), + }, + }); }); }); diff --git a/wrangler.toml b/wrangler.toml index c78f2a6..228d0db 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,4 +1,4 @@ -name = "ubiquityos-kernel" +name = "ubiquity-os-kernel" main = "src/worker.ts" compatibility_date = "2023-12-06" compatibility_flags = [ "nodejs_compat" ]