From 6c8a8dbbe4e06253093781101b6107c12be42967 Mon Sep 17 00:00:00 2001 From: Shivaditya Shivganesh Date: Mon, 23 Sep 2024 11:51:30 -0400 Subject: [PATCH 1/5] feat: label added hook --- README.md | 2 +- manifest.json | 2 +- src/adapters/supabase/helpers/issues.ts | 25 ++ src/handlers/label-added.ts | 77 +++++ src/plugin.ts | 3 + src/types/context.ts | 3 +- src/types/database.ts | 404 ++++++++++++++++++++++++ src/types/plugin-inputs.ts | 3 +- tests/main.test.ts | 1 + 9 files changed, 516 insertions(+), 4 deletions(-) create mode 100644 src/handlers/label-added.ts diff --git a/README.md b/README.md index cac1f27..fa0099e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To set up the `.dev.vars` file, you will need to provide the following variables - 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"] + runsOn: [ "issue_comment.created", "issue_comment.edited", "issue_comment.deleted" , "issues.opened", "issues.edited", "issues.deleted", "issues.labled"] ``` diff --git a/manifest.json b/manifest.json index 328a300..a474f5b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { "name": "@ubiquity-os/comment-vector-embeddings", "description": "Issue comment plugin for Ubiquibot. It enables the storage, updating, and deletion of issue comment embeddings.", - "ubiquity:listeners": ["issue_comment.created", "issue_comment.edited", "issue_comment.deleted", "issues.opened", "issues.edited", "issues.deleted"] + "ubiquity:listeners": ["issue_comment.created", "issue_comment.edited", "issue_comment.deleted", "issues.opened", "issues.edited", "issues.deleted", "issues.labled"] } diff --git a/src/adapters/supabase/helpers/issues.ts b/src/adapters/supabase/helpers/issues.ts index 5f30821..c874cdc 100644 --- a/src/adapters/supabase/helpers/issues.ts +++ b/src/adapters/supabase/helpers/issues.ts @@ -9,6 +9,17 @@ export interface IssueSimilaritySearchResult { similarity: number; } +export interface IssueType { + id: string; + markdown?: string; + plaintext?: string; + payload?: Record; + author_id: number; + created_at: string; + modified_at: string; + embedding: number[]; +} + export class Issues extends SuperSupabase { constructor(supabase: SupabaseClient, context: Context) { super(supabase, context); @@ -66,6 +77,20 @@ export class Issues extends SuperSupabase { } } + async getIssue(issueNodeId: string): Promise { + const { data, error } = await this.supabase + .from("issues") // Provide the second type argument + .select("*") + .eq("id", issueNodeId) + .returns(); + + if (error) { + this.context.logger.error("Error getting issue", error); + return null; + } + return data; + } + 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", { diff --git a/src/handlers/label-added.ts b/src/handlers/label-added.ts new file mode 100644 index 0000000..5852c4f --- /dev/null +++ b/src/handlers/label-added.ts @@ -0,0 +1,77 @@ +import { Context } from "../types"; +import { IssuePayload } from "../types/payload"; + +interface IssueGraphqlResponse { + node: { + title: string; + url: string; + }; +} + +export async function labelAdded(context: Context) { + const { + logger, + adapters: { supabase }, + octokit, + } = context; + const { payload } = context as { payload: IssuePayload }; + const issue = payload.issue; + const issueContent = issue.body + issue.title; + + // On Adding the labels to the issue, the bot should + // create a new comment with users who completed task most similar to the issue + // if the comment already exists, it should update the comment with the new users + const matchResultArray: Array = []; + const similarIssues = await supabase.issue.findSimilarIssues(issueContent, context.config.jobMatchingThreshold, issue.node_id); + if (similarIssues && similarIssues.length > 0) { + // Find the most similar issue and the users who completed the task + similarIssues.sort((a, b) => b.similarity - a.similarity); + similarIssues.forEach(async (issue) => { + const data = await supabase.issue.getIssue(issue.issue_id); + const issuePayload = (data?.payload as IssuePayload) || []; + const users = issuePayload?.issue.assignees; + //Make the string + // ## [User Name](Link to User Profile) + // - [Issue] X% Match + users.forEach(async (user) => { + if (user && user.name && user.url) { + matchResultArray.push(`## [${user.name}](${user.url})\n- [Issue] ${issue.similarity}% Match`); + } + }); + }); + // Fetch if any previous comment exists + const issueResponse: IssueGraphqlResponse = await octokit.graphql( + `query($issueId: ID!) { + node(id: $issueId) { + ... on Issue { + comments(first: 100) { + nodes { + id + body + } + } + } + } + } + `, + { issueId: issue.node_id } + ); + console.log(issueResponse); + + // if(issueResponse.node.comments.nodes.length > 0) { + // const commentId = issueResponse.node.comments.nodes[0].id + // const previousComment = issueResponse.node.comments.nodes[0].body + // const newComment = previousComment + "\n" + matchResultArray.join("\n") + // await octokit.issues.updateComment({ + // owner: payload.repository.owner.login, + // repo: payload.repository.name, + // comment_id: commentId, + // body: newComment + // }) + // } + console.log(matchResultArray); + } + + logger.ok(`Successfully created issue!`); + logger.debug(`Exiting addIssue`); +} diff --git a/src/plugin.ts b/src/plugin.ts index 0d0876c..36728b8 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -14,6 +14,7 @@ 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 { labelAdded } from "./handlers/label-added"; /** * The main plugin function. Split for easier testing. @@ -40,6 +41,8 @@ export async function runPlugin(context: Context) { case "issues.deleted": return await deleteIssues(context); } + } else if (eventName == "issues.labeled") { + await labelAdded(context); } else { logger.error(`Unsupported event: ${eventName}`); } diff --git a/src/types/context.ts b/src/types/context.ts index 1227abf..b11ac2f 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" + | "issues.labeled"; export type SupportedEvents = { [K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent : never; diff --git a/src/types/database.ts b/src/types/database.ts index f741570..df59882 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,6 +1,31 @@ export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; export type Database = { + graphql_public: { + Tables: { + [_ in never]: never; + }; + Views: { + [_ in never]: never; + }; + Functions: { + graphql: { + Args: { + operationName?: string; + query?: string; + variables?: Json; + extensions?: Json; + }; + Returns: Json; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; public: { Tables: { issue_comments: { @@ -9,24 +34,77 @@ export type Database = { created_at: string; embedding: string; id: string; + issue_id: string | null; + markdown: string | null; modified_at: string; + payloadobject: Json | null; plaintext: string | null; + type: string; }; Insert: { author_id: string; created_at?: string; embedding: string; id: string; + issue_id?: string | null; + markdown?: string | null; modified_at?: string; + payloadobject?: Json | null; plaintext?: string | null; + type: string; }; Update: { author_id?: string; created_at?: string; embedding?: string; id?: string; + issue_id?: string | null; + markdown?: string | null; + modified_at?: string; + payloadobject?: Json | null; + plaintext?: string | null; + type?: string; + }; + Relationships: [ + { + foreignKeyName: "issue_comments_issue_id_fkey"; + columns: ["issue_id"]; + isOneToOne: false; + referencedRelation: "issues"; + referencedColumns: ["id"]; + }, + ]; + }; + issues: { + Row: { + created_at: string; + embedding: string; + id: string; + markdown: string | null; + modified_at: string; + payload: Json | null; + plaintext: string | null; + type: string; + }; + Insert: { + created_at?: string; + embedding: string; + id: string; + markdown?: string | null; + modified_at?: string; + payload?: Json | null; + plaintext?: string | null; + type?: string; + }; + Update: { + created_at?: string; + embedding?: string; + id?: string; + markdown?: string | null; modified_at?: string; + payload?: Json | null; plaintext?: string | null; + type?: string; }; Relationships: []; }; @@ -48,6 +126,17 @@ export type Database = { }; Returns: unknown; }; + find_similar_issues: { + Args: { + query_embedding: string; + threshold: number; + }; + Returns: { + id: number; + issue: string; + similarity: number; + }[]; + }; halfvec_avg: { Args: { "": number[]; @@ -215,6 +304,321 @@ export type Database = { [_ in never]: never; }; }; + storage: { + Tables: { + buckets: { + Row: { + allowed_mime_types: string[] | null; + avif_autodetection: boolean | null; + created_at: string | null; + file_size_limit: number | null; + id: string; + name: string; + owner: string | null; + owner_id: string | null; + public: boolean | null; + updated_at: string | null; + }; + Insert: { + allowed_mime_types?: string[] | null; + avif_autodetection?: boolean | null; + created_at?: string | null; + file_size_limit?: number | null; + id: string; + name: string; + owner?: string | null; + owner_id?: string | null; + public?: boolean | null; + updated_at?: string | null; + }; + Update: { + allowed_mime_types?: string[] | null; + avif_autodetection?: boolean | null; + created_at?: string | null; + file_size_limit?: number | null; + id?: string; + name?: string; + owner?: string | null; + owner_id?: string | null; + public?: boolean | null; + updated_at?: string | null; + }; + Relationships: []; + }; + migrations: { + Row: { + executed_at: string | null; + hash: string; + id: number; + name: string; + }; + Insert: { + executed_at?: string | null; + hash: string; + id: number; + name: string; + }; + Update: { + executed_at?: string | null; + hash?: string; + id?: number; + name?: string; + }; + Relationships: []; + }; + objects: { + Row: { + bucket_id: string | null; + created_at: string | null; + id: string; + last_accessed_at: string | null; + metadata: Json | null; + name: string | null; + owner: string | null; + owner_id: string | null; + path_tokens: string[] | null; + updated_at: string | null; + user_metadata: Json | null; + version: string | null; + }; + Insert: { + bucket_id?: string | null; + created_at?: string | null; + id?: string; + last_accessed_at?: string | null; + metadata?: Json | null; + name?: string | null; + owner?: string | null; + owner_id?: string | null; + path_tokens?: string[] | null; + updated_at?: string | null; + user_metadata?: Json | null; + version?: string | null; + }; + Update: { + bucket_id?: string | null; + created_at?: string | null; + id?: string; + last_accessed_at?: string | null; + metadata?: Json | null; + name?: string | null; + owner?: string | null; + owner_id?: string | null; + path_tokens?: string[] | null; + updated_at?: string | null; + user_metadata?: Json | null; + version?: string | null; + }; + Relationships: [ + { + foreignKeyName: "objects_bucketId_fkey"; + columns: ["bucket_id"]; + isOneToOne: false; + referencedRelation: "buckets"; + referencedColumns: ["id"]; + }, + ]; + }; + s3_multipart_uploads: { + Row: { + bucket_id: string; + created_at: string; + id: string; + in_progress_size: number; + key: string; + owner_id: string | null; + upload_signature: string; + user_metadata: Json | null; + version: string; + }; + Insert: { + bucket_id: string; + created_at?: string; + id: string; + in_progress_size?: number; + key: string; + owner_id?: string | null; + upload_signature: string; + user_metadata?: Json | null; + version: string; + }; + Update: { + bucket_id?: string; + created_at?: string; + id?: string; + in_progress_size?: number; + key?: string; + owner_id?: string | null; + upload_signature?: string; + user_metadata?: Json | null; + version?: string; + }; + Relationships: [ + { + foreignKeyName: "s3_multipart_uploads_bucket_id_fkey"; + columns: ["bucket_id"]; + isOneToOne: false; + referencedRelation: "buckets"; + referencedColumns: ["id"]; + }, + ]; + }; + s3_multipart_uploads_parts: { + Row: { + bucket_id: string; + created_at: string; + etag: string; + id: string; + key: string; + owner_id: string | null; + part_number: number; + size: number; + upload_id: string; + version: string; + }; + Insert: { + bucket_id: string; + created_at?: string; + etag: string; + id?: string; + key: string; + owner_id?: string | null; + part_number: number; + size?: number; + upload_id: string; + version: string; + }; + Update: { + bucket_id?: string; + created_at?: string; + etag?: string; + id?: string; + key?: string; + owner_id?: string | null; + part_number?: number; + size?: number; + upload_id?: string; + version?: string; + }; + Relationships: [ + { + foreignKeyName: "s3_multipart_uploads_parts_bucket_id_fkey"; + columns: ["bucket_id"]; + isOneToOne: false; + referencedRelation: "buckets"; + referencedColumns: ["id"]; + }, + { + foreignKeyName: "s3_multipart_uploads_parts_upload_id_fkey"; + columns: ["upload_id"]; + isOneToOne: false; + referencedRelation: "s3_multipart_uploads"; + referencedColumns: ["id"]; + }, + ]; + }; + }; + Views: { + [_ in never]: never; + }; + Functions: { + can_insert_object: { + Args: { + bucketid: string; + name: string; + owner: string; + metadata: Json; + }; + Returns: undefined; + }; + extension: { + Args: { + name: string; + }; + Returns: string; + }; + filename: { + Args: { + name: string; + }; + Returns: string; + }; + foldername: { + Args: { + name: string; + }; + Returns: string[]; + }; + get_size_by_bucket: { + Args: Record; + Returns: { + size: number; + bucket_id: string; + }[]; + }; + list_multipart_uploads_with_delimiter: { + Args: { + bucket_id: string; + prefix_param: string; + delimiter_param: string; + max_keys?: number; + next_key_token?: string; + next_upload_token?: string; + }; + Returns: { + key: string; + id: string; + created_at: string; + }[]; + }; + list_objects_with_delimiter: { + Args: { + bucket_id: string; + prefix_param: string; + delimiter_param: string; + max_keys?: number; + start_after?: string; + next_token?: string; + }; + Returns: { + name: string; + id: string; + metadata: Json; + updated_at: string; + }[]; + }; + operation: { + Args: Record; + Returns: string; + }; + search: { + Args: { + prefix: string; + bucketname: string; + limits?: number; + levels?: number; + offsets?: number; + search?: string; + sortcolumn?: string; + sortorder?: string; + }; + Returns: { + name: string; + id: string; + updated_at: string; + created_at: string; + last_accessed_at: string; + metadata: Json; + }[]; + }; + }; + Enums: { + [_ in never]: never; + }; + CompositeTypes: { + [_ in never]: never; + }; + }; }; type PublicSchema = Database[Extract]; diff --git a/src/types/plugin-inputs.ts b/src/types/plugin-inputs.ts index a942db2..b5e9ce3 100644 --- a/src/types/plugin-inputs.ts +++ b/src/types/plugin-inputs.ts @@ -22,8 +22,9 @@ export const pluginSettingsSchema = T.Object( { matchThreshold: T.Number(), warningThreshold: T.Number(), + jobMatchingThreshold: T.Number(), }, - { default: { matchThreshold: 0.95, warningThreshold: 0.75 } } + { default: { matchThreshold: 0.95, warningThreshold: 0.75, jobMatchingThreshold: 0.75 } } ); export const pluginSettingsValidator = new StandardValidator(pluginSettingsSchema); diff --git a/tests/main.test.ts b/tests/main.test.ts index a8c14b8..bc4ed19 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -166,6 +166,7 @@ function createContextInner( config: { warningThreshold: 0.75, matchThreshold: 0.95, + jobMatchingThreshold: 0.95, }, adapters: {} as Context["adapters"], logger: new Logs("debug"), From 374a5f2605028fc9bbc6681ebc013d3525ed1b75 Mon Sep 17 00:00:00 2001 From: Shivaditya Shivganesh Date: Wed, 25 Sep 2024 02:49:33 -0400 Subject: [PATCH 2/5] feat: added issue-matching on label added, issue created and issue edited --- README.md | 2 +- manifest.json | 2 +- src/adapters/supabase/helpers/issues.ts | 12 +++- src/handlers/issue-assignees-changed.ts | 26 ++++++++ src/handlers/issue-deduplication.ts | 1 - src/handlers/issue-matching.ts | 84 +++++++++++++++++++++++++ src/handlers/label-added.ts | 77 ----------------------- src/plugin.ts | 13 ++-- src/types/context.ts | 4 +- 9 files changed, 133 insertions(+), 88 deletions(-) create mode 100644 src/handlers/issue-assignees-changed.ts create mode 100644 src/handlers/issue-matching.ts delete mode 100644 src/handlers/label-added.ts diff --git a/README.md b/README.md index fa0099e..6b03a0f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To set up the `.dev.vars` file, you will need to provide the following variables - 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", "issues.labled"] + runsOn: [ "issue_comment.created", "issue_comment.edited", "issue_comment.deleted" , "issues.opened", "issues.edited", "issues.deleted", "issues.labeled", "issues.assigned", "issues.unassigned"] ``` diff --git a/manifest.json b/manifest.json index a474f5b..f52ef4e 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { "name": "@ubiquity-os/comment-vector-embeddings", "description": "Issue comment plugin for Ubiquibot. It enables the storage, updating, and deletion of issue comment embeddings.", - "ubiquity:listeners": ["issue_comment.created", "issue_comment.edited", "issue_comment.deleted", "issues.opened", "issues.edited", "issues.deleted", "issues.labled"] + "ubiquity:listeners": ["issue_comment.created", "issue_comment.edited", "issue_comment.deleted", "issues.opened", "issues.edited", "issues.deleted", "issues.labeled", "issues.assigned", "issues.unassigned"] } diff --git a/src/adapters/supabase/helpers/issues.ts b/src/adapters/supabase/helpers/issues.ts index c874cdc..6bfef09 100644 --- a/src/adapters/supabase/helpers/issues.ts +++ b/src/adapters/supabase/helpers/issues.ts @@ -77,13 +77,12 @@ export class Issues extends SuperSupabase { } } - async getIssue(issueNodeId: string): Promise { + async getIssue(issueNodeId: string): Promise { const { data, error } = await this.supabase .from("issues") // Provide the second type argument .select("*") .eq("id", issueNodeId) - .returns(); - + .returns(); if (error) { this.context.logger.error("Error getting issue", error); return null; @@ -104,4 +103,11 @@ export class Issues extends SuperSupabase { } return data; } + + async updatePayload(issueNodeId: string, payload: Record) { + const { error } = await this.supabase.from("issues").update({ payload }).eq("id", issueNodeId); + if (error) { + this.context.logger.error("Error updating issue payload", error); + } + } } diff --git a/src/handlers/issue-assignees-changed.ts b/src/handlers/issue-assignees-changed.ts new file mode 100644 index 0000000..d8d74a8 --- /dev/null +++ b/src/handlers/issue-assignees-changed.ts @@ -0,0 +1,26 @@ +import { Context } from "../types"; +import { IssuePayload } from "../types/payload"; + +export async function updateAssignees(context: Context) { + const { + logger, + adapters: { supabase }, + } = context; + const { payload } = context as { payload: IssuePayload }; + const issue = payload.issue; + + try { + await supabase.issue.updatePayload(issue.node_id, payload); + } catch (error) { + if (error instanceof Error) { + logger.error(`Error updating assignees:`, { error: error, stack: error.stack }); + throw error; + } else { + logger.error(`Error updating assignees:`, { err: error, error: new Error() }); + throw error; + } + } + + logger.ok(`Successfully updated assignees!`); + logger.debug(`Exiting updateAssignees`); +} diff --git a/src/handlers/issue-deduplication.ts b/src/handlers/issue-deduplication.ts index 2378a1e..6174e4f 100644 --- a/src/handlers/issue-deduplication.ts +++ b/src/handlers/issue-deduplication.ts @@ -27,7 +27,6 @@ export async function issueChecker(context: Context): Promise { // 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); diff --git a/src/handlers/issue-matching.ts b/src/handlers/issue-matching.ts new file mode 100644 index 0000000..6835b1a --- /dev/null +++ b/src/handlers/issue-matching.ts @@ -0,0 +1,84 @@ +import { Context } from "../types"; +import { IssuePayload } from "../types/payload"; + +export async function issueMatching(context: Context) { + const { + logger, + adapters: { supabase }, + octokit, + } = context; + const { payload } = context as { payload: IssuePayload }; + const issue = payload.issue; + const issueContent = issue.body + issue.title; + const commentStart = "The following users have completed similar tasks to this issue:"; + + // On Adding the labels to the issue, the bot should + // create a new comment with users who completed task most similar to the issue + // if the comment already exists, it should update the comment with the new users + const matchResultArray: Array = []; + const similarIssues = await supabase.issue.findSimilarIssues(issueContent, context.config.jobMatchingThreshold, issue.node_id); + if (similarIssues && similarIssues.length > 0) { + // Find the most similar issue and the users who completed the task + similarIssues.sort((a, b) => b.similarity - a.similarity); + similarIssues.forEach(async (issue) => { + const data = await supabase.issue.getIssue(issue.issue_id); + if (data) { + const issuePayload = (data[0].payload as IssuePayload) || []; + const users = issuePayload?.issue.assignees; + //Make the string + // ## [User Name](Link to User Profile) + // - [Issue] X% Match + users.forEach(async (user) => { + if (user && user.login && user.html_url) { + const similarityPercentage = Math.round(issue.similarity * 100); + const githubUserLink = user.html_url.replace(/https?:\/\//, "https://www."); + const issueLink = issuePayload.issue.html_url.replace(/https?:\/\//, "https://www."); + matchResultArray.push(`## [${user.login}](${githubUserLink})\n- [Issue](${issueLink}) ${similarityPercentage}% Match`); + } + }); + } + }); + + // Fetch if any previous comment exists + const listIssues = await octokit.issues.listComments({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: issue.number, + }); + //Check if the comment already exists + const existingComment = listIssues.data.find((comment) => comment.body && comment.body.startsWith(commentStart)); + + //Check if matchResultArray is empty + if (matchResultArray.length === 0) { + if (existingComment) { + // If the comment already exists, delete it + await octokit.issues.deleteComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + comment_id: existingComment.id, + }); + } + logger.debug("No similar issues found"); + return; + } + + if (existingComment) { + await context.octokit.issues.updateComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + comment_id: existingComment.id, + body: commentStart + "\n\n" + matchResultArray.join("\n"), + }); + } else { + await context.octokit.issues.createComment({ + owner: payload.repository.owner.login, + repo: payload.repository.name, + issue_number: payload.issue.number, + body: commentStart + "\n\n" + matchResultArray.join("\n"), + }); + } + } + + logger.ok(`Successfully created issue!`); + logger.debug(`Exiting addIssue`); +} diff --git a/src/handlers/label-added.ts b/src/handlers/label-added.ts deleted file mode 100644 index 5852c4f..0000000 --- a/src/handlers/label-added.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Context } from "../types"; -import { IssuePayload } from "../types/payload"; - -interface IssueGraphqlResponse { - node: { - title: string; - url: string; - }; -} - -export async function labelAdded(context: Context) { - const { - logger, - adapters: { supabase }, - octokit, - } = context; - const { payload } = context as { payload: IssuePayload }; - const issue = payload.issue; - const issueContent = issue.body + issue.title; - - // On Adding the labels to the issue, the bot should - // create a new comment with users who completed task most similar to the issue - // if the comment already exists, it should update the comment with the new users - const matchResultArray: Array = []; - const similarIssues = await supabase.issue.findSimilarIssues(issueContent, context.config.jobMatchingThreshold, issue.node_id); - if (similarIssues && similarIssues.length > 0) { - // Find the most similar issue and the users who completed the task - similarIssues.sort((a, b) => b.similarity - a.similarity); - similarIssues.forEach(async (issue) => { - const data = await supabase.issue.getIssue(issue.issue_id); - const issuePayload = (data?.payload as IssuePayload) || []; - const users = issuePayload?.issue.assignees; - //Make the string - // ## [User Name](Link to User Profile) - // - [Issue] X% Match - users.forEach(async (user) => { - if (user && user.name && user.url) { - matchResultArray.push(`## [${user.name}](${user.url})\n- [Issue] ${issue.similarity}% Match`); - } - }); - }); - // Fetch if any previous comment exists - const issueResponse: IssueGraphqlResponse = await octokit.graphql( - `query($issueId: ID!) { - node(id: $issueId) { - ... on Issue { - comments(first: 100) { - nodes { - id - body - } - } - } - } - } - `, - { issueId: issue.node_id } - ); - console.log(issueResponse); - - // if(issueResponse.node.comments.nodes.length > 0) { - // const commentId = issueResponse.node.comments.nodes[0].id - // const previousComment = issueResponse.node.comments.nodes[0].body - // const newComment = previousComment + "\n" + matchResultArray.join("\n") - // await octokit.issues.updateComment({ - // owner: payload.repository.owner.login, - // repo: payload.repository.name, - // comment_id: commentId, - // body: newComment - // }) - // } - console.log(matchResultArray); - } - - logger.ok(`Successfully created issue!`); - logger.debug(`Exiting addIssue`); -} diff --git a/src/plugin.ts b/src/plugin.ts index 36728b8..962e74c 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -14,7 +14,8 @@ 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 { labelAdded } from "./handlers/label-added"; +import { issueMatching } from "./handlers/issue-matching"; +import { updateAssignees } from "./handlers/issue-assignees-changed"; /** * The main plugin function. Split for easier testing. @@ -34,15 +35,19 @@ export async function runPlugin(context: Context) { switch (eventName) { case "issues.opened": await issueChecker(context); - return await addIssue(context); + await addIssue(context); + return await issueMatching(context); case "issues.edited": await issueChecker(context); - return await updateIssue(context); + await updateIssue(context); + return await issueMatching(context); case "issues.deleted": return await deleteIssues(context); } } else if (eventName == "issues.labeled") { - await labelAdded(context); + await issueMatching(context); + } else if (eventName == "issues.assigned" || eventName == "issues.unassigned") { + await updateAssignees(context); } else { logger.error(`Unsupported event: ${eventName}`); } diff --git a/src/types/context.ts b/src/types/context.ts index b11ac2f..3a1cf2c 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -17,7 +17,9 @@ export type SupportedEventsU = | "issues.opened" | "issues.edited" | "issues.deleted" - | "issues.labeled"; + | "issues.labeled" + | "issues.assigned" + | "issues.unassigned"; export type SupportedEvents = { [K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent : never; From d4ce273a3efb0ae62863fcd5c49997ad82596adf Mon Sep 17 00:00:00 2001 From: Shivaditya Shivganesh Date: Fri, 27 Sep 2024 21:34:46 -0400 Subject: [PATCH 3/5] fix: removed code for assign issues --- README.md | 2 +- manifest.json | 2 +- src/handlers/issue-assignees-changed.ts | 26 ------ src/handlers/issue-matching.ts | 108 +++++++++++++++++++----- src/plugin.ts | 5 +- src/types/context.ts | 4 +- 6 files changed, 93 insertions(+), 54 deletions(-) delete mode 100644 src/handlers/issue-assignees-changed.ts diff --git a/README.md b/README.md index 6b03a0f..75ffd81 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ To set up the `.dev.vars` file, you will need to provide the following variables - 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", "issues.labeled", "issues.assigned", "issues.unassigned"] + runsOn: [ "issue_comment.created", "issue_comment.edited", "issue_comment.deleted" , "issues.opened", "issues.edited", "issues.deleted", "issues.labeled"] ``` diff --git a/manifest.json b/manifest.json index f52ef4e..dbd801c 100644 --- a/manifest.json +++ b/manifest.json @@ -1,5 +1,5 @@ { "name": "@ubiquity-os/comment-vector-embeddings", "description": "Issue comment plugin for Ubiquibot. It enables the storage, updating, and deletion of issue comment embeddings.", - "ubiquity:listeners": ["issue_comment.created", "issue_comment.edited", "issue_comment.deleted", "issues.opened", "issues.edited", "issues.deleted", "issues.labeled", "issues.assigned", "issues.unassigned"] + "ubiquity:listeners": ["issue_comment.created", "issue_comment.edited", "issue_comment.deleted", "issues.opened", "issues.edited", "issues.deleted", "issues.labeled"] } diff --git a/src/handlers/issue-assignees-changed.ts b/src/handlers/issue-assignees-changed.ts deleted file mode 100644 index d8d74a8..0000000 --- a/src/handlers/issue-assignees-changed.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Context } from "../types"; -import { IssuePayload } from "../types/payload"; - -export async function updateAssignees(context: Context) { - const { - logger, - adapters: { supabase }, - } = context; - const { payload } = context as { payload: IssuePayload }; - const issue = payload.issue; - - try { - await supabase.issue.updatePayload(issue.node_id, payload); - } catch (error) { - if (error instanceof Error) { - logger.error(`Error updating assignees:`, { error: error, stack: error.stack }); - throw error; - } else { - logger.error(`Error updating assignees:`, { err: error, error: new Error() }); - throw error; - } - } - - logger.ok(`Successfully updated assignees!`); - logger.debug(`Exiting updateAssignees`); -} diff --git a/src/handlers/issue-matching.ts b/src/handlers/issue-matching.ts index 6835b1a..8e56f94 100644 --- a/src/handlers/issue-matching.ts +++ b/src/handlers/issue-matching.ts @@ -1,6 +1,29 @@ import { Context } from "../types"; import { IssuePayload } from "../types/payload"; +export interface IssueGraphqlResponse { + node: { + title: string; + url: string; + state: string; + stateReason: string; + closed: boolean; + repository: { + owner: { + login: string; + }; + name: string; + }; + assignees: { + nodes: Array<{ + login: string; + url: string; + }>; + }; + }; + similarity: number; +} + export async function issueMatching(context: Context) { const { logger, @@ -15,30 +38,67 @@ export async function issueMatching(context: Context) { // On Adding the labels to the issue, the bot should // create a new comment with users who completed task most similar to the issue // if the comment already exists, it should update the comment with the new users - const matchResultArray: Array = []; + const matchResultArray: Map> = new Map(); const similarIssues = await supabase.issue.findSimilarIssues(issueContent, context.config.jobMatchingThreshold, issue.node_id); if (similarIssues && similarIssues.length > 0) { // Find the most similar issue and the users who completed the task + console.log(similarIssues); similarIssues.sort((a, b) => b.similarity - a.similarity); - similarIssues.forEach(async (issue) => { - const data = await supabase.issue.getIssue(issue.issue_id); - if (data) { - const issuePayload = (data[0].payload as IssuePayload) || []; - const users = issuePayload?.issue.assignees; - //Make the string - // ## [User Name](Link to User Profile) - // - [Issue] X% Match - users.forEach(async (user) => { - if (user && user.login && user.html_url) { - const similarityPercentage = Math.round(issue.similarity * 100); - const githubUserLink = user.html_url.replace(/https?:\/\//, "https://www."); - const issueLink = issuePayload.issue.html_url.replace(/https?:\/\//, "https://www."); - matchResultArray.push(`## [${user.login}](${githubUserLink})\n- [Issue](${issueLink}) ${similarityPercentage}% Match`); + const fetchPromises = similarIssues.map(async (issue) => { + logger.info("Issue ID: " + issue.issue_id); + logger.info("Before query"); + const issueObject: IssueGraphqlResponse = await context.octokit.graphql( + `query ($issueNodeId: ID!) { + node(id: $issueNodeId) { + ... on Issue { + title + url + state + repository{ + name + owner { + login + } + } + stateReason + closed + assignees(first: 10) { + nodes { + login + url + } + } + } + } + }`, + { issueNodeId: issue.issue_id } + ); + issueObject.similarity = issue.similarity; + return issueObject; + }); + + const issueList = await Promise.all(fetchPromises); + issueList.forEach((issue) => { + if (issue.node.closed && issue.node.stateReason === "COMPLETED" && issue.node.assignees.nodes.length > 0) { + const assignees = issue.node.assignees.nodes; + assignees.forEach((assignee) => { + const similarityPercentage = Math.round(issue.similarity * 100); + const githubUserLink = assignee.url.replace(/https?:\/\/github.com/, "https://www.github.com"); + const issueLink = issue.node.url.replace(/https?:\/\/github.com/, "https://www.github.com"); + if (matchResultArray.has(assignee.login)) { + matchResultArray + .get(assignee.login) + ?.push( + `## [${assignee.login}](${githubUserLink})\n- [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink}) ${similarityPercentage}% Match` + ); + } else { + matchResultArray.set(assignee.login, [ + `## [${assignee.login}](${githubUserLink})\n- [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink}) ${similarityPercentage}% Match`, + ]); } }); } }); - // Fetch if any previous comment exists const listIssues = await octokit.issues.listComments({ owner: payload.repository.owner.login, @@ -49,7 +109,7 @@ export async function issueMatching(context: Context) { const existingComment = listIssues.data.find((comment) => comment.body && comment.body.startsWith(commentStart)); //Check if matchResultArray is empty - if (matchResultArray.length === 0) { + if (matchResultArray && matchResultArray.size === 0) { if (existingComment) { // If the comment already exists, delete it await octokit.issues.deleteComment({ @@ -67,14 +127,24 @@ export async function issueMatching(context: Context) { owner: payload.repository.owner.login, repo: payload.repository.name, comment_id: existingComment.id, - body: commentStart + "\n\n" + matchResultArray.join("\n"), + body: + commentStart + + "\n\n" + + Array.from(matchResultArray.values()) + .map((arr) => arr.join("\n")) + .join("\n"), }); } else { await context.octokit.issues.createComment({ owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: payload.issue.number, - body: commentStart + "\n\n" + matchResultArray.join("\n"), + body: + commentStart + + "\n\n" + + Array.from(matchResultArray.values()) + .map((arr) => arr.join("\n")) + .join("\n"), }); } } diff --git a/src/plugin.ts b/src/plugin.ts index 962e74c..197948b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -15,7 +15,6 @@ import { addIssue } from "./handlers/add-issue"; import { updateIssue } from "./handlers/update-issue"; import { issueChecker } from "./handlers/issue-deduplication"; import { issueMatching } from "./handlers/issue-matching"; -import { updateAssignees } from "./handlers/issue-assignees-changed"; /** * The main plugin function. Split for easier testing. @@ -45,9 +44,7 @@ export async function runPlugin(context: Context) { return await deleteIssues(context); } } else if (eventName == "issues.labeled") { - await issueMatching(context); - } else if (eventName == "issues.assigned" || eventName == "issues.unassigned") { - await updateAssignees(context); + return await issueMatching(context); } else { logger.error(`Unsupported event: ${eventName}`); } diff --git a/src/types/context.ts b/src/types/context.ts index 3a1cf2c..b11ac2f 100644 --- a/src/types/context.ts +++ b/src/types/context.ts @@ -17,9 +17,7 @@ export type SupportedEventsU = | "issues.opened" | "issues.edited" | "issues.deleted" - | "issues.labeled" - | "issues.assigned" - | "issues.unassigned"; + | "issues.labeled"; export type SupportedEvents = { [K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent : never; From ff7c83a7566063ee1f1ea160cd16473cb2a18e8c Mon Sep 17 00:00:00 2001 From: Shivaditya Shivganesh Date: Fri, 27 Sep 2024 22:01:17 -0400 Subject: [PATCH 4/5] fix: removed logger --- src/handlers/issue-matching.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/handlers/issue-matching.ts b/src/handlers/issue-matching.ts index 8e56f94..22c7622 100644 --- a/src/handlers/issue-matching.ts +++ b/src/handlers/issue-matching.ts @@ -42,11 +42,8 @@ export async function issueMatching(context: Context) { const similarIssues = await supabase.issue.findSimilarIssues(issueContent, context.config.jobMatchingThreshold, issue.node_id); if (similarIssues && similarIssues.length > 0) { // Find the most similar issue and the users who completed the task - console.log(similarIssues); similarIssues.sort((a, b) => b.similarity - a.similarity); const fetchPromises = similarIssues.map(async (issue) => { - logger.info("Issue ID: " + issue.issue_id); - logger.info("Before query"); const issueObject: IssueGraphqlResponse = await context.octokit.graphql( `query ($issueNodeId: ID!) { node(id: $issueNodeId) { From cf4b5eee918f8aa722227fe7df4488c9702b6856 Mon Sep 17 00:00:00 2001 From: Shivaditya Shivganesh Date: Sat, 28 Sep 2024 14:51:25 -0400 Subject: [PATCH 5/5] feat: updated the comment structure --- src/handlers/issue-matching.ts | 41 +++++++++++++++++----------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/src/handlers/issue-matching.ts b/src/handlers/issue-matching.ts index 22c7622..cc1d060 100644 --- a/src/handlers/issue-matching.ts +++ b/src/handlers/issue-matching.ts @@ -24,6 +24,17 @@ export interface IssueGraphqlResponse { similarity: number; } +const commentBuilder = (matchResultArray: Map>): string => { + const commentLines: string[] = [">[!NOTE]", ">The following contributors may be suitable for this task:"]; + matchResultArray.forEach((issues, assignee) => { + commentLines.push(`>### [${assignee}](https://www.github.com/${assignee})`); + issues.forEach((issue) => { + commentLines.push(issue); + }); + }); + return commentLines.join("\n"); +}; + export async function issueMatching(context: Context) { const { logger, @@ -33,7 +44,7 @@ export async function issueMatching(context: Context) { const { payload } = context as { payload: IssuePayload }; const issue = payload.issue; const issueContent = issue.body + issue.title; - const commentStart = "The following users have completed similar tasks to this issue:"; + const commentStart = ">The following contributors may be suitable for this task:"; // On Adding the labels to the issue, the bot should // create a new comment with users who completed task most similar to the issue @@ -80,17 +91,16 @@ export async function issueMatching(context: Context) { const assignees = issue.node.assignees.nodes; assignees.forEach((assignee) => { const similarityPercentage = Math.round(issue.similarity * 100); - const githubUserLink = assignee.url.replace(/https?:\/\/github.com/, "https://www.github.com"); const issueLink = issue.node.url.replace(/https?:\/\/github.com/, "https://www.github.com"); if (matchResultArray.has(assignee.login)) { matchResultArray .get(assignee.login) ?.push( - `## [${assignee.login}](${githubUserLink})\n- [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink}) ${similarityPercentage}% Match` + `> \`${similarityPercentage}% Match\` [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink})` ); } else { matchResultArray.set(assignee.login, [ - `## [${assignee.login}](${githubUserLink})\n- [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink}) ${similarityPercentage}% Match`, + `> \`${similarityPercentage}% Match\` [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink})`, ]); } }); @@ -103,8 +113,7 @@ export async function issueMatching(context: Context) { issue_number: issue.number, }); //Check if the comment already exists - const existingComment = listIssues.data.find((comment) => comment.body && comment.body.startsWith(commentStart)); - + const existingComment = listIssues.data.find((comment) => comment.body && comment.body.includes(">[!NOTE]" + "\n" + commentStart)); //Check if matchResultArray is empty if (matchResultArray && matchResultArray.size === 0) { if (existingComment) { @@ -118,34 +127,24 @@ export async function issueMatching(context: Context) { logger.debug("No similar issues found"); return; } - + const comment = commentBuilder(matchResultArray); if (existingComment) { await context.octokit.issues.updateComment({ owner: payload.repository.owner.login, repo: payload.repository.name, comment_id: existingComment.id, - body: - commentStart + - "\n\n" + - Array.from(matchResultArray.values()) - .map((arr) => arr.join("\n")) - .join("\n"), + body: comment, }); } else { await context.octokit.issues.createComment({ owner: payload.repository.owner.login, repo: payload.repository.name, issue_number: payload.issue.number, - body: - commentStart + - "\n\n" + - Array.from(matchResultArray.values()) - .map((arr) => arr.join("\n")) - .join("\n"), + body: comment, }); } } - logger.ok(`Successfully created issue!`); - logger.debug(`Exiting addIssue`); + logger.ok(`Successfully created issue comment!`); + logger.debug(`Exiting issueMatching handler`); }