Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Always Recommend Contributors #54

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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