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
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const OPEN_SAUCED_REPOS_ENDPOINT = "https://api.opensauced.pizza/v1/repos
export const OPEN_SAUCED_SESSION_ENDPOINT = "https://api.opensauced.pizza/v1/auth/session";
export const OPEN_SAUCED_USER_INSIGHTS_ENDPOINT = "https://api.opensauced.pizza/v1/user/insights";
export const OPEN_SAUCED_AI_PR_DESCRIPTION_ENDPOINT = "https://api.opensauced.pizza/v1/prs/description/generate";
export const OPEN_SAUCED_AI_CODE_REFACTOR_ENDPOINT = "https://api.opensauced.pizza/v1/prs/suggestion/generate";

// GitHub constants/selectors
export const GITHUB_PROFILE_MENU_SELECTOR = ".p-nickname.vcard-username.d-block";
Expand All @@ -20,6 +21,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,84 @@
import { createHtmlElement } from "../../../utils/createHtmlElement";
import openSaucedLogoIcon from "../../../assets/opensauced-icon.svg";
import { GITHUB_PR_SUGGESTION_TEXT_AREA_SELECTOR, SUPABASE_LOGIN_URL } from "../../../constants";
import { generateCodeSuggestion } from "../../../utils/aiprdescription/openai";
import { isOutOfContextBounds } from "../../../utils/fetchGithubAPIData";
import { insertTextAtCursor } from "../../../utils/aiprdescription/cursorPositionInsert";
import { getAIDescriptionConfig } from "../../../utils/aiprdescription/descriptionconfig";
import { getAuthToken, isLoggedIn } from "../../../utils/checkAuthentication";

export const ChangeSuggestorButton = (commentNode: HTMLElement) => {
const changeSuggestorButton = 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),
id: "os-ai-change-gen",
});

return changeSuggestorButton;
};

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 positionElement = (commentNode.querySelector("input[name=position]")!);
const position = positionElement.getAttribute("value")!;

const codeDiv = document.querySelector(`[data-line-number="${position}"]`)?.nextSibling?.nextSibling as HTMLElement;

selectedCode = codeDiv.getElementsByClassName("blob-code-inner")[0].textContent!;
}
if (isOutOfContextBounds([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 token = await getAuthToken();
const suggestionStream = await generateCodeSuggestion(
token,
descriptionConfig.config.language,
descriptionConfig.config.length,
descriptionConfig.config.temperature / 10,
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 insertTextAtCursor(textArea as HTMLTextAreaElement, suggestionStream);
} catch (error: unknown) {
if (error instanceof Error) {
console.error("Description generation error:", error.message);
}
}
};

Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ const handleSubmit = async () => {
}
logo.classList.toggle("animate-spin");
const [diff, commitMessages] = await getDescriptionContext(url, descriptionConfig.config.source);
if(!diff && !commitMessages) {

if (!diff && !commitMessages) {
logo.classList.toggle("animate-spin");
return alert(`No input context was generated.`)
return alert(`No input context was generated.`);
}
if (isOutOfContextBounds([diff, commitMessages], descriptionConfig.config.maxInputLength)) {
logo.classList.toggle("animate-spin");
Expand Down
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, 500);
} else if (isGithubPullRequestPage(window.location.href)) {
prEditWatch(injectDescriptionGeneratorButton);
void injectAddPRToHighlightsButton();
Expand Down
46 changes: 41 additions & 5 deletions src/utils/aiprdescription/openai.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { OPEN_SAUCED_AI_PR_DESCRIPTION_ENDPOINT } from "../../constants";
import { OPEN_SAUCED_AI_PR_DESCRIPTION_ENDPOINT, OPEN_SAUCED_AI_CODE_REFACTOR_ENDPOINT } from "../../constants";
import type { DescriptionTone } from "./descriptionconfig";

export const generateDescription = async (
Expand All @@ -10,32 +10,68 @@ export const generateDescription = async (
diff?: string,
commitMessages?: string[],
): Promise<string | undefined> => {

try {
const response = await fetch(OPEN_SAUCED_AI_PR_DESCRIPTION_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${apiKey}`
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
descriptionLength,
temperature,
tone,
language,
diff,
commitMessages
})
commitMessages,
}),
});

if (response.status === 201) {
const { description } = await response.json();

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

export const generateCodeSuggestion = async (
apiKey: string,
language: string,
descriptionLength: number,
temperature: number,
code: string,
): Promise<string | undefined> => {
try {
const response = await fetch(OPEN_SAUCED_AI_CODE_REFACTOR_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
descriptionLength,
temperature,
language,
code,
}),
});

if (response.status === 201) {
const { suggestion } = await response.json();

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

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.querySelector("#os-ai-change-gen")) {
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.

33 changes: 33 additions & 0 deletions src/utils/dom-utils/prWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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 githubCommentSelector = "inline-comment-form-container";
const observer = new MutationObserver((mutationList: MutationRecord[], observer: MutationObserver) => {
mutationList.forEach(mutation => {
if (Array.from((mutation.target as HTMLElement).classList).includes(githubCommentSelector)) {
setTimeout(() => {
const commentNodes = document.getElementsByClassName(githubCommentSelector);

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}...`);
};