diff --git a/.github/workflows/database.yml b/.github/workflows/database.yml index 5511f5e..4569ea1 100644 --- a/.github/workflows/database.yml +++ b/.github/workflows/database.yml @@ -64,4 +64,4 @@ jobs: echo "No changes to commit" fi env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0b82f..50c35ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,28 +2,26 @@ ## 1.0.0 (2024-09-01) - ### Features -* modified tests ([97e267f](https://github.com/ubiquibot/issue-comment-embeddings/commit/97e267f801ce4e6bd29bbe967de3df4fc3b1942a)) - +- modified tests ([97e267f](https://github.com/ubiquibot/issue-comment-embeddings/commit/97e267f801ce4e6bd29bbe967de3df4fc3b1942a)) ### Bug Fixes -* added config.yml ([c0f784b](https://github.com/ubiquibot/issue-comment-embeddings/commit/c0f784b20e59c2c4714805331c7ae9034fd73f73)) -* added config.yml ([221d34d](https://github.com/ubiquibot/issue-comment-embeddings/commit/221d34d801af6ebd764028be4a5c6200a18b776e)) -* added config.yml ([d12c522](https://github.com/ubiquibot/issue-comment-embeddings/commit/d12c522291db36dcf6aea72e5759e1a055185d8f)) -* cspell fix ([736bea6](https://github.com/ubiquibot/issue-comment-embeddings/commit/736bea6172444fdf783ffff729879d8278ff82f3)) -* fixed tests missing supabase files ([0e870ac](https://github.com/ubiquibot/issue-comment-embeddings/commit/0e870ac50eb68249edf5fc4e46fd509425dd7bbb)) -* github workflow, types package.json, env examples ([16786d7](https://github.com/ubiquibot/issue-comment-embeddings/commit/16786d76ee7a598c885f15af1baeadcf6a471b2c)) -* issue_comments linting added issue_comments:edited, created and deleted ([9c0de23](https://github.com/ubiquibot/issue-comment-embeddings/commit/9c0de237048ce30bf4254960c443bf3938037dce)) -* knip workflow ([f325310](https://github.com/ubiquibot/issue-comment-embeddings/commit/f3253109c290c9fce6d14e6a2e1e328133ac6f81)) -* manifest.json, compute.yml ([21409d5](https://github.com/ubiquibot/issue-comment-embeddings/commit/21409d530c3aad6ff2676fc813314e5b29c1a533)) -* package.json ([806c6c0](https://github.com/ubiquibot/issue-comment-embeddings/commit/806c6c0b393a9b87741a6341fa65bc5b3d22cb15)) -* plugin name ([d91b991](https://github.com/ubiquibot/issue-comment-embeddings/commit/d91b991d717b7fb0b73359ca29ae6de08a1074b9)) -* readme.md ([9c5fbfe](https://github.com/ubiquibot/issue-comment-embeddings/commit/9c5fbfe9ca46eb842779468c85d329b9f941fb82)) -* readme.md ([2fec447](https://github.com/ubiquibot/issue-comment-embeddings/commit/2fec44786526e7c10faaa2c13c4349e1232cf5bd)) -* remove config.yml and wrangler.toml namespace entries ([127cc22](https://github.com/ubiquibot/issue-comment-embeddings/commit/127cc225903c3fe3ca934e8407df4eb9c27e378c)) -* removed config.yml changed name ([744e08c](https://github.com/ubiquibot/issue-comment-embeddings/commit/744e08cebac310ae81c3c102f5f3a9473e6e4b9e)) -* test and linting ([a4ee41e](https://github.com/ubiquibot/issue-comment-embeddings/commit/a4ee41e6fca8723ce2fddc96b1171c89cfe7d5b7)) -* wrangler name ([f890071](https://github.com/ubiquibot/issue-comment-embeddings/commit/f890071c01c5bb1d611a5b7aa07cba84f4546251)) +- added config.yml ([c0f784b](https://github.com/ubiquibot/issue-comment-embeddings/commit/c0f784b20e59c2c4714805331c7ae9034fd73f73)) +- added config.yml ([221d34d](https://github.com/ubiquibot/issue-comment-embeddings/commit/221d34d801af6ebd764028be4a5c6200a18b776e)) +- added config.yml ([d12c522](https://github.com/ubiquibot/issue-comment-embeddings/commit/d12c522291db36dcf6aea72e5759e1a055185d8f)) +- cspell fix ([736bea6](https://github.com/ubiquibot/issue-comment-embeddings/commit/736bea6172444fdf783ffff729879d8278ff82f3)) +- fixed tests missing supabase files ([0e870ac](https://github.com/ubiquibot/issue-comment-embeddings/commit/0e870ac50eb68249edf5fc4e46fd509425dd7bbb)) +- github workflow, types package.json, env examples ([16786d7](https://github.com/ubiquibot/issue-comment-embeddings/commit/16786d76ee7a598c885f15af1baeadcf6a471b2c)) +- issue_comments linting added issue_comments:edited, created and deleted ([9c0de23](https://github.com/ubiquibot/issue-comment-embeddings/commit/9c0de237048ce30bf4254960c443bf3938037dce)) +- knip workflow ([f325310](https://github.com/ubiquibot/issue-comment-embeddings/commit/f3253109c290c9fce6d14e6a2e1e328133ac6f81)) +- manifest.json, compute.yml ([21409d5](https://github.com/ubiquibot/issue-comment-embeddings/commit/21409d530c3aad6ff2676fc813314e5b29c1a533)) +- package.json ([806c6c0](https://github.com/ubiquibot/issue-comment-embeddings/commit/806c6c0b393a9b87741a6341fa65bc5b3d22cb15)) +- plugin name ([d91b991](https://github.com/ubiquibot/issue-comment-embeddings/commit/d91b991d717b7fb0b73359ca29ae6de08a1074b9)) +- readme.md ([9c5fbfe](https://github.com/ubiquibot/issue-comment-embeddings/commit/9c5fbfe9ca46eb842779468c85d329b9f941fb82)) +- readme.md ([2fec447](https://github.com/ubiquibot/issue-comment-embeddings/commit/2fec44786526e7c10faaa2c13c4349e1232cf5bd)) +- remove config.yml and wrangler.toml namespace entries ([127cc22](https://github.com/ubiquibot/issue-comment-embeddings/commit/127cc225903c3fe3ca934e8407df4eb9c27e378c)) +- removed config.yml changed name ([744e08c](https://github.com/ubiquibot/issue-comment-embeddings/commit/744e08cebac310ae81c3c102f5f3a9473e6e4b9e)) +- test and linting ([a4ee41e](https://github.com/ubiquibot/issue-comment-embeddings/commit/a4ee41e6fca8723ce2fddc96b1171c89cfe7d5b7)) +- wrangler name ([f890071](https://github.com/ubiquibot/issue-comment-embeddings/commit/f890071c01c5bb1d611a5b7aa07cba84f4546251)) diff --git a/README.md b/README.md index cac1f27..5a38805 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,28 @@ This is a plugin for [Ubiquibot](https://github.com/ubiquity/ubiquibot-kernel). It listens for issue comments, and adds them to a vector store. It handles comment edits and deletions as well. ## Configuration + - Host the plugin on a server that Ubiquibot can access. -To set up the `.dev.vars` file, you will need to provide the following variables: + To set up the `.dev.vars` file, you will need to provide the following variables: - `SUPABASE_URL`: The URL for your Supabase instance. - `SUPABASE_KEY`: The key for your Supabase instance. - `VOYAGEAI_API_KEY`: The API key for Voyage. ## Usage + - Add the following to your `.ubiquibot-config.yml` file with the appropriate URL: + ```javascript -plugin: http://127.0.0.1:4000 runsOn: [ "issue_comment.created", "issue_comment.edited", "issue_comment.deleted" , "issues.opened", "issues.edited", "issues.deleted"] ``` - ## Testing Locally + - Run `yarn install` to install the dependencies. - Run `yarn worker` to start the server. - Make HTTP requests to the server to test the plugin with content type `Application/JSON` + ``` { "stateId": "", @@ -34,7 +38,7 @@ To set up the `.dev.vars` file, you will need to provide the following variables "id": }, "repository" : { - "name" : "REPONAME", + "name" : "REPO_NAME", "owner":{ "login" : "USERNAME" } @@ -50,7 +54,9 @@ To set up the `.dev.vars` file, you will need to provide the following variables "authToken": "" } ``` + - Replace the placeholders with the appropriate values. ## Testing -- Run `yarn test` to run the tests. \ No newline at end of file + +- Run `yarn test` to run the tests. diff --git a/package.json b/package.json index 6ef92a4..8998462 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", "supabase:generate:local": "supabase gen types typescript --local > src/types/database.ts", "supabase:generate:remote": "cross-env-shell \"supabase gen types typescript --project-id $SUPABASE_PROJECT_ID --schema public > src/types/database.ts\"" }, @@ -91,4 +91,4 @@ ] }, "packageManager": "yarn@1.22.22" -} +} \ No newline at end of file diff --git a/src/adapters/index.ts b/src/adapters/index.ts index ca31d27..3da6b75 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -1,22 +1,12 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Context } from "../types"; -import { Comment } from "./supabase/helpers/comment"; -import { SuperSupabase } from "./supabase/helpers/supabase"; -import { Embedding as VoyageEmbedding } from "./voyage/helpers/embedding"; -import { SuperVoyage } from "./voyage/helpers/voyage"; import { VoyageAIClient } from "voyageai"; -import { Issues } from "./supabase/helpers/issues"; +import { Embeddings } from "./supabase/helpers/embeddings"; export function createAdapters(supabaseClient: SupabaseClient, voyage: VoyageAIClient, context: Context) { return { supabase: { - comment: new Comment(supabaseClient, context), - issue: new Issues(supabaseClient, context), - super: new SuperSupabase(supabaseClient, context), - }, - voyage: { - embedding: new VoyageEmbedding(voyage, context), - super: new SuperVoyage(voyage, context), + embeddings: new Embeddings(voyage, supabaseClient, context), }, }; } diff --git a/src/adapters/supabase/helpers/comment.ts b/src/adapters/supabase/helpers/comment.ts deleted file mode 100644 index 3fa08b2..0000000 --- a/src/adapters/supabase/helpers/comment.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { SupabaseClient } from "@supabase/supabase-js"; -import { SuperSupabase } from "./supabase"; -import { Context } from "../../../types/context"; -import { markdownToPlainText } from "../../utils/markdown-to-plaintext"; - -export interface CommentType { - id: string; - markdown?: string; - author_id: number; - created_at: string; - modified_at: string; - embedding: number[]; -} - -export class Comment extends SuperSupabase { - constructor(supabase: SupabaseClient, context: Context) { - super(supabase, context); - } - - async createComment( - markdown: string | null, - commentNodeId: string, - authorId: number, - payload: Record | null, - isPrivate: boolean, - issueId: string - ) { - //First Check if the comment already exists - const { data, error } = await this.supabase.from("issue_comments").select("*").eq("id", commentNodeId); - if (error) { - this.context.logger.error("Error creating comment", error); - return; - } - if (data && data.length > 0) { - this.context.logger.info("Comment already exists"); - return; - } else { - //Create the embedding for this comment - const embedding = await this.context.adapters.voyage.embedding.createEmbedding(markdown); - let plaintext: string | null = markdownToPlainText(markdown || ""); - if (isPrivate) { - markdown = null as string | null; - payload = null as Record | null; - plaintext = null as string | null; - } - const { error } = await this.supabase - .from("issue_comments") - .insert([{ id: commentNodeId, markdown, plaintext, author_id: authorId, payload, embedding: embedding, issue_id: issueId }]); - if (error) { - this.context.logger.error("Error creating comment", error); - return; - } - } - this.context.logger.info("Comment created successfully"); - } - - async updateComment(markdown: string | null, commentNodeId: string, payload: Record | null, isPrivate: boolean) { - //Create the embedding for this comment - const embedding = Array.from(await this.context.adapters.voyage.embedding.createEmbedding(markdown)); - let plaintext: string | null = markdownToPlainText(markdown || ""); - if (isPrivate) { - markdown = null as string | null; - payload = null as Record | null; - plaintext = null as string | null; - } - const { error } = await this.supabase - .from("issue_comments") - .update({ markdown, plaintext, embedding: embedding, payload, modified_at: new Date() }) - .eq("id", commentNodeId); - if (error) { - this.context.logger.error("Error updating comment", error); - } - } - - async getComment(commentNodeId: string): Promise { - const { data, error } = await this.supabase.from("issue_comments").select("*").eq("id", commentNodeId); - if (error) { - this.context.logger.error("Error getting comment", error); - } - return data; - } - - async deleteComment(commentNodeId: string) { - const { error } = await this.supabase.from("issue_comments").delete().eq("id", commentNodeId); - if (error) { - this.context.logger.error("Error deleting comment", error); - } - } -} diff --git a/src/adapters/supabase/helpers/embeddings.ts b/src/adapters/supabase/helpers/embeddings.ts new file mode 100644 index 0000000..f17d8b1 --- /dev/null +++ b/src/adapters/supabase/helpers/embeddings.ts @@ -0,0 +1,229 @@ +import { SupabaseClient } from "@supabase/supabase-js"; +import { Super } from "./supabase"; +import { Context } from "../../../types/context"; +import { htmlToPlainText, markdownToPlainText } from "../../utils/markdown-to-plaintext"; +import { EmbeddingClass, CommentMetadata, CommentType, IssueSimilaritySearchResult } from "../../../types/embeddings"; +import { VoyageAIClient } from "voyageai"; +import { isIssueCommentEvent, isIssueEvent } from "../../../types/typeguards"; + +const VECTOR_SIZE = 1024; + +/** + * Embeddings class for creating, updating, and deleting embeddings. + * + * Schema is as follows: + * - `source_id` - The unique identifier for the embedding. (e.g. comment node_id, telegram chat_id, etc.) + * - `type` - The type of embedding. (e.g. setup_instructions, dao_info, task, comment). Consider this the category. + * - `plaintext` - The plaintext version of the markdown + * - `embedding` - The embedding vector for the markdown + * - `metadata` - Additional metadata for the embedding. (e.g. author_association, author_id, fileChunkIndex, filePath, isPrivate) + * - `created_at` - The timestamp when the embedding was created + * - `modified_at` - The timestamp when the embedding was last modified + */ +export class Embeddings extends Super { + private _voyageClient: VoyageAIClient; + constructor(voyageClient: VoyageAIClient, supabase: SupabaseClient, context: Context) { + super(supabase, context); + this._voyageClient = voyageClient; + } + + /** + * Creates embeddings for both issue specifications and comments. + * + * Receives only `issue_comment.created` and `issues.opened` events, + * i.e comments and new specifications. + */ + async createConversationEmbeddings( + sourceId: string, + payload: Context<"issue_comment.created" | "issues.opened">["payload"], + type: EmbeddingClass = payload.action === "opened" ? "task" : "comment" + ) { + // First Check if the comment already exists + if (await this.getEmbedding(sourceId)) { + throw new Error(this.context.logger.error("source_id already exists", { sourceId })?.logMessage.raw); + } + + const metadata = this._getMetadata(payload); + + // we should always have an author id + if (!metadata.authorId) { + throw new Error(this.context.logger.error("Author ID not found", { payload })?.logMessage.raw); + } + + // Create the embedding + return await this.createEmbedding(sourceId, type, this._getBody(payload), metadata); + } + + /** + * Updates embeddings for both issue specifications and comments. + * + * Receives `issue_comment.edited`, `issues.edited`, `issue_comment.deleted`, and `issues.deleted` events. + */ + async updateConversationEmbeddings( + sourceId: string, + payload: Context<"issue_comment.edited" | "issue_comment.deleted" | "issues.edited" | "issues.deleted">["payload"], + type: EmbeddingClass = payload.action === "edited" ? "comment" : "task" + ) { + const metadata = this._getMetadata(payload); + + // we should always have an author id + if (!metadata.authorId) { + throw new Error(this.context.logger.error("Author ID not found", { payload })?.logMessage.raw); + } + + // Update the embedding + return await this.updateEmbedding(sourceId, type, this._getBody(payload), metadata); + } + + /** + * Creates embeddings without any Context restrictions. Used for the likes of + * `dao_info` and `setup_instructions`, which are not fundamentally tied + * to any specific recurring webhook event. + */ + async createEmbedding(sourceId: string, type: EmbeddingClass, markdown: string | null | undefined, metadata: Partial) { + if (!markdown) { + throw new Error(this.context.logger.error("Markdown not found", { sourceId })?.logMessage.raw); + } + const toStore: CommentType = { + source_id: sourceId, + type, + plaintext: htmlToPlainText(markdownToPlainText(markdown)).trim(), + embedding: await this._embedWithVoyage(markdown, "document"), + metadata, + created_at: new Date().toISOString(), + modified_at: new Date().toISOString(), + }; + + const { error } = await this.supabase.from("content").insert([toStore]); + + if (error) { + throw new Error( + this.context.logger.error("Error creating embedding", { err: error, toStore: { ...toStore, embedding: "removed for brevity" } })?.logMessage.raw + ); + } + + this.context.logger.info("Embedding created successfully"); + + return toStore; + } + + async updateEmbedding(sourceId: string, type: EmbeddingClass, body: string | null | undefined, metadata: Partial) { + if (!body) { + throw new Error(this.context.logger.error("Markdown not found", { sourceId })?.logMessage.raw); + } + const embeddingData = await this.getEmbedding(sourceId); + + if (!embeddingData) { + return await this.createEmbedding(sourceId, type, body, metadata); + } + + const embedding = await this._embedWithVoyage(body, "document"); + + const toStore: Omit = { + source_id: sourceId, + type, + plaintext: body ? htmlToPlainText(markdownToPlainText(body)).trim() : null, + embedding, + metadata, + modified_at: new Date().toISOString(), + }; + + const { error } = await this.supabase.from("content").update(toStore).eq("source_id", sourceId); + + if (error) { + throw new Error(this.context.logger.error("Error updating comment", { err: error, toStore })?.logMessage.raw); + } + + this.context.logger.info("Comment updated successfully"); + + return toStore; + } + + async getEmbedding(sourceId: string): Promise { + const { data, error } = await this.supabase.from("content").select("*").eq("source_id", sourceId).single(); + if (error && error.code !== "PGRST116") { + this.context.logger.error("Error getting comment", { err: error, sourceId }); + } + return data; + } + + async deleteEmbedding(sourceId: string) { + const { error } = await this.supabase.from("content").delete().eq("source_id", sourceId); + if (error) { + throw new Error(this.context.logger.error("Error deleting comment", { err: error, sourceId })?.logMessage.raw); + } + } + + // Working with embeddings + + async findSimilarIssues(markdown: string, threshold: number, currentId: string): Promise { + const embedding = await this._embedWithVoyage(markdown, "query"); + const { data, error } = await this.supabase.rpc("find_similar_issues", { + current_id: currentId, + query_embedding: embedding, + threshold: threshold, + }); + if (error) { + this.context.logger.error("Error finding similar issues", error); + return []; + } + return data; + } + + // Helpers + + async _embedWithVoyage(text: string | null, inputType: "document" | "query"): Promise { + try { + if (text === null) { + return new Array(VECTOR_SIZE).fill(0); + } else { + const response = await this._voyageClient.embed({ + input: text, + model: "voyage-large-2-instruct", + inputType: inputType + }); + return (response.data && response.data[0]?.embedding) || []; + } + } catch (err) { + throw new Error(this.context.logger.error("Error embedding comment", { err })?.logMessage.raw); + } + } + + private _getMetadata(payload: Context<"issue_comment.edited" | "issue_comment.deleted" | "issues.edited" | "issues.deleted" | "issue_comment.created" | "issues.opened">["payload"]) { + const { + repository: { private: isPrivate, node_id: repoNodeId }, + issue: { node_id: issueNodeId }, + } = payload; + return { + authorAssociation: this._getAuthorAssociation(payload), + authorId: this._getAuthorId(payload), + issueNodeId: issueNodeId, + repoNodeId: repoNodeId, + isPrivate, + }; + } + + private _getAuthorAssociation(payload: Context["payload"]) { + if (isIssueCommentEvent(payload)) { + return payload.comment.author_association; + } else if (isIssueEvent(payload)) { + return payload.issue.author_association; + } + } + + private _getAuthorId(payload: Context["payload"]) { + if (isIssueCommentEvent(payload)) { + return payload.comment.user?.id; + } else if (isIssueEvent(payload)) { + return payload.issue.user?.id; + } + } + + private _getBody(payload: Context["payload"]) { + if (isIssueCommentEvent(payload)) { + return payload.comment.body; + } else if (isIssueEvent(payload)) { + return payload.issue.body; + } + } +} diff --git a/src/adapters/supabase/helpers/issues.ts b/src/adapters/supabase/helpers/issues.ts deleted file mode 100644 index 5f30821..0000000 --- a/src/adapters/supabase/helpers/issues.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { SupabaseClient } from "@supabase/supabase-js"; -import { SuperSupabase } from "./supabase"; -import { Context } from "../../../types/context"; -import { markdownToPlainText } from "../../utils/markdown-to-plaintext"; - -export interface IssueSimilaritySearchResult { - issue_id: string; - issue_plaintext: string; - similarity: number; -} - -export class Issues extends SuperSupabase { - constructor(supabase: SupabaseClient, context: Context) { - super(supabase, context); - } - - async createIssue(issueNodeId: string, payload: Record | null, isPrivate: boolean, markdown: string | null, authorId: number) { - //First Check if the issue already exists - const { data, error } = await this.supabase.from("issues").select("*").eq("id", issueNodeId); - if (error) { - this.context.logger.error("Error creating issue", error); - return; - } - if (data && data.length > 0) { - this.context.logger.info("Issue already exists"); - return; - } else { - const embedding = await this.context.adapters.voyage.embedding.createEmbedding(markdown); - let plaintext: string | null = markdownToPlainText(markdown || ""); - if (isPrivate) { - payload = null; - markdown = null; - plaintext = null; - } - const { error } = await this.supabase.from("issues").insert([{ id: issueNodeId, payload, markdown, plaintext, author_id: authorId, embedding }]); - if (error) { - this.context.logger.error("Error creating issue", error); - return; - } - } - this.context.logger.info("Issue created successfully"); - } - - async updateIssue(markdown: string | null, issueNodeId: string, payload: Record | null, isPrivate: boolean) { - //Create the embedding for this comment - const embedding = Array.from(await this.context.adapters.voyage.embedding.createEmbedding(markdown)); - let plaintext: string | null = markdownToPlainText(markdown || ""); - if (isPrivate) { - markdown = null as string | null; - payload = null as Record | null; - plaintext = null as string | null; - } - const { error } = await this.supabase - .from("issues") - .update({ markdown, plaintext, embedding: embedding, payload, modified_at: new Date() }) - .eq("id", issueNodeId); - if (error) { - this.context.logger.error("Error updating comment", error); - } - } - - async deleteIssue(issueNodeId: string) { - const { error } = await this.supabase.from("issues").delete().eq("id", issueNodeId); - if (error) { - this.context.logger.error("Error deleting comment", error); - } - } - - async findSimilarIssues(markdown: string, threshold: number, currentId: string): Promise { - const embedding = await this.context.adapters.voyage.embedding.createEmbedding(markdown); - const { data, error } = await this.supabase.rpc("find_similar_issues", { - current_id: currentId, - query_embedding: embedding, - threshold: threshold, - }); - if (error) { - this.context.logger.error("Error finding similar issues", error); - return []; - } - return data; - } -} diff --git a/src/adapters/supabase/helpers/supabase.ts b/src/adapters/supabase/helpers/supabase.ts index 34e845c..7a13b85 100644 --- a/src/adapters/supabase/helpers/supabase.ts +++ b/src/adapters/supabase/helpers/supabase.ts @@ -1,7 +1,7 @@ import { SupabaseClient } from "@supabase/supabase-js"; import { Context } from "../../../types/context"; -export class SuperSupabase { +export class Super { protected supabase: SupabaseClient; protected context: Context; diff --git a/src/adapters/utils/markdown-to-plaintext.ts b/src/adapters/utils/markdown-to-plaintext.ts index 93e5787..bc4ff53 100644 --- a/src/adapters/utils/markdown-to-plaintext.ts +++ b/src/adapters/utils/markdown-to-plaintext.ts @@ -6,13 +6,16 @@ import plainTextPlugin from "markdown-it-plain-text"; * @param markdown * @returns */ -export function markdownToPlainText(markdown: string | null): string | null { +export function markdownToPlainText(markdown?: string | null): string { if (!markdown) { - return markdown; + return ""; } const md = markdownit(); md.use(plainTextPlugin); md.render(markdown); - //Package markdown-it-plain-text does not have types - return (md as any).plainText; + return (md as unknown as { plainText: string }).plainText; +} + +export function htmlToPlainText(html: string): string { + return html.replace(/<[^>]*>?/gm, ""); } diff --git a/src/adapters/voyage/helpers/embedding.ts b/src/adapters/voyage/helpers/embedding.ts deleted file mode 100644 index 575543e..0000000 --- a/src/adapters/voyage/helpers/embedding.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { VoyageAIClient } from "voyageai"; -import { Context } from "../../../types"; -import { SuperVoyage } from "./voyage"; -const VECTOR_SIZE = 1024; - -export class Embedding extends SuperVoyage { - protected context: Context; - - constructor(client: VoyageAIClient, context: Context) { - super(client, context); - this.context = context; - } - - async createEmbedding(text: string | null): Promise { - if (text === null) { - return new Array(VECTOR_SIZE).fill(0); - } else { - const response = await this.client.embed({ - input: text, - model: "voyage-large-2-instruct", - }); - return (response.data && response.data[0]?.embedding) || []; - } - } -} diff --git a/src/adapters/voyage/helpers/voyage.ts b/src/adapters/voyage/helpers/voyage.ts deleted file mode 100644 index c08c0af..0000000 --- a/src/adapters/voyage/helpers/voyage.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { VoyageAIClient } from "voyageai"; -import { Context } from "../../../types/context"; - -export class SuperVoyage { - protected client: VoyageAIClient; - protected context: Context; - - constructor(client: VoyageAIClient, context: Context) { - this.client = client; - this.context = context; - } -} diff --git a/src/handlers/add-comments.ts b/src/handlers/add-comments.ts deleted file mode 100644 index 54745a1..0000000 --- a/src/handlers/add-comments.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Context } from "../types"; -import { CommentPayload } from "../types/payload"; - -export async function addComments(context: Context) { - const { - logger, - adapters: { supabase }, - } = context; - const { payload } = context as { payload: CommentPayload }; - const markdown = payload.comment.body; - const authorId = payload.comment.user?.id || -1; - const nodeId = payload.comment.node_id; - const isPrivate = payload.repository.private; - const issueId = payload.issue.node_id; - - try { - if (!markdown) { - throw new Error("Comment body is empty"); - } - await supabase.comment.createComment(markdown, nodeId, authorId, payload, isPrivate, issueId); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error creating comment:`, { error: error, stack: error.stack }); - throw error; - } else { - logger.error(`Error creating comment:`, { err: error, error: new Error() }); - throw error; - } - } - - logger.ok(`Successfully created comment!`); - logger.debug(`Exiting addComments`); -} diff --git a/src/handlers/add-issue.ts b/src/handlers/add-issue.ts deleted file mode 100644 index 969a5c2..0000000 --- a/src/handlers/add-issue.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Context } from "../types"; -import { IssuePayload } from "../types/payload"; - -export async function addIssue(context: Context) { - const { - logger, - adapters: { supabase }, - } = context; - const { payload } = context as { payload: IssuePayload }; - const markdown = payload.issue.body + " " + payload.issue.title || null; - const authorId = payload.issue.user?.id || -1; - const nodeId = payload.issue.node_id; - const isPrivate = payload.repository.private; - - try { - if (!markdown) { - throw new Error("Issue body is empty"); - } - await supabase.issue.createIssue(nodeId, payload, isPrivate, markdown, authorId); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error creating issue:`, { error: error, stack: error.stack }); - throw error; - } else { - logger.error(`Error creating issue:`, { err: error, error: new Error() }); - throw error; - } - } - - logger.ok(`Successfully created issue!`); - logger.debug(`Exiting addIssue`); -} diff --git a/src/handlers/comments/create-comment-embedding.ts b/src/handlers/comments/create-comment-embedding.ts new file mode 100644 index 0000000..ad0f99b --- /dev/null +++ b/src/handlers/comments/create-comment-embedding.ts @@ -0,0 +1,14 @@ +import { CallbackResult } from "../../proxy-callbacks"; +import { Context } from "../../types"; + +export async function createCommentEmbedding(context: Context<"issue_comment.created">): Promise { + const { + logger, + adapters: { supabase }, + } = context; + + const uploaded = await supabase.embeddings.createConversationEmbeddings(context.payload.comment.node_id, context.payload, "comment"); + logger.ok(`Successfully created comment!`, { ...uploaded, embedding: "removed for brevity" }); + + return { status: 200, reason: "success" }; +} diff --git a/src/handlers/comments/delete-comment-embedding.ts b/src/handlers/comments/delete-comment-embedding.ts new file mode 100644 index 0000000..7ae8a8e --- /dev/null +++ b/src/handlers/comments/delete-comment-embedding.ts @@ -0,0 +1,14 @@ +import { CallbackResult } from "../../proxy-callbacks"; +import { Context } from "../../types"; + +export async function deleteCommentEmbedding(context: Context<"issue_comment.deleted">): Promise { + const { + logger, + adapters: { supabase }, + } = context; + + await supabase.embeddings.deleteEmbedding(context.payload.comment.node_id); + logger.ok(`Successfully deleted comment!`, { commentId: context.payload.comment.node_id }); + + return { status: 200, reason: "success" }; +} diff --git a/src/handlers/comments/update-comment-embedding.ts b/src/handlers/comments/update-comment-embedding.ts new file mode 100644 index 0000000..34e639b --- /dev/null +++ b/src/handlers/comments/update-comment-embedding.ts @@ -0,0 +1,17 @@ +import { CallbackResult } from "../../proxy-callbacks"; +import { Context } from "../../types"; + +/** + * Updates embeddings for comments. + */ +export async function updateCommentEmbedding(context: Context<"issue_comment.edited">): Promise { + const { + logger, + adapters: { supabase }, + } = context; + + const updated = await supabase.embeddings.updateConversationEmbeddings(context.payload.comment.node_id, context.payload, "comment"); + logger.ok(`Successfully updated comment!`, { ...updated, embedding: "removed for brevity" }); + + return { status: 200, reason: "success" }; +} diff --git a/src/handlers/delete-comments.ts b/src/handlers/delete-comments.ts deleted file mode 100644 index 8c9e394..0000000 --- a/src/handlers/delete-comments.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Context } from "../types"; -import { CommentPayload } from "../types/payload"; - -export async function deleteComment(context: Context) { - const { - logger, - adapters: { supabase }, - } = context; - const { payload } = context as { payload: CommentPayload }; - const nodeId = payload.comment.node_id; - - try { - await supabase.comment.deleteComment(nodeId); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error deleting comment:`, { error: error, stack: error.stack }); - throw error; - } else { - logger.error(`Error deleting comment:`, { err: error, error: new Error() }); - throw error; - } - } - - logger.ok(`Successfully deleted comment!`); - logger.debug(`Exiting deleteComments`); -} diff --git a/src/handlers/delete-issue.ts b/src/handlers/delete-issue.ts deleted file mode 100644 index b392c69..0000000 --- a/src/handlers/delete-issue.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Context } from "../types"; -import { IssuePayload } from "../types/payload"; - -export async function deleteIssues(context: Context) { - const { - logger, - adapters: { supabase }, - } = context; - const { payload } = context as { payload: IssuePayload }; - const nodeId = payload.issue.node_id; - - try { - await supabase.issue.deleteIssue(nodeId); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error deleting issue:`, { error: error, stack: error.stack }); - throw error; - } else { - logger.error(`Error deleting issue:`, { err: error, error: new Error() }); - throw error; - } - } - - logger.ok(`Successfully deleted issue!`); - logger.debug(`Exiting deleteIssue`); -} diff --git a/src/handlers/onboarding/handle-repo-docs.ts b/src/handlers/onboarding/handle-repo-docs.ts new file mode 100644 index 0000000..1fee191 --- /dev/null +++ b/src/handlers/onboarding/handle-repo-docs.ts @@ -0,0 +1,92 @@ +import { CallbackResult } from "../../proxy-callbacks"; +import { Context } from "../../types"; + +/** + * Will create embeddings for any .md files found in the repository. + * Would benefit from a structured schema, but most of our readmes are + * pretty uniform anyway. + * + * Storage schema looks like: + * + * ```json + * { + * "sourceId": "owner/repo/file.md", + * "type": "setup_instructions", + * "plaintext": "file content", + * "metadata": { + * "author_association": "OWNER", + * "author_id": 123456, + */ +export async function handleRepoDocuments(context: Context<"push">): Promise { + const { + logger, + octokit, + adapters: { supabase }, + payload: { repository, commits, sender, pusher } + } = context; + + const docs = [] + + for (const commit of commits) { + const { added, modified } = commit; + const files = [] + + if (added && added.length > 0) { + files.push(...added) + } + if (modified && modified.length > 0) { + files.push(...modified) + } + + for (const file of files) { + if (file.endsWith(".md")) { + docs.push(file) + } + } + } + + if (docs.length === 0) { + return { status: 200, reason: "no markdown files found" }; + } + + logger.info(`Found ${docs.length} markdown files`); + if (!repository.owner || !repository.name) { + return { status: 200, reason: "no repository owner or name found" }; + } + + /** + * voyageai uses a special encoding schema and we cannot easily + * use their encoder so we will just have to play it by ear for now. + */ + for (const doc of docs) { + const sourceId = repository.full_name + "/" + doc; + const docContent = await octokit.repos.getContent({ + owner: repository.owner.login, + repo: repository.name, + path: doc, + mediaType: { + format: "raw", + } + }); + + if (!docContent.data) { + return { status: 200, reason: "no content found" }; + } + + const text = docContent.data as unknown as string; + + const uploaded = await supabase.embeddings.createEmbedding(sourceId, "setup_instructions", text, { + isPrivate: repository.private, + repo_node_id: repository.node_id, + repo_full_name: repository.full_name, + filePath: doc, + fileChunkIndex: 0, + }); + + logger.info("Uploaded markdown file", { ...uploaded, embedding: "removed for brevity" }); + } + + logger.ok("Successfully uploaded setup instructions", { repository: repository.full_name }); + + return { status: 200, reason: "success" }; +} diff --git a/src/handlers/tasks/create-task-embedding.ts b/src/handlers/tasks/create-task-embedding.ts new file mode 100644 index 0000000..75e4758 --- /dev/null +++ b/src/handlers/tasks/create-task-embedding.ts @@ -0,0 +1,14 @@ +import { CallbackResult } from "../../proxy-callbacks"; +import { Context } from "../../types"; + +export async function addTaskEmbedding(context: Context<"issues.opened">): Promise { + const { + logger, + adapters: { supabase }, + } = context; + + const uploaded = await supabase.embeddings.createConversationEmbeddings(context.payload.issue.node_id, context.payload, "task"); + logger.ok(`Successfully created issue!`, { ...uploaded, embedding: "removed for brevity" }); + + return { status: 200, reason: "success" }; +} diff --git a/src/handlers/tasks/delete-task-embedding.ts b/src/handlers/tasks/delete-task-embedding.ts new file mode 100644 index 0000000..6f52190 --- /dev/null +++ b/src/handlers/tasks/delete-task-embedding.ts @@ -0,0 +1,14 @@ +import { CallbackResult } from "../../proxy-callbacks"; +import { Context } from "../../types"; + +export async function deleteTaskEmbedding(context: Context<"issues.deleted">): Promise { + const { + logger, + adapters: { supabase }, + } = context; + + await supabase.embeddings.deleteEmbedding(context.payload.issue.node_id); + logger.ok(`Successfully deleted issue!`, { issueNodeId: context.payload.issue.node_id }); + + return { status: 200, reason: "success" }; +} diff --git a/src/handlers/issue-deduplication.ts b/src/handlers/tasks/task-deduplication.ts similarity index 68% rename from src/handlers/issue-deduplication.ts rename to src/handlers/tasks/task-deduplication.ts index 2378a1e..fe7dcc4 100644 --- a/src/handlers/issue-deduplication.ts +++ b/src/handlers/tasks/task-deduplication.ts @@ -1,6 +1,5 @@ -import { IssueSimilaritySearchResult } from "../adapters/supabase/helpers/issues"; -import { Context } from "../types"; -import { IssuePayload } from "../types/payload"; +import { Context } from "../../types"; +import { IssueSimilaritySearchResult } from "../../types/embeddings"; export interface IssueGraphqlResponse { node: { @@ -15,28 +14,33 @@ export interface IssueGraphqlResponse { * @param context * @returns true if the issue is similar to an existing issue, false otherwise */ -export async function issueChecker(context: Context): Promise { +export async function taskSimilaritySearch(context: Context<"issues.opened">): Promise { const { logger, adapters: { supabase }, octokit, } = context; - const { payload } = context as { payload: IssuePayload }; - const issue = payload.issue; - const issueContent = issue.body + issue.title; + const { + payload: { issue, repository }, + } = context; + const similarIssues: IssueSimilaritySearchResult[] = []; + + similarIssues.push(...(await supabase.embeddings.findSimilarIssues(issue.title, context.config.warningThreshold, issue.node_id))); + if (issue.body) { + similarIssues.push(...(await supabase.embeddings.findSimilarIssues(issue.body, context.config.warningThreshold, issue.node_id))); + } + + logger.info(`Found ${similarIssues.length} similar issues`); - // Fetch all similar issues based on settings.warningThreshold - const similarIssues = await supabase.issue.findSimilarIssues(issueContent, context.config.warningThreshold, issue.node_id); - console.log(similarIssues); if (similarIssues && similarIssues.length > 0) { - const matchIssues = similarIssues.filter((issue) => issue.similarity >= context.config.matchThreshold); + const matchIssues = similarIssues.filter((issue) => issue?.similarity >= context.config.matchThreshold); // Handle issues that match the MATCH_THRESHOLD (Very Similar) if (matchIssues.length > 0) { logger.info(`Similar issue which matches more than ${context.config.matchThreshold} already exists`); await octokit.issues.update({ - owner: payload.repository.owner.login, - repo: payload.repository.name, + owner: repository.owner.login, + repo: repository.name, issue_number: issue.number, state: "closed", state_reason: "not_planned", @@ -46,7 +50,7 @@ export async function issueChecker(context: Context): Promise { // Handle issues that match the settings.warningThreshold but not the MATCH_THRESHOLD if (similarIssues.length > 0) { logger.info(`Similar issue which matches more than ${context.config.warningThreshold} already exists`); - await handleSimilarIssuesComment(context, payload, issue.number, similarIssues); + await handleSimilarIssuesComment(context, issue.number, similarIssues); return true; } } @@ -61,7 +65,8 @@ export async function issueChecker(context: Context): Promise { * @param issueNumber * @param similarIssues */ -async function handleSimilarIssuesComment(context: Context, payload: IssuePayload, issueNumber: number, similarIssues: IssueSimilaritySearchResult[]) { +async function handleSimilarIssuesComment(context: Context, issueNumber: number, similarIssues: IssueSimilaritySearchResult[]) { + const { payload } = context; const issueList: IssueGraphqlResponse[] = await Promise.all( similarIssues.map(async (issue: IssueSimilaritySearchResult) => { const issueUrl: IssueGraphqlResponse = await context.octokit.graphql( @@ -83,6 +88,10 @@ async function handleSimilarIssuesComment(context: Context, payload: IssuePayloa const commentBody = issueList.map((issue) => `- [${issue.node.title}](${issue.node.url}) Similarity: ${issue.similarity}`).join("\n"); const body = `This issue seems to be similar to the following issue(s):\n\n${commentBody}`; + if (!payload.repository.owner || !payload.repository.name) { + return; + } + const existingComments = await context.octokit.issues.listComments({ owner: payload.repository.owner.login, repo: payload.repository.name, @@ -93,6 +102,10 @@ async function handleSimilarIssuesComment(context: Context, payload: IssuePayloa (comment) => comment.body && comment.body.includes("This issue seems to be similar to the following issue(s)") ); + if (!payload.repository.owner || !payload.repository.name) { + return; + } + if (existingComment) { await context.octokit.issues.updateComment({ owner: payload.repository.owner.login, diff --git a/src/handlers/tasks/update-task-embedding.ts b/src/handlers/tasks/update-task-embedding.ts new file mode 100644 index 0000000..1728978 --- /dev/null +++ b/src/handlers/tasks/update-task-embedding.ts @@ -0,0 +1,14 @@ +import { CallbackResult } from "../../proxy-callbacks"; +import { Context } from "../../types"; + +export async function updateTaskEmbedding(context: Context<"issues.edited">): Promise { + const { + logger, + adapters: { supabase }, + } = context; + + const updated = await supabase.embeddings.updateConversationEmbeddings(context.payload.issue.node_id, context.payload, "task"); + logger.ok(`Successfully updated issue!`, { ...updated, embedding: "removed for brevity" }); + + return { status: 200, reason: "success" }; +} diff --git a/src/handlers/update-comments.ts b/src/handlers/update-comments.ts deleted file mode 100644 index b1b9d18..0000000 --- a/src/handlers/update-comments.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { Context } from "../types"; -import { CommentPayload } from "../types/payload"; - -export async function updateComment(context: Context) { - const { - logger, - adapters: { supabase }, - } = context; - const { payload } = context as { payload: CommentPayload }; - const nodeId = payload.comment.node_id; - const isPrivate = payload.repository.private; - const markdown = payload.comment.body || null; - // Fetch the previous comment and update it in the db - try { - if (!markdown) { - throw new Error("Comment body is empty"); - } - await supabase.comment.updateComment(markdown, nodeId, payload, isPrivate); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error updating comment:`, { error: error, stack: error.stack }); - throw error; - } else { - logger.error(`Error updating comment:`, { err: error, error: new Error() }); - throw error; - } - } - - logger.ok(`Successfully updated comment!`); - logger.debug(`Exiting updateComment`); -} diff --git a/src/handlers/update-issue.ts b/src/handlers/update-issue.ts deleted file mode 100644 index 763b2ba..0000000 --- a/src/handlers/update-issue.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Context } from "../types"; -import { IssuePayload } from "../types/payload"; - -export async function updateIssue(context: Context) { - const { - logger, - adapters: { supabase }, - } = context; - const { payload } = context as { payload: IssuePayload }; - const payloadObject = payload; - const nodeId = payload.issue.node_id; - const isPrivate = payload.repository.private; - const markdown = payload.issue.body + " " + payload.issue.title || null; - // Fetch the previous issue and update it in the db - try { - if (!markdown) { - throw new Error("Issue body is empty"); - } - await supabase.issue.updateIssue(markdown, nodeId, payloadObject, isPrivate); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error updating issue:`, { error: error, stack: error.stack }); - throw error; - } else { - logger.error(`Error updating issue:`, { err: error, error: new Error() }); - throw error; - } - } - - logger.ok(`Successfully updated issue!`); - logger.debug(`Exiting updateIssue`); -} diff --git a/src/plugin.ts b/src/plugin.ts index 0d0876c..83ccfc5 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,48 +1,25 @@ import { Octokit } from "@octokit/rest"; import { Env, PluginInputs } from "./types"; import { Context } from "./types"; -import { isIssueCommentEvent, isIssueEvent } from "./types/typeguards"; import { LogLevel, Logs } from "@ubiquity-dao/ubiquibot-logger"; import { Database } from "./types/database"; import { createAdapters } from "./adapters"; import { createClient } from "@supabase/supabase-js"; -import { addComments } from "./handlers/add-comments"; -import { updateComment } from "./handlers/update-comments"; -import { deleteComment } from "./handlers/delete-comments"; import { VoyageAIClient } from "voyageai"; -import { deleteIssues } from "./handlers/delete-issue"; -import { addIssue } from "./handlers/add-issue"; -import { updateIssue } from "./handlers/update-issue"; -import { issueChecker } from "./handlers/issue-deduplication"; +import { proxyCallbacks } from "./proxy-callbacks"; /** * The main plugin function. Split for easier testing. */ export async function runPlugin(context: Context) { const { logger, eventName } = context; - if (isIssueCommentEvent(context)) { - switch (eventName) { - case "issue_comment.created": - return await addComments(context); - case "issue_comment.deleted": - return await deleteComment(context); - case "issue_comment.edited": - return await updateComment(context); - } - } else if (isIssueEvent(context)) { - switch (eventName) { - case "issues.opened": - await issueChecker(context); - return await addIssue(context); - case "issues.edited": - await issueChecker(context); - return await updateIssue(context); - case "issues.deleted": - return await deleteIssues(context); - } - } else { - logger.error(`Unsupported event: ${eventName}`); + + try { + return proxyCallbacks(context)[eventName]; + } catch (err) { + logger.error(`Error running plugin`, { err }); } + logger.ok(`Exiting plugin`); } diff --git a/src/proxy-callbacks.ts b/src/proxy-callbacks.ts new file mode 100644 index 0000000..54f77b1 --- /dev/null +++ b/src/proxy-callbacks.ts @@ -0,0 +1,102 @@ +import { createCommentEmbedding } from "./handlers/comments/create-comment-embedding"; +import { addTaskEmbedding } from "./handlers/tasks/create-task-embedding"; +import { deleteCommentEmbedding } from "./handlers/comments/delete-comment-embedding"; +import { deleteTaskEmbedding } from "./handlers/tasks/delete-task-embedding"; +import { taskSimilaritySearch } from "./handlers/tasks/task-deduplication"; +import { updateCommentEmbedding } from "./handlers/comments/update-comment-embedding"; +import { updateTaskEmbedding } from "./handlers/tasks/update-task-embedding"; +import { Context, SupportedEvents, SupportedEventsU } from "./types"; +import { handleRepoDocuments } from "./handlers/onboarding/handle-repo-docs"; + +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>; +}; + +/** + * 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": [createCommentEmbedding], + "issue_comment.edited": [updateCommentEmbedding], + "issue_comment.deleted": [deleteCommentEmbedding], + + "issues.opened": [addTaskEmbedding, taskSimilaritySearch], + "issues.edited": [updateTaskEmbedding], + "issues.deleted": [deleteTaskEmbedding], + + "push": [handleRepoDocuments] +} 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) { + context.logger.error(`Failed to handle event ${prop}`, { er }); + return { status: 500, reason: "failed" }; + } + })(); + }, + }); +} + +/** + * 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/types/context.ts b/src/types/context.ts index 1227abf..e5a7709 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -16,7 +16,8 @@ export type SupportedEventsU = | "issue_comment.edited" | "issues.opened" | "issues.edited" - | "issues.deleted"; + | "issues.deleted" + | "push" export type SupportedEvents = { [K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent : never; diff --git a/src/types/embeddings.ts b/src/types/embeddings.ts new file mode 100644 index 0000000..918d7e8 --- /dev/null +++ b/src/types/embeddings.ts @@ -0,0 +1,24 @@ +export type EmbeddingClass = "setup_instructions" | "dao_info" | "task" | "comment"; +export type CommentType = { + source_id: string; + type: string; + plaintext: string | null; + embedding: number[]; + metadata: Partial; + created_at: string; + modified_at: string; +}; +export interface CommentMetadata { + author_association: string | null; + author_id: number; + issue_node_id: string; + repo_node_id: string; + isPrivate: boolean; + [key: string]: any; +} + +export interface IssueSimilaritySearchResult { + issue_id: string; + issue_plaintext: string; + similarity: number; +} diff --git a/src/types/payload.ts b/src/types/payload.ts deleted file mode 100644 index 395fa09..0000000 --- a/src/types/payload.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks"; -export type CommentPayload = WebhookEvent<"issue_comment">["payload"]; -export type IssuePayload = WebhookEvent<"issues">["payload"]; diff --git a/src/types/typeguards.ts b/src/types/typeguards.ts index 01a6c26..1cb28f0 100644 --- a/src/types/typeguards.ts +++ b/src/types/typeguards.ts @@ -6,20 +6,18 @@ import { Context } from "./context"; * of `context` to a specific event payload. */ -/** - * Restricts the scope of `context` to the `issue_comment.created`, `issue_comment.deleted`, and `issue_comment.edited` payloads. - * - * @param context The context object. - */ -export function isIssueCommentEvent(context: Context): context is Context<"issue_comment.created" | "issue_comment.deleted" | "issue_comment.edited"> { - return context.eventName === "issue_comment.created" || context.eventName === "issue_comment.deleted" || context.eventName === "issue_comment.edited"; +export function isIssueCommentEvent( + payload: unknown +): payload is Context<"issue_comment.created" | "issue_comment.edited" | "issue_comment.deleted">["payload"] { + if (typeof payload !== "object" || payload === null) { + return false; + } + return "comment" in payload; } -/** - * Restricts the scope of `context` to the `issues.opened`, `issues.edited`, and `issues.deleted` payloads. - * - * @param context The context object. - */ -export function isIssueEvent(context: Context): context is Context<"issues.opened" | "issues.edited" | "issues.deleted"> { - return context.eventName === "issues.opened" || context.eventName === "issues.edited" || context.eventName === "issues.deleted"; +export function isIssueEvent(payload: unknown): payload is Context<"issues.opened" | "issues.edited" | "issues.deleted">["payload"] { + if (typeof payload !== "object" || payload === null) { + return false; + } + return "issue" in payload; } diff --git a/tests/__mocks__/adapter.ts b/tests/__mocks__/adapter.ts deleted file mode 100644 index d1f634c..0000000 --- a/tests/__mocks__/adapter.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { Context } from "../../src/types"; -import { Comment } from "../../src/adapters/supabase/helpers/comment"; -import { STRINGS } from "./strings"; - -export interface CommentMock { - id: string; - plaintext: string | null; - author_id: number; - payload?: Record | null; - type?: string; - issue_id?: string; - embedding: number[]; -} - -export function createMockAdapters(context: Context) { - const commentMap: Map = new Map(); - return { - supabase: { - comment: { - createComment: jest.fn( - async ( - plaintext: string | null, - commentNodeId: string, - authorId: number, - payload: Record | null, - isPrivate: boolean, - issueId: string - ) => { - if (commentMap.has(commentNodeId)) { - throw new Error("Comment already exists"); - } - const embedding = await context.adapters.voyage.embedding.createEmbedding(plaintext); - if (isPrivate) { - plaintext = null; - } - commentMap.set(commentNodeId, { id: commentNodeId, plaintext, author_id: authorId, embedding, issue_id: issueId }); - } - ), - updateComment: jest.fn(async (plaintext: string | null, commentNodeId: string, payload: Record | null, isPrivate: boolean) => { - if (!commentMap.has(commentNodeId)) { - throw new Error(STRINGS.COMMENT_DOES_NOT_EXIST); - } - const originalComment = commentMap.get(commentNodeId); - if (!originalComment) { - throw new Error(STRINGS.COMMENT_DOES_NOT_EXIST); - } - const { id, author_id } = originalComment; - const embedding = await context.adapters.voyage.embedding.createEmbedding(plaintext); - if (isPrivate) { - plaintext = null; - } - commentMap.set(commentNodeId, { id, plaintext, author_id, embedding }); - }), - deleteComment: jest.fn(async (commentNodeId: string) => { - if (!commentMap.has(commentNodeId)) { - throw new Error(STRINGS.COMMENT_DOES_NOT_EXIST); - } - commentMap.delete(commentNodeId); - }), - getComment: jest.fn(async (commentNodeId: string) => { - if (!commentMap.has(commentNodeId)) { - throw new Error(STRINGS.COMMENT_DOES_NOT_EXIST); - } - return commentMap.get(commentNodeId); - }), - } as unknown as Comment, - }, - voyage: { - embedding: { - createEmbedding: jest.fn(async (text: string) => { - if (text && text.length > 0) { - return new Array(3072).fill(1); - } - return new Array(3072).fill(0); - }), - } as unknown as number[], - }, - }; -} diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index 1681106..80ea000 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -5,12 +5,23 @@ import { factory, nullable, primaryKey } from "@mswjs/data"; * Creates an object that can be used as a db to persist data within tests */ export const db = factory({ + content: { + id: primaryKey(Number), + source_id: String, + type: String, + plaintext: nullable(String), + embedding: Array, + metadata: Object, + created_at: Date, + modified_at: Date, + }, users: { id: primaryKey(Number), login: String, avatar_url: nullable(String), // Add any additional fields based on the schema }, repo: { + node_id: String, id: primaryKey(Number), name: String, full_name: String, // Assuming full_name is needed for repo @@ -62,6 +73,7 @@ export const db = factory({ deployments_url: String, }, issue: { + node_id: String, id: primaryKey(Number), number: Number, title: String, diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index 0019673..3c0ae8e 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -1,5 +1,7 @@ import { http, HttpResponse } from "msw"; import { db } from "./db"; + +const FAKE_DB_URL = "https://fymwbgfvpmbhkqzlpmfdr.supabase.co/rest/v1/content"; /** * Intercepts the routes and returns a custom payload */ @@ -48,6 +50,37 @@ export const handlers = [ item.body = body; return HttpResponse.json(item); }), + // fake DB URL + http.get(FAKE_DB_URL, async ({ request }) => { + const url = new URL(request.url); + const query = url.searchParams.get("source_id"); + const sourceId = query?.split(".")[1]; + + const item = db.content.findMany({ where: { source_id: { equals: sourceId } } }); + if (!item || item.length === 0) { + return new HttpResponse(null); + } + + return HttpResponse.json(item[0]); + }), + // fake DB URL + http.patch(FAKE_DB_URL, async () => { + return HttpResponse.json({}); + }), + // fake DB URL + http.post(FAKE_DB_URL, async () => { + return HttpResponse.json({}); + }), + // fake DB URL + http.delete(FAKE_DB_URL, async () => { + return HttpResponse.json({}); + }), + http.post("https://fymwbgfvpmbhkqzlpmfdr.supabase.co/rest/v1/rpc/find_similar_issues", async () => { + return HttpResponse.json([]); + }), + http.post("https://api.voyageai.com/v1/embeddings", async () => { + return HttpResponse.json({ data: [{ embedding: new Array(12).fill(0) }] }); + }), ]; async function getValue(body: ReadableStream | null) { diff --git a/tests/__mocks__/helpers.ts b/tests/__mocks__/helpers.ts index 236ac8e..addfa3d 100644 --- a/tests/__mocks__/helpers.ts +++ b/tests/__mocks__/helpers.ts @@ -25,6 +25,7 @@ export async function setupTests() { name: STRINGS.TEST_REPO, full_name: `${STRINGS.USER_1}/${STRINGS.TEST_REPO}`, private: false, + node_id: "test_repo1", owner: { login: STRINGS.USER_1, id: 1, @@ -36,6 +37,7 @@ export async function setupTests() { db.issue.create({ id: 1, number: 1, + node_id: "test_issue1", title: "First Issue", body: "This is the body of the first issue.", user: { @@ -67,6 +69,7 @@ export async function setupTests() { db.issue.create({ id: 2, number: 2, + node_id: "test_issue2", title: "Second Issue", body: "This is the body of the second issue.", user: { diff --git a/tests/__mocks__/issue-template.ts b/tests/__mocks__/issue-template.ts index 17f8451..33c96b9 100644 --- a/tests/__mocks__/issue-template.ts +++ b/tests/__mocks__/issue-template.ts @@ -48,7 +48,7 @@ export default { html_url: "", id: 1, name: "undefined", - node_id: "", + node_id: "test_issue1", organizations_url: "", received_events_url: "", repos_url: "", diff --git a/tests/__mocks__/strings.ts b/tests/__mocks__/strings.ts index 83d7306..08695ca 100644 --- a/tests/__mocks__/strings.ts +++ b/tests/__mocks__/strings.ts @@ -15,4 +15,8 @@ export const STRINGS = { CONFIGURABLE_RESPONSE: "Hello, Code Reviewers!", INVALID_COMMAND: "/Goodbye", COMMENT_DOES_NOT_EXIST: "Comment does not exist", + REMOVED_FOR_BREVITY: "removed for brevity", + LOGS_ANON: "_Logs.", + UPDATED_MESSAGE: "Updated message", + ISSUES_OPENED: "issues.opened", }; diff --git a/tests/main.test.ts b/tests/main.test.ts index a8c14b8..a7c2ab0 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -4,7 +4,7 @@ import { drop } from "@mswjs/data"; import { db } from "./__mocks__/db"; import { server } from "./__mocks__/node"; import { expect, describe, beforeAll, beforeEach, afterAll, afterEach, it } from "@jest/globals"; -import { Context, SupportedEvents } from "../src/types/context"; +import { Context, SupportedEvents, SupportedEventsU } from "../src/types/context"; import { Octokit } from "@octokit/rest"; import { STRINGS } from "./__mocks__/strings"; import { createComment, setupTests } from "./__mocks__/helpers"; @@ -13,7 +13,9 @@ import dotenv from "dotenv"; import { Logs } from "@ubiquity-dao/ubiquibot-logger"; import { Env } from "../src/types"; import { runPlugin } from "../src/plugin"; -import { CommentMock, createMockAdapters } from "./__mocks__/adapter"; +import { createAdapters } from "../src/adapters"; +import { createClient } from "@supabase/supabase-js"; +import { VoyageAIClient } from "voyageai"; dotenv.config(); jest.requireActual("@octokit/rest"); @@ -26,6 +28,7 @@ beforeAll(() => { afterEach(() => { server.resetHandlers(); jest.clearAllMocks(); + jest.resetModules(); }); afterAll(() => server.close()); @@ -46,51 +49,129 @@ describe("Plugin tests", () => { expect(content).toEqual(manifest); }); - it("When a comment is created it should add it to the database", async () => { - const { context } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, 1, "sasasCreate"); - await runPlugin(context); - const supabase = context.adapters.supabase; - const commentObject = null; - try { - await supabase.comment.createComment(STRINGS.HELLO_WORLD, "sasasCreate", 1, commentObject, false, "sasasCreateIssue"); - throw new Error("Expected method to reject."); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toBe("Comment already exists"); - } - } - const comment = (await supabase.comment.getComment("sasasCreate")) as unknown as CommentMock; - expect(comment).toBeDefined(); - expect(comment?.plaintext).toBeDefined(); - expect(comment?.plaintext).toBe(STRINGS.HELLO_WORLD); + it("should create and store embeddings for comments", async () => { + const { context, okSpy } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, 1, "test"); + await expect(runPlugin(context)).resolves.toEqual([{ status: 200, reason: "success" }]); + + expect(okSpy).toHaveBeenCalledTimes(1); + expect(okSpy).toHaveBeenNthCalledWith(1, "Successfully created comment!", { + source_id: "test", + type: "comment", + plaintext: `${STRINGS.HELLO_WORLD}`, + embedding: STRINGS.REMOVED_FOR_BREVITY, + metadata: { + authorAssociation: "OWNER", + authorId: 1, + isPrivate: false, + issueNodeId: "test_issue1", + repoNodeId: "test_repo1", + }, + created_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + modified_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + caller: STRINGS.LOGS_ANON, + }); + }); + + it("should update the embeddings for comments", async () => { + const { context: ctx } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, 1, "test"); + await runPlugin(ctx); + + const { context, okSpy } = createContext(STRINGS.UPDATED_MESSAGE, 1, 1, 1, 1, "test", "issue_comment.edited"); + await expect(runPlugin(context)).resolves.toEqual([{ status: 200, reason: "success" }]); + const updatedComment = db.issueComments.findFirst({ where: { id: { equals: 1 } } }); + expect(updatedComment?.body).toEqual(STRINGS.UPDATED_MESSAGE); + expect(okSpy).toHaveBeenCalledTimes(1); + expect(okSpy).toHaveBeenNthCalledWith(1, "Successfully updated comment!", { + source_id: "test", + type: "comment", + plaintext: `${STRINGS.UPDATED_MESSAGE}`, + embedding: STRINGS.REMOVED_FOR_BREVITY, + metadata: { + authorAssociation: "OWNER", + authorId: 1, + issueNodeId: "test_issue1", + repoNodeId: "test_repo1", + isPrivate: false, + }, + modified_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + created_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + caller: STRINGS.LOGS_ANON, + }); + }); + + it("should delete the embeddings for comments", async () => { + const { context: ctx } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, 1, "test"); + await runPlugin(ctx); + + const { context, okSpy } = createContext(STRINGS.UPDATED_MESSAGE, 1, 1, 1, 1, "test", "issue_comment.deleted"); + await expect(runPlugin(context)).resolves.toEqual([{ status: 200, reason: "success" }]); + expect(okSpy).toHaveBeenCalledTimes(1); + expect(okSpy).toHaveBeenNthCalledWith(1, "Successfully deleted comment!", { + commentId: "test", + caller: STRINGS.LOGS_ANON, + }); + }); + + it("should create and store embeddings for issues", async () => { + const { context, okSpy } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, 1, "test", STRINGS.ISSUES_OPENED as SupportedEventsU); + const hasFoundSimilarIssues = false; + await expect(runPlugin(context)).resolves.toEqual([{ status: 200, reason: "success" }, hasFoundSimilarIssues]); + + expect(okSpy).toHaveBeenCalledTimes(1); + expect(okSpy).toHaveBeenNthCalledWith(1, "Successfully created issue!", { + source_id: "test_issue1", + type: "task", + plaintext: `${STRINGS.HELLO_WORLD}`, + embedding: STRINGS.REMOVED_FOR_BREVITY, + metadata: { + authorAssociation: "OWNER", + authorId: 1, + isPrivate: false, + issueNodeId: "test_issue1", + repoNodeId: "test_repo1", + }, + created_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + modified_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + caller: STRINGS.LOGS_ANON, + }); }); - it("When a comment is updated it should update the database", async () => { - const { context } = createContext("Updated Message", 1, 1, 1, 1, "sasasUpdate", "issue_comment.edited"); - const supabase = context.adapters.supabase; - const commentObject = null; - await supabase.comment.createComment(STRINGS.HELLO_WORLD, "sasasUpdate", 1, commentObject, false, "sasasUpdateIssue"); - await runPlugin(context); - const comment = (await supabase.comment.getComment("sasasUpdate")) as unknown as CommentMock; - expect(comment).toBeDefined(); - expect(comment?.plaintext).toBeDefined(); - expect(comment?.plaintext).toBe("Updated Message"); + it("should update the embeddings for issues", async () => { + const { context: ctx } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, 1, "test", STRINGS.ISSUES_OPENED as SupportedEventsU); + await runPlugin(ctx); + + const { context, okSpy } = createContext(STRINGS.UPDATED_MESSAGE, 1, 1, 1, 1, "test", "issues.edited"); + await expect(runPlugin(context)).resolves.toEqual([{ status: 200, reason: "success" }]); + expect(okSpy).toHaveBeenCalledTimes(1); + expect(okSpy).toHaveBeenNthCalledWith(1, "Successfully updated issue!", { + source_id: "test_issue1", + type: "task", + plaintext: `${STRINGS.UPDATED_MESSAGE}`, + embedding: STRINGS.REMOVED_FOR_BREVITY, + metadata: { + authorAssociation: "OWNER", + authorId: 1, + issueNodeId: "test_issue1", + repoNodeId: "test_repo1", + isPrivate: false, + }, + modified_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + created_at: expect.stringMatching(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/), + caller: STRINGS.LOGS_ANON, + }); }); - it("When a comment is deleted it should delete it from the database", async () => { - const { context } = createContext("Text Message", 1, 1, 1, 1, "sasasDelete", "issue_comment.deleted"); - const supabase = context.adapters.supabase; - const commentObject = null; - await supabase.comment.createComment(STRINGS.HELLO_WORLD, "sasasDelete", 1, commentObject, false, "sasasDeleteIssue"); - await runPlugin(context); - try { - await supabase.comment.getComment("sasasDelete"); - throw new Error("Expected method to reject."); - } catch (error) { - if (error instanceof Error) { - expect(error.message).toBe("Comment does not exist"); - } - } + it("should delete the embeddings for issues", async () => { + const { context: ctx } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, 1, "test", STRINGS.ISSUES_OPENED as SupportedEventsU); + await runPlugin(ctx); + + const { context, okSpy } = createContext(STRINGS.UPDATED_MESSAGE, 1, 1, 1, 1, "test", "issues.deleted"); + await expect(runPlugin(context)).resolves.toEqual([{ status: 200, reason: "success" }]); + expect(okSpy).toHaveBeenCalledTimes(1); + expect(okSpy).toHaveBeenNthCalledWith(1, "Successfully deleted issue!", { + issueNodeId: "test_issue1", + caller: STRINGS.LOGS_ANON, + }); }); }); @@ -108,12 +189,21 @@ function createContext( payloadSenderId: number = 1, commentId: number = 1, issueOne: number = 1, - nodeId: string = "sasas", - eventName: Context["eventName"] = "issue_comment.created" -) { + nodeId: string, + eventName: SupportedEventsU = "issue_comment.created" +): { + context: Context<"issue_comment.created">; + infoSpy: jest.SpyInstance; + errorSpy: jest.SpyInstance; + debugSpy: jest.SpyInstance; + okSpy: jest.SpyInstance; + verboseSpy: jest.SpyInstance; + repo: Context["payload"]["repository"]; + issue1: Context<"issue_comment.created">["payload"]["issue"]; +} { const repo = db.repo.findFirst({ where: { id: { equals: repoId } } }) as unknown as Context["payload"]["repository"]; const sender = db.users.findFirst({ where: { id: { equals: payloadSenderId } } }) as unknown as Context["payload"]["sender"]; - const issue1 = db.issue.findFirst({ where: { id: { equals: issueOne } } }) as unknown as Context["payload"]["issue"]; + const issue1 = db.issue.findFirst({ where: { id: { equals: issueOne } } }) as unknown as Context<"issue_comment.created">["payload"]["issue"]; createComment(commentBody, commentId, nodeId); // create it first then pull it from the DB and feed it to _createContext const comment = db.issueComments.findFirst({ @@ -121,7 +211,6 @@ function createContext( }) as unknown as unknown as SupportedEvents["issue_comment.created"]["payload"]["comment"]; const context = createContextInner(repo, sender, issue1, comment, eventName); - context.adapters = createMockAdapters(context) as unknown as Context["adapters"]; const infoSpy = jest.spyOn(context.logger, "info"); const errorSpy = jest.spyOn(context.logger, "error"); const debugSpy = jest.spyOn(context.logger, "debug"); @@ -148,12 +237,12 @@ function createContext( function createContextInner( repo: Context["payload"]["repository"], sender: Context["payload"]["sender"], - issue: Context["payload"]["issue"], + issue: Context<"issue_comment.created">["payload"]["issue"], comment: SupportedEvents["issue_comment.created"]["payload"]["comment"], - eventName: Context["eventName"] = "issue_comment.created" -): Context { - return { - eventName: eventName, + eventName: SupportedEventsU +): Context<"issue_comment.created"> { + const ctx = { + eventName: eventName as "issue_comment.created", payload: { action: "created", sender: sender, @@ -162,14 +251,22 @@ function createContextInner( comment: comment, installation: { id: 1 } as Context["payload"]["installation"], organization: { login: STRINGS.USER_1 } as Context["payload"]["organization"], - } as Context["payload"], + } as Context<"issue_comment.created">["payload"], config: { warningThreshold: 0.75, matchThreshold: 0.95, }, adapters: {} as Context["adapters"], logger: new Logs("debug"), - env: {} as Env, + env: { + SUPABASE_KEY: "test", + // fake DB URL + SUPABASE_URL: "https://fymwbgfvpmbhkqzlpmfdr.supabase.co/", + VOYAGEAI_API_KEY: "test", + } as Env, octokit: octokit, }; + + ctx.adapters = createAdapters(createClient(ctx.env.SUPABASE_URL, ctx.env.SUPABASE_KEY), new VoyageAIClient({ apiKey: ctx.env.VOYAGEAI_API_KEY }), ctx); + return ctx; }