Skip to content

Commit

Permalink
Merge pull request #66 from ubiquity-os/development
Browse files Browse the repository at this point in the history
Merge development into main
  • Loading branch information
gentlementlegen authored Feb 7, 2025
2 parents 8be1dd1 + 5addd77 commit b1f42b1
Show file tree
Hide file tree
Showing 7 changed files with 299 additions and 105 deletions.
9 changes: 6 additions & 3 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { Value } from "@sinclair/typebox/value";
import { LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger";
import { config } from "dotenv";
import { postComment } from "./comment";
import { CommentHandler } from "./comment";
import { Context } from "./context";
import { customOctokit } from "./octokit";
import { verifySignature } from "./signature";
Expand All @@ -30,7 +30,7 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TCo
const inputSchemaErrors = [...Value.Errors(inputSchema, body)];
if (inputSchemaErrors.length) {
console.dir(inputSchemaErrors, { depth: null });
core.setFailed(`Error: Invalid inputs payload: ${inputSchemaErrors.join(",")}`);
core.setFailed(`Error: Invalid inputs payload: ${inputSchemaErrors.map((o) => o.message).join(", ")}`);
return;
}
const signature = body.signature;
Expand All @@ -46,6 +46,7 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TCo
config = Value.Decode(pluginOptions.settingsSchema, Value.Default(pluginOptions.settingsSchema, inputs.settings));
} catch (e) {
console.dir(...Value.Errors(pluginOptions.settingsSchema, inputs.settings), { depth: null });
core.setFailed(`Error: Invalid settings provided.`);
throw e;
}
} else {
Expand All @@ -58,6 +59,7 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TCo
env = Value.Decode(pluginOptions.envSchema, Value.Default(pluginOptions.envSchema, process.env));
} catch (e) {
console.dir(...Value.Errors(pluginOptions.envSchema, process.env), { depth: null });
core.setFailed(`Error: Invalid environment provided.`);
throw e;
}
} else {
Expand All @@ -84,6 +86,7 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TCo
config: config,
env: env,
logger: new Logs(pluginOptions.logLevel),
commentHandler: new CommentHandler(),
};

try {
Expand All @@ -106,7 +109,7 @@ export async function createActionsPlugin<TConfig = unknown, TEnv = unknown, TCo
}

if (pluginOptions.postCommentOnError && loggerError) {
await postComment(context, loggerError);
await context.commentHandler.postComment(context, loggerError);
}
}
}
Expand Down
267 changes: 173 additions & 94 deletions src/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import { Context } from "./context";
import { PluginRuntimeInfo } from "./helpers/runtime-info";
import { sanitizeMetadata } from "./util";

const HEADER_NAME = "UbiquityOS";

export interface CommentOptions {
/*
* Should the comment be posted as send within the log, without adding any sort of formatting.
Expand All @@ -17,117 +15,198 @@ export interface CommentOptions {
updateComment?: boolean;
}

export type PostedGithubComment =
| RestEndpointMethodTypes["issues"]["updateComment"]["response"]["data"]
| RestEndpointMethodTypes["issues"]["createComment"]["response"]["data"]
| RestEndpointMethodTypes["pulls"]["createReplyForReviewComment"]["response"]["data"];

type WithIssueNumber<T> = T & {
issueNumber: number;
};

export type PostComment = {
(
interface IssueContext {
issueNumber: number;
commentId?: number;
owner: string;
repo: string;
}

export class CommentHandler {
public static readonly HEADER_NAME = "UbiquityOS";
private _lastCommentId = { reviewCommentId: null as number | null, issueCommentId: null as number | null };

async _updateIssueComment(
context: Context,
message: LogReturn | Error,
options?: CommentOptions
): Promise<WithIssueNumber<
RestEndpointMethodTypes["issues"]["updateComment"]["response"]["data"] | RestEndpointMethodTypes["issues"]["createComment"]["response"]["data"]
> | null>;
lastCommentId?: number;
};
params: { owner: string; repo: string; body: string; issueNumber: number }
): Promise<WithIssueNumber<PostedGithubComment>> {
if (!this._lastCommentId.issueCommentId) {
throw context.logger.error("issueCommentId is missing");
}
const commentData = await context.octokit.rest.issues.updateComment({
owner: params.owner,
repo: params.repo,
comment_id: this._lastCommentId.issueCommentId,
body: params.body,
});
return { ...commentData.data, issueNumber: params.issueNumber };
}

/**
* Posts a comment on a GitHub issue if the issue exists in the context payload, embedding structured metadata to it.
*/
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.");
return null;
async _updateReviewComment(
context: Context,
params: { owner: string; repo: string; body: string; issueNumber: number }
): Promise<WithIssueNumber<PostedGithubComment>> {
if (!this._lastCommentId.reviewCommentId) {
throw context.logger.error("reviewCommentId is missing");
}
const commentData = await context.octokit.rest.pulls.updateReviewComment({
owner: params.owner,
repo: params.repo,
comment_id: this._lastCommentId.reviewCommentId,
body: params.body,
});
return { ...commentData.data, issueNumber: params.issueNumber };
}

if ("repository" in context.payload && context.payload.repository?.owner?.login) {
const body = await createStructuredMetadataWithMessage(context, message, options);
if (options.updateComment && postComment.lastCommentId) {
const commentData = await context.octokit.rest.issues.updateComment({
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
comment_id: postComment.lastCommentId,
body: body,
});
return { ...commentData.data, issueNumber };
} 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,
async _createNewComment(
context: Context,
params: { owner: string; repo: string; body: string; issueNumber: number; commentId?: number }
): Promise<WithIssueNumber<PostedGithubComment>> {
if (params.commentId) {
const commentData = await context.octokit.rest.pulls.createReplyForReviewComment({
owner: params.owner,
repo: params.repo,
pull_number: params.issueNumber,
comment_id: params.commentId,
body: params.body,
});
postComment.lastCommentId = commentData.data.id;
return { ...commentData.data, issueNumber };
this._lastCommentId.reviewCommentId = commentData.data.id;
return { ...commentData.data, issueNumber: params.issueNumber };
}
} else {
context.logger.info("Cannot post comment because repository is not found in the payload.", { payload: context.payload });

const commentData = await context.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 };
}
return null;
};

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],
_getIssueNumber(context: Context): number | undefined {
if ("issue" in context.payload) return context.payload.issue.number;
if ("pull_request" in context.payload) return context.payload.pull_request.number;
if ("discussion" in context.payload) return context.payload.discussion.number;
return undefined;
}

_getCommentId(context: Context): number | undefined {
return "pull_request" in context.payload && "comment" in context.payload ? context.payload.comment.id : undefined;
}

_extractIssueContext(context: Context): IssueContext | null {
if (!("repository" in context.payload) || !context.payload.repository?.owner?.login) {
return null;
}

const issueNumber = this._getIssueNumber(context);
if (!issueNumber) return null;

return {
issueNumber,
commentId: this._getCommentId(context),
owner: context.payload.repository.owner.login,
repo: context.payload.repository.name,
};
logMessage = message.logMessage;
callingFnName = metadata.caller;
} else {
metadata = { ...message };
}
const jsonPretty = sanitizeMetadata(metadata);

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;
async _processMessage(context: Context, message: LogReturn | Error) {
if (message instanceof Error) {
const metadata = {
message: message.message,
name: message.name,
stack: message.stack,
};
return { metadata, logMessage: context.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(context: Context): string {
if (
"installation" in context.payload &&
context.payload.installation &&
"account" in context.payload.installation &&
context.payload.installation?.account?.name
) {
return context.payload.installation?.account?.name;
}
return context.payload.sender?.login || CommentHandler.HEADER_NAME;
}

async _createMetadataContent(context: Context, metadata: Metadata) {
const jsonPretty = sanitizeMetadata(metadata);
const instigatorName = this._getInstigatorName(context);
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,
};
}
const runUrl = PluginRuntimeInfo.getInstance().runUrl;
const version = await PluginRuntimeInfo.getInstance().version;

const ubiquityMetadataHeader = `<!-- ${HEADER_NAME} - ${callingFnName} - ${version} - @${instigatorName} - ${runUrl}`;
_formatMetadataContent(logMessage: LogReturn["logMessage"], header: string, jsonPretty: string): string {
const metadataVisible = ["```json", jsonPretty, "```"].join("\n");
const metadataHidden = [header, jsonPretty, "-->"].join("\n");

let metadataSerialized: string;
const metadataSerializedVisible = ["```json", jsonPretty, "```"].join("\n");
const metadataSerializedHidden = [ubiquityMetadataHeader, jsonPretty, "-->"].join("\n");
return logMessage?.type === "fatal" ? [metadataVisible, metadataHidden].join("\n") : metadataHidden;
}

async _createCommentBody(context: Context, message: LogReturn | Error, options: CommentOptions): Promise<string> {
const { metadata, logMessage } = await this._processMessage(context, message);
const { header, jsonPretty } = await this._createMetadataContent(context, metadata);
const metadataContent = this._formatMetadataContent(logMessage, header, jsonPretty);

if (logMessage?.type === "fatal") {
// if the log message is fatal, then we want to show the metadata
metadataSerialized = [metadataSerializedVisible, metadataSerializedHidden].join("\n");
} else {
// otherwise we want to hide it
metadataSerialized = metadataSerializedHidden;
return `${options.raw ? logMessage?.raw : logMessage?.diff}\n\n${metadataContent}\n`;
}

// Add carriage returns to avoid any formatting issue
return `${options.raw ? logMessage?.raw : logMessage?.diff}\n\n${metadataSerialized}\n`;
async postComment(
context: Context,
message: LogReturn | Error,
options: CommentOptions = { updateComment: true, raw: false }
): Promise<WithIssueNumber<PostedGithubComment> | null> {
const issueContext = this._extractIssueContext(context);
if (!issueContext) {
context.logger.info("Cannot post comment: missing issue context in payload");
return null;
}

const body = await this._createCommentBody(context, message, options);
const { issueNumber, commentId, owner, repo } = issueContext;
const params = { owner, repo, body, issueNumber };

if (options.updateComment) {
if (this._lastCommentId.issueCommentId && !("pull_request" in context.payload && "comment" in context.payload)) {
return this._updateIssueComment(context, params);
}

if (this._lastCommentId.reviewCommentId && "pull_request" in context.payload && "comment" in context.payload) {
return this._updateReviewComment(context, params);
}
}

return this._createNewComment(context, { ...params, commentId });
}
}
2 changes: 2 additions & 0 deletions src/context.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks";
import { Logs } from "@ubiquity-os/ubiquity-os-logger";
import { CommentHandler } from "./comment";
import { customOctokit } from "./octokit";

export interface Context<TConfig = unknown, TEnv = unknown, TCommand = unknown, TSupportedEvents extends WebhookEventName = WebhookEventName> {
Expand All @@ -12,4 +13,5 @@ export interface Context<TConfig = unknown, TEnv = unknown, TCommand = unknown,
config: TConfig;
env: TEnv;
logger: Logs;
commentHandler: CommentHandler;
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { createPlugin } from "./server";
export { createActionsPlugin } from "./actions";
export { postComment } from "./comment";
export { CommentHandler } from "./comment";
export type { Context } from "./context";
Loading

0 comments on commit b1f42b1

Please sign in to comment.