Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 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 Down Expand Up @@ -86,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 @@ -108,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";
7 changes: 4 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { LogReturn, Logs } from "@ubiquity-os/ubiquity-os-logger";
import { Hono } from "hono";
import { env as honoEnv } from "hono/adapter";
import { HTTPException } from "hono/http-exception";
import { postComment } from "./comment";
import { CommentHandler } from "./comment";
import { Context } from "./context";
import { PluginRuntimeInfo } from "./helpers/runtime-info";
import { customOctokit } from "./octokit";
Expand All @@ -27,7 +27,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TCommand = unkno
return ctx.json(manifest);
});

app.post("/", async (ctx) => {
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" });
}
Expand Down Expand Up @@ -92,6 +92,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TCommand = unkno
config: config,
env: env,
logger: new Logs(pluginOptions.logLevel),
commentHandler: new CommentHandler(),
};

try {
Expand All @@ -108,7 +109,7 @@ export function createPlugin<TConfig = unknown, TEnv = unknown, TCommand = unkno
}

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

throw new HTTPException(500, { message: "Unexpected error" });
Expand Down
Loading