Skip to content

Commit

Permalink
Merge pull request #42 from gentlementlegen/fix/metadata
Browse files Browse the repository at this point in the history
fix: metadata is properly built
  • Loading branch information
gentlementlegen authored Jan 6, 2025
2 parents cdbdd62 + 8e6671b commit f507eb1
Show file tree
Hide file tree
Showing 8 changed files with 150 additions and 48 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ The `createActionsPlugin` function allows users to create plugins that will be a

The `createPlugin` function enables users to create a plugin that will run on Cloudflare Workers environment.

### `postComment`

The `postComment` function enables users to easily post a comment to an issue, a pull-request, or a pull request review thread.

## Getting Started

To set up the project locally, `bun` is the preferred package manager.
Expand Down
Binary file modified bun.lockb
Binary file not shown.
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ubiquity-os/plugin-sdk",
"version": "0.0.0",
"version": "1.1.1",
"description": "SDK for plugin support.",
"author": "Ubiquity DAO",
"license": "MIT",
Expand Down Expand Up @@ -110,7 +110,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-check-file": "^2.8.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-sonarjs": "^2.0.4",
"eslint-plugin-sonarjs": "^3.0.1",
"husky": "^9.0.11",
"jest": "^29.7.0",
"jest-junit": "^16.0.0",
Expand All @@ -129,7 +129,7 @@
},
"lint-staged": {
"*.ts": [
"bun prettier --write",
"prettier --write",
"eslint --fix"
],
"src/**.{ts,json}": [
Expand Down
22 changes: 3 additions & 19 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,14 @@ import { Type as T } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import { LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger";
import { config } from "dotenv";
import { postComment } from "./comment";
import { Context } from "./context";
import { customOctokit } from "./octokit";
import { verifySignature } from "./signature";
import { commandCallSchema } from "./types/command";
import { HandlerReturn } from "./types/sdk";
import { jsonType } from "./types/util";
import { getPluginOptions, Options, sanitizeMetadata } from "./util";
import { getPluginOptions, Options } from "./util";

config();

Expand Down Expand Up @@ -120,28 +121,11 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TCo
}

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

async function postErrorComment(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 error 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: HandlerReturn) {
const octokit = new customOctokit({ auth: repoToken });
await octokit.rest.repos.createDispatchEvent({
Expand Down
73 changes: 57 additions & 16 deletions src/comment.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,76 @@
import { LogReturn, Metadata } from "@ubiquity-os/ubiquity-os-logger";
import { Context } from "./context";
import { LogReturn } from "@ubiquity-os/ubiquity-os-logger";
import { PluginRuntimeInfo } from "./helpers/runtime-info";
import { sanitizeMetadata } from "./util";

const HEADER_NAME = "Ubiquity";
const HEADER_NAME = "UbiquityOS";

/**
* Posts a comment on a GitHub issue if the issue exists in the context payload, embedding structured metadata to it.
*/
export async function postComment(context: Context, message: LogReturn) {
if ("issue" in context.payload && context.payload.repository?.owner?.login) {
const metadata = createStructuredMetadata(message.metadata?.name, message);
export async function postComment(context: Context, message: LogReturn | Error, raw = false) {
let issueNumber;

if ("issue" in context.payload) {
issueNumber = context.payload.issue.number;
} else if ("pull_request" in context.payload) {
issueNumber = context.payload.pull_request.number;
} else if ("discussion" in context.payload) {
issueNumber = context.payload.discussion.number;
} else {
context.logger.info("Cannot post comment because issue is not found in the payload.");
return;
}

if ("repository" in context.payload && context.payload.repository?.owner?.login) {
const body = await createStructuredMetadataWithMessage(context, message, raw);
await context.octokit.rest.issues.createComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
issue_number: context.payload.issue.number,
body: [message.logMessage.diff, metadata].join("\n"),
issue_number: issueNumber,
body: body,
});
} else {
context.logger.info("Cannot post comment because issue is not found in the payload");
context.logger.info("Cannot post comment because repository is not found in the payload.", { payload: context.payload });
}
}

function createStructuredMetadata(className: string | undefined, logReturn: LogReturn) {
const logMessage = logReturn.logMessage;
const metadata = logReturn.metadata;
async function createStructuredMetadataWithMessage(context: Context, message: LogReturn | Error, raw = false) {
let logMessage;
let callingFnName;
let instigatorName;
let metadata: Metadata;

if (message instanceof Error) {
metadata = {
message: message.message,
name: message.name,
stack: message.stack,
};
callingFnName = message.stack?.split("\n")[2]?.match(/at (\S+)/)?.[1] ?? "anonymous";
logMessage = context.logger.error(message.message).logMessage;
} else if (message.metadata) {
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],
};
logMessage = message.logMessage;
callingFnName = metadata.caller;
} else {
metadata = { ...message };
}
const jsonPretty = sanitizeMetadata(metadata);
const stack = logReturn.metadata?.stack;
const stackLine = (Array.isArray(stack) ? stack.join("\n") : stack)?.split("\n")[2] ?? "";
const caller = stackLine.match(/at (\S+)/)?.[1] ?? "";
const ubiquityMetadataHeader = `<!-- ${HEADER_NAME} - ${className} - ${caller} - ${metadata?.revision}`;

if ("installation" in context.payload && context.payload.installation && "account" in context.payload.installation) {
instigatorName = context.payload.installation?.account?.name;
} else {
instigatorName = context.payload.sender?.login || HEADER_NAME;
}
const runUrl = PluginRuntimeInfo.getInstance().runUrl;
const version = await PluginRuntimeInfo.getInstance().version;

const ubiquityMetadataHeader = `<!-- ${HEADER_NAME} - ${runUrl} - ${callingFnName} - ${version} - @${instigatorName}`;

let metadataSerialized: string;
const metadataSerializedVisible = ["```json", jsonPretty, "```"].join("\n");
Expand All @@ -44,5 +85,5 @@ function createStructuredMetadata(className: string | undefined, logReturn: LogR
}

// Add carriage returns to avoid any formatting issue
return `\n${metadataSerialized}\n`;
return `${raw ? logMessage?.raw : logMessage?.diff}\n\n${metadataSerialized}\n`;
}
47 changes: 47 additions & 0 deletions src/helpers/runtime-info.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import github from "@actions/github";
import { getRuntimeKey } from "hono/adapter";

export abstract class PluginRuntimeInfo {
private static _instance: PluginRuntimeInfo | null = null;
protected _env: Record<string, unknown> = {};

protected constructor(env?: Record<string, string>) {
if (env) {
this._env = env;
}
}

public static getInstance(env?: Record<string, string>) {
if (!PluginRuntimeInfo._instance) {
PluginRuntimeInfo._instance = getRuntimeKey() === "workerd" ? new CfRuntimeInfo(env) : new NodeRuntimeInfo(env);
}
return PluginRuntimeInfo._instance;
}

public abstract get version(): Promise<string>;
public abstract get runUrl(): string;
}

export class CfRuntimeInfo extends PluginRuntimeInfo {
public get version(): Promise<string> {
// See also https://developers.cloudflare.com/workers/runtime-apis/bindings/version-metadata/
return Promise.resolve((this._env.CLOUDFLARE_VERSION_METADATA as { id: string })?.id ?? "CLOUDFLARE_VERSION_METADATA");
}
public get runUrl(): string {
const accountId = this._env.CLOUDFLARE_ACCOUNT_ID ?? "<missing-cloudflare-account-id>";
const workerName = this._env.CLOUDFLARE_WORKER_NAME;
const toTime = Date.now() + 60000;
const fromTime = Date.now() - 60000;
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}`;
}
}

export class NodeRuntimeInfo extends PluginRuntimeInfo {
public get version() {
return Promise.resolve(github.context.sha);
}
public get runUrl() {
return github.context.payload.repository ? `${github.context.payload.repository?.html_url}/actions/runs/${github.context.runId}` : "http://localhost";
}
}
10 changes: 6 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { env as honoEnv } from "hono/adapter";
import { HTTPException } from "hono/http-exception";
import { postComment } from "./comment";
import { Context } from "./context";
import { PluginRuntimeInfo } from "./helpers/runtime-info";
import { customOctokit } from "./octokit";
import { verifySignature } from "./signature";
import { Manifest } from "./types/manifest";
Expand Down Expand Up @@ -79,6 +80,9 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TCommand = unkno
env = ctx.env as TEnv;
}

const workerName = new URL(inputs.ref).hostname.split(".")[0];
PluginRuntimeInfo.getInstance({ ...env, CLOUDFLARE_WORKER_NAME: workerName });

let command: TCommand | null = null;
if (inputs.command && pluginOptions.commandSchema) {
try {
Expand Down Expand Up @@ -107,10 +111,8 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TCommand = unkno
} 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) {
let loggerError: LogReturn | Error | null;
if (error instanceof Error || error instanceof LogReturn) {
loggerError = error;
} else {
loggerError = context.logger.error(`Error: ${error}`);
Expand Down
36 changes: 30 additions & 6 deletions tests/sdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,18 @@ describe("SDK worker tests", () => {
expect(res.status).toEqual(400);
});
it("Should handle thrown errors", async () => {
jest.unstable_mockModule(githubActionImportPath, () => ({
default: {
context: {
runId: "1",
payload: {
inputs: {},
},
repo: "repo",
sha: "1234",
},
},
}));
const createComment = jest.fn();
server.use(
http.post(
Expand Down Expand Up @@ -187,7 +199,7 @@ describe("SDK worker tests", () => {
issueCommentedEvent.eventPayload,
{ shouldFail: true },
"test",
"main",
"http://localhost:4000",
null
);

Expand All @@ -207,7 +219,7 @@ describe("SDK worker tests", () => {
! test error
\`\`\`
<!-- Ubiquity - undefined - - undefined
<!-- UbiquityOS - http://localhost - handler - 1234 - @gentlementlegen
{
"caller": "handler"
}
Expand All @@ -216,10 +228,18 @@ describe("SDK worker tests", () => {
});
});
it("Should accept correct request", async () => {
const inputs = await getWorkerInputs("stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, { shouldFail: false }, "test", "main", {
name: "test",
parameters: { param1: "test" },
});
const inputs = await getWorkerInputs(
"stateId",
issueCommentedEvent.eventName,
issueCommentedEvent.eventPayload,
{ shouldFail: false },
"test",
"http://localhost:4000",
{
name: "test",
parameters: { param1: "test" },
}
);

const res = await app.request("/", {
headers: {
Expand Down Expand Up @@ -250,12 +270,14 @@ describe("SDK actions tests", () => {
parameters: { param1: "test" },
});
jest.unstable_mockModule(githubActionImportPath, () => ({
default: {},
context: {
runId: "1",
payload: {
inputs: githubInputs,
},
repo: repo,
sha: "1234",
},
}));
const setOutput = jest.fn();
Expand Down Expand Up @@ -308,6 +330,7 @@ describe("SDK actions tests", () => {
const githubInputs = await getWorkflowInputs("stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, {}, "test_token", "main", null);

jest.unstable_mockModule("@actions/github", () => ({
default: {},
context: {
runId: "1",
payload: {
Expand Down Expand Up @@ -344,6 +367,7 @@ describe("SDK actions tests", () => {
const githubInputs = await getWorkflowInputs("stateId", issueCommentedEvent.eventName, issueCommentedEvent.eventPayload, {}, "test_token", "main", null);

jest.unstable_mockModule(githubActionImportPath, () => ({
default: {},
context: {
runId: "1",
payload: {
Expand Down

0 comments on commit f507eb1

Please sign in to comment.