Skip to content

Commit

Permalink
Merge pull request ubiquity-os#100 from ubiquity/actions-sdk
Browse files Browse the repository at this point in the history
Actions SDK
  • Loading branch information
whilefoo authored Sep 12, 2024
2 parents 975fec5 + ba98cb6 commit 02ecfd0
Show file tree
Hide file tree
Showing 11 changed files with 453 additions and 94 deletions.
Binary file modified bun.lockb
Binary file not shown.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions src/github/handlers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}

Expand Down
119 changes: 119 additions & 0 deletions src/sdk/actions.ts
Original file line number Diff line number Diff line change
@@ -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<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | 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<TConfig, TEnv, TSupportedEvents> = {
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<!--\n${getGithubWorkflowRunUrl()}\n${sanitizeMetadata(error.metadata)}\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,
},
});
}
2 changes: 2 additions & 0 deletions src/sdk/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,5 @@ dkRj2Je2kag9b3FMxskv1npNSrPVcSc5lGNYlnZnfxIAnCknOC118JjitlrpT6wd
8wIDAQAB
-----END PUBLIC KEY-----
`;
export const KERNEL_APP_ID = 975031;
export const BOT_USER_ID = 178941584;
2 changes: 2 additions & 0 deletions src/sdk/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
export { createPlugin } from "./server";
export { createActionsPlugin } from "./actions";
export type { Context } from "./context";
export * from "./constants";
70 changes: 59 additions & 11 deletions src/sdk/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,33 @@ 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<TConfig = unknown, TEnv = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName>(
handler: (context: Context<TConfig, TEnv, TSupportedEvents>) => Promise<Record<string, unknown> | undefined>,
manifest: Manifest,
options?: Options
) {
const pluginOptions = {
kernelPublicKey: options?.kernelPublicKey || KERNEL_PUBLIC_KEY,
logLevel: options?.logLevel || LOG_LEVEL.INFO,
postCommentOnError: options?.postCommentOnError || true,
settingsSchema: options?.settingsSchema,
envSchema: options?.envSchema,
};

const app = new Hono();

app.get("/manifest.json", (ctx) => {
Expand All @@ -32,34 +46,68 @@ export async function createPlugin<TConfig = unknown, TEnv = unknown, TSupported
const payload = await ctx.req.json();
const signature = payload.signature;
delete payload.signature;
if (!(await verifySignature(options?.kernelPublicKey || KERNEL_PUBLIC_KEY, payload, signature))) {
if (!(await verifySignature(pluginOptions.kernelPublicKey, payload, signature))) {
throw new HTTPException(400, { message: "Invalid signature" });
}

try {
new customOctokit({ auth: payload.authToken });
} catch (error) {
console.error("SDK ERROR", error);
throw new HTTPException(500, { message: "Unexpected error" });
let config: TConfig;
if (pluginOptions.settingsSchema) {
config = Value.Decode(pluginOptions.settingsSchema, payload.settings);
} else {
config = payload.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<TConfig, TEnv, TSupportedEvents> = {
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 {
const result = await handler(context);
return ctx.json({ stateId: payload.stateId, output: result });
} catch (error) {
console.error(error);

let loggerError: LogReturn | null;
if (error instanceof Error) {
loggerError = context.logger.error(`Error: ${error}`, { error: error });
} else if (error instanceof LogReturn) {
loggerError = error;
} else {
loggerError = context.logger.error(`Error: ${error}`);
}

if (pluginOptions.postCommentOnError && loggerError) {
await postComment(context, loggerError);
}

throw new HTTPException(500, { message: "Unexpected error" });
}
});

return app;
}

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<!--\n${sanitizeMetadata(error.metadata)}\n-->`,
});
} else {
context.logger.info("Cannot post comment because issue is not found in the payload");
}
}
5 changes: 5 additions & 0 deletions src/sdk/util.ts
Original file line number Diff line number Diff line change
@@ -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, "&lt;").replace(/>/g, "&gt;").replace(/--/g, "&#45;&#45;");
}
Loading

0 comments on commit 02ecfd0

Please sign in to comment.