Skip to content

Commit

Permalink
Merge branch 'development' into feat/pull-precheck
Browse files Browse the repository at this point in the history
  • Loading branch information
Keyrxng committed Oct 23, 2024
2 parents 53d9a96 + 1c4601b commit af762e6
Show file tree
Hide file tree
Showing 11 changed files with 179 additions and 60 deletions.
41 changes: 41 additions & 0 deletions src/handlers/comment-created-callback.ts
Original file line number Diff line number Diff line change
@@ -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<CallbackResult> {
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<!--\n${JSON.stringify(tokenUsage, null, 2)}\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);
}
}
6 changes: 5 additions & 1 deletion src/handlers/comments.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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("#")) {
Expand Down
65 changes: 65 additions & 0 deletions src/helpers/callback-proxy.ts
Original file line number Diff line number Diff line change
@@ -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);
}
31 changes: 31 additions & 0 deletions src/helpers/errors.ts
Original file line number Diff line number Diff line change
@@ -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, "&lt;").replace(/>/g, "&gt;").replace(/--/g, "&#45;&#45;");
}

export async function bubbleUpErrorComment(context: Context, err: unknown, post = true): Promise<LogReturn> {
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<!--\n${sanitizeMetadata(errorMessage?.metadata)}\n-->`);
}

return errorMessage;
}
5 changes: 3 additions & 2 deletions src/helpers/issue-fetching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,11 +42,11 @@ export async function fetchLinkedIssues(params: FetchParams) {
return { streamlinedComments: {}, linkedIssues: [], specAndBodies: {}, seen: new Set<string>() };
}
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);
Expand Down
3 changes: 2 additions & 1 deletion src/helpers/issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -239,7 +240,7 @@ export async function pullReadmeFromRepoForIssue(params: FetchParams): Promise<s
readme = Buffer.from(response.data.content, "base64").toString();
}
} catch (error) {
throw new Error(`Error fetching README from repository: ${error}`);
throw logger.error(`Error fetching README from repository: ${error}`);
}
return readme;
}
2 changes: 2 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// @ts-expect-error - no types found
import * as core from "@actions/core";
// @ts-expect-error - no types found
import * as github from "@actions/github";
import { Value } from "@sinclair/typebox/value";
import { envSchema } from "./types/env";
Expand Down
51 changes: 3 additions & 48 deletions src/plugin.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
import { Octokit } from "@octokit/rest";
import { PluginInputs } from "./types";
import { Context } from "./types";
import { askQuestion } from "./handlers/ask-llm";
import { addCommentToIssue } from "./handlers/add-comment";
import { LogLevel, LogReturn, Logs } from "@ubiquity-dao/ubiquibot-logger";
import { LogLevel, Logs } from "@ubiquity-dao/ubiquibot-logger";
import { Env } from "./types/env";
import { createAdapters } from "./adapters";
import { createClient } from "@supabase/supabase-js";
import { VoyageAIClient } from "voyageai";
import OpenAI from "openai";
import { proxyCallbacks } from "./helpers/callback-proxy";

export async function plugin(inputs: PluginInputs, env: Env) {
const octokit = new Octokit({ auth: inputs.authToken });
Expand All @@ -35,49 +34,5 @@ export async function plugin(inputs: PluginInputs, env: Env) {
}

export async function runPlugin(context: Context) {
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)) {
logger.info("Comment does not mention the app. Skipping.");
return;
}
if (context.payload.comment.user?.type === "Bot") {
logger.info("Comment is from a bot. Skipping.");
return;
}
if (question.replace(slugRegex, "").trim().length === 0) {
logger.info("Comment is empty. Skipping.");
return;
}
logger.info(`Asking question: ${question}`);
let commentToPost;
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<!--\n${JSON.stringify(tokenUsage, null, 2)}\n--!>`;
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<!--\n${sanitizeMetadata(errorMessage?.metadata)}\n-->`;
}
await addCommentToIssue(context, commentToPost);
}

function sanitizeMetadata(obj: LogReturn["metadata"]): string {
return JSON.stringify(obj, null, 2).replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/--/g, "&#45;&#45;");
return proxyCallbacks(context)[context.eventName];
}
24 changes: 24 additions & 0 deletions src/types/proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Context, SupportedEvents, SupportedEventsU } from "./context";

export type CallbackResult = { status: 200 | 201 | 204 | 404 | 500; reason: string; content?: string | Record<string, unknown> };

/**
* The `Context` type is a generic type defined as `Context<TEvent, TPayload>`,
* 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<ProxyCallbacks> 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<Result>
* ```
*/

export type ProxyCallbacks = {
[K in SupportedEventsU]: Array<(context: Context<K, SupportedEvents[K]>) => Promise<CallbackResult>>;
};
7 changes: 1 addition & 6 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response> {
Expand Down Expand Up @@ -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" } });
}
4 changes: 2 additions & 2 deletions tests/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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}`);

Expand Down

0 comments on commit af762e6

Please sign in to comment.