diff --git a/package.json b/package.json index 2d2aca5..ece6959 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "knip-ci": "knip --no-exit-code --reporter json --config .github/knip.ts", "prepare": "husky install", "test": "jest --setupFiles dotenv/config --coverage", - "worker": "wrangler dev --env dev --port 5000" + "worker": "wrangler dev --env dev --port 4000" }, "keywords": [ "typescript", @@ -32,7 +32,7 @@ "@octokit/webhooks": "13.2.7", "@sinclair/typebox": "0.32.33", "@supabase/supabase-js": "^2.45.4", - "@ubiquity-dao/ubiquibot-logger": "^1.3.0", + "@ubiquity-os/ubiquity-os-logger": "^1.3.2", "dotenv": "^16.4.5", "github-diff-tool": "^1.0.6", "gpt-tokenizer": "^2.5.1", diff --git a/src/handlers/ask-llm.ts b/src/handlers/ask-llm.ts index f07e154..f0a262c 100644 --- a/src/handlers/ask-llm.ts +++ b/src/handlers/ask-llm.ts @@ -6,6 +6,7 @@ import { recursivelyFetchLinkedIssues } from "../helpers/issue-fetching"; import { formatChatHistory } from "../helpers/format-chat-history"; import { fetchRepoDependencies, fetchRepoLanguageStats } from "./ground-truths/chat-bot"; import { findGroundTruths } from "./ground-truths/find-ground-truths"; +import { bubbleUpErrorComment } from "../helpers/errors"; /** * Asks a question to GPT and returns the response @@ -39,43 +40,40 @@ export async function askGpt(context: Context, question: string, formattedChat: const { env: { UBIQUITY_OS_APP_NAME }, config: { model, similarityThreshold, maxTokens }, + adapters: { + supabase: { comment, issue }, + voyage: { reranker }, + openai: { completions }, + }, + logger, } = context; - let similarComments: CommentSimilaritySearchResult[] = []; - let similarIssues: IssueSimilaritySearchResult[] = []; - try { - similarComments = (await context.adapters.supabase.comment.findSimilarComments(question, 1 - similarityThreshold, "")) || []; - } catch (error) { - context.logger.error(`Error fetching similar comments: ${(error as Error).message}`); - } - try { - similarIssues = (await context.adapters.supabase.issue.findSimilarIssues(question, 1 - similarityThreshold, "")) || []; - } catch (error) { - context.logger.error(`Error fetching similar issues: ${(error as Error).message}`); - } - let similarText = similarComments.map((comment: CommentSimilaritySearchResult) => comment.comment_plaintext); - similarText.push(...similarIssues.map((issue: IssueSimilaritySearchResult) => issue.issue_plaintext)); - // Remove Null Results (Private Comments) - similarText = similarText.filter((text) => text !== null); - formattedChat = formattedChat.filter((text) => text !== null); - similarText = similarText.filter((text) => text !== ""); - const rerankedText = similarText.length > 0 ? await context.adapters.voyage.reranker.reRankResults(similarText, question) : []; - const languages = await fetchRepoLanguageStats(context); - let dependencies = {}; - let devDependencies = {}; + try { - const deps = await fetchRepoDependencies(context); - dependencies = deps.dependencies; - devDependencies = deps.devDependencies; + const [similarComments, similarIssues] = await Promise.all([ + comment.findSimilarComments(question, 1 - similarityThreshold, ""), + issue.findSimilarIssues(question, 1 - similarityThreshold, "") + ]); + + const similarText = [ + ...similarComments?.map((comment: CommentSimilaritySearchResult) => comment.comment_plaintext) || [], + ...similarIssues?.map((issue: IssueSimilaritySearchResult) => issue.issue_plaintext) || [] + ]; + + formattedChat = formattedChat.filter(text => text); + + const rerankedText = similarText.length > 0 ? await reranker.reRankResults(similarText, question) : []; + const [languages, { dependencies, devDependencies }] = await Promise.all([ + fetchRepoLanguageStats(context), + fetchRepoDependencies(context) + ]); + + const groundTruths = await findGroundTruths(context, "chat-bot", { languages, dependencies, devDependencies }); + + const numTokens = await completions.findTokenLength(question, rerankedText, formattedChat, groundTruths); + logger.info(`Number of tokens: ${numTokens}`); + + return completions.createCompletion(question, model, rerankedText, formattedChat, groundTruths, UBIQUITY_OS_APP_NAME, maxTokens); } catch (error) { - context.logger.error(`Unable to Fetch Dependencies: ${(error as Error).message}`); + throw bubbleUpErrorComment(context, error, false); } - const groundTruths = await findGroundTruths(context, "chat-bot", { - languages, - dependencies, - devDependencies, - }); - //Calculate the current context size in tokens - const numTokens = await context.adapters.openai.completions.findTokenLength(question, rerankedText, formattedChat, groundTruths); - context.logger.info(`Number of tokens: ${numTokens}`); - return context.adapters.openai.completions.createCompletion(question, model, rerankedText, formattedChat, groundTruths, UBIQUITY_OS_APP_NAME, maxTokens); -} +} \ No newline at end of file diff --git a/src/handlers/comment-created-callback.ts b/src/handlers/comment-created-callback.ts new file mode 100644 index 0000000..ae44fbe --- /dev/null +++ b/src/handlers/comment-created-callback.ts @@ -0,0 +1,79 @@ +import { Context, SupportedEvents } from "../types"; +import { addCommentToIssue } from "./add-comment"; +import { askQuestion } from "./ask-llm"; +import { CallbackResult } from "../types/proxy"; +import { bubbleUpErrorComment, sanitizeMetadata } from "../helpers/errors"; +import { LogReturn } from "@ubiquity-os/ubiquity-os-logger"; + +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.trim(); + const slugRegex = new RegExp(`^@${UBIQUITY_OS_APP_NAME}`, "i"); + + if (!slugRegex.test(question)) { + return { status: 204, reason: logger.info("Comment does not mention the app. Skipping.").logMessage.raw }; + } + + if (!question.length || question.replace(slugRegex, "").trim().length === 0) { + return { status: 204, reason: logger.info("No question provided. Skipping.").logMessage.raw }; + } + + if (context.payload.comment.user?.type === "Bot") { + return { status: 204, reason: logger.info("Comment is from a bot. Skipping.").logMessage.raw }; + } + + logger.info(`Asking question: ${question}`); + let commentToPost; + try { + const response = await askQuestion(context, question); + const { answer, tokenUsage, groundTruths } = response; + if (!answer) { + throw logger.error(`No answer from OpenAI`); + } + logger.info(`Answer: ${answer}`, { tokenUsage }); + + const metadata = { + groundTruths, + tokenUsage, + }; + + const metadataString = createStructuredMetadata("LLM Ground Truths and Token Usage", logger.info(`Answer: ${answer}`, { metadata })); + commentToPost = answer + metadataString; + await addCommentToIssue(context, commentToPost); + return { status: 200, reason: logger.info("Comment posted successfully").logMessage.raw }; + } catch (error) { + throw await bubbleUpErrorComment(context, error, false); + } +} + +function createStructuredMetadata(header: string | undefined, logReturn: LogReturn) { + let logMessage, metadata; + if (logReturn) { + logMessage = logReturn.logMessage; + metadata = logReturn.metadata; + } + + const jsonPretty = sanitizeMetadata(metadata); + const stackLine = new Error().stack?.split("\n")[2] ?? ""; + const caller = stackLine.match(/at (\S+)/)?.[1] ?? ""; + const ubiquityMetadataHeader = `\n\n"].join("\n"); + + 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 metadataSerialized; +} 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/handlers/ground-truths/chat-bot.ts b/src/handlers/ground-truths/chat-bot.ts index 30072ef..6d087d2 100644 --- a/src/handlers/ground-truths/chat-bot.ts +++ b/src/handlers/ground-truths/chat-bot.ts @@ -12,17 +12,23 @@ export async function fetchRepoDependencies(context: Context) { }, } = context; - const { data: packageJson } = await octokit.repos.getContent({ - owner, - repo, - path: "package.json", - }); + try { + const { data: packageJson } = await octokit.repos.getContent({ + owner, + repo, + path: "package.json", + }); - if ("content" in packageJson) { - return extractDependencies(JSON.parse(Buffer.from(packageJson.content, "base64").toString())); - } else { - throw logger.error(`No package.json found in ${owner}/${repo}`); + if ("content" in packageJson) { + return extractDependencies(JSON.parse(Buffer.from(packageJson.content, "base64").toString())); + } + } catch (err) { + logger.error(`Error fetching package.json for ${owner}/${repo}`, { err }); } + return { + dependencies: {}, + devDependencies: {}, + }; } export function extractDependencies(packageJson: Record>) { @@ -44,21 +50,24 @@ export async function fetchRepoLanguageStats(context: Context) { }, }, } = context; + try { + const { data: languages } = await octokit.repos.listLanguages({ + owner, + repo, + }); - const { data: languages } = await octokit.repos.listLanguages({ - owner, - repo, - }); - - const totalBytes = Object.values(languages).reduce((acc, bytes) => acc + bytes, 0); + const totalBytes = Object.values(languages).reduce((acc, bytes) => acc + bytes, 0); - const stats = Object.entries(languages).reduce( - (acc, [language, bytes]) => { - acc[language] = bytes / totalBytes; - return acc; - }, - {} as Record - ); + const stats = Object.entries(languages).reduce( + (acc, [language, bytes]) => { + acc[language] = bytes / totalBytes; + return acc; + }, + {} as Record + ); - return Array.from(Object.entries(stats)).sort((a, b) => b[1] - a[1]); + return Array.from(Object.entries(stats)).sort((a, b) => b[1] - a[1]); + } catch (err) { + throw logger.error(`Error fetching language stats for ${owner}/${repo}`, { err }); + } } 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 index 354a399..52dae30 100644 --- a/src/helpers/errors.ts +++ b/src/helpers/errors.ts @@ -1,3 +1,31 @@ -import { Logs } from "@ubiquity-dao/ubiquibot-logger"; // import is fixed in #13 - +import { LogReturn, Logs } from "@ubiquity-os/ubiquity-os-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 aa953ad..9f96d7b 100644 --- a/src/helpers/issue-fetching.ts +++ b/src/helpers/issue-fetching.ts @@ -1,9 +1,9 @@ import { GithubDiff } from "github-diff-tool"; import { createKey, getAllStreamlinedComments } from "../handlers/comments"; import { Context } from "../types"; -import { IssueWithUser, LinkedPullsToIssue, SimplifiedComment, User } from "../types/github-types"; -import { FetchParams, Issue, Comments, LinkedIssues } from "../types/github-types"; +import { IssueComments, FetchParams, Issue, LinkedIssues, LinkedPullsToIssue, ReviewComments, SimplifiedComment } from "../types/github-types"; import { StreamlinedComment } from "../types/llm"; +import { logger } from "./errors"; import { dedupeStreamlinedComments, fetchCodeLinkedFromIssue, @@ -42,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); @@ -56,7 +56,7 @@ export async function fetchLinkedIssues(params: FetchParams) { comments.push({ body: issue.body, - user: issue.user as User, + user: issue.user, id: issue.id.toString(), org: params.owner, repo: params.repo, @@ -69,7 +69,7 @@ export async function fetchLinkedIssues(params: FetchParams) { if (readme) { comments.push({ body: readme, - user: issue.user as User, + user: issue.user, id: issue.id.toString(), org: params.owner, repo: params.repo, @@ -78,7 +78,7 @@ export async function fetchLinkedIssues(params: FetchParams) { } } catch (error) { params.context.logger.error(`Error fetching README`, { - error: error as Error, + err: error, owner, repo, issue, @@ -197,7 +197,7 @@ export async function fetchPullRequestDiff(context: Context, org: string, repo: return prDiffs.filter((diff): diff is { diff: string; diffSize: number } => diff !== null).sort((a, b) => a.diffSize - b.diffSize); } catch (error) { logger.error(`Error fetching pull request diff`, { - error: error as Error, + err: error, owner: org, repo, pull_number: issue, @@ -220,10 +220,10 @@ export async function fetchIssue(params: FetchParams): Promise { repo: repo || payload.repository.name, issue_number: issueNum || payload.issue.number, }); - return response.data as IssueWithUser; + return response.data; } catch (error) { logger.error(`Error fetching issue`, { - error: error as Error, + err: error, owner: owner || payload.repository.owner.login, repo: repo || payload.repository.name, issue_number: issueNum || payload.issue.number, @@ -242,7 +242,8 @@ export async function fetchIssueComments(params: FetchParams) { const { octokit, payload, logger } = params.context; const { issueNum, owner, repo } = params; const issue = await fetchIssue(params); - let comments: Comments = []; + let reviewComments: ReviewComments[] = []; + let issueComments: IssueComments[] = []; try { if (issue?.pull_request) { const response = await octokit.rest.pulls.listReviewComments({ @@ -250,14 +251,14 @@ export async function fetchIssueComments(params: FetchParams) { repo: repo || payload.repository.name, pull_number: issueNum || payload.issue.number, }); - comments = response.data; + reviewComments = response.data; } else { const response = await octokit.rest.issues.listComments({ owner: owner || payload.repository.owner.login, repo: repo || payload.repository.name, issue_number: issueNum || payload.issue.number, }); - comments = response.data; + issueComments = response.data; } } catch (e) { logger.error(`Error fetching comments `, { @@ -266,9 +267,8 @@ export async function fetchIssueComments(params: FetchParams) { repo: repo || payload.repository.name, issue_number: issueNum || payload.issue.number, }); - comments = []; } - comments = comments.filter((comment) => comment.user?.type !== "Bot") as Comments; + const comments = [...issueComments, ...reviewComments].filter((comment) => comment.user?.type !== "Bot"); const simplifiedComments = castCommentsToSimplifiedComments(comments, params); return { @@ -298,21 +298,38 @@ export async function fetchAndHandleIssue( return streamlinedComments[key] || []; } -function castCommentsToSimplifiedComments(comments: Comments, params: FetchParams): SimplifiedComment[] { +function castCommentsToSimplifiedComments(comments: (IssueComments | ReviewComments)[], params: FetchParams): SimplifiedComment[] { if (!comments) { return []; } + return comments .filter((comment) => comment.body !== undefined) - .map((comment) => ({ - id: comment.id.toString(), - org: params.owner || params.context.payload.repository.owner.login, - repo: params.repo || params.context.payload.repository.name, - issueUrl: comment.html_url, - body: comment.body as string, - user: comment.user as User, - url: comment.html_url, - })); + .map((comment) => { + if ("pull_request_review_id" in comment) { + return { + body: comment.body, + user: comment.user, + id: comment.id.toString(), + org: params.owner || params.context.payload.repository.owner.login, + repo: params.repo || params.context.payload.repository.name, + issueUrl: comment.html_url, + }; + } + + if ("issue_url" in comment) { + return { + body: comment.body, + user: comment.user, + id: comment.id.toString(), + org: params.owner || params.context.payload.repository.owner.login, + repo: params.repo || params.context.payload.repository.name, + issueUrl: comment.issue_url, + }; + } + + throw logger.error("Comment type not recognized", { comment, params }); + }); } export async function fetchLinkedPullRequests(owner: string, repo: string, issueNumber: number, context: Context) { @@ -343,7 +360,7 @@ export async function fetchLinkedPullRequests(owner: string, repo: string, issue return repository.issue.closedByPullRequestsReferences.nodes; } catch (error) { context.logger.error(`Error fetching linked PRs from issue`, { - error: error as Error, + err: error, owner, repo, issueNumber, diff --git a/src/helpers/issue.ts b/src/helpers/issue.ts index d52405d..b63b7d5 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. @@ -84,6 +85,7 @@ function createLinkedIssueOrPr(url: string): LinkedIssues { repo, issueNumber: parseInt(issueNumber), url, + body: undefined, }; } @@ -184,7 +186,7 @@ export async function pullReadmeFromRepoForIssue(params: FetchParams): Promise`; - } - await addCommentToIssue(context, commentToPost); -} - -function sanitizeMetadata(obj: LogReturn["metadata"]): string { - return JSON.stringify(obj, null, 2).replace(//g, ">").replace(/--/g, "--"); -} - -function createStructuredMetadata(header: string | undefined, logReturn: LogReturn) { - let logMessage, metadata; - if (logReturn) { - logMessage = logReturn.logMessage; - metadata = logReturn.metadata; - } - - const jsonPretty = sanitizeMetadata(metadata); - const stackLine = new Error().stack?.split("\n")[2] ?? ""; - const caller = stackLine.match(/at (\S+)/)?.[1] ?? ""; - const ubiquityMetadataHeader = `\n\n"].join("\n"); - - 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 metadataSerialized; + return proxyCallbacks(context)[context.eventName]; } diff --git a/src/types/context.ts b/src/types/context.ts index 73f74b7..8588ad9 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -1,7 +1,7 @@ import { Octokit } from "@octokit/rest"; import { EmitterWebhookEvent as WebhookEvent, EmitterWebhookEventName as WebhookEventName } from "@octokit/webhooks"; import { PluginSettings } from "./plugin-inputs"; -import { Logs } from "@ubiquity-dao/ubiquibot-logger"; +import { Logs } from "@ubiquity-os/ubiquity-os-logger"; import { Env } from "./env"; import { createAdapters } from "../adapters"; diff --git a/src/types/github-types.d.ts b/src/types/github-types.ts similarity index 73% rename from src/types/github-types.d.ts rename to src/types/github-types.ts index 7f72549..2830da7 100644 --- a/src/types/github-types.d.ts +++ b/src/types/github-types.ts @@ -2,14 +2,10 @@ import { RestEndpointMethodTypes } from "@octokit/rest"; import { Context } from "./context"; export type Issue = RestEndpointMethodTypes["issues"]["get"]["response"]["data"]; -export type Comments = - | RestEndpointMethodTypes["issues"]["listComments"]["response"]["data"] - | RestEndpointMethodTypes["pulls"]["listReviewComments"]["response"]["data"]; +export type IssueComments = RestEndpointMethodTypes["issues"]["listComments"]["response"]["data"][0]; +export type ReviewComments = RestEndpointMethodTypes["pulls"]["listReviewComments"]["response"]["data"][0]; export type User = RestEndpointMethodTypes["users"]["getByUsername"]["response"]["data"]; -//Modify the Issue add User Type -export type IssueWithUser = Issue & { user: User }; - export type FetchParams = { context: Context; issueNum?: number; @@ -23,12 +19,12 @@ export type LinkedIssues = { owner: string; url: string; comments?: SimplifiedComment[] | null | undefined; - body?: string; + body: string | undefined; }; export type SimplifiedComment = { - user: User | Partial; - body: string; + user: Partial | null; + body: string | undefined; id: string; org: string; repo: string; @@ -36,8 +32,8 @@ export type SimplifiedComment = { }; export type FetchedCodes = { - body: string; - user: User | Partial; + body: string | undefined; + user: Partial | null; issueUrl: string; id: string; org: string; 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 361f7e3..3ceaeb5 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -2,7 +2,7 @@ import { db } from "./__mocks__/db"; import { server } from "./__mocks__/node"; import usersGet from "./__mocks__/users-get.json"; import { expect, describe, beforeAll, beforeEach, afterAll, afterEach, it } from "@jest/globals"; -import { Logs } from "@ubiquity-dao/ubiquibot-logger"; +import { Logs } from "@ubiquity-os/ubiquity-os-logger"; import { Context, SupportedEventsU } from "../src/types"; import { drop } from "@mswjs/data"; import issueTemplate from "./__mocks__/issue-template"; @@ -94,7 +94,7 @@ describe("Ask plugin tests", () => { createComments([transformCommentTemplate(1, 1, TEST_QUESTION, "ubiquity", "test-repo", true)]); await runPlugin(ctx); - expect(infoSpy).toHaveBeenCalledWith("Comment is empty. Skipping."); + expect(infoSpy).toHaveBeenCalledWith("No question provided. Skipping."); }); it("Should throw if OPENAI_API_KEY is not defined", () => { const settings = {}; @@ -107,7 +107,6 @@ describe("Ask plugin tests", () => { createComments([transformCommentTemplate(1, 1, TEST_QUESTION, "ubiquity", "test-repo", true)]); await runPlugin(ctx); - expect(infoSpy).toHaveBeenCalledTimes(5); expect(infoSpy).toHaveBeenNthCalledWith(1, `Asking question: @UbiquityOS ${TEST_QUESTION}`); expect(infoSpy).toHaveBeenNthCalledWith(4, "Answer: This is a mock answer for the chat", { caller: LOG_CALLER, @@ -131,8 +130,6 @@ describe("Ask plugin tests", () => { await runPlugin(ctx); - expect(infoSpy).toHaveBeenCalledTimes(5); - expect(infoSpy).toHaveBeenNthCalledWith(1, `Asking question: @UbiquityOS ${TEST_QUESTION}`); const prompt = `=== Current Issue #1 Specification === ubiquity/test-repo/1 === @@ -169,7 +166,7 @@ describe("Ask plugin tests", () => { `; const normalizedExpected = normalizeString(prompt); - const normalizedReceived = normalizeString(infoSpy.mock.calls[1][0]); + const normalizedReceived = normalizeString(infoSpy.mock.calls[1][0] as string); expect(normalizedReceived).toEqual(normalizedExpected); }); @@ -188,7 +185,7 @@ function transformCommentTemplate(commentId: number, issueNumber: number, body: login: "ubiquity", type: "User", }, - body: TEST_QUESTION, + body: body, url: "https://api.github.com/repos/ubiquity/test-repo/issues/comments/1", html_url: "https://www.github.com/ubiquity/test-repo/issues/1", owner: "ubiquity", diff --git a/yarn.lock b/yarn.lock index 6c98d95..ad622f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2493,10 +2493,10 @@ "@typescript-eslint/types" "7.13.1" eslint-visitor-keys "^3.4.3" -"@ubiquity-dao/ubiquibot-logger@^1.3.0": - version "1.3.1" - resolved "https://registry.yarnpkg.com/@ubiquity-dao/ubiquibot-logger/-/ubiquibot-logger-1.3.1.tgz#c3f45d70014dcc2551442c28101046e1c8ea6886" - integrity sha512-kDLnVP87Y3yZV6NnqIEDAOz+92IW0nIcccML2lUn93uZ5ada78vfdTPtwPJo8tkXl1Z9qMKAqqHkwBMp1Ksnag== +"@ubiquity-os/ubiquity-os-logger@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@ubiquity-os/ubiquity-os-logger/-/ubiquity-os-logger-1.3.2.tgz#4423bc0baeac5c2f73123d15fd961310521163cd" + integrity sha512-oTIzR8z4jAQmaeJp98t1bZUKE3Ws9pas0sbxt58fC37MwXclPMWrLO+a0JlhPkdJYsvpv/q/79wC2MKVhOIVXQ== JSONStream@^1.3.5: version "1.3.5"