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

Open
wants to merge 30 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
30 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
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
260 changes: 260 additions & 0 deletions src/github/handlers/push-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
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 { PluginChainState, PluginInput, PluginOutput, pluginOutputSchema } from "../types/plugin";
import { isGithubPlugin, PluginConfiguration } from "../types/plugin-configuration";
import { Value, ValueErrorType } from "@sinclair/typebox/value";
import { pluginValidationResponseSchema, StateValidation, stateValidationErrorSchemaValidator, 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 { 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)) as PluginChainState<"push">;

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 && state.eventPayload.repository.owner) {
const body = [];
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 createCommitComment(
context,
{
owner: state.eventPayload.repository.owner.login,
repo: state.eventPayload.repository.name,
commitSha: state.eventPayload.after as string,
userLogin: state.eventPayload.sender?.login,
},
body
);
}
} catch (e) {
console.error("handleActionValidationWorkflowCompleted", e);
}
}

async function createCommitComment(
context: GitHubContext,
{ owner, repo, commitSha, userLogin }: { owner: string; repo: string; commitSha: string; userLogin?: string },
body: string[]
) {
const { octokit } = context;

const commit = (
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
await octokit.rest.repos.listCommentsForCommit({
owner: owner,
repo: repo,
commit_sha: commitSha,
})
).data
.filter((o) => o.user?.type === "Bot")
.pop();
if (commit) {
await octokit.rest.repos.updateCommitComment({
owner: owner,
repo: repo,
commit_sha: commitSha,
comment_id: commit.id,
body: `${commit.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 { payload, eventHandler } = context;
const errors: (ValueError | YAML.YAMLError)[] = [];

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());
const responseWithDefaults = Value.Default(pluginValidationResponseSchema, response) as StateValidation;
if (!stateValidationErrorSchemaValidator.test(responseWithDefaults)) {
console.error("Malformed response from the endpoints");
for (const err of stateValidationErrorSchemaValidator.errors(responseWithDefaults)) {
console.error(err);
}
}
const decodedResponse = Value.Decode(pluginValidationResponseSchema, responseWithDefaults);
if (decodedResponse.errors.length) {
errors.push(...decodedResponse.errors.map((err) => ({ ...err, path: `plugins/${i}/uses/${j}/with${err.path}` })));
}
} catch (e) {
errors.push({
path: `plugins/${i}/uses/${j}`,
message: `Failed to reach plugin endpoint: ${e}`,
value: plugin,
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
type: 0,
schema: stateValidationSchema,
});
}
} else {
try {
await dispatchWorkflow(context, {
owner: plugin.owner,
repository: plugin.repo,
workflowId: "validate-schema.yml",
ref: plugin.ref,
inputs: inputs.getWorkflowInputs(),
});
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`,
},
});
} catch (e) {
errors.push({
path: `plugins/${i}/uses/${j}`,
message: `Failed to reach plugin action: ${e}`,
value: JSON.stringify(plugin),
type: 0,
schema: stateValidationSchema,
});
}
}
}
}
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) {
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)[] = [];
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);
}
}
}
}
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>;
};
36 changes: 36 additions & 0 deletions src/github/types/state-validation-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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(),
});

const validationErrorSchema = Type.Object(
{
path: Type.String({ default: "/" }),
message: Type.String(),
type: Type.Number({ default: 0 }),
value: Type.Any({ default: undefined }),
schema: Type.Any({ default: {} }),
},
{ default: {} }
);

export const pluginValidationResponseSchema = Type.Object(
{
message: Type.Optional(Type.String()),
errors: Type.Array(validationErrorSchema, { default: [] }),
},
{ default: {} }
);

export const stateValidationErrorSchemaValidator = new StandardValidator(pluginValidationResponseSchema);

export type StateValidation = StaticDecode<typeof stateValidationSchema>;
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
Loading