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

fix: metadata is properly built #42

Merged
merged 20 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
105e4d2
chore(WIP): metadata is build from the Logs / Error payload
gentlementlegen Dec 19, 2024
cb81852
chore(WIP): display relevant info in header
gentlementlegen Dec 19, 2024
c8f45f0
feat: runtime info populated for worker and node environments
gentlementlegen Dec 20, 2024
a882e68
chore: added raw option for comment posting
gentlementlegen Dec 20, 2024
f62c491
chore: test compilation
gentlementlegen Dec 20, 2024
64cf2f8
chore: test compilation
gentlementlegen Dec 20, 2024
e9b025d
docs: update README.md
gentlementlegen Dec 20, 2024
41c7ca7
docs: update env variable names
gentlementlegen Dec 20, 2024
01d7dab
docs: update env variable names
gentlementlegen Dec 20, 2024
80f5f32
chore: update cf version
gentlementlegen Dec 20, 2024
6d668b8
chore: fixed name and version of the package.json
gentlementlegen Dec 20, 2024
03066be
chore: fixed runtime info missing id to avoid crash
gentlementlegen Dec 28, 2024
e6223ff
chore: update README.md
gentlementlegen Dec 29, 2024
cb14447
test: fixed SDK tests for metadata
gentlementlegen Dec 29, 2024
a06e498
chore: refactored createStructuredMetadataWithMessage
gentlementlegen Jan 2, 2025
9174d44
chore: merged if statements for Error and LogReturn instances
gentlementlegen Jan 2, 2025
bceb92b
chore: simplify name for Cloudflare worker name fetch
gentlementlegen Jan 2, 2025
6af08b7
chore: moved the instigator login name last in the metadata header
gentlementlegen Jan 2, 2025
9132c32
test: fixed metadata header test
gentlementlegen Jan 2, 2025
8e6671b
chore: fixed hostname split to retrieve the worker name
gentlementlegen Jan 5, 2025
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
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";
0x4007 marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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) {
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
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;
}
gentlementlegen marked this conversation as resolved.
Show resolved Hide resolved
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
Loading