diff --git a/bun.lockb b/bun.lockb index 1e0f6d5..bc3ab85 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index e18c717..a156b4f 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "dependencies": { "@actions/core": "1.10.1", "@actions/github": "6.0.0", + "@cfworker/json-schema": "2.0.1", "@octokit/auth-app": "7.1.0", "@octokit/core": "6.1.2", "@octokit/plugin-paginate-rest": "11.3.3", diff --git a/src/github/handlers/index.ts b/src/github/handlers/index.ts index 217c937..d476b46 100644 --- a/src/github/handlers/index.ts +++ b/src/github/handlers/index.ts @@ -8,6 +8,7 @@ import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/wor import { PluginInput } from "../types/plugin"; import { isGithubPlugin, PluginConfiguration } from "../types/plugin-configuration"; import { getManifest, getPluginsForEvent } from "../utils/plugins"; +import handlePushEvent from "./push-event"; function tryCatchWrapper(fn: (event: EmitterWebhookEvent) => unknown) { return async (event: EmitterWebhookEvent) => { @@ -22,6 +23,7 @@ function tryCatchWrapper(fn: (event: EmitterWebhookEvent) => unknown) { export function bindHandlers(eventHandler: GitHubEventHandler) { eventHandler.on("repository_dispatch", repositoryDispatch); eventHandler.on("issue_comment.created", issueCommentCreated); + eventHandler.on("push", handlePushEvent); eventHandler.onAny(tryCatchWrapper((event) => handleEvent(event, eventHandler))); // onAny should also receive GithubContext but the types in octokit/webhooks are weird } diff --git a/src/github/handlers/push-event.ts b/src/github/handlers/push-event.ts new file mode 100644 index 0000000..642f81c --- /dev/null +++ b/src/github/handlers/push-event.ts @@ -0,0 +1,161 @@ +import { Validator } from "@cfworker/json-schema"; +import { ValueErrorType } from "@sinclair/typebox/value"; +import { ValueError } from "typebox-validators"; +import YAML, { LineCounter, Node, YAMLError } from "yaml"; +import { GitHubContext } from "../github-context"; +import { configSchema, PluginConfiguration } from "../types/plugin-configuration"; +import { CONFIG_FULL_PATH, getConfigurationFromRepo } from "../utils/config"; +import { getManifest } from "../utils/plugins"; + +function constructErrorBody( + errors: Iterable | (YAML.YAMLError | ValueError)[], + rawData: string | null, + repository: GitHubContext<"push">["payload"]["repository"], + after: string +) { + const body = []; + if (errors) { + for (const error of errors) { + body.push("> [!CAUTION]\n"); + if (error instanceof YAMLError) { + body.push(`> https://github.com/${repository.owner?.login}/${repository.name}/blob/${after}/${CONFIG_FULL_PATH}#L${error.linePos?.[0].line || 0}`); + } else if (rawData) { + const lineCounter = new LineCounter(); + const doc = YAML.parseDocument(rawData, { lineCounter }); + const path = error.path.split("/").filter((o) => o); + if (error.type === ValueErrorType.ObjectRequiredProperty) { + path.splice(path.length - 1, 1); + } + const node = doc.getIn(path, true) as Node; + const linePosStart = lineCounter.linePos(node?.range?.[0] || 0); + body.push(`> https://github.com/${repository.owner?.login}/${repository.name}/blob/${after}/${CONFIG_FULL_PATH}#L${linePosStart.line}`); + } + const message = []; + if (error instanceof YAMLError) { + message.push(error.message); + } else { + message.push(`path: ${error.path}\n`); + message.push(`value: ${error.value}\n`); + message.push(`message: ${error.message}`); + } + body.push(`\n> \`\`\`\n`); + body.push(`> ${message.join("").replaceAll("\n", "\n> ")}`); + body.push(`\n> \`\`\`\n\n`); + } + } + return body; +} + +async function createCommitComment( + context: GitHubContext, + { owner, repo, commitSha, userLogin }: { owner: string; repo: string; commitSha: string; userLogin?: string }, + body: string[] +) { + const { octokit } = context; + + const comment = ( + await octokit.rest.repos.listCommentsForCommit({ + owner: owner, + repo: repo, + commit_sha: commitSha, + }) + ).data + .filter((o) => o.user?.type === "Bot") + .pop(); + if (comment) { + await octokit.rest.repos.updateCommitComment({ + owner: owner, + repo: repo, + commit_sha: commitSha, + comment_id: comment.id, + body: `${comment.body}\n${body.join("")}`, + }); + } else { + body.unshift(`@${userLogin} Configuration is invalid.\n`); + await octokit.rest.repos.createCommitComment({ + owner: owner, + repo: repo, + commit_sha: commitSha, + body: body.join(""), + }); + } +} + +async function checkPluginConfigurations(context: GitHubContext<"push">, config: PluginConfiguration, rawData: string | null) { + const errors: (ValueError | YAML.YAMLError)[] = []; + const doc = rawData ? YAML.parseDocument(rawData) : null; + + for (let i = 0; i < config.plugins.length; ++i) { + const { uses } = config.plugins[i]; + for (let j = 0; j < uses.length; ++j) { + const { plugin, with: args } = uses[j]; + const manifest = await getManifest(context, plugin); + if (!manifest?.configuration) { + errors.push({ + path: `plugins/${i}/uses/${j}`, + message: `Failed to fetch the manifest configuration.`, + value: plugin, + type: 0, + schema: configSchema, + }); + } else { + const validator = new Validator(manifest.configuration, "7", false); + const result = validator.validate(args); + + if (!result.valid) { + for (const error of result.errors) { + const path = error.instanceLocation.replace("#", `plugins/${i}/uses/${j}/with`); + const value = doc?.getIn(path.split("/").filter((o) => o)); + errors.push({ + path, + message: error.error, + value, + type: 0, + schema: configSchema, + }); + } + } + } + } + } + return errors; +} + +export default async function handlePushEvent(context: GitHubContext<"push">) { + const { payload } = context; + const { repository, commits, after } = payload; + + const didConfigurationFileChange = commits.some((commit) => commit.modified?.includes(CONFIG_FULL_PATH) || commit.added?.includes(CONFIG_FULL_PATH)); + + if (!didConfigurationFileChange || !repository.owner) { + return; + } + + console.log("Configuration file changed, will run configuration checks."); + + const { config, errors: configurationErrors, rawData } = await getConfigurationFromRepo(context, repository.name, repository.owner.login); + const errors: (ValueError | YAML.YAMLError)[] = []; + if (!configurationErrors && config) { + errors.push(...(await checkPluginConfigurations(context, config, rawData))); + } else if (configurationErrors) { + errors.push(...configurationErrors); + } + try { + if (errors.length) { + const body = []; + body.push(...constructErrorBody(errors, rawData, repository, after)); + await createCommitComment( + context, + { + owner: repository.owner.login, + repo: repository.name, + commitSha: after, + userLogin: payload.sender?.login, + }, + body + ); + } + } catch (e) { + console.error("handlePushEventError", e); + } +} diff --git a/src/github/handlers/repository-dispatch.ts b/src/github/handlers/repository-dispatch.ts index b421b8e..2441d58 100644 --- a/src/github/handlers/repository-dispatch.ts +++ b/src/github/handlers/repository-dispatch.ts @@ -1,8 +1,8 @@ -import { GitHubContext } from "../github-context"; -import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch"; import { Value } from "@sinclair/typebox/value"; -import { PluginInput, PluginChainState, expressionRegex, pluginOutputSchema } from "../types/plugin"; +import { GitHubContext } from "../github-context"; +import { expressionRegex, PluginChainState, PluginInput, pluginOutputSchema } from "../types/plugin"; import { isGithubPlugin } from "../types/plugin-configuration"; +import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch"; export async function repositoryDispatch(context: GitHubContext<"repository_dispatch">) { console.log("Repository dispatch event received", context.payload.client_payload); diff --git a/src/github/types/plugin-configuration.ts b/src/github/types/plugin-configuration.ts index 1ead40c..02316d7 100644 --- a/src/github/types/plugin-configuration.ts +++ b/src/github/types/plugin-configuration.ts @@ -75,9 +75,14 @@ const handlerSchema = T.Array( { default: [] } ); -export const configSchema = T.Object({ - plugins: handlerSchema, -}); +export const configSchema = T.Object( + { + plugins: handlerSchema, + }, + { + additionalProperties: false, + } +); export const configSchemaValidator = new StandardValidator(configSchema); diff --git a/src/github/types/plugin.ts b/src/github/types/plugin.ts index d429678..79b4cec 100644 --- a/src/github/types/plugin.ts +++ b/src/github/types/plugin.ts @@ -81,4 +81,5 @@ export type PluginChainState; }; diff --git a/src/github/utils/config.ts b/src/github/utils/config.ts index ea26f33..554ecd2 100644 --- a/src/github/utils/config.ts +++ b/src/github/utils/config.ts @@ -1,5 +1,5 @@ -import { Value } from "@sinclair/typebox/value"; -import YAML from "yaml"; +import { TransformDecodeCheckError, Value, ValueError } from "@sinclair/typebox/value"; +import YAML, { YAMLError } from "yaml"; import { GitHubContext } from "../github-context"; import { expressionRegex } from "../types/plugin"; import { configSchema, configSchemaValidator, PluginConfiguration } from "../types/plugin-configuration"; @@ -8,14 +8,14 @@ import { getManifest } from "./plugins"; export const CONFIG_FULL_PATH = ".github/.ubiquibot-config.yml"; export const CONFIG_ORG_REPO = "ubiquibot-config"; -async function getConfigurationFromRepo(context: GitHubContext, repository: string, owner: string) { - const targetRepoConfiguration: PluginConfiguration = parseYaml( - await download({ - context, - repository, - owner, - }) - ); +export async function getConfigurationFromRepo(context: GitHubContext, repository: string, owner: string) { + const rawData = await download({ + context, + repository, + owner, + }); + const { yaml, errors } = parseYaml(rawData); + const targetRepoConfiguration: PluginConfiguration | null = yaml; if (targetRepoConfiguration) { try { const configSchemaWithDefaults = Value.Default(configSchema, targetRepoConfiguration) as Readonly; @@ -25,13 +25,13 @@ async function getConfigurationFromRepo(context: GitHubContext, repository: stri console.error(error); } } - return Value.Decode(configSchema, configSchemaWithDefaults); + return { config: Value.Decode(configSchema, configSchemaWithDefaults), errors, rawData }; } catch (error) { console.error(`Error decoding configuration for ${owner}/${repository}, will ignore.`, error); - return null; + return { config: null, errors: [error instanceof TransformDecodeCheckError ? error.error : error] as ValueError[], rawData }; } } - return null; + return { config: null, errors, rawData }; } /** @@ -65,8 +65,8 @@ export async function getConfig(context: GitHubContext): Promise { - if (configuration) { - mergedConfiguration = mergeConfigurations(mergedConfiguration, configuration); + if (configuration.config) { + mergedConfiguration = mergeConfigurations(mergedConfiguration, configuration.config); } }); @@ -157,10 +157,11 @@ export function parseYaml(data: null | string) { try { if (data) { const parsedData = YAML.parse(data); - return parsedData ?? null; + return { yaml: parsedData ?? null, errors: null }; } } catch (error) { console.error("Error parsing YAML", error); + return { errors: [error] as YAMLError[], yaml: null }; } - return null; + return { yaml: null, errors: null }; } diff --git a/src/github/utils/plugins.ts b/src/github/utils/plugins.ts index a084694..2c8a034 100644 --- a/src/github/utils/plugins.ts +++ b/src/github/utils/plugins.ts @@ -45,7 +45,7 @@ async function fetchWorkerManifest(url: string): Promise { if (_manifestCache[url]) { return _manifestCache[url]; } - const manifestUrl = `${url}/manifest.json`; + const manifestUrl = `${url}/manifest`; try { const result = await fetch(manifestUrl); const manifest = decodeManifest(await result.json()); diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 6dcd92c..28ad2e9 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -14,6 +14,7 @@ export const manifestSchema = T.Object({ description: T.Optional(T.String({ default: "" })), commands: T.Optional(T.Record(T.String(), commandSchema, { default: {} })), "ubiquity:listeners": T.Optional(T.Array(runEvent, { default: [] })), + configuration: T.Optional(T.Record(T.String(), T.Any(), { default: {} })), }); export const manifestValidator = new StandardValidator(manifestSchema); diff --git a/tests/configuration.test.ts b/tests/configuration.test.ts index a5eaeed..331fa4b 100644 --- a/tests/configuration.test.ts +++ b/tests/configuration.test.ts @@ -111,6 +111,7 @@ describe("Configuration tests", () => { "ubiquity:example": "example", }, }, + configuration: {}, description: "", "ubiquity:listeners": [], }, @@ -122,6 +123,7 @@ describe("Configuration tests", () => { "ubiquity:example": "example", }, }, + configuration: {}, description: "", "ubiquity:listeners": [], }, diff --git a/tests/events.test.ts b/tests/events.test.ts index bf4ee9e..b5f16d7 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -30,7 +30,7 @@ afterAll(() => { describe("Event related tests", () => { beforeEach(() => { server.use( - http.get("https://plugin-a.internal/manifest.json", () => + http.get("https://plugin-a.internal/manifest", () => HttpResponse.json({ name: "plugin", commands: { diff --git a/tests/main.test.ts b/tests/main.test.ts index 3749fa2..8a3eb7d 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -37,7 +37,7 @@ afterAll(() => { describe("Worker tests", () => { beforeEach(() => { server.use( - http.get("https://plugin-a.internal/manifest.json", () => + http.get("https://plugin-a.internal/manifest", () => HttpResponse.json({ name: "plugin", commands: {