Skip to content
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
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
370 changes: 370 additions & 0 deletions github-pr-approve-helper.user.js
Original file line number Diff line number Diff line change
@@ -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}`);
})();