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

Task matching #20

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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.labeled"]
```


Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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.labeled"]
}
31 changes: 31 additions & 0 deletions src/adapters/supabase/helpers/issues.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,17 @@ export interface IssueSimilaritySearchResult {
similarity: number;
}

export interface IssueType {
id: string;
markdown?: string;
plaintext?: string;
payload?: Record<string, unknown>;
author_id: number;
created_at: string;
modified_at: string;
embedding: number[];
}

export class Issues extends SuperSupabase {
constructor(supabase: SupabaseClient, context: Context) {
super(supabase, context);
Expand Down Expand Up @@ -66,6 +77,19 @@ export class Issues extends SuperSupabase {
}
}

async getIssue(issueNodeId: string): Promise<IssueType[] | null> {
const { data, error } = await this.supabase
.from("issues") // Provide the second type argument
.select("*")
.eq("id", issueNodeId)
.returns<IssueType[]>();
if (error) {
this.context.logger.error("Error getting issue", error);
return null;
}
return data;
}

async findSimilarIssues(markdown: string, threshold: number, currentId: string): Promise<IssueSimilaritySearchResult[] | null> {
const embedding = await this.context.adapters.voyage.embedding.createEmbedding(markdown);
const { data, error } = await this.supabase.rpc("find_similar_issues", {
Expand All @@ -79,4 +103,11 @@ export class Issues extends SuperSupabase {
}
return data;
}

async updatePayload(issueNodeId: string, payload: Record<string, unknown>) {
const { error } = await this.supabase.from("issues").update({ payload }).eq("id", issueNodeId);
if (error) {
this.context.logger.error("Error updating issue payload", error);
}
}
}
1 change: 0 additions & 1 deletion src/handlers/issue-deduplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ export async function issueChecker(context: Context): Promise<boolean> {

// 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);

Expand Down
150 changes: 150 additions & 0 deletions src/handlers/issue-matching.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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;
}

const commentBuilder = (matchResultArray: Map<string, Array<string>>): string => {
const commentLines: string[] = [">[!NOTE]", ">The following contributors may be suitable for this task:"];
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
matchResultArray.forEach((issues, assignee) => {
commentLines.push(`>### [${assignee}](https://www.github.com/${assignee})`);
0x4007 marked this conversation as resolved.
Show resolved Hide resolved
issues.forEach((issue) => {
commentLines.push(issue);
});
});
return commentLines.join("\n");
};

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 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
// if the comment already exists, it should update the comment with the new users
const matchResultArray: Map<string, Array<string>> = 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
similarIssues.sort((a, b) => b.similarity - a.similarity);
const fetchPromises = similarIssues.map(async (issue) => {
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 issueLink = issue.node.url.replace(/https?:\/\/github.com/, "https://www.github.com");
if (matchResultArray.has(assignee.login)) {
matchResultArray
.get(assignee.login)
?.push(
`> \`${similarityPercentage}% Match\` [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink})`
);
} else {
matchResultArray.set(assignee.login, [
`> \`${similarityPercentage}% Match\` [${issue.node.repository.owner.login}/${issue.node.repository.name}#${issue.node.url.split("/").pop()}](${issueLink})`,
]);
}
});
}
});
// 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.includes(">[!NOTE]" + "\n" + commentStart));
//Check if matchResultArray is empty
if (matchResultArray && matchResultArray.size === 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;
}
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: comment,
});
} else {
await context.octokit.issues.createComment({
owner: payload.repository.owner.login,
repo: payload.repository.name,
issue_number: payload.issue.number,
body: comment,
});
}
}

logger.ok(`Successfully created issue comment!`);
logger.debug(`Exiting issueMatching handler`);
}
9 changes: 7 additions & 2 deletions src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { issueMatching } from "./handlers/issue-matching";

/**
* The main plugin function. Split for easier testing.
Expand All @@ -33,13 +34,17 @@ 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") {
return await issueMatching(context);
} else {
logger.error(`Unsupported event: ${eventName}`);
}
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 SupportedEventsU =
| "issue_comment.edited"
| "issues.opened"
| "issues.edited"
| "issues.deleted";
| "issues.deleted"
| "issues.labeled";

export type SupportedEvents = {
[K in SupportedEventsU]: K extends WebhookEventName ? WebhookEvent<K> : never;
Expand Down
Loading