diff --git a/src/handlers/comment-created-callback.ts b/src/handlers/comment-created-callback.ts new file mode 100644 index 0000000..b366f70 --- /dev/null +++ b/src/handlers/comment-created-callback.ts @@ -0,0 +1,41 @@ +import { Context, SupportedEvents } from "../types"; +import { addCommentToIssue } from "./add-comment"; +import { askQuestion } from "./ask-llm"; +import { CallbackResult } from "../types/proxy"; +import { bubbleUpErrorComment } from "../helpers/errors"; + +export async function issueCommentCreatedCallback( + context: Context<"issue_comment.created", SupportedEvents["issue_comment.created"]> +): Promise { + const { + logger, + env: { UBIQUITY_OS_APP_NAME }, + } = context; + const question = context.payload.comment.body; + const slugRegex = new RegExp(`@${UBIQUITY_OS_APP_NAME} `, "gi"); + if (!question.match(slugRegex)) { + return { status: 204, reason: logger.info("Comment does not mention the app. Skipping.").logMessage.raw }; + } + if (context.payload.comment.user?.type === "Bot") { + return { status: 204, reason: logger.info("Comment is from a bot. Skipping.").logMessage.raw }; + } + if (question.replace(slugRegex, "").trim().length === 0) { + return { status: 204, reason: logger.info("Comment is empty. Skipping.").logMessage.raw }; + } + logger.info(`Asking question: ${question}`); + + try { + const response = await askQuestion(context, question); + const { answer, tokenUsage } = response; + if (!answer) { + throw logger.error(`No answer from OpenAI`); + } + logger.info(`Answer: ${answer}`, { tokenUsage }); + const tokens = `\n\n`; + const commentToPost = answer + tokens; + await addCommentToIssue(context, commentToPost); + return { status: 200, reason: logger.info("Comment posted successfully").logMessage.raw }; + } catch (error) { + throw await bubbleUpErrorComment(context, error, false); + } +} diff --git a/src/handlers/comments.ts b/src/handlers/comments.ts index d7e2d0f..b033686 100644 --- a/src/handlers/comments.ts +++ b/src/handlers/comments.ts @@ -1,3 +1,4 @@ +import { logger } from "../helpers/errors"; import { splitKey } from "../helpers/issue"; import { LinkedIssues, SimplifiedComment } from "../types/github-types"; import { StreamlinedComment } from "../types/llm"; @@ -53,7 +54,10 @@ export function createKey(issueUrl: string, issue?: number) { } if (!key) { - throw new Error("Invalid issue url"); + throw logger.error("Invalid issue URL", { + issueUrl, + issueNumber: issue, + }); } if (key.includes("#")) { diff --git a/src/helpers/callback-proxy.ts b/src/helpers/callback-proxy.ts new file mode 100644 index 0000000..a50da5e --- /dev/null +++ b/src/helpers/callback-proxy.ts @@ -0,0 +1,65 @@ +import { issueCommentCreatedCallback } from "../handlers/comment-created-callback"; +import { Context, SupportedEventsU } from "../types"; +import { ProxyCallbacks } from "../types/proxy"; +import { bubbleUpErrorComment } from "./errors"; + +/** + * The `callbacks` object defines an array of callback functions for each supported event type. + * + * Since multiple callbacks might need to be executed for a single event, we store each + * callback in an array. This design allows for extensibility and flexibility, enabling + * us to add more callbacks for a particular event without modifying the core logic. + */ +const callbacks = { + "issue_comment.created": [issueCommentCreatedCallback], +} as ProxyCallbacks; + +/** + * The `proxyCallbacks` function returns a Proxy object that intercepts access to the + * `callbacks` object. This Proxy enables dynamic handling of event callbacks, including: + * + * - **Event Handling:** When an event occurs, the Proxy looks up the corresponding + * callbacks in the `callbacks` object. If no callbacks are found for the event, + * it returns a `skipped` status. + * + * - **Error Handling:** If an error occurs while processing a callback, the Proxy + * logs the error and returns a `failed` status. + * + * The Proxy uses the `get` trap to intercept attempts to access properties on the + * `callbacks` object. This trap allows us to asynchronously execute the appropriate + * callbacks based on the event type, ensuring that the correct context is passed to + * each callback. + */ +export function proxyCallbacks(context: Context): ProxyCallbacks { + return new Proxy(callbacks, { + get(target, prop: SupportedEventsU) { + if (!target[prop]) { + context.logger.info(`No callbacks found for event ${prop}`); + return { status: 204, reason: "skipped" }; + } + return (async () => { + try { + return await Promise.all(target[prop].map((callback) => handleCallback(callback, context))); + } catch (er) { + return { status: 500, reason: (await bubbleUpErrorComment(context, er)).logMessage.raw }; + } + })(); + }, + }); +} + +/** + * Why do we need this wrapper function? + * + * By using a generic `Function` type for the callback parameter, we bypass strict type + * checking temporarily. This allows us to pass a standard `Context` object, which we know + * contains the correct event and payload types, to the callback safely. + * + * We can trust that the `ProxyCallbacks` type has already ensured that each callback function + * matches the expected event and payload types, so this function provides a safe and + * flexible way to handle callbacks without introducing type or logic errors. + */ +// eslint-disable-next-line @typescript-eslint/ban-types +export function handleCallback(callback: Function, context: Context) { + return callback(context); +} diff --git a/src/helpers/errors.ts b/src/helpers/errors.ts new file mode 100644 index 0000000..84da985 --- /dev/null +++ b/src/helpers/errors.ts @@ -0,0 +1,31 @@ +import { LogReturn, Logs } from "@ubiquity-dao/ubiquibot-logger"; +import { Context } from "../types"; +import { addCommentToIssue } from "../handlers/add-comment"; +export const logger = new Logs("debug"); + +export function handleUncaughtError(error: unknown) { + logger.error("An uncaught error occurred", { err: error }); + const status = 500; + return new Response(JSON.stringify({ error }), { status: status, headers: { "content-type": "application/json" } }); +} + +export function sanitizeMetadata(obj: LogReturn["metadata"]): string { + return JSON.stringify(obj, null, 2).replace(//g, ">").replace(/--/g, "--"); +} + +export async function bubbleUpErrorComment(context: Context, err: unknown, post = true): Promise { + let errorMessage; + if (err instanceof LogReturn) { + errorMessage = err; + } else if (err instanceof Error) { + errorMessage = context.logger.error(err.message, { stack: err.stack }); + } else { + errorMessage = context.logger.error("An error occurred", { err }); + } + + if (post) { + await addCommentToIssue(context, `${errorMessage?.logMessage.diff}\n`); + } + + return errorMessage; +} diff --git a/src/helpers/issue-fetching.ts b/src/helpers/issue-fetching.ts index fc1df5a..744e74e 100644 --- a/src/helpers/issue-fetching.ts +++ b/src/helpers/issue-fetching.ts @@ -3,6 +3,7 @@ import { Context } from "../types"; import { IssueWithUser, SimplifiedComment, User } from "../types/github-types"; import { FetchParams, Issue, Comments, LinkedIssues } from "../types/github-types"; import { StreamlinedComment } from "../types/llm"; +import { logger } from "./errors"; import { dedupeStreamlinedComments, fetchCodeLinkedFromIssue, @@ -41,11 +42,11 @@ export async function fetchLinkedIssues(params: FetchParams) { return { streamlinedComments: {}, linkedIssues: [], specAndBodies: {}, seen: new Set() }; } if (!issue.body || !issue.html_url) { - throw new Error("Issue body or URL not found"); + throw logger.error("Issue body or URL not found", { issueUrl: issue.html_url }); } if (!params.owner || !params.repo) { - throw new Error("Owner, repo, or issue number not found"); + throw logger.error("Owner or repo not found"); } const issueKey = createKey(issue.html_url); const [owner, repo, issueNumber] = splitKey(issueKey); diff --git a/src/helpers/issue.ts b/src/helpers/issue.ts index 68fcda7..0effb4a 100644 --- a/src/helpers/issue.ts +++ b/src/helpers/issue.ts @@ -2,6 +2,7 @@ import { createKey } from "../handlers/comments"; import { FetchedCodes, FetchParams, LinkedIssues } from "../types/github-types"; import { StreamlinedComment } from "../types/llm"; import { Context } from "../types/context"; // Import Context type +import { logger } from "./errors"; /** * Removes duplicate streamlined comments based on their body content. @@ -239,7 +240,7 @@ export async function pullReadmeFromRepoForIssue(params: FetchParams): Promise`; - commentToPost = answer + tokens; - } catch (err) { - let errorMessage; - if (err instanceof LogReturn) { - errorMessage = err; - } else if (err instanceof Error) { - errorMessage = context.logger.error(err.message, { error: err, stack: err.stack }); - } else { - errorMessage = context.logger.error("An error occurred", { err }); - } - commentToPost = `${errorMessage?.logMessage.diff}\n`; - } - await addCommentToIssue(context, commentToPost); -} - -function sanitizeMetadata(obj: LogReturn["metadata"]): string { - return JSON.stringify(obj, null, 2).replace(//g, ">").replace(/--/g, "--"); + return proxyCallbacks(context)[context.eventName]; } diff --git a/src/types/proxy.ts b/src/types/proxy.ts new file mode 100644 index 0000000..b770913 --- /dev/null +++ b/src/types/proxy.ts @@ -0,0 +1,24 @@ +import { Context, SupportedEvents, SupportedEventsU } from "./context"; + +export type CallbackResult = { status: 200 | 201 | 204 | 404 | 500; reason: string; content?: string | Record }; + +/** + * The `Context` type is a generic type defined as `Context`, + * where `TEvent` is a string representing the event name (e.g., "issues.labeled") + * and `TPayload` is the webhook payload type for that event, derived from + * the `SupportedEvents` type map. + * + * The `ProxyCallbacks` object is cast to allow optional callbacks + * for each event type. This is useful because not all events may have associated callbacks. + * As opposed to Partial which could mean an undefined object. + * + * The expected function signature for callbacks looks like this: + * + * ```typescript + * fn(context: Context<"issues.labeled", SupportedEvents["issues.labeled"]>): Promise + * ``` + */ + +export type ProxyCallbacks = { + [K in SupportedEventsU]: Array<(context: Context) => Promise>; +}; diff --git a/src/worker.ts b/src/worker.ts index b713c77..a8dcee2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -3,6 +3,7 @@ import { pluginSettingsSchema, pluginSettingsValidator } from "./types"; import { Env, envValidator } from "./types/env"; import manifest from "../manifest.json"; import { plugin } from "./plugin"; +import { handleUncaughtError } from "./helpers/errors"; export default { async fetch(request: Request, env: Env): Promise { @@ -62,9 +63,3 @@ export default { } }, }; - -function handleUncaughtError(error: unknown) { - console.error(error); - const status = 500; - return new Response(JSON.stringify({ error }), { status: status, headers: { "content-type": "application/json" } }); -} diff --git a/tests/main.test.ts b/tests/main.test.ts index 57c0e72..935a113 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -106,7 +106,7 @@ describe("Ask plugin tests", () => { createComments([transformCommentTemplate(1, 1, TEST_QUESTION, "ubiquity", "test-repo", true)]); await runPlugin(ctx); - expect(infoSpy).toHaveBeenCalledTimes(3); + expect(infoSpy).toHaveBeenCalledTimes(4); expect(infoSpy).toHaveBeenNthCalledWith(1, `Asking question: @UbiquityOS ${TEST_QUESTION}`); expect(infoSpy).toHaveBeenNthCalledWith(3, "Answer: This is a mock answer for the chat", { caller: LOG_CALLER, @@ -130,7 +130,7 @@ describe("Ask plugin tests", () => { await runPlugin(ctx); - expect(infoSpy).toHaveBeenCalledTimes(3); + expect(infoSpy).toHaveBeenCalledTimes(4); expect(infoSpy).toHaveBeenNthCalledWith(1, `Asking question: @UbiquityOS ${TEST_QUESTION}`);