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

feat: generate code suggestions via AI #90

Merged
merged 13 commits into from
May 23, 2023
2 changes: 2 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export const GITHUB_LOGGED_IN_USER_USERNAME_SELECTOR = "meta[name=\"user-login\"
export const GITHUB_PR_COMMENT_HEADER_SELECTOR = "timeline-comment-header clearfix d-flex";
export const GITHUB_NEW_PR_COMMENT_EDITOR_SELECTOR = "flex-nowrap d-none d-md-inline-block mr-md-0 mr-3";
export const GITHUB_PR_COMMENT_EDITOR_SELECTOR = "flex-nowrap d-inline-block mr-3";
export const GITHUB_REVIEW_SUGGESTION_SELECTOR = "js-suggestion-button-placeholder";
export const GITHUB_REPO_ACTIONS_SELECTOR = ".pagehead-actions";
export const GITHUB_PR_COMMENT_TEXT_AREA_SELECTOR = "pull_request[body]";
export const GITHUB_PR_SUGGESTION_TEXT_AREA_SELECTOR = "[name='comment[body]']";
export const GITHUB_PR_BASE_BRANCH_SELECTOR = "css-truncate css-truncate-target";
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { createHtmlElement } from "../../../utils/createHtmlElement";
import openSaucedLogoIcon from "../../../assets/opensauced-icon.svg";
import { GITHUB_PR_SUGGESTION_TEXT_AREA_SELECTOR, OPEN_AI_COMPLETION_MODEL_NAME, SUPABASE_LOGIN_URL } from "../../../constants";
import { generateCodeSuggestion } from "../../../utils/aiprdescription/openai";
import { isContextWithinBounds } from "../../../utils/fetchGithubAPIData";
import { insertAtCursorFromStream } from "../../../utils/aiprdescription/cursorPositionInsert";
import { getAIDescriptionConfig } from "../../../utils/aiprdescription/descriptionconfig";
import { isLoggedIn } from "../../../utils/checkAuthentication";

export const ChangeSuggestorButton = (commentNode: HTMLElement) => {
const changeSuggestoreButton = createHtmlElement("a", {
innerHTML: `<span id="ai-change-gen" class="toolbar-item btn-octicon">
<img class="octicon octicon-heading" height="16px" width="16px" id="ai-description-button-logo" src=${chrome.runtime.getURL(openSaucedLogoIcon)}>
</span>
<tool-tip for="ai-change-gen" class="sr-only" role="tooltip">Get Refactor Suggestions</tool-tip>`,
onclick: async () => handleSubmit(commentNode),
});

return changeSuggestoreButton;
};
diivi marked this conversation as resolved.
Show resolved Hide resolved

const handleSubmit = async (commentNode: HTMLElement) => {
try {
if (!(await isLoggedIn())) {
return window.open(SUPABASE_LOGIN_URL, "_blank");
}
const logo = document.getElementById("ai-description-button-logo");

if (!logo) {
return;
}

const descriptionConfig = await getAIDescriptionConfig();

if (!descriptionConfig) {
return;
}
if (!descriptionConfig.enabled) {
return alert("AI PR description is disabled!");
}

logo.classList.toggle("animate-spin");

const selectedLines = document.querySelectorAll(".code-review.selected-line");
let selectedCode = Array.from(selectedLines).map(line => line.textContent)
.join("\n");

// find input with name="position" and get its value
if (!selectedCode) {
const position = (commentNode.querySelector("input[name=position]")!).value;

selectedCode = document.querySelector(`[data-line-number="${position}"]`)?.nextSibling?.nextSibling.getElementsByClassName("blob-code-inner")[0].textContent;
}
if (!isContextWithinBounds([selectedCode, [] ], descriptionConfig.config.maxInputLength)) {
logo.classList.toggle("animate-spin");
return alert(`Max input length exceeded. Try reducing the number of selected lines to refactor.`);
}
const suggestionStream = await generateCodeSuggestion(
descriptionConfig.config.openai_api_key,
OPEN_AI_COMPLETION_MODEL_NAME,
descriptionConfig.config.language,
descriptionConfig.config.length,
descriptionConfig.config.temperature / 10,
descriptionConfig.config.tone,
selectedCode,
);

logo.classList.toggle("animate-spin");
if (!suggestionStream) {
return console.error("No description was generated!");
}
const textArea = commentNode.querySelector(GITHUB_PR_SUGGESTION_TEXT_AREA_SELECTOR)!;

void insertAtCursorFromStream(textArea as HTMLTextAreaElement, suggestionStream);
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Description generation error:", error.message);
}
}
};

6 changes: 5 additions & 1 deletion src/content-scripts/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isGithubPullRequestPage,
isGithubRepoPage,
isPullRequestCreatePage,
isPullRequestFilesChangedPage,
} from "../utils/urlMatchers";
import { isOpenSaucedUser } from "../utils/fetchOpenSaucedApiData";
import injectViewOnOpenSauced from "../utils/dom-utils/viewOnOpenSauced";
Expand All @@ -13,14 +14,17 @@ import injectAddPRToHighlightsButton from "../utils/dom-utils/addPRToHighlights"
import injectRepoVotingButtons from "../utils/dom-utils/repoVotingButtons";
import domUpdateWatch from "../utils/dom-utils/domUpdateWatcher";
import injectDescriptionGeneratorButton from "../utils/dom-utils/addDescriptionGenerator";
import prEditWatch from "../utils/dom-utils/prEditWatcher";
import injectChangeSuggestorButton from "../utils/dom-utils/changeSuggestorButton";
import prEditWatch, { prReviewWatch } from "../utils/dom-utils/prWatcher";

const processGithubPage = async () => {
if (prefersDarkMode(document.cookie)) {
document.documentElement.classList.add("dark");
}
if (isPullRequestCreatePage(window.location.href)) {
void injectDescriptionGeneratorButton();
} else if (isPullRequestFilesChangedPage(window.location.href)) {
prReviewWatch(injectChangeSuggestorButton);
} else if (isGithubPullRequestPage(window.location.href)) {
prEditWatch(injectDescriptionGeneratorButton);
void injectAddPRToHighlightsButton();
Expand Down
53 changes: 51 additions & 2 deletions src/utils/aiprdescription/openai.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { DescriptionTone } from "./descriptionconfig";
import { OpenAI, CreateChatCompletionRequest } from "openai-streams";

const generatePrompt = (
const generatePRDescriptionPrompt = (
locale: string,
maxLength: number,
tone: DescriptionTone,
Expand All @@ -12,6 +12,16 @@ const generatePrompt = (
"Exclude anything unnecessary such as translation. Your entire response will be passed directly into a pull request description",
].join("\n");

const generateCodeSuggestionPrompt = (
locale: string,
maxLength: number,
tone: DescriptionTone,
) => [
`Generate a code refactor suggestion for a given code snippet written in ${locale} with the specifications mentioned below`,
`The code snippet must be a maximum of ${maxLength} characters.`,
"Exclude anything unnecessary such as translation. Start with \"```suggestion\" and end with ``` to create a valid GitHub suggestion codeblock.",
].join("\n");

const createChatCompletion = async (
apiKey: string,
json: CreateChatCompletionRequest,
Expand Down Expand Up @@ -44,7 +54,46 @@ export const generateDescription = async (
messages: [
{
role: "system",
content: generatePrompt(locale, maxLength, tone),
content: generatePRDescriptionPrompt(locale, maxLength, tone),
},
{
role: "user",
content,
},
],
temperature,
n: 1,
},
);

return completion;
} catch (error: unknown) {
if (error instanceof Error) {
console.error("OpenAI error: ", error.message);
}
}
};

export const generateCodeSuggestion = async (
apiKey: string,
model: "gpt-3.5-turbo" | "gpt-3.5-turbo-0301",
locale: string,
maxLength: number,
temperature: number,
tone: DescriptionTone,
code: string,
) => {
const content = `Code: ${code}`;

try {
const completion = await createChatCompletion(
apiKey,
{
model,
messages: [
{
role: "system",
content: generateCodeSuggestionPrompt(locale, maxLength, tone),
},
{
role: "user",
Expand Down
19 changes: 19 additions & 0 deletions src/utils/dom-utils/changeSuggestorButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { ChangeSuggestorButton } from "../../content-scripts/components/AICodeRefactor/ChangeSuggestorButton";
import { GITHUB_REVIEW_SUGGESTION_SELECTOR } from "../../constants";
import { isPublicRepository } from "../fetchGithubAPIData";

const injectChangeSuggestorButton = async (commentNode: HTMLElement) => {
if (!(await isPublicRepository(window.location.href))) {
return;
}

const suggestChangesIcon = commentNode.getElementsByClassName(GITHUB_REVIEW_SUGGESTION_SELECTOR)[0];
const changeSuggestorButton = ChangeSuggestorButton(commentNode);

if (suggestChangesIcon.firstChild?.isEqualNode(changeSuggestorButton)) {
return;
}
suggestChangesIcon.insertBefore(changeSuggestorButton, suggestChangesIcon.firstChild);
};

export default injectChangeSuggestorButton;
14 changes: 0 additions & 14 deletions src/utils/dom-utils/prEditWatcher.ts

This file was deleted.

32 changes: 32 additions & 0 deletions src/utils/dom-utils/prWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const prEditWatch = (callback: () => void, delayInMs = 0) => {
const observer = new MutationObserver((mutationList: MutationRecord[], observer: MutationObserver) => {
for (const mutation of mutationList) {
if (Array.from((mutation.target as HTMLElement).classList).includes("is-comment-editing")) {
setTimeout(callback, delayInMs);
observer.disconnect();
}
}
});

observer.observe(document.body, { attributes: true, subtree: true });
};

export const prReviewWatch = (callback: (node: HTMLElement) => void, delayInMs = 0) => {
const observer = new MutationObserver((mutationList: MutationRecord[], observer: MutationObserver) => {
mutationList.forEach(mutation => {
if (Array.from((mutation.target as HTMLElement).classList).includes("inline-comments")) {
setTimeout(() => {
const commentNodes = document.getElementsByClassName("inline-comments");

Array.from(commentNodes).forEach(node => {
callback(node as HTMLElement);
});
}, delayInMs);
}
});
});

observer.observe(document.body, { attributes: true, subtree: true, childList: true });
};

export default prEditWatch;
17 changes: 11 additions & 6 deletions src/utils/urlMatchers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ export const isGithubProfilePage = (url: string) => {
return githubProfilePattern.test(url);
};


export const isGithubRepoPage = (url: string) => {
const githubRepoPattern = /github\.com\/[^/]+\/[^/]+$/;

Expand All @@ -47,17 +46,23 @@ export const isPullRequestCreatePage = (url: string) => {
return githubPullRequestPattern.test(url);
};

export const isPullRequestFilesChangedPage = (url: string) => {
const githubPullRequestFilesChangedPattern = /github\.com\/[\w.-]+\/[^/]+\/pull\/\d+\/files/;

return githubPullRequestFilesChangedPattern.test(url);
};

export const getPullRequestAPIURL = (url: string) => {
const apiURL = url.replace(/github\.com/, "api.github.com/repos");

if (isGithubPullRequestPage(url)) {
return apiURL.replace("pull", "pulls");
}

if (url.match(/compare\/.*\.\.\./)) {
return apiURL;
}
const baseBranch = document.getElementsByClassName(GITHUB_PR_BASE_BRANCH_SELECTOR)[1].textContent;
if (url.match(/compare\/.*\.\.\./)) {
return apiURL;
}
const baseBranch = document.getElementsByClassName(GITHUB_PR_BASE_BRANCH_SELECTOR)[1].textContent;

return apiURL.replace(/compare\//, `compare/${baseBranch}...`);
return apiURL.replace(/compare\//, `compare/${baseBranch}...`);
};