Skip to content

Commit

Permalink
Merge pull request #54 from sshivaditya2019/issuematch
Browse files Browse the repository at this point in the history
  • Loading branch information
0x4007 authored Dec 3, 2024
2 parents ca03b56 + 881b0a4 commit 2d5169f
Show file tree
Hide file tree
Showing 22 changed files with 816 additions and 237 deletions.
2 changes: 1 addition & 1 deletion dist/index.js

Large diffs are not rendered by default.

7 changes: 6 additions & 1 deletion manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
"issues.opened",
"issues.edited",
"issues.deleted",
"issues.labeled"
"issues.labeled",
"issues.closed"
],
"configuration": {
"default": {},
Expand All @@ -25,6 +26,10 @@
"jobMatchingThreshold": {
"default": 0.75,
"type": "number"
},
"alwaysRecommend": {
"default": 0,
"type": "number"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/supabase/helpers/comment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export interface CommentType {
embedding: number[];
}

interface CommentData {
export interface CommentData {
markdown: string | null;
id: string;
author_id: number;
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/supabase/helpers/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface IssueSimilaritySearchResult {
similarity: number;
}

interface IssueData {
export interface IssueData {
markdown: string | null;
id: string;
author_id: number;
Expand Down
82 changes: 82 additions & 0 deletions src/handlers/complete-issue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { Context } from "../types";
import { removeFootnotes } from "./issue-deduplication";

export async function completeIssue(context: Context<"issues.closed">) {
const {
logger,
adapters: { supabase },
payload,
} = context;

// Only handle issues closed as completed
if (payload.issue.state_reason !== "completed") {
logger.debug("Issue not marked as completed, skipping");
return;
}

// Skip issues without assignees
if (!payload.issue.assignees || payload.issue.assignees.length === 0) {
logger.debug("Issue has no assignees, skipping");
return;
}

const id = payload.issue.node_id;
const isPrivate = payload.repository.private;
const markdown = payload.issue.body && payload.issue.title ? payload.issue.body + " " + payload.issue.title : null;
const authorId = payload.issue.user?.id || -1;

try {
if (!markdown) {
logger.error("Issue body is empty");
return;
}

// Clean issue by removing footnotes
const cleanedIssue = removeFootnotes(markdown);

// Add completed status to payload
const updatedPayload = {
...payload,
issue: {
...payload.issue,
completed: true,
completed_at: new Date().toISOString(),
has_assignees: true, // Flag to indicate this is a valid completed issue with assignees
},
};

// Check if issue exists
const existingIssue = await supabase.issue.getIssue(id);

if (existingIssue && existingIssue.length > 0) {
// Update existing issue
await supabase.issue.updateIssue({
markdown: cleanedIssue,
id,
payload: updatedPayload,
isPrivate,
author_id: authorId,
});
logger.ok(`Successfully updated completed issue! ${payload.issue.id}`, payload.issue);
} else {
// Create new issue if it doesn't exist
await supabase.issue.createIssue({
id,
payload: updatedPayload,
isPrivate,
markdown: cleanedIssue,
author_id: authorId,
});
logger.ok(`Successfully created completed issue! ${payload.issue.id}`, payload.issue);
}
} catch (error) {
if (error instanceof Error) {
logger.error(`Error handling completed issue:`, { error: error, stack: error.stack, issue: payload.issue });
throw error;
} else {
logger.error(`Error handling completed issue:`, { err: error, issue: payload.issue });
throw error;
}
}
logger.debug(`Exiting completeIssue`);
}
38 changes: 17 additions & 21 deletions src/handlers/issue-deduplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@ export interface IssueGraphqlResponse {
* Checks if the current issue is a duplicate of an existing issue.
* If a similar issue is found, a footnote is added to the current issue.
* @param context The context object
* @returns True if a similar issue is found, false otherwise
**/
export async function issueChecker(context: Context<"issues.opened" | "issues.edited">): Promise<boolean> {
export async function issueChecker(context: Context<"issues.opened" | "issues.edited">) {
const {
logger,
adapters: { supabase },
Expand All @@ -35,7 +34,7 @@ export async function issueChecker(context: Context<"issues.opened" | "issues.ed
let issueBody = issue.body;
if (!issueBody) {
logger.info("Issue body is empty", { issue });
return false;
return;
}
issueBody = removeFootnotes(issueBody);
const similarIssues = await supabase.issue.findSimilarIssues({
Expand All @@ -48,9 +47,9 @@ export async function issueChecker(context: Context<"issues.opened" | "issues.ed
processedIssues = processedIssues.filter((issue) =>
matchRepoOrgToSimilarIssueRepoOrg(payload.repository.owner.login, issue.node.repository.owner.login, payload.repository.name, issue.node.repository.name)
);
const matchIssues = processedIssues.filter((issue) => parseFloat(issue.similarity) >= context.config.matchThreshold);
const matchIssues = processedIssues.filter((issue) => parseFloat(issue.similarity) / 100 >= context.config.matchThreshold);
if (matchIssues.length > 0) {
logger.info(`Similar issue which matches more than ${context.config.matchThreshold} already exists`);
logger.info(`Similar issue which matches more than ${context.config.matchThreshold} already exists`, { matchIssues });
//To the issue body, add a footnote with the link to the similar issue
const updatedBody = await handleMatchIssuesComment(context, payload, issueBody, processedIssues);
issueBody = updatedBody || issueBody;
Expand All @@ -62,12 +61,12 @@ export async function issueChecker(context: Context<"issues.opened" | "issues.ed
state: "closed",
state_reason: "not_planned",
});
return true;
return;
}
if (processedIssues.length > 0) {
logger.info(`Similar issue which matches more than ${context.config.warningThreshold} already exists`);
logger.info(`Similar issue which matches more than ${context.config.warningThreshold} already exists`, { processedIssues });
await handleSimilarIssuesComment(context, payload, issueBody, issue.number, processedIssues);
return true;
return;
}
} else {
//Use the IssueBody (Without footnotes) to update the issue when no similar issues are found
Expand All @@ -82,32 +81,29 @@ export async function issueChecker(context: Context<"issues.opened" | "issues.ed
}
}
context.logger.info("No similar issues found");
return false;
}

function matchRepoOrgToSimilarIssueRepoOrg(repoOrg: string, similarIssueRepoOrg: string, repoName: string, similarIssueRepoName: string): boolean {
return repoOrg === similarIssueRepoOrg && repoName === similarIssueRepoName;
}

function splitIntoSentences(text: string): string[] {
const sentenceRegex = /([^.!?\s][^.!?]*(?:[.!?](?!['"]?\s|$)[^.!?]*)*[.!?]?['"]?(?=\s|$))/g;
const sentences: string[] = [];
let match;
while ((match = sentenceRegex.exec(text)) !== null) {
sentences.push(match[0].trim());
}
return sentences;
}

/**
* Finds the most similar sentence in a similar issue to a sentence in the current issue.
* @param issueContent The content of the current issue
* @param similarIssueContent The content of the similar issue
* @returns The most similar sentence and its similarity score
*/
function findMostSimilarSentence(issueContent: string, similarIssueContent: string, context: Context): { sentence: string; similarity: number; index: number } {
// Regex to match sentences while preserving URLs
const sentenceRegex = /([^.!?\s][^.!?]*(?:[.!?](?!['"]?\s|$)[^.!?]*)*[.!?]?['"]?(?=\s|$))/g;
// Function to split text into sentences while preserving URLs
const splitIntoSentences = (text: string): string[] => {
const sentences: string[] = [];
let match;
while ((match = sentenceRegex.exec(text)) !== null) {
sentences.push(match[0].trim());
}
return sentences;
};

const issueSentences = splitIntoSentences(issueContent);
const similarIssueSentences = splitIntoSentences(similarIssueContent);

Expand Down
45 changes: 37 additions & 8 deletions src/handlers/issue-matching.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,8 @@ export interface IssueGraphqlResponse {

/**
* Checks if the current issue is a duplicate of an existing issue.
* If a similar issue is found, a comment is added to the current issue.
* If a similar completed issue is found, it will add a comment to the issue with the assignee(s) of the similar issue.
* @param context The context object
* @returns True if a similar issue is found, false otherwise
**/
export async function issueMatching(context: Context<"issues.opened" | "issues.edited" | "issues.labeled">) {
const {
Expand All @@ -42,11 +41,16 @@ export async function issueMatching(context: Context<"issues.opened" | "issues.e
const issueContent = issue.body + issue.title;
const commentStart = ">The following contributors may be suitable for this task:";
const matchResultArray: Map<string, Array<string>> = new Map();

// If alwaysRecommend is enabled, use a lower threshold to ensure we get enough recommendations
const threshold = context.config.alwaysRecommend && context.config.alwaysRecommend > 0 ? 0 : context.config.jobMatchingThreshold;

const similarIssues = await supabase.issue.findSimilarIssues({
markdown: issueContent,
threshold: context.config.jobMatchingThreshold,
threshold: threshold,
currentId: issue.node_id,
});

if (similarIssues && similarIssues.length > 0) {
similarIssues.sort((a: IssueSimilaritySearchResult, b: IssueSimilaritySearchResult) => b.similarity - a.similarity); // Sort by similarity
const fetchPromises = similarIssues.map(async (issue: IssueSimilaritySearchResult) => {
Expand Down Expand Up @@ -88,7 +92,10 @@ export async function issueMatching(context: Context<"issues.opened" | "issues.e
}
});
const issueList = (await Promise.all(fetchPromises)).filter((issue) => issue !== null);

logger.debug("Fetched similar issues", { issueList });
issueList.forEach((issue: IssueGraphqlResponse) => {
// Only use completed issues that have assignees
if (issue.node.closed && issue.node.stateReason === "COMPLETED" && issue.node.assignees.nodes.length > 0) {
const assignees = issue.node.assignees.nodes;
assignees.forEach((assignee: { login: string; url: string }) => {
Expand All @@ -108,6 +115,7 @@ export async function issueMatching(context: Context<"issues.opened" | "issues.e
});
}
});

// Fetch if any previous comment exists
const listIssues: RestEndpointMethodTypes["issues"]["listComments"]["response"] = await octokit.rest.issues.listComments({
owner: payload.repository.owner.login,
Expand All @@ -116,8 +124,10 @@ export async function issueMatching(context: Context<"issues.opened" | "issues.e
});
//Check if the comment already exists
const existingComment = listIssues.data.find((comment) => comment.body && comment.body.includes(">[!NOTE]" + "\n" + commentStart));
//Check if matchResultArray is empty
if (matchResultArray && matchResultArray.size === 0) {

logger.debug("Matched issues", { matchResultArray, length: matchResultArray.size });

if (matchResultArray.size === 0) {
if (existingComment) {
// If the comment already exists, delete it
await octokit.rest.issues.deleteComment({
Expand All @@ -126,10 +136,29 @@ export async function issueMatching(context: Context<"issues.opened" | "issues.e
comment_id: existingComment.id,
});
}
logger.debug("No similar issues found");
logger.debug("No suitable contributors found");
return;
}
const comment = commentBuilder(matchResultArray);

// Convert Map to array and sort by highest similarity
const sortedContributors = Array.from(matchResultArray.entries())
.map(([login, matches]) => ({
login,
matches,
maxSimilarity: Math.max(...matches.map((match) => parseInt(match.match(/`(\d+)% Match`/)?.[1] || "0"))),
}))
.sort((a, b) => b.maxSimilarity - a.maxSimilarity);

logger.debug("Sorted contributors", { sortedContributors });

// Use alwaysRecommend if specified
const numToShow = context.config.alwaysRecommend || 3;
const limitedContributors = new Map(sortedContributors.slice(0, numToShow).map(({ login, matches }) => [login, matches]));

const comment = commentBuilder(limitedContributors);

logger.debug("Comment to be added", { comment });

if (existingComment) {
await context.octokit.rest.issues.updateComment({
owner: payload.repository.owner.login,
Expand All @@ -147,7 +176,7 @@ export async function issueMatching(context: Context<"issues.opened" | "issues.e
}
}

logger.ok(`Exiting issueMatching handler!`, { similarIssues: similarIssues || "No similar issues found" });
logger.info(`Exiting issueMatching handler!`, { similarIssues: similarIssues || "No similar issues found" });
}

/**
Expand Down
11 changes: 7 additions & 4 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Context } from "./types";
import { Database } from "./types/database";
import { isIssueCommentEvent, isIssueEvent } from "./types/typeguards";
import { issueTransfer } from "./handlers/transfer-issue";
import { completeIssue } from "./handlers/complete-issue";

/**
* The main plugin function. Split for easier testing.
Expand Down Expand Up @@ -44,16 +45,18 @@ export async function runPlugin(context: Context) {
switch (eventName) {
case "issues.opened":
await addIssue(context as Context<"issues.opened">);
await issueChecker(context as Context<"issues.opened">);
return await issueMatching(context as Context<"issues.opened">);
await issueMatching(context as Context<"issues.opened">);
return await issueChecker(context as Context<"issues.opened">);
case "issues.edited":
await issueChecker(context as Context<"issues.edited">);
await updateIssue(context as Context<"issues.edited">);
return await issueMatching(context as Context<"issues.edited">);
await issueMatching(context as Context<"issues.edited">);
return await issueChecker(context as Context<"issues.edited">);
case "issues.deleted":
return await deleteIssues(context as Context<"issues.deleted">);
case "issues.transferred":
return await issueTransfer(context as Context<"issues.transferred">);
case "issues.closed":
return await completeIssue(context as Context<"issues.closed">);
}
} else if (eventName == "issues.labeled") {
return await issueMatching(context as Context<"issues.labeled">);
Expand Down
3 changes: 2 additions & 1 deletion src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export type SupportedEvents =
| "issues.edited"
| "issues.deleted"
| "issues.labeled"
| "issues.transferred";
| "issues.transferred"
| "issues.closed";

export type Context<TEvents extends SupportedEvents = SupportedEvents> = PluginContext<PluginSettings, Env, null, TEvents> & {
adapters: ReturnType<typeof createAdapters>;
Expand Down
1 change: 1 addition & 0 deletions src/types/plugin-input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const pluginSettingsSchema = T.Object(
matchThreshold: T.Number({ default: 0.95 }),
warningThreshold: T.Number({ default: 0.75 }),
jobMatchingThreshold: T.Number({ default: 0.75 }),
alwaysRecommend: T.Optional(T.Number({ default: 0 })),
},
{ default: {} }
);
Expand Down
9 changes: 6 additions & 3 deletions src/types/typeguards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,18 @@ export function isIssueCommentEvent(context: Context): context is Context<"issue
}

/**
* Restricts the scope of `context` to the `issues.opened`, `issues.edited`, and `issues.deleted` payloads.
* Restricts the scope of `context` to the `issues.opened`, `issues.edited`, `issues.deleted`, `issues.transferred`, and `issues.closed` payloads.
*
* @param context The context object.
*/
export function isIssueEvent(context: Context): context is Context<"issues.opened" | "issues.edited" | "issues.deleted" | "issues.transferred"> {
export function isIssueEvent(
context: Context
): context is Context<"issues.opened" | "issues.edited" | "issues.deleted" | "issues.transferred" | "issues.closed"> {
return (
context.eventName === "issues.opened" ||
context.eventName === "issues.edited" ||
context.eventName === "issues.deleted" ||
context.eventName === "issues.transferred"
context.eventName === "issues.transferred" ||
context.eventName === "issues.closed"
);
}
Loading

0 comments on commit 2d5169f

Please sign in to comment.