Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: configuration annotations #112

Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
2e11120
chore(WIP): listen to configuration push
gentlementlegen Sep 19, 2024
2fe47a4
chore(WIP): comment validation on push
gentlementlegen Sep 19, 2024
53b0c41
chore(WIP): error details posting
gentlementlegen Sep 19, 2024
b479b20
chore(WIP): error details posting with line
gentlementlegen Sep 19, 2024
b431101
chore: removed additional properties
gentlementlegen Sep 19, 2024
8f14418
chore: fixed error types
gentlementlegen Sep 20, 2024
3f67859
chore: split error body construction
gentlementlegen Sep 20, 2024
16d94b8
chore(WIP): plugin remote check
gentlementlegen Sep 20, 2024
65a1507
chore(WIP): plugin remote check
gentlementlegen Sep 20, 2024
de80953
feat!: payload is fetched from KV
gentlementlegen Sep 21, 2024
65d5b39
chore: removed logs
gentlementlegen Sep 21, 2024
f122def
chore: typebox validation for payload
gentlementlegen Sep 21, 2024
e95b6a8
chore: split path on missing property
gentlementlegen Sep 21, 2024
a7c5121
chore: removed logs
gentlementlegen Sep 21, 2024
17d696e
chore: error on unreachable endpoint
gentlementlegen Sep 21, 2024
d605dcb
chore: split error configuration
gentlementlegen Sep 21, 2024
fa0a7ad
chore: fix knip
gentlementlegen Sep 21, 2024
0722881
chore: fix types
gentlementlegen Sep 21, 2024
f4fe027
chore: fix tests
gentlementlegen Sep 21, 2024
45c9ae4
chore: joined comments
gentlementlegen Sep 23, 2024
4fd3ea6
chore: made errors optional
gentlementlegen Sep 23, 2024
c9c66f7
chore: made message optional
gentlementlegen Sep 23, 2024
3e99da1
chore: made type optional
gentlementlegen Sep 23, 2024
106fc0c
chore: added defaults to errors
gentlementlegen Sep 23, 2024
1857db3
chore: added defaults to errors
gentlementlegen Sep 23, 2024
2f23b6d
chore: reduced nesting of conditions
gentlementlegen Sep 26, 2024
bef8f71
chore: renamed commit to comment
gentlementlegen Sep 26, 2024
ef2c633
Merge branch 'development' into feat/configuration-annotations
gentlementlegen Sep 26, 2024
883e8ca
chore: renamed validator
gentlementlegen Sep 26, 2024
6a2913a
Merge branch 'development' into feat/configuration-annotations
gentlementlegen Sep 26, 2024
60bf359
chore: using json cloudflare version
gentlementlegen Sep 30, 2024
cee9a55
chore: removed unused schema
gentlementlegen Sep 30, 2024
5fd057c
Merge branch 'development' into feat/configuration-annotations
gentlementlegen Sep 30, 2024
4947196
chore: resolve conflict
gentlementlegen Sep 30, 2024
062b2a5
chore: fix tests
gentlementlegen Sep 30, 2024
e6a23a0
chore: renamed return value string
gentlementlegen Oct 3, 2024
bce22fe
chore: change error output to yml highlighting
gentlementlegen Oct 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified bun.lockb
Binary file not shown.
2 changes: 2 additions & 0 deletions src/github/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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
}

Expand Down
195 changes: 195 additions & 0 deletions src/github/handlers/push-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import { GitHubContext } from "../github-context";
import { CONFIG_FULL_PATH, getConfigurationFromRepo } from "../utils/config";
import YAML, { LineCounter, Node, YAMLError } from "yaml";
import { ValueError } from "typebox-validators";
import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/workflow-dispatch";
import { PluginInput, PluginOutput, pluginOutputSchema } from "../types/plugin";
import { isGithubPlugin } from "../types/plugin-configuration";
import { Value, ValueErrorType } from "@sinclair/typebox/value";
import { StateValidation, stateValidationSchema } from "../types/state-validation-payload";

function constructErrorBody(
errors: Iterable<ValueError> | (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;
}

export async function handleActionValidationWorkflowCompleted(context: GitHubContext<"repository_dispatch">) {
const { octokit, payload } = context;
const { client_payload } = payload;
let pluginOutput: PluginOutput;
let stateValidation: StateValidation;

try {
pluginOutput = Value.Decode(pluginOutputSchema, client_payload);
} catch (error) {
console.error("[handleActionValidationWorkflowCompleted]: Cannot decode plugin output", error);
throw error;
}

const state = await context.eventHandler.pluginChainState.get(pluginOutput.state_id);

if (!state) {
console.error(`[handleActionValidationWorkflowCompleted]: No state found for plugin chain ${pluginOutput.state_id}`);
return;
}

console.log("Received Action output result for validation, will process.", pluginOutput.output);

const errors = pluginOutput.output.errors as ValueError[];
try {
stateValidation = Value.Decode(stateValidationSchema, state.additionalProperties);
} catch (e) {
console.error(`[handleActionValidationWorkflowCompleted]: Cannot decode state properties`);
throw e;
}
if (!stateValidation) {
console.error(`[handleActionValidationWorkflowCompleted]: State validation is invalid for ${pluginOutput.state_id}`);
return;
}

const { rawData, path } = stateValidation;
try {
if (errors.length) {
const body = [];
body.push(`@${state.eventPayload.sender?.login} Configuration is invalid.\n`);
if (errors.length) {
body.push(
...constructErrorBody(
errors.map((err) => ({ ...err, path: `${path}${err.path}` })),
rawData as string,
state.eventPayload.repository as GitHubContext<"push">["payload"]["repository"],
state.eventPayload.after as string
)
);
}
await octokit.rest.repos.createCommitComment({
owner: state.eventPayload.repository.owner.login,
repo: state.eventPayload.repository.name,
commit_sha: state.eventPayload.after as string,
body: body.join(""),
});
}
} catch (e) {
console.error("handleActionValidationWorkflowCompleted", e);
}
}

export default async function handlePushEvent(context: GitHubContext<"push">) {
const { octokit, payload, eventHandler } = 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) {
console.log("Configuration file changed, will run configuration checks.");

if (repository.owner) {
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
const { config, errors: configurationErrors, rawData } = await getConfigurationFromRepo(context, repository.name, repository.owner.login);
const errors: (ValueError | YAML.YAMLError)[] = [];
// TODO test unreachable endpoints
if (!configurationErrors && config) {
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 isGithubPluginObject = isGithubPlugin(plugin);
const stateId = crypto.randomUUID();
const token = payload.installation ? await eventHandler.getToken(payload.installation.id) : "";
const ref = isGithubPluginObject ? (plugin.ref ?? (await getDefaultBranch(context, plugin.owner, plugin.repo))) : plugin;
const inputs = new PluginInput(context.eventHandler, stateId, context.key, payload, args, token, ref);

if (!isGithubPluginObject) {
try {
const response = await dispatchWorker(`${plugin}/manifest`, await inputs.getWorkerInputs());
if (response.errors) {
errors.push(...response.errors.map((err) => ({ ...err, path: `plugins/${i}/uses/${j}/with${err.path}` })));
}
} catch (e) {
console.error("Failed to reach plugin endpoint", e);
errors.push({
path: `plugins/${i}/uses/${j}`,
message: "Failed to reach plugin endpoint",
value: plugin,
type: 0,
schema: stateValidationSchema,
});
}
} else {
await eventHandler.pluginChainState.put(stateId, {
eventPayload: payload,
currentPlugin: 0,
eventId: "",
eventName: "push",
inputs: [inputs],
outputs: new Array(uses.length),
pluginChain: uses,
additionalProperties: {
rawData,
path: `plugins/${i}/uses/${j}/with`,
},
});
await dispatchWorkflow(context, {
owner: plugin.owner,
repository: plugin.repo,
workflowId: "validate-schema.yml",
ref: plugin.ref,
inputs: inputs.getWorkflowInputs(),
});
}
}
}
} else if (configurationErrors) {
errors.push(...configurationErrors);
}
try {
if (errors.length) {
const body = [];
body.push(`@${payload.sender?.login} Configuration is invalid.\n`);
body.push(...constructErrorBody(errors, rawData, repository, after));
await octokit.rest.repos.createCommitComment({
owner: repository.owner.login,
repo: repository.name,
commit_sha: after,
body: body.join(""),
});
}
} catch (e) {
console.error("handlePushEventError", e);
}
}
}
}
5 changes: 4 additions & 1 deletion src/github/handlers/repository-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@ import { dispatchWorker, dispatchWorkflow, getDefaultBranch } from "../utils/wor
import { Value } from "@sinclair/typebox/value";
import { PluginInput, PluginChainState, expressionRegex, pluginOutputSchema } from "../types/plugin";
import { isGithubPlugin } from "../types/plugin-configuration";
import { handleActionValidationWorkflowCompleted } from "./push-event";

export async function repositoryDispatch(context: GitHubContext<"repository_dispatch">) {
console.log("Repository dispatch event received", context.payload.client_payload);

if (context.payload.action !== "return_data_to_ubiquibot_kernel") {
if (context.payload.action === "configuration_validation") {
return handleActionValidationWorkflowCompleted(context);
} else if (context.payload.action !== "return_data_to_ubiquibot_kernel") {
console.log("Skipping non-ubiquibot event");
return;
}
Expand Down
11 changes: 8 additions & 3 deletions src/github/types/plugin-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions src/github/types/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,4 +81,5 @@ export type PluginChainState<T extends EmitterWebhookEventName = EmitterWebhookE
pluginChain: PluginChain;
inputs: PluginInput[];
outputs: PluginOutput[];
additionalProperties?: Record<string, unknown>;
};
17 changes: 17 additions & 0 deletions src/github/types/state-validation-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { StaticDecode, Type } from "@sinclair/typebox";
import { StandardValidator } from "typebox-validators";

export const stateValidationSchema = Type.Object({
/**
* The YAML raw data
*/
rawData: Type.String(),
/**
* Path to the YAML element in the document
*/
path: Type.String(),
});

export const stateValidationValidator = new StandardValidator(stateValidationSchema);

export type StateValidation = StaticDecode<typeof stateValidationSchema>;
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
35 changes: 18 additions & 17 deletions src/github/utils/config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<unknown>;
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -65,8 +65,8 @@ export async function getConfig(context: GitHubContext): Promise<PluginConfigura
]);

configurations.forEach((configuration) => {
if (configuration) {
mergedConfiguration = mergeConfigurations(mergedConfiguration, configuration);
if (configuration.config) {
mergedConfiguration = mergeConfigurations(mergedConfiguration, configuration.config);
}
});

Expand Down Expand Up @@ -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 };
}
2 changes: 1 addition & 1 deletion src/github/utils/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ async function fetchWorkerManifest(url: string): Promise<Manifest | null> {
if (_manifestCache[url]) {
return _manifestCache[url];
}
const manifestUrl = `${url}/manifest.json`;
const manifestUrl = `${url}/manifest`;
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
try {
const result = await fetch(manifestUrl);
const manifest = decodeManifest(await result.json());
Expand Down
Loading