Last active
May 14, 2026 20:40
-
-
Save autarch/7813add135721511844ca76adb1b23c8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Spruce Bulk Patch Actions | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.1 | |
| // @description Multi-select patches and bulk unschedule/hide | |
| // @include https://spruce.corp.mongodb.com/user/*/patches* | |
| // @run-at document-idle | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| const GQL_ENDPOINT = 'https://evergreen.mongodb.com/graphql/query'; | |
| const TOOLBAR_ID = 'bulk-patch-toolbar'; | |
| const selected = new Set(); | |
| let isWorking = false; | |
| async function gql(query, variables) { | |
| const res = await fetch(GQL_ENDPOINT, { | |
| method: 'POST', | |
| credentials: 'include', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ query, variables }), | |
| }); | |
| const data = await res.json(); | |
| if (data.errors && data.errors.length > 0) { | |
| throw new Error(data.errors.map(e => e.message).join('; ')); | |
| } | |
| return data.data; | |
| } | |
| function unscheduleVersionTasks(versionId) { | |
| return gql( | |
| `mutation UnscheduleVersionTasks($versionId: String!, $abort: Boolean!) { | |
| unscheduleVersionTasks(versionId: $versionId, abort: $abort) | |
| }`, | |
| { versionId, abort: true } | |
| ); | |
| } | |
| function setPatchVisibility(patchIds, hidden) { | |
| return gql( | |
| `mutation SetPatchVisibility($patchIds: [String!]!, $hidden: Boolean!) { | |
| setPatchVisibility(patchIds: $patchIds, hidden: $hidden) { | |
| id | |
| hidden | |
| } | |
| }`, | |
| { patchIds, hidden } | |
| ); | |
| } | |
| function updateToolbar() { | |
| const toolbar = document.getElementById(TOOLBAR_ID); | |
| if (!toolbar) return; | |
| const count = selected.size; | |
| const countEl = toolbar.querySelector('.bulk-count'); | |
| const unschedBtn = toolbar.querySelector('.bulk-unschedule'); | |
| const hideBtn = toolbar.querySelector('.bulk-hide'); | |
| countEl.textContent = count === 0 ? '' : `${count} selected`; | |
| const shouldDisable = isWorking || count === 0; | |
| unschedBtn.disabled = shouldDisable; | |
| hideBtn.disabled = shouldDisable; | |
| } | |
| function setStatus(toolbar, msg, isError) { | |
| const statusEl = toolbar.querySelector('.bulk-status'); | |
| statusEl.textContent = msg; | |
| statusEl.style.color = isError ? '#F77B7B' : '#00A35C'; | |
| if (msg) { | |
| setTimeout(() => { | |
| statusEl.textContent = ''; | |
| statusEl.style.color = ''; | |
| }, 4000); | |
| } | |
| } | |
| async function handleUnschedule() { | |
| const toolbar = document.getElementById(TOOLBAR_ID); | |
| const ids = [...selected]; | |
| isWorking = true; | |
| updateToolbar(); | |
| toolbar.querySelector('.bulk-status').textContent = 'Working…'; | |
| toolbar.querySelector('.bulk-status').style.color = '#fff'; | |
| const results = await Promise.allSettled(ids.map(id => unscheduleVersionTasks(id))); | |
| const failed = results.filter(r => r.status === 'rejected').length; | |
| results.forEach((r, i) => { | |
| if (r.status === 'rejected') console.error(`Failed to unschedule ${ids[i]}:`, r.reason); | |
| }); | |
| isWorking = false; | |
| updateToolbar(); | |
| if (failed > 0) { | |
| setStatus(toolbar, `Done — ${failed} of ${ids.length} failed. Check console.`, true); | |
| } else { | |
| setStatus(toolbar, `Unscheduled tasks for ${ids.length} patch(es).`, false); | |
| } | |
| } | |
| async function handleHide() { | |
| const toolbar = document.getElementById(TOOLBAR_ID); | |
| const ids = [...selected]; | |
| isWorking = true; | |
| updateToolbar(); | |
| toolbar.querySelector('.bulk-status').textContent = 'Working…'; | |
| toolbar.querySelector('.bulk-status').style.color = '#fff'; | |
| try { | |
| const result = await setPatchVisibility(ids, true); | |
| const hiddenIds = new Set((result?.setPatchVisibility ?? []).map(p => p.id)); | |
| hiddenIds.forEach(id => { | |
| const marker = document.querySelector(`[data-patch-id="${id}"]`); | |
| if (marker) marker.closest('[data-cy="patch-card"]')?.remove(); | |
| selected.delete(id); | |
| }); | |
| setStatus(toolbar, `Hid ${hiddenIds.size} patch(es).`, false); | |
| } catch (e) { | |
| console.error('Failed to hide patches:', e); | |
| setStatus(toolbar, `Error: ${e.message}`, true); | |
| } | |
| isWorking = false; | |
| updateToolbar(); | |
| } | |
| function pruneStaleSelection() { | |
| selected.forEach(id => { | |
| if (!document.querySelector(`[data-patch-id="${id}"]`)) { | |
| selected.delete(id); | |
| } | |
| }); | |
| } | |
| function injectToolbar() { | |
| if (document.getElementById(TOOLBAR_ID)) return; | |
| const firstCard = document.querySelector('[data-cy="patch-card"]'); | |
| if (!firstCard) return; | |
| const listContainer = firstCard.parentElement; | |
| if (!listContainer) return; | |
| const styleEl = document.createElement('style'); | |
| styleEl.textContent = ` | |
| #${TOOLBAR_ID} button:disabled { opacity: 0.45; cursor: not-allowed; } | |
| #${TOOLBAR_ID} button:not(:disabled):hover { opacity: 0.85; } | |
| `; | |
| document.head.appendChild(styleEl); | |
| const toolbar = document.createElement('div'); | |
| toolbar.id = TOOLBAR_ID; | |
| toolbar.style.cssText = [ | |
| 'position: sticky', | |
| 'top: 0', | |
| 'z-index: 1000', | |
| 'padding: 10px 16px', | |
| 'background: #1a1e26', | |
| 'display: flex', | |
| 'align-items: center', | |
| 'gap: 12px', | |
| 'border-bottom: 1px solid #2e3d52', | |
| 'margin-bottom: 4px', | |
| ].join(';'); | |
| const btnStyle = [ | |
| 'background: #00A35C', | |
| 'color: #fff', | |
| 'border: none', | |
| 'border-radius: 4px', | |
| 'padding: 6px 14px', | |
| 'font-size: 13px', | |
| 'font-weight: 600', | |
| 'cursor: pointer', | |
| 'transition: opacity 0.15s', | |
| ].join(';'); | |
| toolbar.innerHTML = ` | |
| <button class="bulk-unschedule" style="${btnStyle}" disabled>Unschedule All Tasks</button> | |
| <button class="bulk-hide" style="${btnStyle}" disabled>Hide Patches</button> | |
| <span class="bulk-count" style="color: #fff; font-size: 13px; min-width: 80px;"></span> | |
| <span class="bulk-status" style="font-size: 13px; margin-left: 4px;"></span> | |
| `; | |
| toolbar.querySelector('.bulk-unschedule').addEventListener('click', handleUnschedule); | |
| toolbar.querySelector('.bulk-hide').addEventListener('click', handleHide); | |
| listContainer.insertBefore(toolbar, listContainer.firstChild); | |
| } | |
| function injectCheckboxes() { | |
| pruneStaleSelection(); | |
| injectToolbar(); | |
| updateToolbar(); | |
| document.querySelectorAll('[data-cy="patch-card"]:not([data-bulk-patched])').forEach(card => { | |
| const link = card.querySelector('[data-cy="patch-card-patch-link"]'); | |
| if (!link) return; | |
| const href = link.getAttribute('href') || ''; | |
| const match = href.match(/\/(?:version|patch)\/([^/]+)\//); | |
| if (!match) return; | |
| const patchId = match[1]; | |
| const dropdownBtn = card.querySelector('[data-cy="patch-card-dropdown"]'); | |
| if (!dropdownBtn) return; | |
| card.setAttribute('data-bulk-patched', 'true'); | |
| const marker = document.createElement('span'); | |
| marker.setAttribute('data-patch-id', patchId); | |
| marker.style.display = 'none'; | |
| card.appendChild(marker); | |
| const cb = document.createElement('input'); | |
| cb.type = 'checkbox'; | |
| cb.className = 'bulk-checkbox'; | |
| cb.dataset.patchId = patchId; | |
| cb.style.cssText = [ | |
| 'margin-left: 8px', | |
| 'margin-right: 4px', | |
| 'cursor: pointer', | |
| 'width: 16px', | |
| 'height: 16px', | |
| 'flex-shrink: 0', | |
| 'accent-color: #00A35C', | |
| ].join(';'); | |
| cb.addEventListener('change', () => { | |
| if (cb.checked) { | |
| selected.add(patchId); | |
| } else { | |
| selected.delete(patchId); | |
| } | |
| updateToolbar(); | |
| }); | |
| // Place before the dropdown container so the card's own flex layout centers it | |
| dropdownBtn.parentElement.insertAdjacentElement('beforebegin', cb); | |
| }); | |
| } | |
| // Debounced MutationObserver to handle React re-renders | |
| let debounceTimer; | |
| const observer = new MutationObserver(() => { | |
| clearTimeout(debounceTimer); | |
| debounceTimer = setTimeout(injectCheckboxes, 150); | |
| }); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| // Initial injection after React mounts | |
| setTimeout(injectCheckboxes, 500); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment