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

Merge development into main #44

Merged
merged 27 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
81c706c
feat!: comments can be reused on posting
gentlementlegen Dec 31, 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
f507eb1
Merge pull request #42 from gentlementlegen/fix/metadata
gentlementlegen Jan 6, 2025
f56e5e4
chore: merge develop
gentlementlegen Jan 6, 2025
76cca90
test: added tests for comment reuse
gentlementlegen Jan 6, 2025
fa326f6
test: fixed throw error test
gentlementlegen Jan 6, 2025
02819b9
Merge pull request #43 from gentlementlegen/feat/comment-reuse
gentlementlegen Jan 6, 2025
f6aeac0
chore: update .cspell.json
gentlementlegen Jan 6, 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
2 changes: 1 addition & 1 deletion .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"ignorePaths": ["**/*.json", "**/*.css", "node_modules", "**/*.log"],
"useGitignore": true,
"language": "en",
"words": ["dataurl", "devpool", "outdir", "servedir", "typebox"],
"words": ["dataurl", "devpool", "outdir", "servedir", "typebox", "gentlementlegen", "workerd"],
"dictionaries": ["typescript", "node", "software-terms"],
"import": ["@cspell/dict-typescript/cspell-ext.json", "@cspell/dict-node/cspell-ext.json", "@cspell/dict-software-terms"],
"ignoreRegExpList": ["[0-9a-fA-F]{6}"]
Expand Down
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
113 changes: 92 additions & 21 deletions src/comment.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,106 @@
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";

export interface CommentOptions {
/*
* Should the comment be posted as send within the log, without adding any sort of formatting.
*/
raw?: boolean;
/*
* Should the previously posted comment be reused instead of posting a new comment.
*/
updateComment?: boolean;
}

export type PostComment = {
(context: Context, message: LogReturn | Error, options?: CommentOptions): Promise<void>;
lastCommentId?: number;
};

/**
* 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);
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"),
});
export const postComment: PostComment = async function (
context: Context,
message: LogReturn | Error,
options: CommentOptions = { updateComment: true, 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");
context.logger.info("Cannot post comment because issue is not found in the payload.");
return;
}
}

function createStructuredMetadata(className: string | undefined, logReturn: LogReturn) {
const logMessage = logReturn.logMessage;
const metadata = logReturn.metadata;
if ("repository" in context.payload && context.payload.repository?.owner?.login) {
const body = await createStructuredMetadataWithMessage(context, message, options);
if (options.updateComment && postComment.lastCommentId) {
await context.octokit.rest.issues.updateComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
comment_id: postComment.lastCommentId,
body: body,
});
} else {
const commentData = await context.octokit.rest.issues.createComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
issue_number: issueNumber,
body: body,
});
postComment.lastCommentId = commentData.data.id;
}
} else {
context.logger.info("Cannot post comment because repository is not found in the payload.", { payload: context.payload });
}
};

async function createStructuredMetadataWithMessage(context: Context, message: LogReturn | Error, options: CommentOptions) {
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 +115,5 @@ function createStructuredMetadata(className: string | undefined, logReturn: LogR
}

// Add carriage returns to avoid any formatting issue
return `\n${metadataSerialized}\n`;
return `${options.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
59 changes: 59 additions & 0 deletions tests/comment.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it, jest } from "@jest/globals";
import { Logs } from "@ubiquity-os/ubiquity-os-logger";
import { Context, postComment } from "../src";

describe("Post comment tests", () => {
it("Should reuse a message if the reuse option is true", async () => {
const logger = new Logs("debug");
const createComment = jest.fn(() => ({
data: {
id: 1234,
},
}));
const updateComment = jest.fn(() => ({
data: {
id: 1234,
},
}));
jest.unstable_mockModule("@octokit/core", () => ({
Octokit: jest.fn(() => ({
rest: {
issues: {
createComment,
updateComment,
},
},
})),
}));
const { Octokit } = await import("@octokit/core");
const ctx = {
payload: {
issue: {
number: 1,
},
repository: {
owner: {
login: "ubiquity-os",
},
name: "plugin-sdk",
},
},
logger,
octokit: new Octokit(),
} as unknown as Context;
await postComment(ctx, logger.ok("test"), { updateComment: true });
await postComment(ctx, logger.ok("test 2"), { updateComment: true });
expect(createComment).toHaveBeenCalledWith({
owner: "ubiquity-os",
repo: "plugin-sdk",
issue_number: 1,
body: expect.anything(),
});
expect(updateComment).toHaveBeenCalledWith({
owner: "ubiquity-os",
repo: "plugin-sdk",
comment_id: 1234,
body: expect.anything(),
});
});
});
Loading
Loading