@ubiquity-os/plugin-sdk
Version:
SDK for plugin support.
523 lines (507 loc) • 19.9 kB
JavaScript
// src/server.ts
import { Value as Value2 } from "@sinclair/typebox/value";
import { Logs } from "@ubiquity-os/ubiquity-os-logger";
import { Hono } from "hono";
import { env as honoEnv } from "hono/adapter";
import { HTTPException } from "hono/http-exception";
// src/helpers/runtime-info.ts
import github from "@actions/github";
import { getRuntimeKey } from "hono/adapter";
var PluginRuntimeInfo = class _PluginRuntimeInfo {
static _instance = null;
_env = {};
constructor(env) {
if (env) {
this._env = env;
}
}
static getInstance(env) {
if (!_PluginRuntimeInfo._instance) {
_PluginRuntimeInfo._instance = getRuntimeKey() === "workerd" ? new CfRuntimeInfo(env) : new NodeRuntimeInfo(env);
}
return _PluginRuntimeInfo._instance;
}
};
var CfRuntimeInfo = class extends PluginRuntimeInfo {
get version() {
return Promise.resolve(this._env.CLOUDFLARE_VERSION_METADATA?.id ?? "CLOUDFLARE_VERSION_METADATA");
}
get runUrl() {
const accountId = this._env.CLOUDFLARE_ACCOUNT_ID ?? "<missing-cloudflare-account-id>";
const workerName = this._env.CLOUDFLARE_WORKER_NAME;
const toTime = Date.now() + 6e4;
const fromTime = Date.now() - 6e4;
const timeParam = encodeURIComponent(`{"type":"absolute","to":${toTime},"from":${fromTime}}`);
return `https://dash.cloudflare.com/${accountId}/workers/services/view/${workerName}/production/observability/logs?granularity=0&time=${timeParam}`;
}
};
var NodeRuntimeInfo = class extends PluginRuntimeInfo {
get version() {
return Promise.resolve(github.context.sha);
}
get runUrl() {
return github.context.payload.repository ? `${github.context.payload.repository?.html_url}/actions/runs/${github.context.runId}` : "http://localhost";
}
};
// src/util.ts
import { LOG_LEVEL } from "@ubiquity-os/ubiquity-os-logger";
// src/constants.ts
var KERNEL_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAs96DOU+JqM8SyNXOB6u3
uBKIFiyrcST/LZTYN6y7LeJlyCuGPqSDrWCfjU9Ph5PLf9TWiNmeM8DGaOpwEFC7
U3NRxOSglo4plnQ5zRwIHHXvxyK400sQP2oISXymISuBQWjEIqkC9DybQrKwNzf+
I0JHWPqmwMIw26UvVOtXGOOWBqTkk+N2+/9f8eDIJP5QQVwwszc8s1rXOsLMlVIf
wShw7GO4E2jyK8TSJKpyjV8eb1JJMDwFhPiRrtZfQJUtDf2mV/67shQww61BH2Y/
Plnalo58kWIbkqZoq1yJrL5sFb73osM5+vADTXVn79bkvea7W19nSkdMiarYt4Hq
JQIDAQAB
-----END PUBLIC KEY-----
`;
// src/util.ts
function sanitizeMetadata(obj) {
return JSON.stringify(obj, null, 2).replace(/</g, "<").replace(/>/g, ">").replace(/--/g, "--");
}
function getPluginOptions(options) {
return {
// Important to use || and not ?? to not consider empty strings
kernelPublicKey: options?.kernelPublicKey || KERNEL_PUBLIC_KEY,
logLevel: options?.logLevel || LOG_LEVEL.INFO,
postCommentOnError: options?.postCommentOnError ?? true,
settingsSchema: options?.settingsSchema,
envSchema: options?.envSchema,
commandSchema: options?.commandSchema,
bypassSignatureVerification: options?.bypassSignatureVerification || false
};
}
// src/comment.ts
var CommentHandler = class _CommentHandler {
static HEADER_NAME = "UbiquityOS";
_lastCommentId = { reviewCommentId: null, issueCommentId: null };
async _updateIssueComment(context2, params) {
if (!this._lastCommentId.issueCommentId) {
throw context2.logger.error("issueCommentId is missing");
}
const commentData = await context2.octokit.rest.issues.updateComment({
owner: params.owner,
repo: params.repo,
comment_id: this._lastCommentId.issueCommentId,
body: params.body
});
return { ...commentData.data, issueNumber: params.issueNumber };
}
async _updateReviewComment(context2, params) {
if (!this._lastCommentId.reviewCommentId) {
throw context2.logger.error("reviewCommentId is missing");
}
const commentData = await context2.octokit.rest.pulls.updateReviewComment({
owner: params.owner,
repo: params.repo,
comment_id: this._lastCommentId.reviewCommentId,
body: params.body
});
return { ...commentData.data, issueNumber: params.issueNumber };
}
async _createNewComment(context2, params) {
if (params.commentId) {
const commentData2 = await context2.octokit.rest.pulls.createReplyForReviewComment({
owner: params.owner,
repo: params.repo,
pull_number: params.issueNumber,
comment_id: params.commentId,
body: params.body
});
this._lastCommentId.reviewCommentId = commentData2.data.id;
return { ...commentData2.data, issueNumber: params.issueNumber };
}
const commentData = await context2.octokit.rest.issues.createComment({
owner: params.owner,
repo: params.repo,
issue_number: params.issueNumber,
body: params.body
});
this._lastCommentId.issueCommentId = commentData.data.id;
return { ...commentData.data, issueNumber: params.issueNumber };
}
_getIssueNumber(context2) {
if ("issue" in context2.payload) return context2.payload.issue.number;
if ("pull_request" in context2.payload) return context2.payload.pull_request.number;
if ("discussion" in context2.payload) return context2.payload.discussion.number;
return void 0;
}
_getCommentId(context2) {
return "pull_request" in context2.payload && "comment" in context2.payload ? context2.payload.comment.id : void 0;
}
_extractIssueContext(context2) {
if (!("repository" in context2.payload) || !context2.payload.repository?.owner?.login) {
return null;
}
const issueNumber = this._getIssueNumber(context2);
if (!issueNumber) return null;
return {
issueNumber,
commentId: this._getCommentId(context2),
owner: context2.payload.repository.owner.login,
repo: context2.payload.repository.name
};
}
async _processMessage(context2, message) {
if (message instanceof Error) {
const metadata2 = {
message: message.message,
name: message.name,
stack: message.stack
};
return { metadata: metadata2, logMessage: context2.logger.error(message.message).logMessage };
}
const metadata = message.metadata ? {
...message.metadata,
message: message.metadata.message,
stack: message.metadata.stack || message.metadata.error?.stack,
caller: message.metadata.caller || message.metadata.error?.stack?.split("\n")[2]?.match(/at (\S+)/)?.[1]
} : { ...message };
return { metadata, logMessage: message.logMessage };
}
_getInstigatorName(context2) {
if ("installation" in context2.payload && context2.payload.installation && "account" in context2.payload.installation && context2.payload.installation?.account?.name) {
return context2.payload.installation?.account?.name;
}
return context2.payload.sender?.login || _CommentHandler.HEADER_NAME;
}
async _createMetadataContent(context2, metadata) {
const jsonPretty = sanitizeMetadata(metadata);
const instigatorName = this._getInstigatorName(context2);
const runUrl = PluginRuntimeInfo.getInstance().runUrl;
const version = await PluginRuntimeInfo.getInstance().version;
const callingFnName = metadata.caller || "anonymous";
return {
header: `<!-- ${_CommentHandler.HEADER_NAME} - ${callingFnName} - ${version} - @${instigatorName} - ${runUrl}`,
jsonPretty
};
}
_formatMetadataContent(logMessage, header, jsonPretty) {
const metadataVisible = ["```json", jsonPretty, "```"].join("\n");
const metadataHidden = [header, jsonPretty, "-->"].join("\n");
return logMessage?.type === "fatal" ? [metadataVisible, metadataHidden].join("\n") : metadataHidden;
}
async _createCommentBody(context2, message, options) {
const { metadata, logMessage } = await this._processMessage(context2, message);
const { header, jsonPretty } = await this._createMetadataContent(context2, metadata);
const metadataContent = this._formatMetadataContent(logMessage, header, jsonPretty);
return `${options.raw ? logMessage?.raw : logMessage?.diff}
${metadataContent}
`;
}
async postComment(context2, message, options = { updateComment: true, raw: false }) {
const issueContext = this._extractIssueContext(context2);
if (!issueContext) {
context2.logger.info("Cannot post comment: missing issue context in payload");
return null;
}
const body = await this._createCommentBody(context2, message, options);
const { issueNumber, commentId, owner, repo } = issueContext;
const params = { owner, repo, body, issueNumber };
if (options.updateComment) {
if (this._lastCommentId.issueCommentId && !("pull_request" in context2.payload && "comment" in context2.payload)) {
return this._updateIssueComment(context2, params);
}
if (this._lastCommentId.reviewCommentId && "pull_request" in context2.payload && "comment" in context2.payload) {
return this._updateReviewComment(context2, params);
}
}
return this._createNewComment(context2, { ...params, commentId });
}
};
// src/error.ts
import { LogReturn as LogReturn2 } from "@ubiquity-os/ubiquity-os-logger";
function transformError(context2, error) {
let loggerError;
if (error instanceof AggregateError) {
loggerError = context2.logger.error(
error.errors.map((err) => {
if (err instanceof LogReturn2) {
return err.logMessage.raw;
} else if (err instanceof Error) {
return err.message;
} else {
return err;
}
}).join("\n\n"),
{ error }
);
} else if (error instanceof Error || error instanceof LogReturn2) {
loggerError = error;
} else {
loggerError = context2.logger.error(String(error));
}
return loggerError;
}
// src/octokit.ts
import { Octokit } from "@octokit/core";
import { paginateRest } from "@octokit/plugin-paginate-rest";
import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
import { retry } from "@octokit/plugin-retry";
import { throttling } from "@octokit/plugin-throttling";
import { paginateGraphQL } from "@octokit/plugin-paginate-graphql";
var defaultOptions = {
throttle: {
onAbuseLimit: (retryAfter, options, octokit) => {
octokit.log.warn(`Abuse limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`);
return true;
},
onRateLimit: (retryAfter, options, octokit) => {
octokit.log.warn(`Rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`);
return true;
},
onSecondaryRateLimit: (retryAfter, options, octokit) => {
octokit.log.warn(`Secondary rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`);
return true;
}
}
};
var customOctokit = Octokit.plugin(throttling, retry, paginateRest, restEndpointMethods, paginateGraphQL).defaults((instanceOptions) => {
return { ...defaultOptions, ...instanceOptions };
});
// src/signature.ts
async function verifySignature(publicKeyPem, inputs, signature) {
try {
const inputsOrdered = {
stateId: inputs.stateId,
eventName: inputs.eventName,
eventPayload: inputs.eventPayload,
settings: inputs.settings,
authToken: inputs.authToken,
ref: inputs.ref,
command: inputs.command
};
const pemContents = publicKeyPem.replace("-----BEGIN PUBLIC KEY-----", "").replace("-----END PUBLIC KEY-----", "").trim();
const binaryDer = Uint8Array.from(atob(pemContents), (c) => c.charCodeAt(0));
const publicKey = await crypto.subtle.importKey(
"spki",
binaryDer,
{
name: "RSASSA-PKCS1-v1_5",
hash: "SHA-256"
},
true,
["verify"]
);
const signatureArray = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
const dataArray = new TextEncoder().encode(JSON.stringify(inputsOrdered));
return await crypto.subtle.verify("RSASSA-PKCS1-v1_5", publicKey, signatureArray, dataArray);
} catch (error) {
console.error(error);
return false;
}
}
// src/types/input-schema.ts
import { Type as T2 } from "@sinclair/typebox";
// src/types/command.ts
import { Type as T } from "@sinclair/typebox";
var commandCallSchema = T.Union([T.Null(), T.Object({ name: T.String(), parameters: T.Unknown() })]);
// src/types/util.ts
import { Type } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
function jsonType(type) {
return Type.Transform(Type.String()).Decode((value) => {
const parsed = JSON.parse(value);
return Value.Decode(type, Value.Default(type, parsed));
}).Encode((value) => JSON.stringify(value));
}
// src/types/input-schema.ts
var inputSchema = T2.Object({
stateId: T2.String(),
eventName: T2.String(),
eventPayload: jsonType(T2.Record(T2.String(), T2.Any())),
command: jsonType(commandCallSchema),
authToken: T2.String(),
settings: jsonType(T2.Record(T2.String(), T2.Any())),
ref: T2.String(),
signature: T2.String()
});
// src/server.ts
function createPlugin(handler, manifest, options) {
const pluginOptions = getPluginOptions(options);
const app = new Hono();
app.get("/manifest.json", (ctx) => {
return ctx.json(manifest);
});
app.post("/", async function appPost(ctx) {
if (ctx.req.header("content-type") !== "application/json") {
throw new HTTPException(400, { message: "Content-Type must be application/json" });
}
const body = await ctx.req.json();
const inputSchemaErrors = [...Value2.Errors(inputSchema, body)];
if (inputSchemaErrors.length) {
console.dir(inputSchemaErrors, { depth: null });
throw new HTTPException(400, { message: "Invalid body" });
}
const signature = body.signature;
if (!pluginOptions.bypassSignatureVerification && !await verifySignature(pluginOptions.kernelPublicKey, body, signature)) {
throw new HTTPException(400, { message: "Invalid signature" });
}
const inputs = Value2.Decode(inputSchema, body);
let config2;
if (pluginOptions.settingsSchema) {
try {
config2 = Value2.Decode(pluginOptions.settingsSchema, Value2.Default(pluginOptions.settingsSchema, inputs.settings));
} catch (e) {
console.dir(...Value2.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null });
throw e;
}
} else {
config2 = inputs.settings;
}
let env;
const honoEnvironment = honoEnv(ctx);
if (pluginOptions.envSchema) {
try {
env = Value2.Decode(pluginOptions.envSchema, Value2.Default(pluginOptions.envSchema, honoEnvironment));
} catch (e) {
console.dir(...Value2.Errors(pluginOptions.envSchema, honoEnvironment), { depth: null });
throw e;
}
} else {
env = ctx.env;
}
const workerName = new URL(inputs.ref).hostname.split(".")[0];
PluginRuntimeInfo.getInstance({ ...env, CLOUDFLARE_WORKER_NAME: workerName });
let command = null;
if (inputs.command && pluginOptions.commandSchema) {
try {
command = Value2.Decode(pluginOptions.commandSchema, Value2.Default(pluginOptions.commandSchema, inputs.command));
} catch (e) {
console.log(...Value2.Errors(pluginOptions.commandSchema, inputs.command), { depth: null });
throw e;
}
} else if (inputs.command) {
command = inputs.command;
}
const context2 = {
eventName: inputs.eventName,
payload: inputs.eventPayload,
command,
octokit: new customOctokit({ auth: inputs.authToken }),
config: config2,
env,
logger: new Logs(pluginOptions.logLevel),
commentHandler: new CommentHandler()
};
try {
const result = await handler(context2);
return ctx.json({ stateId: inputs.stateId, output: result ?? {} });
} catch (error) {
console.error(error);
const loggerError = transformError(context2, error);
if (pluginOptions.postCommentOnError && loggerError) {
await context2.commentHandler.postComment(context2, loggerError);
}
throw new HTTPException(500, { message: "Unexpected error" });
}
});
return app;
}
// src/actions.ts
import * as core from "@actions/core";
import * as github2 from "@actions/github";
import { Value as Value3 } from "@sinclair/typebox/value";
import { LogReturn as LogReturn3, Logs as Logs2 } from "@ubiquity-os/ubiquity-os-logger";
import { config } from "dotenv";
config();
async function createActionsPlugin(handler, options) {
const pluginOptions = getPluginOptions(options);
const pluginGithubToken = process.env.PLUGIN_GITHUB_TOKEN;
if (!pluginGithubToken) {
core.setFailed("Error: PLUGIN_GITHUB_TOKEN env is not set");
return;
}
const body = github2.context.payload.inputs;
const inputSchemaErrors = [...Value3.Errors(inputSchema, body)];
if (inputSchemaErrors.length) {
console.dir(inputSchemaErrors, { depth: null });
core.setFailed(`Error: Invalid inputs payload: ${inputSchemaErrors.map((o) => o.message).join(", ")}`);
return;
}
const signature = body.signature;
if (!pluginOptions.bypassSignatureVerification && !await verifySignature(pluginOptions.kernelPublicKey, body, signature)) {
core.setFailed(`Error: Invalid signature`);
return;
}
const inputs = Value3.Decode(inputSchema, body);
let config2;
if (pluginOptions.settingsSchema) {
try {
config2 = Value3.Decode(pluginOptions.settingsSchema, Value3.Default(pluginOptions.settingsSchema, inputs.settings));
} catch (e) {
console.dir(...Value3.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null });
core.setFailed(`Error: Invalid settings provided.`);
throw e;
}
} else {
config2 = inputs.settings;
}
let env;
if (pluginOptions.envSchema) {
try {
env = Value3.Decode(pluginOptions.envSchema, Value3.Default(pluginOptions.envSchema, process.env));
} catch (e) {
console.dir(...Value3.Errors(pluginOptions.envSchema, process.env), { depth: null });
core.setFailed(`Error: Invalid environment provided.`);
throw e;
}
} else {
env = process.env;
}
let command = null;
if (inputs.command && pluginOptions.commandSchema) {
try {
command = Value3.Decode(pluginOptions.commandSchema, Value3.Default(pluginOptions.commandSchema, inputs.command));
} catch (e) {
console.dir(...Value3.Errors(pluginOptions.commandSchema, inputs.command), { depth: null });
throw e;
}
} else if (inputs.command) {
command = inputs.command;
}
const context2 = {
eventName: inputs.eventName,
payload: inputs.eventPayload,
command,
octokit: new customOctokit({ auth: inputs.authToken }),
config: config2,
env,
logger: new Logs2(pluginOptions.logLevel),
commentHandler: new CommentHandler()
};
try {
const result = await handler(context2);
core.setOutput("result", result);
await returnDataToKernel(pluginGithubToken, inputs.stateId, result);
} catch (error) {
console.error(error);
const loggerError = transformError(context2, error);
if (loggerError instanceof LogReturn3) {
core.setFailed(loggerError.logMessage.diff);
} else if (loggerError instanceof Error) {
core.setFailed(loggerError);
}
if (pluginOptions.postCommentOnError && loggerError) {
await context2.commentHandler.postComment(context2, loggerError);
}
}
}
async function returnDataToKernel(repoToken, stateId, output) {
const octokit = new customOctokit({ auth: repoToken });
await octokit.rest.repos.createDispatchEvent({
owner: github2.context.repo.owner,
repo: github2.context.repo.repo,
event_type: "return-data-to-ubiquity-os-kernel",
client_payload: {
state_id: stateId,
output: output ? JSON.stringify(output) : null
}
});
}
export {
CommentHandler,
createActionsPlugin,
createPlugin
};