diff --git a/README.md b/README.md index 6cb0f2a..3f3bda1 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,15 @@ Misc Userscripts 2. Get information or install: - Install a script directly from GitHub by clicking on the "install" link in the table below. -| Userscript Wiki | Direct Install | Created | Updated | -| ----------------------------- | :----------------: | :--------: | :--------: | -| [CloudWatch Helper][cwh-wiki] | [install][cwh-raw] | 30.03.2021 | 13.12.2021 | +| Userscript Wiki | Direct Install | Created | Updated | +| ------------------------------------- | :-----------------: | :--------: | :--------: | +| [CloudWatch Helper][cwh-wiki] | [install][cwh-raw] | 30.03.2021 | 13.12.2021 | +| [GitHub PR Approve Helper][gpah-wiki] | [install][gpah-raw] | 04.08.2026 | 04.08.2026 | [cwh-wiki]: https://github.com/MishaKav/userscripts/wiki/CloudWatch-Helper [cwh-raw]: https://raw.githubusercontent.com/MishaKav/userscripts/main/cloudwatch-helper.user.js +[gpah-wiki]: https://github.com/MishaKav/userscripts/wiki/GitHub-PR-Approve-Helper +[gpah-raw]: https://raw.githubusercontent.com/MishaKav/userscripts/main/github-pr-approve-helper.user.js ## Updating diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js new file mode 100644 index 0000000..0ed4e3a --- /dev/null +++ b/github-pr-approve-helper.user.js @@ -0,0 +1,370 @@ +// ==UserScript== +// @name GitHub PR Approve Helper +// @namespace https://github.com/MishaKav/userscripts/github-pr-approve-helper +// @version 1.0.0 +// @description A userscript that auto-fills the review comment with LGTM when you select Approve in the GitHub pull request review dialog +// @author Misha Kav +// @copyright 2026, Misha Kav +// @match https://github.com/linear-b/* +// @icon https://github.com/favicon.ico +// @grant none +// @sandbox DOM +// @run-at document-end +// @updateURL https://raw.githubusercontent.com/MishaKav/userscripts/main/github-pr-approve-helper.user.js +// @downloadURL https://raw.githubusercontent.com/MishaKav/userscripts/main/github-pr-approve-helper.user.js +// @supportURL https://github.com/MishaKav/userscripts/issues +// ==/UserScript== + +(function () { + 'use strict'; + + // keep in sync with @version above, shown in the logs and the badge + const VERSION = '1.0.0'; + + // automatically select the approve option when the review dialog opens + const AUTO_SELECT_APPROVE = true; + + // always inserted on approve - pick from the dropdown to replace it + const DEFAULT_COMMENT = 'LGTM'; + + // the alternatives offered in the dropdown ('🎲 Random' picks one of these) + const APPROVE_COMMENTS = [ + 'LGTM', + 'Nice', + 'Looks good to me!', + 'Great job! 🎉', + 'Well done 💪', + 'Clean and simple, LGTM 🔥', + ]; + + // the script only fills comments on PRs of these orgs/users, add as you like + // (checked at runtime in addition to @match, so it stays correct when + // github soft-navigates between orgs without a full page load) + const ALLOWED_ORGS = ['linear-b']; + + // marker for text we inserted, so we never delete anything the user typed + const AUTO_FILL_ATTRIBUTE = 'data-approve-helper-text'; + + // marker for dialogs we already processed, so the "approve pre-selected on + // open" fill happens once per open and never fights the user + const SEEN_ATTRIBUTE = 'data-approve-helper-seen'; + + // flash a small badge on PR pages, so it's visible the script is running + // without opening devtools - set to false to disable + const SHOW_ACTIVE_BADGE = true; + + const DROPDOWN_ID = 'gpah-comment-select'; + const BADGE_ID = 'gpah-active-badge'; + const RANDOM_OPTION_VALUE = '__random__'; + + // 'pull_request_review[event]' - legacy "Review changes" dropdown + // 'reviewEvent' - new react "Finish your review" dialog + const REVIEW_RADIO_NAMES = ['pull_request_review[event]', 'reviewEvent']; + + const SELECTORS = { + REVIEW_CONTAINER: + '#review-changes-modal, form[action*="/reviews"], dialog, [role="dialog"]', + REVIEW_RADIOS: REVIEW_RADIO_NAMES.map( + (name) => `input[type="radio"][name="${name}"]`, + ).join(', '), + REVIEW_TEXTAREAS: [ + 'textarea#pull_request_review_body', // legacy dropdown + 'textarea[name="pull_request_review[body]"]', // legacy fallback + 'textarea[aria-label="Markdown value"]', // new react dialog + 'textarea[placeholder="Leave a comment"]', // react fallback + 'textarea', // last resort, scoped to the review container only + ], + }; + + const isPullRequestPage = () => /\/pull\/\d+/.test(location.pathname); + + const isAllowedOrgPage = () => + ALLOWED_ORGS.some((org) => + location.pathname.toLowerCase().startsWith(`/${org.toLowerCase()}/`), + ); + + const isReviewRadio = (el) => + el?.matches?.('input[type="radio"]') && + (REVIEW_RADIO_NAMES.includes(el.name) || + /^(approve|comment|reject|request[ _-]?changes)$/i.test(el.value)); + + const isApprove = (radio) => /^approve$/i.test(radio.value); + + const pickComment = () => + APPROVE_COMMENTS[Math.floor(Math.random() * APPROVE_COMMENTS.length)]; + + const getReviewTextarea = (container) => { + for (const selector of SELECTORS.REVIEW_TEXTAREAS) { + const textarea = container.querySelector(selector); + if (textarea) { + return textarea; + } + } + return null; + }; + + // react-controlled textarea ignores a plain `.value =`, so assign through + // the native prototype setter and fire bubbled events for react to notice + const setNativeValue = (textarea, text) => { + const nativeSetter = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + 'value', + )?.set; + + if (nativeSetter) { + nativeSetter.call(textarea, text); + } else { + textarea.value = text; + } + + textarea.dispatchEvent(new Event('input', { bubbles: true })); + textarea.dispatchEvent(new Event('change', { bubbles: true })); + }; + + const fillComment = (textarea) => { + // never overwrite anything the user already typed + if (textarea.value.trim() !== '') { + return; + } + + setNativeValue(textarea, DEFAULT_COMMENT); + textarea.setAttribute(AUTO_FILL_ATTRIBUTE, DEFAULT_COMMENT); + console.log(`[GitHub PR Approve Helper] filled review comment: "${DEFAULT_COMMENT}"`); + }; + + const clearAutoComment = (textarea) => { + const autoText = textarea.getAttribute(AUTO_FILL_ATTRIBUTE); + + // not inserted by us, or edited by the user since - keep it + if (autoText === null || textarea.value !== autoText) { + return; + } + + setNativeValue(textarea, ''); + textarea.removeAttribute(AUTO_FILL_ATTRIBUTE); + console.log('[GitHub PR Approve Helper] cleared auto comment'); + }; + + const handleReviewRadio = (radio) => { + if (!radio.checked) { + return; + } + + const container = radio.closest(SELECTORS.REVIEW_CONTAINER); + const textarea = container && getReviewTextarea(container); + + if (!textarea) { + return; + } + + if (isApprove(radio)) { + fillComment(textarea); + } else { + clearAutoComment(textarea); + } + }; + + // use composedPath so the real target is found even inside shadow dom + const getEventTarget = (event) => { + const target = event.composedPath?.()[0] ?? event.target; + return target instanceof Element ? target : null; + }; + + const onReviewOptionChange = (event) => { + if (!isPullRequestPage() || !isAllowedOrgPage()) { + return; + } + + const radio = getEventTarget(event); + + if (isReviewRadio(radio)) { + handleReviewRadio(radio); + } + }; + + // fallback for clicks that don't produce a change event, e.g. clicking the + // approve option when it's already selected, or clicking its label + const onReviewOptionClick = (event) => { + if (!isPullRequestPage() || !isAllowedOrgPage()) { + return; + } + + const target = getEventTarget(event); + const radio = target?.matches?.('input[type="radio"]') + ? target + : target?.closest('label')?.control; + + if (!isReviewRadio(radio)) { + return; + } + + // let the browser/react finish updating the checked state first + setTimeout(() => handleReviewRadio(radio), 0); + }; + + const createCommentDropdown = () => { + const select = document.createElement('select'); + select.id = DROPDOWN_ID; + // compact pill floating in the dialog header, next to the close button - + // it never disturbs the layout of the react-rendered dialog content + select.style.cssText = [ + 'position: absolute', + 'top: 12px', + 'right: 48px', + 'max-width: 200px', + 'padding: 4px 8px', + 'font-size: 12px', + 'font-weight: 500', + 'color: #1f2328', + 'background: #f6f8fa', + 'border: 1px solid #d0d7de', + 'border-radius: 6px', + 'cursor: pointer', + 'z-index: 100', + ].join(';'); + + const options = [ + { value: '', text: `💬 ${DEFAULT_COMMENT}…` }, + { value: RANDOM_OPTION_VALUE, text: '🎲 Random' }, + ...APPROVE_COMMENTS.map((comment) => ({ value: comment, text: comment })), + ]; + + for (const { value, text } of options) { + const option = document.createElement('option'); + option.value = value; + option.textContent = text; + select.appendChild(option); + } + + select.addEventListener('change', () => { + const comment = + select.value === RANDOM_OPTION_VALUE ? pickComment() : select.value; + // back to the placeholder, so the same option can be picked again + select.selectedIndex = 0; + + const container = select.closest(SELECTORS.REVIEW_CONTAINER); + const textarea = container && getReviewTextarea(container); + + if (!comment || !textarea) { + return; + } + + // explicit pick from the dropdown replaces whatever is in the box + setNativeValue(textarea, comment); + textarea.setAttribute(AUTO_FILL_ATTRIBUTE, comment); + textarea.focus(); + console.log(`[GitHub PR Approve Helper] inserted comment: "${comment}"`); + }); + + return select; + }; + + // shown once per page/soft-navigation, tracked by pathname + let badgeShownFor = null; + + const showActiveBadge = () => { + if ( + !SHOW_ACTIVE_BADGE || + !isPullRequestPage() || + !isAllowedOrgPage() || + badgeShownFor === location.pathname || + document.getElementById(BADGE_ID) + ) { + return; + } + + badgeShownFor = location.pathname; + + const badge = document.createElement('div'); + badge.id = BADGE_ID; + badge.textContent = `✅ Approve Helper v${VERSION} active`; + badge.style.cssText = [ + 'position: fixed', + 'bottom: 16px', + 'right: 16px', + 'padding: 6px 12px', + 'background: #1f883d', + 'color: #fff', + 'font: 12px -apple-system, sans-serif', + 'border-radius: 6px', + 'box-shadow: 0 3px 12px rgba(0, 0, 0, 0.3)', + 'pointer-events: none', + 'z-index: 2147483647', + ].join(';'); + + document.body.appendChild(badge); + setTimeout(() => badge.remove(), 2500); + }; + + const processReviewContainers = () => { + if (!isPullRequestPage() || !isAllowedOrgPage()) { + return; + } + + const containers = document.querySelectorAll(SELECTORS.REVIEW_CONTAINER); + + for (const container of containers) { + // only real review dialogs (they contain the approve/comment radios) + const radios = [...container.querySelectorAll(SELECTORS.REVIEW_RADIOS)]; + const textarea = radios.length > 0 && getReviewTextarea(container); + + if (!textarea) { + continue; + } + + if (!container.querySelector(`#${DROPDOWN_ID}`)) { + // anchor the absolutely-positioned dropdown to the dialog itself + if (getComputedStyle(container).position === 'static') { + container.style.position = 'relative'; + } + container.appendChild(createCommentDropdown()); + } + + // once per dialog open: select approve (github opens with the last + // used option, and a pre-selected radio fires no change event) + if (!container.hasAttribute(SEEN_ATTRIBUTE)) { + container.setAttribute(SEEN_ATTRIBUTE, 'true'); + const approveRadio = radios.find(isApprove); + + if (!approveRadio) { + continue; + } + + if (AUTO_SELECT_APPROVE && !approveRadio.checked) { + approveRadio.click(); + } + + if (approveRadio.checked) { + fillComment(textarea); + } + } + } + }; + + // the review dialog is created on demand (and react re-creates it on every + // open), so watch the page and process it whenever it shows up - mutation + // bursts are coalesced into one scan per animation frame + let scanScheduled = false; + const scheduleScan = () => { + if (scanScheduled) { + return; + } + scanScheduled = true; + requestAnimationFrame(() => { + scanScheduled = false; + processReviewContainers(); + showActiveBadge(); + }); + }; + + const observer = new MutationObserver(scheduleScan); + observer.observe(document.body, { childList: true, subtree: true }); + + // capture-phase listeners on document survive github's soft navigation + // and the review dialog being re-created every time it opens + document.addEventListener('change', onReviewOptionChange, true); + document.addEventListener('click', onReviewOptionClick, true); + processReviewContainers(); + showActiveBadge(); + console.log(`[GitHub PR Approve Helper] v${VERSION} ready on ${location.href}`); +})();