From 0e387771a60c17417bb0c8806f2684eae4c69c42 Mon Sep 17 00:00:00 2001 From: Anush Date: Thu, 18 May 2023 21:44:26 +0530 Subject: [PATCH] feat: OpenSauced AI PR description (#79) * WIP just starting * not sure why it is not appending new element * feat: AI highlight button * feat: cursor position text insert, pr info * feat: AI description config window * feat: pr actions dropdown * chore: Create PR description button * feat: OpenAI Completion * feat: type response from stream * feat: Config window new values, save button * chore: Default description config setters * chore: form config submit * refactor: formatting, linting fixes * chore: description config type update, dropdown toggle * chore: disable state handle * chore: updated title * fix: missing base branch, spinner * chore: request errors to console * feat: conditional diff, commitmsg fetching * feat: Input context length check, redirect when logged out * feat: highlights dropdown collapse * feat: Edit PR button injection, tokenizer length check * feat: Private repository check * feat: "Description generated using OpenSauced" append --------- Co-authored-by: Brian 'bdougie' Douglas --- npm-shrinkwrap.json | 96 +++++- package.json | 4 + src/App.tsx | 2 + src/constants.ts | 6 + .../AddPRToHighlightsButton.ts | 38 ++- .../DescriptionGeneratorButton.ts | 73 +++++ src/content-scripts/github.ts | 13 +- src/hooks/useRefs.ts | 11 + src/pages/aiprdescription.tsx | 284 ++++++++++++++++++ src/pages/home.tsx | 46 +-- src/pages/profile.tsx | 3 +- .../aiprdescription/configurationReducer.ts | 38 +++ .../aiprdescription/cursorPositionInsert.ts | 19 ++ .../aiprdescription/descriptionconfig.ts | 62 ++++ src/utils/aiprdescription/openai.ts | 65 ++++ src/utils/checkAuthentication.ts | 2 +- .../dom-utils/addDescriptionGenerator.ts | 20 ++ src/utils/dom-utils/addPRToHighlights.ts | 4 + src/utils/dom-utils/prEditWatcher.ts | 14 + src/utils/fetchGithubAPIData.ts | 79 +++++ src/utils/urlMatchers.ts | 23 ++ src/worker/background.ts | 2 + 22 files changed, 873 insertions(+), 31 deletions(-) create mode 100644 src/content-scripts/components/GenerateAIDescription/DescriptionGeneratorButton.ts create mode 100644 src/hooks/useRefs.ts create mode 100644 src/pages/aiprdescription.tsx create mode 100644 src/utils/aiprdescription/configurationReducer.ts create mode 100644 src/utils/aiprdescription/cursorPositionInsert.ts create mode 100644 src/utils/aiprdescription/descriptionconfig.ts create mode 100644 src/utils/aiprdescription/openai.ts create mode 100644 src/utils/dom-utils/addDescriptionGenerator.ts create mode 100644 src/utils/dom-utils/prEditWatcher.ts create mode 100644 src/utils/fetchGithubAPIData.ts diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 4d180860..3909c00d 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -10,13 +10,17 @@ "dependencies": { "@types/chrome": "^0.0.231", "@types/node-emoji": "^1.8.2", + "gpt-tokenizer": "^1.0.5", "node-emoji": "^1.11.0", + "openai-streams": "^5.3.0", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-hot-toast": "^2.4.1", "react-icons": "^4.8.0" }, "devDependencies": { "@crxjs/vite-plugin": "^1.0.14", + "@types/node": "^20.1.4", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.59.1", @@ -775,6 +779,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/node": { + "version": "20.1.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.1.4.tgz", + "integrity": "sha512-At4pvmIOki8yuwLtd7BNHl3CiWNbtclUbNtScGx4OHfBd4/oWoJC8KRCIxXwkdndzhxOsPXihrsOoydxBjlE9Q==", + "dev": true + }, "node_modules/@types/node-emoji": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/@types/node-emoji/-/node-emoji-1.8.2.tgz", @@ -1756,7 +1766,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -1925,6 +1934,14 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dotenv": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.0.3.tgz", + "integrity": "sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.365", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.365.tgz", @@ -2630,6 +2647,14 @@ "node": ">=0.10.0" } }, + "node_modules/eventsource-parser": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-1.0.0.tgz", + "integrity": "sha512-9jgfSCa3dmEme2ES3mPByGXfgZ87VbP97tng1G2nWwWx6bV2nYxm2AWCrbQjXToSe+yYlqaZNtxffR9IeQr95g==", + "engines": { + "node": ">=14.18" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2948,6 +2973,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/goober": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.13.tgz", + "integrity": "sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -2960,6 +2993,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gpt-tokenizer": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/gpt-tokenizer/-/gpt-tokenizer-1.0.5.tgz", + "integrity": "sha512-uLq42+uNAJENy1AcVJ3VIIA5BimrcDG8AhbohUro2wYF9nYPq92AlGWe299NqC9pBIY3r+oqwYse0OtIWkJAwg==" + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4001,6 +4039,19 @@ "wrappy": "1" } }, + "node_modules/openai-streams": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/openai-streams/-/openai-streams-5.3.0.tgz", + "integrity": "sha512-481B3eWLQ4P4E7jvF38vRzz2/aQz6CLHx2XSV7J+w83791fhiUxvTNGw8uh/M04N0X5AdCqPaEPqHc+sfxNTUQ==", + "dependencies": { + "dotenv": "^16.0.3", + "eventsource-parser": "^1.0.0", + "yield-stream": "^3.0.0" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/optionator": { "version": "0.9.1", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz", @@ -4402,6 +4453,21 @@ "react": "^18.2.0" } }, + "node_modules/react-hot-toast": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.4.1.tgz", + "integrity": "sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==", + "dependencies": { + "goober": "^2.1.10" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, "node_modules/react-icons": { "version": "4.8.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-4.8.0.tgz", @@ -4615,6 +4681,14 @@ "node": ">=8" } }, + "node_modules/shim-streams": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/shim-streams/-/shim-streams-0.0.2.tgz", + "integrity": "sha512-9Otb+FCl13XxRp1nVddtsCbwvB7AEMTjzc3/fixowyzvSVoCzu/VEstblB2SdIDbd61u5D/zpS5u9fGzDdOoZg==", + "engines": { + "node": ">=14" + } + }, "node_modules/side-channel": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", @@ -5150,6 +5224,14 @@ } } }, + "node_modules/web-streams-polyfill": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", + "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5247,6 +5329,18 @@ "node": ">= 6" } }, + "node_modules/yield-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/yield-stream/-/yield-stream-3.0.0.tgz", + "integrity": "sha512-I0fHp7xcR+ilGKgtLP+6d0SxTLfJcLDX/iiTEQZtg0U/cqWKvcHy4D9em6adhs3emSgik8YAZ2Jmv7gc1kyuhA==", + "dependencies": { + "shim-streams": "^0.0.2", + "web-streams-polyfill": "^3.2.1" + }, + "engines": { + "node": ">=14" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 36b5a732..ebbeaec5 100644 --- a/package.json +++ b/package.json @@ -15,13 +15,17 @@ "dependencies": { "@types/chrome": "^0.0.231", "@types/node-emoji": "^1.8.2", + "gpt-tokenizer": "^1.0.5", "node-emoji": "^1.11.0", + "openai-streams": "^5.3.0", "react": "^18.0.0", "react-dom": "^18.0.0", + "react-hot-toast": "^2.4.1", "react-icons": "^4.8.0" }, "devDependencies": { "@crxjs/vite-plugin": "^1.0.14", + "@types/node": "^20.1.4", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", "@typescript-eslint/eslint-plugin": "^5.59.1", diff --git a/src/App.tsx b/src/App.tsx index 50db230a..05451293 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import Home from "./pages/home"; import Loading from "./pages/loading"; import { Profile } from "./pages/profile"; import { useAuth } from "./hooks/useAuth"; +import AIPRDescription from "./pages/aiprdescription"; export const RouteContext = createContext<{ page: { name: string, props?: any }, setCurrentPage:(page: RouteKeys, props?: any) => void }>({ page: { name: "loading" }, setCurrentPage: () => {} }); @@ -13,6 +14,7 @@ const routes = { home: , loading: , profile: , + aiprdescription: , }; type RouteKeys = keyof typeof routes; diff --git a/src/constants.ts b/src/constants.ts index 25dedb79..8fa3bc0f 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,6 +3,8 @@ export const SUPABASE_LOGIN_URL = "https://ibcwmlhcimymasokhgvn.supabase.co/auth export const SUPABASE_AUTH_COOKIE_NAME = "supabase-auth-token"; export const OPEN_SAUCED_AUTH_TOKEN_KEY = "os-access-token"; export const OPEN_SAUCED_INSIGHTS_DOMAIN = "insights.opensauced.pizza"; +export const AI_PR_DESCRIPTION_CONFIG_KEY = "ai-pr-description-config"; +export const OPEN_AI_COMPLETION_MODEL_NAME = "gpt-3.5-turbo"; // API endpoints export const OPEN_SAUCED_USERS_ENDPOINT = "https://api.opensauced.pizza/v1/users"; @@ -16,4 +18,8 @@ export const GITHUB_PROFILE_EDIT_MENU_SELECTOR = "button.js-profile-editable-edi export const GITHUB_PR_AUTHOR_USERNAME_SELECTOR = "author Link--primary text-bold css-overflow-wrap-anywhere"; 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_REPO_ACTIONS_SELECTOR = ".pagehead-actions"; +export const GITHUB_PR_COMMENT_TEXT_AREA_SELECTOR = "pull_request[body]"; +export const GITHUB_PR_BASE_BRANCH_SELECTOR = "css-truncate css-truncate-target"; diff --git a/src/content-scripts/components/AddPRToHighlights/AddPRToHighlightsButton.ts b/src/content-scripts/components/AddPRToHighlights/AddPRToHighlightsButton.ts index d2d464d7..7e0213a5 100644 --- a/src/content-scripts/components/AddPRToHighlights/AddPRToHighlightsButton.ts +++ b/src/content-scripts/components/AddPRToHighlights/AddPRToHighlightsButton.ts @@ -3,12 +3,38 @@ import openSaucedLogoIcon from "../../../assets/opensauced-icon.svg"; export const AddPRToHighlightsButton = () => { const addPRToHighlightsButton = createHtmlElement("a", { - href: `https://insights.opensauced.pizza/feed?url=${encodeURIComponent(window.location.href)}`, - target: "_blank", - rel: "noopener noreferrer", - innerHTML: ` - - `, + className: "relative cursor-pointer", + innerHTML: ` + `, + onclick: () => { + const menu = document.getElementById("details-menu-os"); + + if (!menu) { + return; +} + menu.classList.toggle("hidden"); + }, + }); + + addPRToHighlightsButton.addEventListener("click", e => { + e.stopPropagation(); + }); + + document.addEventListener("click", () => { + const menu = document.getElementById("details-menu-os"); + + if (!menu) { + return; +} + menu.classList.add("hidden"); }); return addPRToHighlightsButton; diff --git a/src/content-scripts/components/GenerateAIDescription/DescriptionGeneratorButton.ts b/src/content-scripts/components/GenerateAIDescription/DescriptionGeneratorButton.ts new file mode 100644 index 00000000..0719e4f6 --- /dev/null +++ b/src/content-scripts/components/GenerateAIDescription/DescriptionGeneratorButton.ts @@ -0,0 +1,73 @@ +import { createHtmlElement } from "../../../utils/createHtmlElement"; +import openSaucedLogoIcon from "../../../assets/opensauced-icon.svg"; +import { getPullRequestAPIURL } from "../../../utils/urlMatchers"; +import { getDescriptionContext, isContextWithinBounds } from "../../../utils/fetchGithubAPIData"; +import { generateDescription } from "../../../utils/aiprdescription/openai"; +import { GITHUB_PR_COMMENT_TEXT_AREA_SELECTOR, OPEN_AI_COMPLETION_MODEL_NAME, SUPABASE_LOGIN_URL } from "../../../constants"; +import { insertAtCursorFromStream } from "../../../utils/aiprdescription/cursorPositionInsert"; +import { getAIDescriptionConfig } from "../../../utils/aiprdescription/descriptionconfig"; +import { isLoggedIn } from "../../../utils/checkAuthentication"; + +export const DescriptionGeneratorButton = () => { + const descriptionGeneratorButton = createHtmlElement("a", { + innerHTML: ` + + + Generate PR description`, + onclick: handleSubmit, + + }); + + return descriptionGeneratorButton; +}; + +const handleSubmit = async () => { + try { + if (!(await isLoggedIn())) { + return window.open(SUPABASE_LOGIN_URL, "_blank"); + } + const logo = document.getElementById("ai-description-button-logo"); + + if (!logo) { + return; + } + const url = getPullRequestAPIURL(window.location.href); + const descriptionConfig = await getAIDescriptionConfig(); + + if (!descriptionConfig) { + return; +} + if (!descriptionConfig.enabled) { + return alert("AI PR description is disabled!"); +} + logo.classList.toggle("animate-spin"); + const [diff, commitMessages] = await getDescriptionContext(url, descriptionConfig.config.source); + + if (!isContextWithinBounds([diff, commitMessages], descriptionConfig.config.maxInputLength)) { + logo.classList.toggle("animate-spin"); + return alert(`Max input length exceeded. Try setting the description source to commit-messages.`); + } + const descriptionStream = await generateDescription( +descriptionConfig.config.openai_api_key, + OPEN_AI_COMPLETION_MODEL_NAME, + descriptionConfig.config.language, + descriptionConfig.config.length, + descriptionConfig.config.temperature / 10, + descriptionConfig.config.tone, + diff, + commitMessages, + ); + + logo.classList.toggle("animate-spin"); + if (!descriptionStream) { + return console.error("No description was generated!"); + } + const textArea = document.getElementsByName(GITHUB_PR_COMMENT_TEXT_AREA_SELECTOR)[0] as HTMLTextAreaElement; + + void insertAtCursorFromStream(textArea, descriptionStream); + } catch (error: unknown) { + if (error instanceof Error) { + console.error("Description generation error:", error.message); +} + } +}; diff --git a/src/content-scripts/github.ts b/src/content-scripts/github.ts index fff6d6a2..a54cc7bc 100644 --- a/src/content-scripts/github.ts +++ b/src/content-scripts/github.ts @@ -3,6 +3,7 @@ import { isGithubProfilePage, isGithubPullRequestPage, isGithubRepoPage, + isPullRequestCreatePage, } from "../utils/urlMatchers"; import { isOpenSaucedUser } from "../utils/fetchOpenSaucedApiData"; import injectViewOnOpenSauced from "../utils/dom-utils/viewOnOpenSauced"; @@ -11,14 +12,18 @@ import { prefersDarkMode } from "../utils/colorPreference"; 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"; const processGithubPage = async () => { if (prefersDarkMode(document.cookie)) { document.documentElement.classList.add("dark"); } - - if (isGithubPullRequestPage(window.location.href)) { - await injectAddPRToHighlightsButton(); + if (isPullRequestCreatePage(window.location.href)) { + void injectDescriptionGeneratorButton(); + } else if (isGithubPullRequestPage(window.location.href)) { + prEditWatch(injectDescriptionGeneratorButton); + void injectAddPRToHighlightsButton(); } else if (isGithubProfilePage(window.location.href)) { const username = getGithubUsername(window.location.href); @@ -37,7 +42,7 @@ const processGithubPage = async () => { await injectRepoVotingButtons(ownerName, repoName); } - domUpdateWatch(processGithubPage, 25); + domUpdateWatch(processGithubPage, 50); }; void processGithubPage(); diff --git a/src/hooks/useRefs.ts b/src/hooks/useRefs.ts new file mode 100644 index 00000000..a9f9d116 --- /dev/null +++ b/src/hooks/useRefs.ts @@ -0,0 +1,11 @@ +import { useRef } from "react"; + +export const useRefs = () => { + const refs = useRef>({}); + + const setRefFromKey = (key: string) => (element: HTMLElement | null) => { + refs.current[key] = element; + }; + + return { refs: refs.current, setRefFromKey }; + }; diff --git a/src/pages/aiprdescription.tsx b/src/pages/aiprdescription.tsx new file mode 100644 index 00000000..e43626d4 --- /dev/null +++ b/src/pages/aiprdescription.tsx @@ -0,0 +1,284 @@ +import React, { useContext, useEffect, useReducer, useState } from "react"; +import { BiInfoCircle } from "react-icons/bi"; +import { FaChevronLeft } from "react-icons/fa"; +import OpenSaucedLogo from "../assets/opensauced-logo.svg"; +import { RouteContext } from "../App"; +import { ImSwitch } from "react-icons/im"; +import toast, { Toaster } from "react-hot-toast"; + +import { + getAIDescriptionConfig, + DescriptionTone, + DescriptionSource, + DescriptionLanguage, + setAIDescriptionConfig, + getDefaultDescriptionConfig, + setDefaultDescriptionConfig, +} from "../utils/aiprdescription/descriptionconfig"; +import { useRefs } from "../hooks/useRefs"; +import { configurationReducer } from "../utils/aiprdescription/configurationReducer"; + +const AIPRDescription = () => { + const { setCurrentPage } = useContext(RouteContext); + const [config, dispatch] = useReducer(configurationReducer, getDefaultDescriptionConfig()); + const { refs, setRefFromKey } = useRefs(); + + const tones: DescriptionTone[] = ["exciting", "persuasive", "informative", "humorous", "formal"]; + const sources: DescriptionSource[] = ["diff", "commitMessage", "both"]; + const languages: DescriptionLanguage[] = ["english", "spanish", "french", "german", "italian", "portuguese", "dutch", "russian", "chinese", "korean"]; + const [formDisabled, setFormDisabled] = useState(true); + + + useEffect(() => { + const descriptionConfig = async () => { + const configData = await getAIDescriptionConfig(); + + dispatch({ type: "SET", value: configData }); + setFormDisabled(!configData?.enabled); + console.log(config.config.openai_api_key); + }; + + void descriptionConfig(); + }, []); + + const handleFormSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const openai_api_key = refs.openai_api_key!.getAttribute("value")!; + const length = parseInt(refs.length?.getAttribute("value")!); + const temperature = Number(Number(refs.temperature?.getAttribute("value")!)); + const maxInputLength = parseInt(refs.maxInputLength?.getAttribute("value")!); + const language = (refs.language as HTMLSelectElement).value as DescriptionLanguage; + const source = (refs.source as HTMLSelectElement).value as DescriptionSource; + const tone = (refs.tone as HTMLSelectElement).value as DescriptionTone; + + void setAIDescriptionConfig({ + enabled: true, + config: { openai_api_key, length, temperature, maxInputLength, language, source, tone }, + }); + toast.success("Configuration updated!"); + }; + + return ( + + <> + + +
+
+
+ + + OpenSauced logo +
+ + +
+ +
+
+
+

+ OpenSauced AI +

+ +

+ OpenAI API Key + + window.open( + "https://platform.openai.com/account/api-keys", + "_blank", + )} + /> +

+ + dispatch({ type: "SET_OPENAI_API_KEY", value: e.currentTarget.value })} + /> + +
+
+

+ Description Length [ + + {config.config.length} + + ] +

+ + dispatch({ type: "SET_LENGTH", value: parseInt(e.target.value) })} + /> +
+ +
+

+ Temperature [ + + {config.config.temperature / 10} + + ] +

+ + dispatch({ type: "SET_TEMPERATURE", value: parseInt(e.target.value) })} + /> +
+ +
+

+ Max Input Length [ + + {config.config.maxInputLength} + + ] +

+ + dispatch({ type: "SET_MAX_INPUT_LENGTH", value: parseInt(e.target.value) })} + /> +
+ +
+

Description Language

+ + +
+ +
+

Description Tone

+ + +
+ +
+

Description Source

+ + +
+
+ + +
+
+
+
+ + ); +}; + +export default AIPRDescription; diff --git a/src/pages/home.tsx b/src/pages/home.tsx index c52940b2..ca5b83ad 100644 --- a/src/pages/home.tsx +++ b/src/pages/home.tsx @@ -1,5 +1,5 @@ import { useContext } from "react"; -import { HiArrowTopRightOnSquare, HiUserCircle } from "react-icons/hi2"; +import { HiArrowTopRightOnSquare, HiPencil, HiUserCircle } from "react-icons/hi2"; import { RouteContext } from "../App"; import OpenSaucedLogo from "../assets/opensauced-logo.svg"; import { useAuth } from "../hooks/useAuth"; @@ -19,7 +19,7 @@ const Home = () => { src={OpenSaucedLogo} /> - {user && + {user && ( } + + )}
@@ -60,23 +61,32 @@ const Home = () => { Go to Dashboard - { - currentTabIsOpensaucedUser && - + + {currentTabIsOpensaucedUser && ( + - } +{checkedUser} + 's profile + + )}
diff --git a/src/pages/profile.tsx b/src/pages/profile.tsx index 49b07b3b..659c1957 100644 --- a/src/pages/profile.tsx +++ b/src/pages/profile.tsx @@ -96,7 +96,8 @@ export const Profile = () => { />

- @{page.props.userName} + @ +{page.props.userName}

diff --git a/src/utils/aiprdescription/configurationReducer.ts b/src/utils/aiprdescription/configurationReducer.ts new file mode 100644 index 00000000..6c612a15 --- /dev/null +++ b/src/utils/aiprdescription/configurationReducer.ts @@ -0,0 +1,38 @@ +import { DescriptionConfig, getDefaultDescriptionConfig } from "./descriptionconfig"; + +export const configurationReducer = (state: DescriptionConfig, action: { type: string, value: any }) => { + let newState: DescriptionConfig = { ...state }; + + switch (action.type) { + case "SET": + newState = action.value; + break; + case "SET_OPENAI_API_KEY": + newState.config.openai_api_key = action.value; + break; + case "SET_LENGTH": + newState.config.length = action.value; + break; + case "SET_TEMPERATURE": + newState.config.temperature = action.value; + break; + case "SET_MAX_INPUT_LENGTH": + newState.config.maxInputLength = action.value; + break; + case "SET_LANGUAGE": + newState.config.language = action.value; + break; + case "SET_SOURCE": + newState.config.source = action.value; + break; + case "SET_TONE": + newState.config.tone = action.value; + break; + case "CLEAR": + newState = getDefaultDescriptionConfig(); + break; + default: + return newState; + } + return newState; +}; diff --git a/src/utils/aiprdescription/cursorPositionInsert.ts b/src/utils/aiprdescription/cursorPositionInsert.ts new file mode 100644 index 00000000..e0114607 --- /dev/null +++ b/src/utils/aiprdescription/cursorPositionInsert.ts @@ -0,0 +1,19 @@ +// This function is used to insert text at the cursor position in the text area +export const insertAtCursorFromStream = async (textArea: HTMLTextAreaElement, stream: ReadableStream) => { + const reader = stream.getReader(); + const decoder = new TextDecoder("utf-8"); + + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } else { + const chunk = decoder.decode(value); + const [start, end] = [textArea.selectionStart, textArea.selectionEnd]; + + textArea.setRangeText(chunk, start, end, "end"); + } + } + textArea.setRangeText("\nDescription generated using [OpenSauced](https://opensauced.ai/).", textArea.selectionStart, textArea.selectionEnd, "end"); +}; diff --git a/src/utils/aiprdescription/descriptionconfig.ts b/src/utils/aiprdescription/descriptionconfig.ts new file mode 100644 index 00000000..22fb6acf --- /dev/null +++ b/src/utils/aiprdescription/descriptionconfig.ts @@ -0,0 +1,62 @@ +import { AI_PR_DESCRIPTION_CONFIG_KEY } from "../../constants"; + +export type DescriptionTone = "exciting" | "persuasive" | "informative" | "humorous" | "formal"; +export type DescriptionSource = "diff" | "commitMessage" | "both"; +export type DescriptionLanguage = + | "english" + | "spanish" + | "french" + | "german" + | "italian" + | "portuguese" + | "dutch" + | "russian" + | "chinese" + | "korean"; + +export interface DescriptionConfig { + enabled: boolean; + config: { + openai_api_key: string; + length: number; + maxInputLength: number; + temperature: number; + language: DescriptionLanguage; + tone: DescriptionTone; + source: DescriptionSource; + }; + [key: string]: any; +} + +export const getAIDescriptionConfig = async (): Promise< + DescriptionConfig | undefined +> => { + const response: DescriptionConfig | undefined = ( + await chrome.storage.local.get(AI_PR_DESCRIPTION_CONFIG_KEY) + )[AI_PR_DESCRIPTION_CONFIG_KEY]; + + return response; +}; + +export const setAIDescriptionConfig = async (data: DescriptionConfig): Promise => { + await chrome.storage.local.set({ [AI_PR_DESCRIPTION_CONFIG_KEY]: data }); +}; + +export const getDefaultDescriptionConfig = (): DescriptionConfig => ({ + enabled: false, + config: { + openai_api_key: "", + length: 500, + maxInputLength: 3900, + temperature: 7, + language: "english", + tone: "informative", + source: "diff", + }, + }); + +export const setDefaultDescriptionConfig = () => { + const defaultConfig = getDefaultDescriptionConfig(); + + void setAIDescriptionConfig(defaultConfig); +}; diff --git a/src/utils/aiprdescription/openai.ts b/src/utils/aiprdescription/openai.ts new file mode 100644 index 00000000..344cbe79 --- /dev/null +++ b/src/utils/aiprdescription/openai.ts @@ -0,0 +1,65 @@ +import type { DescriptionTone } from "./descriptionconfig"; +import { OpenAI, CreateChatCompletionRequest } from "openai-streams"; + +const generatePrompt = ( + locale: string, + maxLength: number, + tone: DescriptionTone, +) => [ + `Generate an apt github PR description written in present tense and ${tone} tone for the given code diff/commit-messages with the specifications mentioned below`, + `Description language: ${locale}`, + `Description must be a maximum of ${maxLength} characters.`, + "Exclude anything unnecessary such as translation. Your entire response will be passed directly into a pull request description", +].join("\n"); + +const createChatCompletion = async ( + apiKey: string, + json: CreateChatCompletionRequest, +): Promise> => { + const stream = await OpenAI("chat", json, { + apiKey, + mode: "tokens", + }); + + return stream; +}; + +export const generateDescription = async ( + apiKey: string, + model: "gpt-3.5-turbo" | "gpt-3.5-turbo-0301", + locale: string, + maxLength: number, + temperature: number, + tone: DescriptionTone, + diff?: string, + commitMessages?: string[], +) => { + const content = `${diff ? `Diff: ${diff}\n` : ""}${commitMessages ? `\nCommit Messages: ${commitMessages.join(",")}` : ""}`; + + try { + const completion = await createChatCompletion( + apiKey, + { + model, + messages: [ + { + role: "system", + content: generatePrompt(locale, maxLength, tone), + }, + { + role: "user", + content, + }, + ], + temperature, + n: 1, + }, + ); + + return completion; + } catch (error: unknown) { + if (error instanceof Error) { + console.error("OpenAI error: ", error.message); +} + } +}; diff --git a/src/utils/checkAuthentication.ts b/src/utils/checkAuthentication.ts index 91959f10..60c6b7ef 100644 --- a/src/utils/checkAuthentication.ts +++ b/src/utils/checkAuthentication.ts @@ -3,7 +3,7 @@ import { SUPABASE_AUTH_COOKIE_NAME, OPEN_SAUCED_INSIGHTS_DOMAIN, } from "../constants"; -import { checkTokenValidity } from "../utils/fetchOpenSaucedApiData"; +import { checkTokenValidity } from "./fetchOpenSaucedApiData"; import setAccessTokenInChromeStorage from "../utils/setAccessToken"; export const checkAuthentication = () => { diff --git a/src/utils/dom-utils/addDescriptionGenerator.ts b/src/utils/dom-utils/addDescriptionGenerator.ts new file mode 100644 index 00000000..9604c36d --- /dev/null +++ b/src/utils/dom-utils/addDescriptionGenerator.ts @@ -0,0 +1,20 @@ +import { DescriptionGeneratorButton } from "../../content-scripts/components/GenerateAIDescription/DescriptionGeneratorButton"; +import { GITHUB_NEW_PR_COMMENT_EDITOR_SELECTOR, GITHUB_PR_COMMENT_EDITOR_SELECTOR } from "../../constants"; +import { isGithubPullRequestPage } from "../urlMatchers"; +import { isPublicRepository } from "../fetchGithubAPIData"; + +const injectDescriptionGeneratorButton = async () => { + if (!(await isPublicRepository(window.location.href))) { + return; +} + const selector = isGithubPullRequestPage(window.location.href) ? GITHUB_PR_COMMENT_EDITOR_SELECTOR : GITHUB_NEW_PR_COMMENT_EDITOR_SELECTOR; + const commentFormatRow = document.getElementsByClassName(selector)[0]; + const addGeneratorButton = DescriptionGeneratorButton(); + + if (commentFormatRow.firstChild?.isEqualNode(addGeneratorButton)) { + return; + } + commentFormatRow.insertBefore(addGeneratorButton, commentFormatRow.firstChild); +}; + +export default injectDescriptionGeneratorButton; diff --git a/src/utils/dom-utils/addPRToHighlights.ts b/src/utils/dom-utils/addPRToHighlights.ts index 909a7867..558ca17b 100644 --- a/src/utils/dom-utils/addPRToHighlights.ts +++ b/src/utils/dom-utils/addPRToHighlights.ts @@ -5,6 +5,7 @@ import { GITHUB_PR_COMMENT_HEADER_SELECTOR, } from "../../constants"; import { isLoggedIn } from "../checkAuthentication"; +import { isPublicRepository } from "../fetchGithubAPIData"; const injectAddPRToHighlightsButton = async () => { if (!(await isLoggedIn())) { @@ -19,6 +20,9 @@ const injectAddPRToHighlightsButton = async () => { ?.getAttribute("content"); if (loggedInUserUserName && prAuthorUserName === loggedInUserUserName) { + if (!(await isPublicRepository(window.location.href))) { + return; +} const commentFormatRow = document.getElementsByClassName( GITHUB_PR_COMMENT_HEADER_SELECTOR, )[0]; diff --git a/src/utils/dom-utils/prEditWatcher.ts b/src/utils/dom-utils/prEditWatcher.ts new file mode 100644 index 00000000..778c68e6 --- /dev/null +++ b/src/utils/dom-utils/prEditWatcher.ts @@ -0,0 +1,14 @@ +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 default prEditWatch; diff --git a/src/utils/fetchGithubAPIData.ts b/src/utils/fetchGithubAPIData.ts new file mode 100644 index 00000000..e94572ae --- /dev/null +++ b/src/utils/fetchGithubAPIData.ts @@ -0,0 +1,79 @@ +import { DescriptionSource } from "./aiprdescription/descriptionconfig"; +import { isWithinTokenLimit } from "gpt-tokenizer"; + +type DescriptionContextPromise = Promise<[string | undefined, string[] | undefined]>; +type DescriptionContext = Awaited; + +interface Commit { + commit: { + message: string; + }; +} + +export const getPRDiff = async (url: string) => { + const response = await fetch( + url, + { headers: { Accept: "application/vnd.github.v3.diff" } }, + ); + const diff = await response.text(); + + return diff.replace(/.*/, "").substring(1); +}; + +export const getPRCommitMessages = async (url: string) => { + const response = await fetch(url); + const data = await response.json(); + + if (!Array.isArray(data.commits)) { + return undefined; +} + const commitMessages: string[] = data.commits?.map((commit: Commit) => commit.commit.message); + + return commitMessages; +}; + +export const getDescriptionContext = async (url: string, source: DescriptionSource): DescriptionContextPromise => { + let promises: [Promise, Promise] = [Promise.resolve(undefined), Promise.resolve(undefined)]; + + if (source === "diff") { + promises = [getPRDiff(url), Promise.resolve(undefined)]; +} else if (source === "commitMessage") { + promises = [Promise.resolve(undefined), getPRCommitMessages(url)]; +} else { + promises = [getPRDiff(url), getPRCommitMessages(url)]; +} + const response = await Promise.all(promises); + + return response; +}; + +export const isContextWithinBounds = (context: DescriptionContext, limit: number): boolean => { + let text = ""; + + if (context[0]) { + text += context[0]; +} + if (context[1]) { + text += context[1].join(""); +} + + return Boolean(isWithinTokenLimit(text, limit)); +}; + +export const isPublicRepository = async (url: string): Promise => { + try { + const { username, repoName } = url.match( + /^https?:\/\/(www\.)?github.com\/(?[\w.-]+)\/?(?[\w.-]+)?/, + )?.groups ?? {}; + + if (!username || !repoName) { + return false; +} + const response = await fetch(`https://api.github.com/repos/${username}/${repoName}`); + const data = await response.json(); + + return (response.status === 200 && data.private === false); + } catch (e: unknown) { + return false; + } +}; diff --git a/src/utils/urlMatchers.ts b/src/utils/urlMatchers.ts index 9d3f8933..73adda33 100644 --- a/src/utils/urlMatchers.ts +++ b/src/utils/urlMatchers.ts @@ -1,3 +1,5 @@ +import { GITHUB_PR_BASE_BRANCH_SELECTOR } from "../constants"; + export const getGithubUsername = (url: string) => { const match = url.match(/github\.com\/([\w.-]+)/); @@ -38,3 +40,24 @@ export const isGithubRepoPage = (url: string) => { return githubRepoPattern.test(url); }; + +export const isPullRequestCreatePage = (url: string) => { + const githubPullRequestPattern = /github\.com\/[\w.-]+\/[^/]+\/compare\/\w+/; + + return githubPullRequestPattern.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; + + return apiURL.replace(/compare\//, `compare/${baseBranch}...`); +}; diff --git a/src/worker/background.ts b/src/worker/background.ts index a953bd53..d8bf9074 100644 --- a/src/worker/background.ts +++ b/src/worker/background.ts @@ -1,5 +1,6 @@ import { checkAuthentication } from "../utils/checkAuthentication"; import { SUPABASE_AUTH_COOKIE_NAME, OPEN_SAUCED_INSIGHTS_DOMAIN } from "../constants"; +import { setDefaultDescriptionConfig } from "../utils/aiprdescription/descriptionconfig"; chrome.cookies.onChanged.addListener(changeInfo => { if ( @@ -11,4 +12,5 @@ chrome.cookies.onChanged.addListener(changeInfo => { }); chrome.runtime.onInstalled.addListener(checkAuthentication); +chrome.runtime.onInstalled.addListener(setDefaultDescriptionConfig); chrome.runtime.onStartup.addListener(checkAuthentication);