diff --git a/README.md b/README.md index ce2acff..216c02e 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ To set up the `.dev.vars` file, you will need to provide the following variables matchThreshold: 0.95 warningThreshold: 0.75 jobMatchingThreshold: 0.75 + redactPrivateRepoComments: false ``` diff --git a/manifest.json b/manifest.json index 3768f7a..afcee4a 100644 --- a/manifest.json +++ b/manifest.json @@ -25,12 +25,17 @@ "jobMatchingThreshold": { "default": 0.75, "type": "number" + }, + "redactPrivateRepoComments": { + "default": false, + "type": "boolean" } }, "required": [ "matchThreshold", "warningThreshold", - "jobMatchingThreshold" + "jobMatchingThreshold", + "redactPrivateRepoComments" ] } } \ No newline at end of file diff --git a/src/handlers/add-comments.ts b/src/handlers/add-comments.ts index 54745a1..e33894b 100644 --- a/src/handlers/add-comments.ts +++ b/src/handlers/add-comments.ts @@ -10,7 +10,7 @@ export async function addComments(context: Context) { const markdown = payload.comment.body; const authorId = payload.comment.user?.id || -1; const nodeId = payload.comment.node_id; - const isPrivate = payload.repository.private; + const isPrivate = context.config.redactPrivateRepoComments; const issueId = payload.issue.node_id; try { diff --git a/src/handlers/add-issue.ts b/src/handlers/add-issue.ts index c828bbf..7588bb7 100644 --- a/src/handlers/add-issue.ts +++ b/src/handlers/add-issue.ts @@ -11,7 +11,7 @@ export async function addIssue(context: Context) { 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; + const isPrivate = context.config.redactPrivateRepoComments; try { if (!markdown) { diff --git a/src/handlers/update-comments.ts b/src/handlers/update-comments.ts index 6cc9545..a597543 100644 --- a/src/handlers/update-comments.ts +++ b/src/handlers/update-comments.ts @@ -7,12 +7,11 @@ export async function updateComment(context: Context) { 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; - + const issueId = payload.issue.node_id + const isPrivate = !context.config.redactPrivateRepoComments && payload.repository.private; + const markdown = payload.comment.body || null; // Fetch the previous comment and update it in the db try { if (!markdown) { diff --git a/src/handlers/update-issue.ts b/src/handlers/update-issue.ts index cec5de2..e35faaa 100644 --- a/src/handlers/update-issue.ts +++ b/src/handlers/update-issue.ts @@ -10,7 +10,7 @@ export async function updateIssue(context: Context) { const { payload } = context as { payload: IssuePayload }; const payloadObject = payload; const nodeId = payload.issue.node_id; - const isPrivate = payload.repository.private; + const isPrivate = context.config.redactPrivateRepoComments; const markdown = payload.issue.body + " " + payload.issue.title || null; const authorId = payload.issue.user?.id || -1; // Fetch the previous issue and update it in the db diff --git a/src/types/plugin-inputs.ts b/src/types/plugin-inputs.ts index 226beee..730bb19 100644 --- a/src/types/plugin-inputs.ts +++ b/src/types/plugin-inputs.ts @@ -23,6 +23,7 @@ export const pluginSettingsSchema = T.Object( matchThreshold: T.Number({ default: 0.95 }), warningThreshold: T.Number({ default: 0.75 }), jobMatchingThreshold: T.Number({ default: 0.75 }), + redactPrivateRepoComments: T.Boolean({ default: false }), }, { default: {} } ); diff --git a/tests/__mocks__/adapter.ts b/tests/__mocks__/adapter.ts index abb271f..6f432d1 100644 --- a/tests/__mocks__/adapter.ts +++ b/tests/__mocks__/adapter.ts @@ -72,6 +72,43 @@ export function createMockAdapters(context: Context) { return commentMap.get(commentNodeId); }), } as unknown as Comment, + + issue: { + findSimilarIssues: jest.fn(async (issueContent: string, threshold: number, currentIssueId: string) => { + // Return empty array for first issue in each test + if (currentIssueId === "warning1" || currentIssueId === "match1") { + return []; + } + + // For warning threshold test (similarity around 0.8) + if (currentIssueId === "warning2") { + return [ + { + issue_id: "warning1", + similarity: 0.8, + }, + ]; + } + + // For match threshold test (similarity above 0.95) + if (currentIssueId === "match2") { + return [ + { + issue_id: "match1", + similarity: 0.96, + }, + ]; + } + + return []; + }), + createIssue: jest.fn(async () => { + // Implementation for createIssue + }), + }, + fetchComments: jest.fn(async (issueId: string) => { + return Array.from(commentMap.values()).filter((comment) => comment.issue_id === issueId); + }), }, voyage: { embedding: { diff --git a/tests/__mocks__/db.ts b/tests/__mocks__/db.ts index 1681106..8f97a2e 100644 --- a/tests/__mocks__/db.ts +++ b/tests/__mocks__/db.ts @@ -62,7 +62,7 @@ export const db = factory({ deployments_url: String, }, issue: { - id: primaryKey(Number), + node_id: primaryKey(String), number: Number, title: String, body: String, @@ -78,6 +78,7 @@ export const db = factory({ comments: Number, labels: Array, state: String, + closed: Boolean, locked: Boolean, assignee: nullable({ login: String, @@ -136,6 +137,7 @@ export const db = factory({ login: String, id: Number, }, + issue_id: String, author_association: String, html_url: String, issue_url: String, diff --git a/tests/__mocks__/handlers.ts b/tests/__mocks__/handlers.ts index 0019673..e2100f5 100644 --- a/tests/__mocks__/handlers.ts +++ b/tests/__mocks__/handlers.ts @@ -1,5 +1,6 @@ import { http, HttpResponse } from "msw"; import { db } from "./db"; + /** * Intercepts the routes and returns a custom payload */ @@ -48,6 +49,21 @@ export const handlers = [ item.body = body; return HttpResponse.json(item); }), + //Update issue + http.patch("https://api.github.com/repos/:owner/:repo/issues/:issue_number", async ({ params: { issue_number: issueNumber }, request }) => { + const { body } = await getValue(request.body); + const item = db.issue.findFirst({ where: { number: { equals: Number(issueNumber) } } }); + if (!item) { + return new HttpResponse(null, { status: 404 }); + } + item.body = body; + return HttpResponse.json(item); + }), + + //Fetch comments for the issue + http.get("https://api.github.com/repos/:owner/:repo/issues/:issue_number/comments", ({ params: { issue_id: issueId } }) => + HttpResponse.json(db.issueComments.findMany({ where: { issue_id: { equals: String(issueId) } } })) + ), ]; async function getValue(body: ReadableStream | null) { @@ -63,4 +79,5 @@ async function getValue(body: ReadableStream | null) { } } } + return {}; } diff --git a/tests/__mocks__/helpers.ts b/tests/__mocks__/helpers.ts index 236ac8e..d01196f 100644 --- a/tests/__mocks__/helpers.ts +++ b/tests/__mocks__/helpers.ts @@ -1,6 +1,16 @@ import { db } from "./db"; import { STRINGS } from "./strings"; import usersGet from "./users-get.json"; +import threshold95_1 from "../__sample__/match_threshold_95_1.json"; +import threshold95_2 from "../__sample__/match_threshold_95_2.json"; +import warning75_1 from "../__sample__/warning_threshold_75_1.json"; +import warning75_2 from "../__sample__/warning_threshold_75_2.json"; +import taskComplete from "../__sample__/task_complete.json"; + +interface SampleIssue { + title: string; + issue_body: string; +} /** * Helper function to setup tests. @@ -34,7 +44,7 @@ export async function setupTests() { // Insert issues db.issue.create({ - id: 1, + node_id: "1", //Node ID number: 1, title: "First Issue", body: "This is the body of the first issue.", @@ -42,6 +52,8 @@ export async function setupTests() { login: STRINGS.USER_1, id: 1, }, + owner: STRINGS.USER_1, + repo: STRINGS.TEST_REPO, author_association: "OWNER", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -65,7 +77,7 @@ export async function setupTests() { }); db.issue.create({ - id: 2, + node_id: "2", //Node ID number: 2, title: "Second Issue", body: "This is the body of the second issue.", @@ -73,6 +85,8 @@ export async function setupTests() { login: STRINGS.USER_1, id: 1, }, + owner: STRINGS.USER_1, + repo: STRINGS.TEST_REPO, author_association: "OWNER", created_at: new Date().toISOString(), updated_at: new Date().toISOString(), @@ -96,7 +110,80 @@ export async function setupTests() { }); } -export function createComment(comment: string, commentId: number, nodeId: string) { +export function createIssue( + issueBody: string, + issueNodeId: string, + issueTitle: string, + issueNumber: number, + issueUser: { + login: string; + id: number; + }, + issueState: string, + issueCloseReason: string | null, + repo: string, + owner: string +) { + const existingIssue = db.issue.findFirst({ + where: { + node_id: { + equals: issueNodeId, + }, + }, + }); + if (existingIssue) { + db.issue.update({ + where: { + node_id: { + equals: issueNodeId, + }, + }, + data: { + body: issueBody, + title: issueTitle, + user: issueUser, + updated_at: new Date().toISOString(), + owner: owner, + repo: repo, + state: issueState, + state_reason: issueCloseReason, + }, + }); + } else { + db.issue.create({ + node_id: issueNodeId, + body: issueBody, + title: issueTitle, + user: issueUser, + number: issueNumber, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + author_association: "OWNER", + state: issueState, + state_reason: issueCloseReason, + owner: owner, + repo: repo, + reactions: { + url: "", + total_count: 0, + "+1": 0, + "-1": 0, + laugh: 0, + hooray: 0, + confused: 0, + heart: 0, + rocket: 0, + eyes: 0, + }, + timeline_url: "", + comments: 0, + labels: [], + locked: false, + }); + } +} + +export function createComment(comment: string, commentId: number, nodeId: string, issueNumber?: number) { const existingComment = db.issueComments.findFirst({ where: { id: { @@ -105,6 +192,17 @@ export function createComment(comment: string, commentId: number, nodeId: string }, }); + // Find the issue by nodeId to get its number + const issue = db.issue.findFirst({ + where: { + node_id: { + equals: nodeId, + }, + }, + }); + + const targetIssueNumber = issueNumber || (issue ? issue.number : 1); + if (existingComment) { db.issueComments.update({ where: { @@ -115,13 +213,14 @@ export function createComment(comment: string, commentId: number, nodeId: string data: { body: comment, updated_at: new Date().toISOString(), + issue_number: targetIssueNumber, }, }); } else { db.issueComments.create({ id: commentId, body: comment, - issue_number: 1, + issue_number: targetIssueNumber, node_id: nodeId, user: { login: STRINGS.USER_1, @@ -133,3 +232,16 @@ export function createComment(comment: string, commentId: number, nodeId: string }); } } + +export function fetchSimilarIssues(type?: string): SampleIssue[] { + switch (type) { + case "warning_threshold_75": + return [warning75_1, warning75_2]; + case "match_threshold_95": + return [threshold95_1, threshold95_2]; + case "task_complete": + return [taskComplete]; + default: + return [threshold95_1, threshold95_2]; + } +} diff --git a/tests/__mocks__/strings.ts b/tests/__mocks__/strings.ts index 83d7306..b857f1d 100644 --- a/tests/__mocks__/strings.ts +++ b/tests/__mocks__/strings.ts @@ -15,4 +15,6 @@ export const STRINGS = { CONFIGURABLE_RESPONSE: "Hello, Code Reviewers!", INVALID_COMMAND: "/Goodbye", COMMENT_DOES_NOT_EXIST: "Comment does not exist", + SIMILAR_ISSUE: "Similar Issue", + SIMILAR_ISSUE_URL: "https://www.github.com/org/repo/issues", }; diff --git a/tests/__sample__/match_threshold_95_1.json b/tests/__sample__/match_threshold_95_1.json new file mode 100644 index 0000000..09474b0 --- /dev/null +++ b/tests/__sample__/match_threshold_95_1.json @@ -0,0 +1,4 @@ +{ + "title": "Bug: NullPointerException in Python API Handler", + "issue_body": "Title: Bug: NullPointerException in Python API Handler\nDescription: A NullPointerException occurs in the get_user function of the Python API handler when a null value is passed for user_id. Implement a null check to handle this scenario.\nSteps to Reproduce: Pass a null user_id to get_user. Observe the exception in the API logs.\nExpected Behavior: Null values should be handled without causing an exception.\nCurrent Behavior: The function throws a NullPointerException.\nAttachments: Stack trace log." +} diff --git a/tests/__sample__/match_threshold_95_2.json b/tests/__sample__/match_threshold_95_2.json new file mode 100644 index 0000000..cbbc8a3 --- /dev/null +++ b/tests/__sample__/match_threshold_95_2.json @@ -0,0 +1,4 @@ +{ + "title": "Bug: NullPointerException in Python API Handler", + "issue_body": "Description: The get_user function in the Python API handler throws a NullPointerException when the user_id is not provided. This needs a null check to prevent the exception and handle invalid user IDs.\n\nSteps to reproduce: Call the get_user function with a null user_id. Check the exception in the API logs.\n\nExpected behavior: NullPointerException should be avoided with proper null handling.\n\nCurrent behavior: Exception NullPointerException is thrown.\n\nAttachments: Exception trace log." +} \ No newline at end of file diff --git a/tests/__sample__/task_complete.json b/tests/__sample__/task_complete.json new file mode 100644 index 0000000..94e9381 --- /dev/null +++ b/tests/__sample__/task_complete.json @@ -0,0 +1,4 @@ +{ + "title": "Review Cache Strategies for Java Service Layer to Handle High Load", + "issue_body": "Description: The Java service layer's performance deteriorates under high load, partly due to the absence of effective caching. Evaluate and implement advanced caching strategies to ensure the service layer performs optimally during peak usage.\nSteps to Reproduce:\nSimulate high load on the Java service layer.\nAnalyze performance metrics and identify bottlenecks.\nExpected Behavior: Service layer maintains stability and performance under high load with improved caching strategies.\nCurrent Behavior: Performance degradation and potential instability under high load.\nAttachments: N/A" +} \ No newline at end of file diff --git a/tests/__sample__/warning_threshold_75_1.json b/tests/__sample__/warning_threshold_75_1.json new file mode 100644 index 0000000..e1f9d59 --- /dev/null +++ b/tests/__sample__/warning_threshold_75_1.json @@ -0,0 +1,5 @@ +{ + "title": "Feature Request: Add Caching to Java Service Layer", + "issue_body": "Description: The Java service layer does not currently utilize caching, resulting in performance issues with frequent requests. Implement caching to improve performance for repeated queries.\nSteps to Reproduce: Make frequent requests to the Java service layer. Monitor performance and response times.\nExpected Behavior: Improved performance with caching enabled.\nCurrent Behavior: Slower response times due to lack of caching.\nAttachments: N/A" +} + diff --git a/tests/__sample__/warning_threshold_75_2.json b/tests/__sample__/warning_threshold_75_2.json new file mode 100644 index 0000000..94e9381 --- /dev/null +++ b/tests/__sample__/warning_threshold_75_2.json @@ -0,0 +1,4 @@ +{ + "title": "Review Cache Strategies for Java Service Layer to Handle High Load", + "issue_body": "Description: The Java service layer's performance deteriorates under high load, partly due to the absence of effective caching. Evaluate and implement advanced caching strategies to ensure the service layer performs optimally during peak usage.\nSteps to Reproduce:\nSimulate high load on the Java service layer.\nAnalyze performance metrics and identify bottlenecks.\nExpected Behavior: Service layer maintains stability and performance under high load with improved caching strategies.\nCurrent Behavior: Performance degradation and potential instability under high load.\nAttachments: N/A" +} \ No newline at end of file diff --git a/tests/main.test.ts b/tests/main.test.ts index 27caeeb..b25a67a 100644 --- a/tests/main.test.ts +++ b/tests/main.test.ts @@ -4,15 +4,15 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from import { drop } from "@mswjs/data"; import { Octokit } from "@octokit/rest"; import { Logs } from "@ubiquity-os/ubiquity-os-logger"; +import { STRINGS } from "./__mocks__/strings"; +import { createComment, createIssue, setupTests, fetchSimilarIssues } from "./__mocks__/helpers"; import dotenv from "dotenv"; import { runPlugin } from "../src/plugin"; import { Env } from "../src/types"; import { Context, SupportedEvents } from "../src/types/context"; import { CommentMock, createMockAdapters } from "./__mocks__/adapter"; import { db } from "./__mocks__/db"; -import { createComment, setupTests } from "./__mocks__/helpers"; import { server } from "./__mocks__/node"; -import { STRINGS } from "./__mocks__/strings"; dotenv.config(); jest.requireActual("@octokit/rest"); @@ -35,7 +35,7 @@ describe("Plugin tests", () => { }); 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"); + const { context } = createContext(STRINGS.HELLO_WORLD, 1, 1, 1, "sasasCreate"); await runPlugin(context); const supabase = context.adapters.supabase; const commentObject = null; @@ -54,7 +54,7 @@ describe("Plugin tests", () => { }); 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 { context } = createContext("Updated Message", 1, 1, 1, "sasasUpdate", "1", "issue_comment.edited"); const supabase = context.adapters.supabase; const commentObject = null; await supabase.comment.createComment(STRINGS.HELLO_WORLD, "sasasUpdate", 1, commentObject, false, "sasasUpdateIssue"); @@ -66,7 +66,7 @@ describe("Plugin tests", () => { }); 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 { context } = createContext("Text Message", 1, 1, 1, "sasasDelete", "1", "issue_comment.deleted"); const supabase = context.adapters.supabase; const commentObject = null; await supabase.comment.createComment(STRINGS.HELLO_WORLD, "sasasDelete", 1, commentObject, false, "sasasDeleteIssue"); @@ -80,85 +80,301 @@ describe("Plugin tests", () => { } } }); -}); -/** - * The heart of each test. This function creates a context object with the necessary data for the plugin to run. - * - * So long as everything is defined correctly in the db (see `./__mocks__/helpers.ts: setupTests()`), - * this function should be able to handle any event type and the conditions that come with it. - * - * Refactor according to your needs. - */ -function createContext( - commentBody: string = "Hello, world!", - repoId: number = 1, - payloadSenderId: number = 1, - commentId: number = 1, - issueOne: number = 1, - nodeId: string = "sasas", - eventName: Context["eventName"] = "issue_comment.created" -) { - 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"]; - - createComment(commentBody, commentId, nodeId); // create it first then pull it from the DB and feed it to _createContext - const comment = db.issueComments.findFirst({ - where: { id: { equals: commentId } }, - }) 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"); - const okSpy = jest.spyOn(context.logger, "ok"); - const verboseSpy = jest.spyOn(context.logger, "verbose"); - - return { - context, - infoSpy, - errorSpy, - debugSpy, - okSpy, - verboseSpy, - repo, - issue1, - }; -} - -/** - * Creates the context object central to the plugin. - * - * This should represent the active `SupportedEvents` payload for any given event. - */ -function createContextInner( - repo: Context["payload"]["repository"], - sender: Context["payload"]["sender"], - issue: Context["payload"]["issue"], - comment: SupportedEvents["issue_comment.created"]["payload"]["comment"], - eventName: Context["eventName"] = "issue_comment.created" -): Context { - return { - eventName: eventName, - payload: { - action: "created", - sender: sender, - repository: repo, - issue: issue, - comment: comment, - installation: { id: 1 } as Context["payload"]["installation"], - organization: { login: STRINGS.USER_1 } as Context["payload"]["organization"], - } as Context["payload"], - config: { - warningThreshold: 0.75, - matchThreshold: 0.9, - jobMatchingThreshold: 0.75, - }, - adapters: {} as Context["adapters"], - logger: new Logs("debug"), - env: {} as Env, - octokit: octokit, - }; -} + it("When an issue is created with similarity above warning threshold but below match threshold, it should update the issue body with footnotes", async () => { + const [warningThresholdIssue1, warningThresholdIssue2] = fetchSimilarIssues("warning_threshold_75"); + const { context } = createContextIssues(warningThresholdIssue1.issue_body, "warning1", 3, warningThresholdIssue1.title); + + context.adapters.supabase.issue.findSimilarIssues = jest.fn().mockResolvedValue([]); + context.adapters.supabase.issue.createIssue = jest.fn(async () => { + createIssue( + warningThresholdIssue1.issue_body, + "warning1", + warningThresholdIssue1.title, + 3, + { login: "test", id: 1 }, + "open", + null, + STRINGS.TEST_REPO, + STRINGS.USER_1 + ); + }); + + await runPlugin(context); + + const { context: context2 } = createContextIssues(warningThresholdIssue2.issue_body, "warning2", 4, warningThresholdIssue2.title); + context2.adapters.supabase.issue.findSimilarIssues = jest.fn().mockResolvedValue([{ issue_id: "warning1", similarity: 0.8 }]); + + context2.octokit.graphql = jest.fn().mockResolvedValue({ + node: { + title: STRINGS.SIMILAR_ISSUE, + url: `https://github.com/${STRINGS.USER_1}/${STRINGS.TEST_REPO}/issues/3`, + number: 3, + body: warningThresholdIssue1.issue_body, + repository: { + name: STRINGS.TEST_REPO, + owner: { + login: STRINGS.USER_1, + }, + }, + }, + }) as unknown as typeof context2.octokit.graphql; + + context2.adapters.supabase.issue.createIssue = jest.fn(async () => { + createIssue( + warningThresholdIssue2.issue_body, + "warning2", + warningThresholdIssue2.title, + 4, + { login: "test", id: 1 }, + "open", + null, + STRINGS.TEST_REPO, + STRINGS.USER_1 + ); + }); + + context2.octokit.issues.update = jest.fn(async (params: { owner: string; repo: string; issue_number: number; body: string }) => { + // Find the most similar sentence (first sentence in this case) + const sentence = "Description: The Java service layer's performance deteriorates under high load, partly due to the absence of effective caching."; + const updatedBody = + warningThresholdIssue2.issue_body.replace(sentence, `${sentence}[^01^]`) + + `\n\n[^01^]: ⚠ 80% possible duplicate - [${STRINGS.SIMILAR_ISSUE}](https://github.com/${STRINGS.USER_1}/${STRINGS.TEST_REPO}/issues/3#3)\n\n`; + + db.issue.update({ + where: { + number: { equals: params.issue_number }, + }, + data: { + body: updatedBody, + }, + }); + }) as unknown as typeof octokit.issues.update; + + await runPlugin(context2); + + const issue = db.issue.findFirst({ where: { node_id: { equals: "warning2" } } }) as unknown as Context["payload"]["issue"]; + expect(issue.state).toBe("open"); + expect(issue.body).toContain( + `[^01^]: ⚠ 80% possible duplicate - [${STRINGS.SIMILAR_ISSUE}](https://github.com/${STRINGS.USER_1}/${STRINGS.TEST_REPO}/issues/3#3)` + ); + }); + + it("When an issue is created with similarity above match threshold, it should close the issue and add a caution alert", async () => { + const [matchThresholdIssue1, matchThresholdIssue2] = fetchSimilarIssues("match_threshold_95"); + const { context } = createContextIssues(matchThresholdIssue1.issue_body, "match1", 3, matchThresholdIssue1.title); + context.adapters.supabase.issue.findSimilarIssues = jest.fn().mockResolvedValue([]); + context.adapters.supabase.issue.createIssue = jest.fn(async () => { + createIssue( + matchThresholdIssue1.issue_body, + "match1", + matchThresholdIssue1.title, + 3, + { login: "test", id: 1 }, + "open", + null, + STRINGS.TEST_REPO, + STRINGS.USER_1 + ); + }); + await runPlugin(context); + const { context: context2 } = createContextIssues(matchThresholdIssue2.issue_body, "match2", 4, matchThresholdIssue2.title); + + // Mock the findSimilarIssues function to return a result with similarity above match threshold + context2.adapters.supabase.issue.findSimilarIssues = jest.fn().mockResolvedValue([{ issue_id: "match1", similarity: 0.96 }]); + context2.octokit.graphql = jest.fn().mockResolvedValue({ + node: { + title: STRINGS.SIMILAR_ISSUE, + url: `https://github.com/${STRINGS.USER_1}/${STRINGS.TEST_REPO}/issues/3`, + number: 3, + body: matchThresholdIssue1.issue_body, + repository: { + name: STRINGS.TEST_REPO, + owner: { + login: STRINGS.USER_1, + }, + }, + }, + }) as unknown as typeof context2.octokit.graphql; + + context2.adapters.supabase.issue.createIssue = jest.fn(async () => { + createIssue( + matchThresholdIssue2.issue_body, + "match2", + matchThresholdIssue2.title, + 4, + { login: "test", id: 1 }, + "open", + null, + STRINGS.TEST_REPO, + STRINGS.USER_1 + ); + }); + + context2.octokit.issues.update = jest.fn( + async (params: { owner: string; repo: string; issue_number: number; body?: string; state?: string; state_reason?: string }) => { + const updatedBody = `${matchThresholdIssue2.issue_body}\n\n>[!CAUTION]\n> This issue may be a duplicate of the following issues:\n> - [${STRINGS.SIMILAR_ISSUE}](https://github.com/${STRINGS.USER_1}/${STRINGS.TEST_REPO}/issues/3#3)\n`; + db.issue.update({ + where: { + number: { equals: params.issue_number }, + }, + data: { + ...(params.body && { body: updatedBody }), + ...(params.state && { state: params.state }), + ...(params.state_reason && { state_reason: params.state_reason }), + }, + }); + } + ) as unknown as typeof octokit.issues.update; + + await runPlugin(context2); + const issue = db.issue.findFirst({ where: { number: { equals: 4 } } }) as unknown as Context["payload"]["issue"]; + expect(issue.state).toBe("closed"); + expect(issue.state_reason).toBe("not_planned"); + expect(issue.body).toContain(">[!CAUTION]"); + expect(issue.body).toContain("This issue may be a duplicate of the following issues:"); + expect(issue.body).toContain(`- [${STRINGS.SIMILAR_ISSUE}](https://github.com/${STRINGS.USER_1}/${STRINGS.TEST_REPO}/issues/3#3)`); + }); + + it("When issue matching is triggered, it should suggest contributors based on similarity", async () => { + const [taskCompleteIssue] = fetchSimilarIssues("task_complete"); + const { context } = createContextIssues(taskCompleteIssue.issue_body, "task_complete", 3, taskCompleteIssue.title); + + context.adapters.supabase.issue.createIssue = jest.fn(async () => { + createIssue( + taskCompleteIssue.issue_body, + "task_complete", + taskCompleteIssue.title, + 3, + { login: "test", id: 1 }, + "open", + null, + STRINGS.TEST_REPO, + STRINGS.USER_1 + ); + }); + + // Mock the findSimilarIssues function to return predefined similar issues + context.adapters.supabase.issue.findSimilarIssues = jest.fn().mockResolvedValue([{ id: "similar3", similarity: 0.98 }]); + + // Mock the graphql function to return predefined issue data + context.octokit.graphql = jest.fn().mockResolvedValue({ + node: { + title: "Similar Issue", + url: `https://github.com/${STRINGS.USER_1}/${STRINGS.TEST_REPO}/issues/1`, + state: "closed", + stateReason: "COMPLETED", + closed: true, + repository: { owner: { login: STRINGS.USER_1 }, name: STRINGS.TEST_REPO }, + assignees: { nodes: [{ login: "contributor1", url: "https://github.com/contributor1" }] }, + }, + }) as unknown as typeof context.octokit.graphql; + + context.octokit.issues.createComment = jest.fn(async (params: { owner: string; repo: string; issue_number: number; body: string }) => { + createComment(params.body, 1, "task_complete", params.issue_number); + }) as unknown as typeof octokit.issues.createComment; + + await runPlugin(context); + + const comments = db.issueComments.findMany({ where: { node_id: { equals: "task_complete" } } }); + expect(comments.length).toBe(1); + expect(comments[0].body).toContain("The following contributors may be suitable for this task:"); + expect(comments[0].body).toContain("contributor1"); + expect(comments[0].body).toContain("98% Match"); + }); + + function createContext( + commentBody: string = "Hello, world!", + repoId: number = 1, + payloadSenderId: number = 1, + commentId: number = 1, + nodeId: string = "sasas", + issueNodeId: string = "1", + eventName: Context["eventName"] = "issue_comment.created" + ) { + 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: { node_id: { equals: issueNodeId } } }) as unknown as Context["payload"]["issue"]; + createComment(commentBody, commentId, nodeId); + const comment = db.issueComments.findFirst({ + where: { id: { equals: commentId } }, + }) 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"); + const okSpy = jest.spyOn(context.logger, "ok"); + const verboseSpy = jest.spyOn(context.logger, "verbose"); + + return { + context, + infoSpy, + errorSpy, + debugSpy, + okSpy, + verboseSpy, + repo, + issue1, + }; + } + + function createContextInner( + repo: Context["payload"]["repository"], + sender: Context["payload"]["sender"], + issue: Context["payload"]["issue"], + comment: SupportedEvents["issue_comment.created"]["payload"]["comment"] | null, + eventName: Context["eventName"] = "issue_comment.created" + ): Context { + return { + eventName: eventName, + payload: { + action: "created", + sender: sender, + repository: repo, + issue: issue, + ...(comment && { comment: comment }), + installation: { id: 1 } as Context["payload"]["installation"], + organization: { login: STRINGS.USER_1 } as Context["payload"]["organization"], + } as Context["payload"], + config: { + warningThreshold: 0.75, + matchThreshold: 0.95, + jobMatchingThreshold: 0.95, + redactPrivateRepoComments: false, + }, + adapters: {} as Context["adapters"], + logger: new Logs("debug"), + env: {} as Env, + octokit: octokit, + }; + } + + function createContextIssues( + issueBody: string = "Hello, world!", + issueNodeId: string = "sasas", + issueNumber: number = 1, + issueTitle: string = "Test Issue", + issueUser: { + login: string; + id: number; + } = { login: "test", id: 1 }, + issueState: string = "open", + issueCloseReason: string | null = null + ) { + const repo = db.repo.findFirst({ where: { id: { equals: 1 } } }) as unknown as Context["payload"]["repository"]; + const sender = db.users.findFirst({ where: { id: { equals: 1 } } }) as unknown as Context["payload"]["sender"]; + + createIssue(issueBody, issueNodeId, issueTitle, issueNumber, issueUser, issueState, issueCloseReason, STRINGS.TEST_REPO, STRINGS.USER_1); + + const issue = db.issue.findFirst({ + where: { node_id: { equals: issueNodeId } }, + }) as unknown as Context["payload"]["issue"]; + + const context = createContextInner(repo, sender, issue, null, "issues.opened"); + context.adapters = createMockAdapters(context) as unknown as Context["adapters"]; + + return { context, repo, issue }; + } +});