// ==UserScript==
// @name Auto-Merge Dependabot PRs
// @namespace typpi.online
// @version 6.6
// @description Merges Dependabot PRs in any of your repositories - pulls the PRs into a table and lets you select which ones to merge.
// @author Nick2bad4u
// @match https://github.com/notifications
// @match https://github.com/*/*/pull/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @connect api.github.com
// @license UnLicense
// @tag github
// @icon https://www.google.com/s2/favicons?sz=64&domain=github.com
// @homepageURL https://github.com/Nick2bad4u/UserStyles
// @supportURL https://github.com/Nick2bad4u/UserStyles/issues
// ==/UserScript==
/* global GM_getValue, GM_setValue, GM_addStyle, GM_xmlhttpRequest */
// @var number merge_delay "Delay between merge requests in milliseconds" 2000
// Utility wrappers for GM_* APIs with graceful fallback to localStorage
function safeGM_getValue(key, defaultValue) {
if (typeof GM_getValue === 'function') {
try {
return GM_getValue(key, defaultValue);
} catch (e) {
console.warn('[Auto-Merge Dependabot PRs] GM_getValue failed, falling back to localStorage:', e);
}
}
try {
const val = localStorage.getItem(key);
return val !== null ? JSON.parse(val) : defaultValue;
} catch (e) {
console.error('[Auto-Merge Dependabot PRs] localStorage getItem failed:', e);
return defaultValue;
}
}
function safeGM_setValue(key, value) {
if (typeof GM_setValue === 'function') {
try {
return GM_setValue(key, value);
} catch (e) {
console.warn('[Auto-Merge Dependabot PRs] GM_setValue failed, falling back to localStorage:', e);
}
}
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (e) {
console.error('[Auto-Merge Dependabot PRs] localStorage setItem failed:', e);
}
}
function safeGM_addStyle(css) {
if (typeof GM_addStyle === 'function') {
try {
GM_addStyle(css);
return;
} catch (e) {
console.warn('[Auto-Merge Dependabot PRs] GM_addStyle failed, falling back to <style>:', e);
}
}
try {
const fallbackStyle = document.createElement('style');
fallbackStyle.textContent = css;
document.head.appendChild(fallbackStyle);
} catch (e) {
console.error('[Auto-Merge Dependabot PRs] Fallback <style> injection failed:', e);
}
}
(async function () {
'use strict';
// Delay between each merge request in milliseconds, configurable via the 'merge_delay' variable stored in safeGM_getValue (default is 2000ms)
let delay = safeGM_getValue('merge_delay', 2000);
if (delay <= 0) {
delay = 2000; // default value if invalid
} else {
delay = Number(delay);
}
/**
* Shows a modal dialog for secure GitHub token input.
* @returns {Promise<string>} The entered token.
*/
async function showSecureTokenInputModal() {
return new Promise((resolve) => {
let modal = document.getElementById('merge-dependabot-token-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'merge-dependabot-token-modal';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
modal.setAttribute('aria-labelledby', 'merge-dependabot-token-modal-title');
modal.setAttribute('aria-describedby', 'merge-dependabot-token-modal-desc');
modal.tabIndex = -1;
modal.className = 'merge-dependabot-modal';
modal.style = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: white;
border: 1px solid #ccc;
padding: 20px;
z-index: 1000;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
`;
modal.innerHTML = `
<h3 id="merge-dependabot-token-modal-title">Enter GitHub Token</h3>
<p id="merge-dependabot-token-modal-desc">Please enter your GitHub token securely:</p>
<input type="password" id="merge-dependabot-token-input" style="width: 100%; padding: 8px; margin-bottom: 10px;" aria-label="GitHub token" class="merge-dependabot-token-input" />
<button id="merge-dependabot-submit-token" style="padding: 8px 16px;" class="merge-dependabot-btn">Submit</button>
<button id="merge-dependabot-close-token-modal" aria-label="Close token modal" style="margin-left:10px;" class="merge-dependabot-btn">Close</button>
`;
document.body.appendChild(modal);
const input = document.getElementById('merge-dependabot-token-input');
const submitBtn = document.getElementById('merge-dependabot-submit-token');
const closeBtn = document.getElementById('merge-dependabot-close-token-modal');
// Focus management
setTimeout(() => input.focus(), 0);
const focusableEls = [input, submitBtn, closeBtn];
let lastFocused = document.activeElement;
function trapFocus(e) {
if (e.key === 'Tab') {
const idx = focusableEls.indexOf(document.activeElement);
if (e.shiftKey) {
if (idx === 0) {
e.preventDefault();
focusableEls[focusableEls.length - 1].focus();
}
} else {
if (idx === focusableEls.length - 1) {
e.preventDefault();
focusableEls[0].focus();
}
}
}
}
modal.addEventListener('keydown', trapFocus);
submitBtn.addEventListener('click', () => {
const tokenInput = input.value;
console.log('[Auto-Merge Dependabot PRs] Token entered via modal.');
modal.remove();
if (lastFocused) lastFocused.focus();
resolve(tokenInput);
});
closeBtn.addEventListener('click', () => {
modal.remove();
if (lastFocused) lastFocused.focus();
resolve('');
});
}
});
}
/**
* Initializes the script by ensuring a valid token and username are set.
*/
async function initialize() {
let token;
try {
// Attempt to retrieve and decrypt the token
token = await retrieveAndDecryptToken();
console.log('[Auto-Merge Dependabot PRs] Token retrieved and decrypted.');
} catch (error) {
console.error('[Auto-Merge Dependabot PRs] Failed to retrieve and decrypt token:', error);
alert('Failed to retrieve and decrypt token. Please check the console for more details.');
throw error; // Stop further execution
}
if (!token) {
while (!token) {
token = await showSecureTokenInputModal();
if (!token) {
alert('GitHub token is required. Please enter a valid token.');
token = null;
} else {
try {
await validateGitHubToken(token);
console.log('[Auto-Merge Dependabot PRs] GitHub token validated.');
} catch (e) {
console.error('[Auto-Merge Dependabot PRs] Invalid GitHub token:', e);
alert('Invalid GitHub token. Please enter a valid token.');
token = null;
}
}
}
try {
await encryptAndStoreToken(token);
console.log('[Auto-Merge Dependabot PRs] Token encrypted and stored.');
} catch (error) {
console.error('[Auto-Merge Dependabot PRs] Failed to encrypt and store token:', error);
alert('Failed to encrypt and store token. Please check the console for more details.');
throw error; // Stop further execution
}
}
let username = safeGM_getValue('github_username') || '';
if (typeof username !== 'string' || username.trim() === '' || /[^a-zA-Z0-9-_]/.test(username)) {
username = ''; // Reset to empty if invalid
}
while (!username || username.trim() === '') {
username = prompt('Please enter your GitHub username:');
if (username && username.trim() !== '') {
try {
await validateGitHubUsername(username, token);
safeGM_setValue('github_username', username);
console.log('[Auto-Merge Dependabot PRs] GitHub username validated and saved.');
} catch (e) {
console.error('[Auto-Merge Dependabot PRs] Invalid GitHub username:', e);
alert('Invalid GitHub username. Please enter a valid username.');
username = '';
}
} else {
alert('GitHub username is required.');
}
}
}
/**
* Validates the GitHub token by making an authenticated request.
* @param {string} token
*/
async function validateGitHubToken(token) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://api.github.com/user',
headers: {
Authorization: `token ${token}`,
},
onload: function (response) {
if (response.status === 200) {
resolve();
} else {
console.warn('[Auto-Merge Dependabot PRs] Token validation failed:', response.responseText);
reject(new Error(`Token validation failed: ${response.responseText}`));
}
},
onerror: function (error) {
console.error('[Auto-Merge Dependabot PRs] Token validation error:', error);
reject(error);
},
});
});
}
/**
* Validates the GitHub username by making an authenticated request.
* @param {string} username
* @param {string} token
*/
async function validateGitHubUsername(username, token) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.github.com/users/${username}`,
headers: {
Authorization: `token ${token}`,
},
onload: function (response) {
if (response.status === 200) {
resolve();
} else {
console.warn('[Auto-Merge Dependabot PRs] Username validation failed:', response.responseText);
reject(new Error(`GitHub username validation failed: ${response.responseText}`));
}
},
onerror: function (error) {
console.error('[Auto-Merge Dependabot PRs] Username validation error:', error);
reject(error);
},
});
});
}
await initialize();
async function encryptAndStoreToken(token) {
try {
const textEncoder = new TextEncoder();
const encodedToken = textEncoder.encode(token);
let key;
const storedKey = safeGM_getValue('encryption_key', null);
if (storedKey) {
try {
key = await crypto.subtle.importKey('jwk', JSON.parse(storedKey), { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
} catch (error) {
console.error('Failed to parse or import encryption key:', error);
alert('The stored encryption key is invalid or corrupted. Please reset your token and encryption key.');
throw error; // Stop further execution
}
} else {
key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
safeGM_setValue('encryption_key', JSON.stringify(await crypto.subtle.exportKey('jwk', key)));
}
const iv = crypto.getRandomValues(new Uint8Array(12));
const encryptedToken = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: iv }, key, encodedToken);
safeGM_setValue(
'github_token',
JSON.stringify({
iv: Array.from(iv),
token: Array.from(new Uint8Array(encryptedToken)),
}),
);
} catch (error) {
console.error('Failed to encrypt and store token:', error);
alert('An error occurred while encrypting and storing the token. Please check the console for details.');
throw error; // Stop further execution
}
}
async function retrieveAndDecryptToken() {
try {
const storedData = safeGM_getValue('github_token', null);
if (!storedData) return '';
let iv, token;
try {
({ iv, token } = JSON.parse(storedData));
} catch (error) {
console.error('Stored token is corrupted or invalid:', error);
alert('The stored token is corrupted or invalid. Please reset your token.');
return ''; // Return an empty string to indicate failure
}
const key = safeGM_getValue('encryption_key', null);
if (!key) {
throw new Error('Encryption key is missing.');
}
const importedKey = await crypto.subtle.importKey('jwk', JSON.parse(key), { name: 'AES-GCM' }, true, ['decrypt']);
const decryptedToken = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(iv) }, importedKey, new Uint8Array(token));
const textDecoder = new TextDecoder();
return textDecoder.decode(decryptedToken);
} catch (error) {
console.error('Failed to retrieve and decrypt token:', error);
alert('An error occurred while retrieving and decrypting the token. Please check the console for details.');
return ''; // Return an empty string to indicate failure
}
}
async function fetchAllRepositories(username, token, orgs = []) {
async function fetchPaginatedRepos(url, token) {
let repos = [];
let page = 1;
while (true) {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${url}&page=${page}`,
headers: {
Authorization: `token ${token}`,
},
onload: function (response) {
handleRateLimit(response);
if (response.status === 200) {
resolve(response);
} else {
reject(new Error(`Failed to fetch repositories: ${response.responseText}`));
}
},
onerror: function (error) {
reject(error);
},
});
});
const pageRepos = JSON.parse(response.responseText);
if (pageRepos.length === 0) break;
repos = repos.concat(pageRepos);
page++;
}
return repos;
}
const [userRepos, orgRepos] = await Promise.all([
fetchPaginatedRepos(`https://api.github.com/users/${username}/repos?per_page=100`, token),
Promise.all(orgs.filter(Boolean).map((org) => fetchPaginatedRepos(`https://api.github.com/orgs/${org}/repos?per_page=100`, token))),
]);
return [...userRepos, ...orgRepos.flat()];
}
const botUsernames = safeGM_getValue('dependabot_usernames', ['dependabot[bot]', 'dependabot-preview[bot]'])
.map((username) => username.trim())
.filter(Boolean);
async function fetchDependabotPRs(owner, repo, token) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `https://api.github.com/repos/${owner}/${repo}/pulls?per_page=100&state=open`,
headers: {
Authorization: `token ${token}`,
},
onload: function (response) {
handleRateLimit(response);
if (response.status === 200) {
const pulls = JSON.parse(response.responseText);
// Only keep PRs authored by the configured bot usernames
const filtered = pulls.filter((pr) => pr.user && botUsernames.includes(pr.user.login));
resolve(filtered);
} else {
console.error(`Failed to fetch PRs for repo ${repo}:`, response.responseText);
reject(new Error(`Failed to fetch PRs for repo ${repo}: ${response.responseText}`));
}
},
onerror: function (error) {
console.error(`Error fetching PRs for repo ${repo}:`, error);
reject(error);
},
});
});
}
async function mergeDependabotPRs(prs, username, repo, token) {
let statusContainer = document.getElementById('merge-status');
if (!statusContainer) {
statusContainer = document.createElement('div');
statusContainer.id = 'merge-status';
statusContainer.classList.add('merge-status');
document.body.appendChild(statusContainer);
}
let index = 0;
async function processNextPR() {
if (index < prs.length) {
const pr = prs[index];
try {
await mergePR(pr, username, repo, token);
const messageElement = document.createElement('div');
messageElement.innerHTML = `PR #${pr.number} merged successfully!<br>`;
messageElement.id = `merge-status-${pr.number}`;
statusContainer.appendChild(messageElement);
setTimeout(() => messageElement.remove(), 7000);
} catch (error) {
console.error(`Error merging PR #${pr.number}:`, error);
const messageElement = document.createElement('div');
messageElement.innerHTML = `Failed to merge PR #${pr.number}: ${error.message || 'Unknown error'}<br>`;
messageElement.id = `merge-status-${pr.number}`;
statusContainer.appendChild(messageElement);
setTimeout(() => messageElement.remove(), 7000);
}
index++;
setTimeout(processNextPR, delay);
} else {
setTimeout(() => {
statusContainer.remove();
removeAllPRSelectionContainers();
}, 10000);
}
}
try {
processNextPR();
} catch (error) {
console.error(`Error processing PRs for repo ${repo}:`, error);
const messageElement = document.createElement('div');
messageElement.innerHTML = `Failed to process PRs for repo ${repo}: ${error.message || 'Unknown error'}<br>`;
statusContainer.appendChild(messageElement);
setTimeout(() => messageElement.remove(), 7000);
removeAllPRSelectionContainers();
}
}
function mergePR(pr, username, repo, token, retries = 3) {
if (retries === 0) retries = 1; // Ensure at least one retry attempt
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'PUT',
url: `https://api.github.com/repos/${username}/${repo}/pulls/${pr.number}/merge`,
headers: {
Authorization: `token ${token}`,
'Content-Type': 'application/json',
},
data: JSON.stringify({
commit_title: `Merge PR #${pr.number}`,
merge_method: 'merge',
}),
onload: function (response) {
if (response.status === 200) {
resolve();
} else {
const responseBody = JSON.parse(response.responseText || '{}');
if (response.status === 409 || (responseBody.message && responseBody.message.includes('merge conflict'))) {
// Permanent error: merge conflict
reject(new Error(`Merge conflict for PR #${pr.number}: ${responseBody.message || 'Unknown conflict'}`));
} else if (response.status === 403 && response.headers['x-ratelimit-remaining'] === '0') {
// Rate limit exceeded
const resetTimeHeader = response.headers['x-ratelimit-reset'];
const resetTime = resetTimeHeader ? new Date(resetTimeHeader * 1000) : null;
reject(new Error(`Rate limit exceeded. Retry after ${resetTime ? resetTime.toLocaleTimeString() : 'some time'}.`));
} else if (retries > 0) {
// Transient error: retry
console.warn(`Retrying merge for PR #${pr.number}. Retries left: ${retries}`);
setTimeout(() => {
mergePR(pr, username, repo, token, retries - 1)
.then(resolve)
.catch(reject);
}, 2000); // Retry after 2 seconds
} else {
reject(new Error(`Failed to merge PR #${pr.number}: ${response.responseText}`));
}
}
},
onerror: function (error) {
if (retries > 0) {
console.warn(`Retrying merge for PR #${pr.number} due to error. Retries left: ${retries}`);
setTimeout(() => {
mergePR(pr, username, repo, token, retries - 1)
.then(resolve)
.catch(reject);
}, 2000); // Retry after 2 seconds
} else {
reject(error);
}
},
});
});
}
function addButton() {
try {
const mergeButton = document.createElement('button');
mergeButton.textContent = 'Merge Dependabot PRs';
mergeButton.classList.add('merge-dependabot-merge-button', 'merge-button');
mergeButton.id = 'merge-dependabot-merge-button';
mergeButton.addEventListener('click', async () => {
try {
let token = await retrieveAndDecryptToken();
if (!token) {
alert('Invalid or missing GitHub token. Please check your settings.');
return;
}
const username = safeGM_getValue('github_username');
const orgs = (safeGM_getValue('github_orgs', '') || '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const statusElement = getStatusElement();
updateStatusElement(statusElement, 'Fetching repositories...');
let repos;
try {
repos = await fetchAllRepositories(username, token, orgs);
} catch (error) {
console.error('Error fetching repositories:', error);
updateStatusElement(statusElement, 'Failed to fetch repositories. Please check the console for details.');
return; // Stop further execution
}
let allPRs = [];
for (const repo of repos) {
if (repo.archived) {
updateStatusElement(statusElement, `Skipping archived repo: ${repo.name}`);
continue;
}
updateStatusElement(statusElement, `Fetching PRs for repo: ${repo.name}`);
try {
const prs = await fetchDependabotPRs(username, repo.name, token);
allPRs = allPRs.concat(prs.map((pr) => ({ ...pr, repo: repo.name })));
} catch (error) {
console.error(`Error fetching PRs for repo ${repo.name}:`, error);
updateStatusElement(statusElement, `Failed to fetch PRs for repo: ${repo.name}.`);
}
}
if (allPRs.length > 0) {
updateStatusElement(statusElement, 'Displaying PR selection...');
displayPRSelection(allPRs, username, token);
} else {
updateStatusElement(statusElement, 'No Dependabot PRs found to merge.');
displayNoPRsMessage();
}
setTimeout(() => {
statusElement.innerHTML = '';
statusElement.remove();
}, 10000);
} catch (error) {
console.error('Error during merge operation:', error);
alert('An unexpected error occurred. Please check the console for details.');
}
});
const container = document.getElementById('merge-dependabot-merge-button-container') || createMergeButtonContainer();
container.appendChild(mergeButton);
function createMergeButtonContainer() {
const container = document.createElement('div');
container.id = 'merge-dependabot-merge-button-container';
container.className = 'merge-dependabot-merge-button-container';
container.style.position = 'fixed';
container.style.bottom = '10px';
container.style.right = '10px';
container.style.zIndex = '1000';
document.body.appendChild(container);
return container;
}
// Add the cog icon to the merge button
addCogToMergeButton();
} catch (error) {
console.error('Failed to add merge button:', error);
alert('An error occurred while adding the merge button. Please check the console for details.');
}
}
function getStatusElement() {
let statusElement = document.getElementById('merge-status');
if (!statusElement) {
statusElement = document.createElement('div');
statusElement.id = 'merge-status';
statusElement.classList.add('merge-status');
document.body.appendChild(statusElement);
}
return statusElement;
}
function updateStatusElement(element, message) {
element.innerHTML = message;
}
// Utility: Remove all lingering PR selection containers
function removeAllPRSelectionContainers() {
const containers = document.querySelectorAll('.merge-dependabot-pr-selection-container');
containers.forEach((el) => el.remove());
}
function displayPRSelection(prs, username, token) {
try {
removeAllPRSelectionContainers(); // Clean up any old containers first
const container = document.createElement('div');
container.classList.add('merge-dependabot-pr-selection-container');
container.setAttribute('role', 'dialog');
container.setAttribute('aria-modal', 'true');
container.setAttribute('aria-labelledby', 'merge-dependabot-pr-selection-title');
container.tabIndex = -1;
container.id = 'merge-dependabot-pr-selection-container';
container.style.position = 'fixed';
container.style.bottom = '50px';
container.style.right = '10px';
container.style.zIndex = '1000';
container.style.backgroundColor = '#79e4f2';
container.style.color = '#000000';
container.style.padding = '10px';
container.style.border = '1px solid #ccc';
container.style.maxHeight = '300px';
container.style.overflowY = 'auto';
container.style.minWidth = '350px';
container.style.boxSizing = 'border-box';
container.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
// Add close (X) button
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.className = 'merge-dependabot-pr-selection-close';
closeBtn.title = 'Close';
closeBtn.setAttribute('aria-label', 'Close PR selection dialog');
closeBtn.id = 'merge-dependabot-pr-selection-close';
closeBtn.onclick = () => {
container.remove();
const status = document.getElementById('merge-status');
if (status) status.remove();
removeAllPRSelectionContainers(); // Ensure all are removed
if (container.lastFocused) container.lastFocused.focus();
};
container.appendChild(closeBtn);
const title = document.createElement('h3');
title.id = 'merge-dependabot-pr-selection-title';
title.textContent = 'Select Dependabot PRs to Merge';
container.appendChild(title);
const prList = document.createElement('div');
prList.className = 'merge-dependabot-pr-list';
prList.id = 'merge-dependabot-pr-list';
let lastChecked = null; // Track the last clicked checkbox
prs.forEach((pr) => {
const prItem = document.createElement('div');
prItem.className = 'merge-dependabot-pr-item';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = pr.number;
checkbox.id = `merge-dependabot-pr-checkbox-${pr.repo}-${pr.number}`;
checkbox.className = 'merge-dependabot-pr-checkbox';
const label = document.createElement('label');
label.textContent = `Repo: ${pr.repo} - PR #${pr.number}: ${pr.title}`;
label.style = 'margin-left: 5px;';
label.setAttribute('for', checkbox.id);
label.className = 'merge-dependabot-pr-label';
// Add event listener for shift-click selection
checkbox.addEventListener('click', (event) => {
if (event.shiftKey && lastChecked) {
const checkboxes = Array.from(prList.querySelectorAll('input[type="checkbox"]'));
const start = Math.min(checkboxes.indexOf(lastChecked), checkboxes.indexOf(checkbox));
const end = Math.max(checkboxes.indexOf(lastChecked), checkboxes.indexOf(checkbox));
for (let i = start; i <= end; i++) {
checkboxes[i].checked = lastChecked.checked;
}
}
lastChecked = checkbox; // Update the last clicked checkbox
});
prItem.appendChild(checkbox);
prItem.appendChild(label);
prList.appendChild(prItem);
});
const mergeSelectedButton = document.createElement('button');
mergeSelectedButton.textContent = 'Merge Selected PRs';
mergeSelectedButton.setAttribute('aria-label', 'Merge selected pull requests');
mergeSelectedButton.className = 'merge-dependabot-btn';
mergeSelectedButton.id = 'merge-dependabot-merge-selected-btn';
mergeSelectedButton.addEventListener('click', async () => {
// Get all selected checkboxes
const selectedCheckboxes = Array.from(prList.querySelectorAll('input[type="checkbox"]:checked'));
// Map selected checkboxes to their corresponding PRs
const selectedPRs = selectedCheckboxes.map((checkbox) => prs.find((pr) => pr.number == checkbox.value));
if (selectedPRs.length > 0) {
container.innerHTML = '<div id="merge-status">Merging PRs...<br></div>';
// Remove the container after merging is done (with a delay to show status)
const groupedPRs = selectedPRs.reduce((acc, pr) => {
if (!acc[pr.repo]) {
acc[pr.repo] = [];
}
acc[pr.repo].push(pr);
return acc;
}, {});
// Merge PRs grouped by repository
for (const [repo, prs] of Object.entries(groupedPRs)) {
try {
await mergeDependabotPRs(prs, username, repo, token);
} catch (error) {
console.error(`Error merging PRs for repo ${repo}:`, error);
const status = document.getElementById('merge-status');
if (status) status.remove();
container.remove();
removeAllPRSelectionContainers();
alert(`Failed to merge PRs for repo ${repo}. Please check the console for details.`);
return;
}
setTimeout(() => {
const status = document.getElementById('merge-status');
if (status) status.remove();
container.remove();
removeAllPRSelectionContainers();
}, 11000); // Wait for status to finish
}
} else {
container.innerHTML = 'No PRs selected for merging.';
setTimeout(() => {
container.remove();
removeAllPRSelectionContainers();
}, 2000);
}
});
container.appendChild(prList);
container.appendChild(mergeSelectedButton);
document.body.appendChild(container);
// Focus management for modal
const focusableEls = [closeBtn, mergeSelectedButton, ...Array.from(prList.querySelectorAll('input[type="checkbox"]'))];
container.lastFocused = document.activeElement;
setTimeout(() => mergeSelectedButton.focus(), 0);
container.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
const idx = focusableEls.indexOf(document.activeElement);
if (e.shiftKey) {
if (idx === 0) {
e.preventDefault();
focusableEls[focusableEls.length - 1].focus();
}
} else {
if (idx === focusableEls.length - 1) {
e.preventDefault();
focusableEls[0].focus();
}
}
}
});
} catch (error) {
console.error('Failed to display PR selection:', error);
removeAllPRSelectionContainers(); // Clean up on error
const status = document.getElementById('merge-status');
if (status) status.remove();
alert('An error occurred while displaying the PR selection. Please check the console for details.');
}
}
function displayNoPRsMessage() {
removeAllPRSelectionContainers(); // Clean up any old containers first
const container = document.createElement('div');
container.classList.add('pr-container');
container.textContent = 'No Dependabot PRs found to merge.';
document.body.appendChild(container);
// Automatically hide the message after 5 seconds (5000 milliseconds)
setTimeout(() => {
container.remove();
// Also remove the merge-status container
const statusContainer = document.getElementById('merge-status');
if (statusContainer) {
statusContainer.remove();
}
}, 5000);
}
const mainCSS = `
.merge-button, mergebutton, body > div.pr-selection-container > button {
position: fixed;
bottom: 10px;
right: 10px;
z-index: 1000;
background-color: #2ea44f;
color: #ffffff;
border: none;
padding: 10px;
border-radius: 5px;
cursor: pointer;
}
.merge-button:hover, mergebutton:hover {
background-color: #79e4f2;
color: #ffffff;
border: none;
padding: 10px;
border-radius: 5px;
cursor: pointer;
}
#merge-status, .merge-status {
position: fixed;
bottom: 90px;
right: 10px;
z-index: 1000;
background-color: #79e4f2;
padding: 10px;
border: 1px solid #ccc;
margin-top: 10px;
font-size: 0.9em;
color: #333;
max-width: 300px;
overflow-wrap: break-word;
}
#merge-status > div {
margin-bottom: 5px;
}
.pr-container {
background-color: #ff0000;
color: #ffffff;
position: fixed;
bottom: 130px;
right: 10px;
z-index: 1000;
padding: 10px;
border: 1px solid #cccccc;
}
.merge-button {
transition: background-color 0.3s ease;
}
.pr-selection-container {
position: fixed;
bottom: 50px;
right: 10px;
z-index: 1000;
background-color: #79e4f2;
color: #000000;
padding: 10px;
border: 1px solid #ccc;
max-height: 300px;
overflow-y: auto;
min-width: 350px;
box-sizing: border-box;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.pr-selection-close {
display: inline-block;
width: 32px;
height: 32px;
line-height: 32px;
text-align: center;
position: absolute;
top: 2px;
right: 6px;
cursor: pointer;
font-weight: bold;
color: #333;
background: none;
border: none;
font-size: 1.2em;
padding: 0;
}
`;
safeGM_addStyle(mainCSS);
window.addEventListener('load', addButton);
function showConfigPanel() {
const configPanel = document.createElement('div');
configPanel.setAttribute('role', 'dialog');
configPanel.setAttribute('aria-modal', 'true');
configPanel.setAttribute('aria-labelledby', 'merge-dependabot-config-panel-title');
configPanel.tabIndex = -1;
configPanel.id = 'merge-dependabot-config-panel';
configPanel.className = 'merge-dependabot-modal';
configPanel.style = `
position: fixed;
top: 10%;
left: 50%;
transform: translate(-50%, -10%);
background-color: white;
border: 1px solid #ccc;
padding: 20px;
z-index: 1000;
`;
configPanel.innerHTML = `
<h3 id="merge-dependabot-config-panel-title">Configuration</h3>
<label>GitHub Username: <input id="merge-dependabot-config-username" type="text" value="${safeGM_getValue('github_username', '')}" class="merge-dependabot-config-input" /></label><br>
<label>Organizations (comma separated): <input id="merge-dependabot-config-orgs" type="text" value="${safeGM_getValue('github_orgs', '')}" class="merge-dependabot-config-input" /></label><br>
<label>Merge Delay (ms): <input id="merge-dependabot-config-merge-delay" type="number" value="${safeGM_getValue('merge_delay', 2000)}" class="merge-dependabot-config-input" /></label><br>
<label>Bot Usernames (comma separated): <input id="merge-dependabot-config-bot-usernames" type="text" value="${safeGM_getValue('dependabot_usernames', ['dependabot[bot]', 'dependabot-preview[bot]']).join(', ')}" class="merge-dependabot-config-input" /></label><br>
<button id="merge-dependabot-save-config" class="merge-dependabot-btn">Save</button>
<button id="merge-dependabot-reset-token" class="merge-dependabot-btn">Reset Token</button>
<button id="merge-dependabot-close-config" aria-label="Close configuration panel" class="merge-dependabot-btn">Close</button>
`;
document.body.appendChild(configPanel);
const saveBtn = document.getElementById('merge-dependabot-save-config');
const resetBtn = document.getElementById('merge-dependabot-reset-token');
const closeBtn = document.getElementById('merge-dependabot-close-config');
const focusableEls = [saveBtn, resetBtn, closeBtn];
let lastFocused = document.activeElement;
setTimeout(() => saveBtn.focus(), 0);
configPanel.addEventListener('keydown', function (e) {
if (e.key === 'Tab') {
const idx = focusableEls.indexOf(document.activeElement);
if (e.shiftKey) {
if (idx === 0) {
e.preventDefault();
focusableEls[focusableEls.length - 1].focus();
}
} else {
if (idx === focusableEls.length - 1) {
e.preventDefault();
focusableEls[0].focus();
}
}
}
});
document.getElementById('merge-dependabot-save-config').addEventListener('click', () => {
const username = document.getElementById('merge-dependabot-config-username').value;
const orgs = document.getElementById('merge-dependabot-config-orgs').value;
const mergeDelay = parseInt(document.getElementById('merge-dependabot-config-merge-delay').value, 10);
safeGM_setValue('github_username', username);
safeGM_setValue('github_orgs', orgs);
safeGM_setValue('merge_delay', isNaN(mergeDelay) || mergeDelay <= 0 ? 2000 : mergeDelay);
const botUsernamesInput = document.getElementById('merge-dependabot-config-bot-usernames').value;
const botUsernames = botUsernamesInput
.split(',')
.map((username) => username.trim())
.filter(Boolean);
safeGM_setValue('dependabot_usernames', botUsernames);
alert('Configuration saved!');
configPanel.remove();
if (lastFocused) lastFocused.focus();
});
document.getElementById('merge-dependabot-reset-token').addEventListener('click', () => {
safeGM_setValue('github_token', null);
safeGM_setValue('encryption_key', null);
alert('Token and encryption key have been reset. Please reload and re-enter your token.');
configPanel.remove();
if (lastFocused) lastFocused.focus();
});
document.getElementById('merge-dependabot-close-config').addEventListener('click', () => {
configPanel.remove();
if (lastFocused) lastFocused.focus();
});
}
function addCogToMergeButton() {
const mergeButton = document.querySelector('.merge-dependabot-merge-button');
if (mergeButton) {
// Create the cog icon
const cogIcon = document.createElement('span');
cogIcon.textContent = '⚙️';
cogIcon.style = `
margin-left: 10px;
cursor: pointer;
font-size: 1.2em;
`;
cogIcon.title = 'Settings';
// Attach the click event to open the configuration panel
cogIcon.addEventListener('click', (event) => {
event.stopPropagation(); // Prevent triggering the merge button click
showConfigPanel();
});
// Append the cog icon to the merge button
mergeButton.appendChild(cogIcon);
}
}
function handleRateLimit(response) {
if (response.status === 403 && response.headers['x-ratelimit-remaining'] === '0') {
const resetTimeHeader = response.headers['x-ratelimit-reset'];
if (resetTimeHeader) {
const resetTime = new Date(resetTimeHeader * 1000);
alert(`Rate limit exceeded. Please wait until ${resetTime.toLocaleTimeString()} to retry.`);
} else {
const fallbackWaitTime = 60; // Default fallback wait time in seconds
const currentTime = new Date();
const fallbackResetTime = new Date(currentTime.getTime() + fallbackWaitTime * 1000);
alert(`Rate limit exceeded. Please wait until approximately ${fallbackResetTime.toLocaleTimeString()} to retry.`);
}
throw new Error('Rate limit exceeded');
}
}
})();