Skip to content

Instantly share code, notes, and snippets.

@Stanislas-Poisson
Created September 5, 2025 07:36
Show Gist options
  • Select an option

  • Save Stanislas-Poisson/b46889323b747b67c891d68a57f456ac to your computer and use it in GitHub Desktop.

Select an option

Save Stanislas-Poisson/b46889323b747b67c891d68a57f456ac to your computer and use it in GitHub Desktop.
Tampermonkey-PFS-PR.js
// ==UserScript==
// @name GitHub PR Filters - ParisFashionShops
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Finder-style filters with URL persistence for ParisFashionShops PRs
// @author Stanislas Poisson <stanislas.poisson@parisfashionshops.com>
// @match https://github.com/ParisFashionShops/*/pulls*
// @grant none
// ==/UserScript==
;(function () {
'use strict'
// Filter state management - ALL EMPTY BY DEFAULT
const filterState = {
branch: '', // Empty = "All branches"
assignment: '', // Empty = "All PRs"
status: '', // Empty = "All statuses"
review: '', // Empty = "All reviews"
draft: '', // Empty = "All types"
date: '', // Empty = "Any time"
}
let searchTimer = null
let countdownInterval = null
const SEARCH_DELAY = 1500
// Parse current URL to restore filter state
function parseUrlFilters() {
const urlParams = new URLSearchParams(window.location.search)
const query = urlParams.get('q')
console.debug('Parsing URL query:', query)
// Reset ALL to empty (= "All" options selected)
filterState.branch = ''
filterState.assignment = ''
filterState.status = ''
filterState.review = ''
filterState.draft = ''
filterState.date = ''
// If no query or only basic GitHub defaults, keep everything empty
if (
!query ||
'is:pr is:open' === query.trim() ||
'is:open is:pr' === query.trim() ||
'' === query.trim()
) {
console.debug('No specific filters detected, showing ALL options selected')
return
}
// Parse specific filters only
const branchMatch = query.match(/base:(\w+)/)
if (branchMatch) {
filterState.branch = branchMatch[1]
console.debug('Found branch filter:', branchMatch[1])
}
if (query.includes('assignee:@me')) {
filterState.assignment = 'assigned-to-me'
} else if (query.includes('author:@me')) {
filterState.assignment = 'my-prs'
} else if (query.includes('review-requested:@me')) {
filterState.assignment = 'review-requested'
} else if (query.includes('reviewed-by:@me')) {
filterState.assignment = 'reviewed-by-me'
}
if (
query.includes('review:approved') &&
query.includes('status:success') &&
query.includes('-is:draft')
) {
filterState.status = 'ready-to-merge'
} else if (query.includes('review:approved') && query.includes('status:failure')) {
filterState.status = 'almost-ready'
} else if (query.includes('status:failure')) {
filterState.status = 'conflicts'
} else if (query.includes('-status:failure')) {
filterState.status = 'no-conflicts'
}
if (query.includes('review:approved') && !filterState.status) {
filterState.review = 'approved'
} else if (query.includes('review:changes-requested')) {
filterState.review = 'changes-requested'
} else if (query.includes('review:none')) {
filterState.review = 'no-review'
}
if (query.includes('is:draft')) {
filterState.draft = 'draft'
} else if (query.includes('-is:draft') && !filterState.status) {
filterState.draft = 'no-draft'
}
if (query.includes('updated:>7-days-ago')) {
filterState.date = 'recent-7d'
} else if (query.includes('updated:>14-days-ago')) {
filterState.date = 'recent-14d'
} else if (query.includes('updated:<30-days-ago')) {
filterState.date = 'old-30d'
} else if (query.includes('updated:<60-days-ago')) {
filterState.date = 'old-60d'
}
console.debug('Final parsed filters:', filterState)
}
// Create the Finder-style interface
function createFilterInterface() {
const container = document.createElement('div')
container.style.cssText = `
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 16px;
margin: 16px 0;
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
position: sticky;
top: 0;
z-index: 10;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
`
const title = document.createElement('h3')
title.textContent = 'Smart PR Filters'
title.style.cssText = `
margin: 0 0 16px 0;
color: #24292f;
font-size: 16px;
font-weight: 600;
text-align: center;
`
// Finder-style columns container
const columnsContainer = document.createElement('div')
columnsContainer.style.cssText = `
display: flex;
gap: 2px;
height: 200px;
background: white;
border-radius: 8px;
overflow: hidden;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
`
// Filter columns definition - ALL OPTIONS FIRST
const filterColumns = [
{
title: '🎯 Target Branch',
key: 'branch',
width: '180px',
options: [
{ value: '', label: 'All branches', desc: 'any target branch' },
{ value: 'dev', label: 'dev', desc: 'development branch' },
{ value: 'preprod', label: 'preprod', desc: 'pre-production' },
{ value: 'main', label: 'main', desc: 'main branch' },
],
},
{
title: '👤 Assignment',
key: 'assignment',
width: '200px',
options: [
{ value: '', label: 'All PRs', desc: 'any assignee or author' },
{ value: 'assigned-to-me', label: 'Assigned to me', desc: 'you are assigned' },
{ value: 'my-prs', label: 'My PRs', desc: 'you created' },
{ value: 'review-requested', label: 'Review requested', desc: 'your review needed' },
{ value: 'reviewed-by-me', label: 'Already reviewed', desc: 'you reviewed' },
],
},
{
title: '⚙️ Technical Status',
key: 'status',
width: '220px',
options: [
{ value: '', label: 'All statuses', desc: 'any technical status' },
{ value: 'conflicts', label: 'With conflicts', desc: 'merge conflicts' },
{ value: 'no-conflicts', label: 'No conflicts', desc: 'clean to merge' },
{ value: 'almost-ready', label: 'Almost ready', desc: 'approved but checks failing' },
{ value: 'ready-to-merge', label: 'Ready to merge', desc: 'approved and checks pass' },
],
},
{
title: '📝 Review State',
key: 'review',
width: '200px',
options: [
{ value: '', label: 'All reviews', desc: 'any review state' },
{ value: 'approved', label: 'Approved', desc: 'has approval' },
{ value: 'changes-requested', label: 'Changes requested', desc: 'needs changes' },
{ value: 'no-review', label: 'No review yet', desc: 'awaiting review' },
],
},
{
title: '📄 Draft Status',
key: 'draft',
width: '180px',
options: [
{ value: '', label: 'All types', desc: 'draft and ready' },
{ value: 'draft', label: 'Drafts only', desc: 'work in progress' },
{ value: 'no-draft', label: 'Ready only', desc: 'not draft' },
],
},
{
title: '📅 Last Update',
key: 'date',
width: '200px',
options: [
{ value: '', label: 'Any time', desc: 'any update date' },
{ value: 'recent-7d', label: 'Last 7 days', desc: 'updated this week' },
{ value: 'recent-14d', label: 'Last 2 weeks', desc: 'updated recently' },
{ value: 'old-30d', label: 'Older than 30d', desc: 'needs attention' },
{ value: 'old-60d', label: 'Older than 60d', desc: 'stale PRs' },
],
},
]
// Create columns
filterColumns.forEach((column) => {
const columnDiv = createFinderColumn(column)
columnsContainer.appendChild(columnDiv)
})
// Bottom section with query preview and controls
const bottomSection = document.createElement('div')
bottomSection.style.cssText = `
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 16px;
gap: 16px;
`
// Query preview (left side)
const queryPreview = document.createElement('div')
queryPreview.id = 'query-preview'
queryPreview.style.cssText = `
flex: 1;
background: rgba(255,255,255,0.8);
border: 1px solid #e1e4e8;
border-radius: 6px;
padding: 12px;
font-size: 13px;
color: #656d76;
min-height: 20px;
line-height: 1.3;
`
// Controls (right side)
const controlsContainer = document.createElement('div')
controlsContainer.style.cssText = `
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
`
// Countdown and search button
const countdownContainer = document.createElement('div')
countdownContainer.style.cssText = `
display: flex;
align-items: center;
gap: 12px;
`
// Progress bar
const progressContainer = document.createElement('div')
progressContainer.style.cssText = `
width: 120px;
height: 4px;
background: #e1e4e8;
border-radius: 2px;
overflow: hidden;
position: relative;
`
const progressBar = document.createElement('div')
progressBar.id = 'progress-bar'
progressBar.style.cssText = `
height: 100%;
width: 100%;
background: #0969da;
border-radius: 2px;
transition: width 0.05s linear;
`
progressContainer.appendChild(progressBar)
// Countdown text
const countdownText = document.createElement('span')
countdownText.id = 'countdown-text'
countdownText.style.cssText = `
font-size: 12px;
color: #656d76;
font-family: 'SF Mono', Monaco, monospace;
min-width: 60px;
text-align: right;
`
// Search button
const searchButton = document.createElement('button')
searchButton.textContent = 'Search Now'
searchButton.style.cssText = `
padding: 8px 16px;
background: #0969da;
color: white;
border: 1px solid #0969da;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.2s;
`
searchButton.addEventListener('mouseenter', () => {
searchButton.style.background = '#0550ae'
})
searchButton.addEventListener('mouseleave', () => {
searchButton.style.background = '#0969da'
})
searchButton.addEventListener('click', applyFilters)
// Reset button
const resetButton = document.createElement('button')
resetButton.textContent = 'Reset All'
resetButton.style.cssText = `
padding: 6px 16px;
background: transparent;
color: #656d76;
border: 1px solid #d0d7de;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
`
resetButton.addEventListener('click', resetFilters)
countdownContainer.appendChild(progressContainer)
countdownContainer.appendChild(countdownText)
countdownContainer.appendChild(searchButton)
controlsContainer.appendChild(countdownContainer)
controlsContainer.appendChild(resetButton)
bottomSection.appendChild(queryPreview)
bottomSection.appendChild(controlsContainer)
container.appendChild(title)
container.appendChild(columnsContainer)
container.appendChild(bottomSection)
return container
}
// Create Finder-style column
function createFinderColumn(column) {
const columnDiv = document.createElement('div')
columnDiv.style.cssText = `
width: ${column.width};
border-right: 1px solid #e1e4e8;
display: flex;
flex-direction: column;
background: white;
`
// Column header
const header = document.createElement('div')
header.textContent = column.title
header.style.cssText = `
padding: 8px 12px;
background: #f6f8fa;
border-bottom: 1px solid #e1e4e8;
font-size: 12px;
font-weight: 600;
color: #656d76;
text-transform: uppercase;
letter-spacing: 0.5px;
`
// Options container
const optionsContainer = document.createElement('div')
optionsContainer.style.cssText = `
flex: 1;
overflow-y: auto;
`
// Create options
column.options.forEach((option) => {
const optionDiv = document.createElement('div')
optionDiv.style.cssText = `
padding: 10px 12px;
cursor: pointer;
border-bottom: 1px solid #f6f8fa;
transition: all 0.2s;
font-size: 13px;
position: relative;
`
// Check if this option is selected
const isSelected = filterState[column.key] === option.value
console.debug(
`Option ${option.label}: filterState.${column.key} = '${filterState[column.key]}', option.value = '${
option.value
}', isSelected = ${isSelected}`
)
if (isSelected) {
optionDiv.style.background = '#dbeafe'
optionDiv.style.borderLeft = '3px solid #0969da'
optionDiv.style.fontWeight = '600'
}
// Option label
const label = document.createElement('div')
label.textContent = option.label
label.style.cssText = `
color: ${isSelected ? '#0969da' : '#24292f'};
margin-bottom: 2px;
`
// Option description
const desc = document.createElement('div')
desc.textContent = option.desc
desc.style.cssText = `
font-size: 11px;
color: #656d76;
font-style: italic;
`
optionDiv.appendChild(label)
optionDiv.appendChild(desc)
// Hover effects
optionDiv.addEventListener('mouseenter', () => {
if (!isSelected) {
optionDiv.style.background = '#f6f8fa'
}
})
optionDiv.addEventListener('mouseleave', () => {
if (!isSelected) {
optionDiv.style.background = 'white'
}
})
// Click handler
optionDiv.addEventListener('click', () => {
console.debug(`Clicked on ${column.key}: ${option.value}`)
handleFilterChange(column.key, option.value)
refreshColumnSelections()
})
optionsContainer.appendChild(optionDiv)
})
columnDiv.appendChild(header)
columnDiv.appendChild(optionsContainer)
return columnDiv
}
// Handle filter change
function handleFilterChange(key, value) {
console.debug(`Filter change: ${key} = ${value}`)
filterState[key] = value
setTimeout(() => {
updateQueryPreview()
startCountdown()
}, 10)
}
// Refresh column selections
function refreshColumnSelections() {
const existingInterface = document.getElementById('pr-filters-addon')
if (existingInterface) {
const newInterface = createFilterInterface()
newInterface.id = 'pr-filters-addon'
existingInterface.parentNode.replaceChild(newInterface, existingInterface)
setTimeout(() => {
updateQueryPreview()
}, 50)
}
}
// Update query preview with natural language
function updateQueryPreview() {
const preview = document.getElementById('query-preview')
if (!preview) {
console.debug('Preview element not found')
return
}
const parts = []
let hasSpecificFilters = false
// Check each filter
if (filterState.branch) {
parts.push(`targeting ${filterState.branch} branch`)
hasSpecificFilters = true
}
switch (filterState.assignment) {
case 'assigned-to-me':
parts.push(hasSpecificFilters ? 'assigned to you' : 'PRs assigned to you')
hasSpecificFilters = true
break
case 'my-prs':
parts.push(hasSpecificFilters ? 'created by you' : 'PRs created by you')
hasSpecificFilters = true
break
case 'review-requested':
parts.push(
hasSpecificFilters
? 'where your review is requested'
: 'PRs where your review is requested'
)
hasSpecificFilters = true
break
case 'reviewed-by-me':
parts.push(
hasSpecificFilters ? 'you have already reviewed' : 'PRs you have already reviewed'
)
hasSpecificFilters = true
break
default:
break
}
switch (filterState.status) {
case 'conflicts':
parts.push('with merge conflicts')
hasSpecificFilters = true
break
case 'no-conflicts':
parts.push('without conflicts')
hasSpecificFilters = true
break
case 'almost-ready':
parts.push('that are approved but have failing checks')
hasSpecificFilters = true
break
case 'ready-to-merge':
parts.push('that are ready to merge (approved + passing checks)')
hasSpecificFilters = true
break
default:
break
}
switch (filterState.review) {
case 'approved':
parts.push('with approvals')
hasSpecificFilters = true
break
case 'changes-requested':
parts.push('with requested changes')
hasSpecificFilters = true
break
case 'no-review':
parts.push('awaiting review')
hasSpecificFilters = true
break
default:
break
}
switch (filterState.draft) {
case 'draft':
parts.push('in draft mode')
hasSpecificFilters = true
break
case 'no-draft':
parts.push('ready for review (not draft)')
hasSpecificFilters = true
break
default:
break
}
switch (filterState.date) {
case 'recent-7d':
parts.push('updated in the last 7 days')
hasSpecificFilters = true
break
case 'recent-14d':
parts.push('updated in the last 2 weeks')
hasSpecificFilters = true
break
case 'old-30d':
parts.push('not updated for 30+ days')
hasSpecificFilters = true
break
case 'old-60d':
parts.push('not updated for 60+ days')
hasSpecificFilters = true
break
default:
break
}
let finalText
if (!hasSpecificFilters) {
finalText = 'Searching for all open PRs.'
} else {
finalText = `Searching for ${
1 === parts.length && parts[0].startsWith('PRs') ? parts[0] : `PRs ${parts.join(' ')}`
}.`
}
preview.textContent = finalText
console.debug('Updated preview:', finalText)
}
// Start visual countdown
function startCountdown() {
if (searchTimer) {
clearTimeout(searchTimer)
}
if (countdownInterval) {
clearInterval(countdownInterval)
}
const progressBar = document.getElementById('progress-bar')
const countdownText = document.getElementById('countdown-text')
if (!progressBar || !countdownText) {
console.debug('Progress elements not found, retrying countdown...')
setTimeout(startCountdown, 100)
return
}
console.debug('Starting countdown...')
let timeLeft = SEARCH_DELAY
const startTime = Date.now()
progressBar.style.width = '100%'
countdownInterval = setInterval(() => {
const elapsed = Date.now() - startTime
timeLeft = Math.max(0, SEARCH_DELAY - elapsed)
const progress = (timeLeft / SEARCH_DELAY) * 100
progressBar.style.width = `${progress}%`
const seconds = Math.floor(timeLeft / 1000)
const ms = Math.floor((timeLeft % 1000) / 10)
countdownText.textContent = `${seconds}.${ms.toString().padStart(2, '0')}s`
if (0 >= timeLeft) {
clearInterval(countdownInterval)
countdownText.textContent = 'Searching...'
}
}, 50)
searchTimer = setTimeout(() => {
clearInterval(countdownInterval)
applyFilters()
}, SEARCH_DELAY)
}
// Build GitHub query from filter state
function buildQuery() {
const queryParts = ['org:ParisFashionShops', 'is:pr', 'is:open']
if (filterState.branch) {
queryParts.push(`base:${filterState.branch}`)
}
switch (filterState.assignment) {
case 'assigned-to-me':
queryParts.push('assignee:@me')
break
case 'my-prs':
queryParts.push('author:@me')
break
case 'review-requested':
queryParts.push('review-requested:@me')
break
case 'reviewed-by-me':
queryParts.push('reviewed-by:@me')
break
default:
break
}
switch (filterState.status) {
case 'conflicts':
queryParts.push('status:failure')
break
case 'no-conflicts':
queryParts.push('-status:failure')
break
case 'almost-ready':
queryParts.push('review:approved', 'status:failure')
break
case 'ready-to-merge':
queryParts.push('review:approved', 'status:success', '-is:draft')
break
default:
break
}
switch (filterState.review) {
case 'approved':
queryParts.push('review:approved')
break
case 'changes-requested':
queryParts.push('review:changes-requested')
break
case 'no-review':
queryParts.push('review:none')
break
default:
break
}
switch (filterState.draft) {
case 'draft':
queryParts.push('is:draft')
break
case 'no-draft':
queryParts.push('-is:draft')
break
default:
break
}
switch (filterState.date) {
case 'recent-7d':
queryParts.push('updated:>7-days-ago')
break
case 'recent-14d':
queryParts.push('updated:>14-days-ago')
break
case 'old-30d':
queryParts.push('updated:<30-days-ago')
break
case 'old-60d':
queryParts.push('updated:<60-days-ago')
break
default:
break
}
return queryParts.join(' ')
}
// Apply filters
function applyFilters() {
if (searchTimer) {
clearTimeout(searchTimer)
}
if (countdownInterval) {
clearInterval(countdownInterval)
}
const query = buildQuery()
const baseUrl = `${window.location.origin}${window.location.pathname}`
const url = `${baseUrl}?q=${encodeURIComponent(query)}`
console.debug('Applying filters with query:', query)
const countdownText = document.getElementById('countdown-text')
if (countdownText) {
countdownText.textContent = 'Searching...'
}
window.location.href = url
}
// Reset filters - EVERYTHING BACK TO EMPTY
function resetFilters() {
if (searchTimer) {
clearTimeout(searchTimer)
}
if (countdownInterval) {
clearInterval(countdownInterval)
}
console.debug('Resetting all filters to empty (All options)')
// ALL BACK TO EMPTY = "All" OPTIONS
filterState.branch = ''
filterState.assignment = ''
filterState.status = ''
filterState.review = ''
filterState.draft = ''
filterState.date = ''
// Go to base URL without query params
const baseUrl = `${window.location.origin}${window.location.pathname}`
window.location.href = baseUrl
}
// Initialize
function init() {
console.debug('Initializing PR filters...')
// Parse URL first
parseUrlFilters()
const checkAndInit = () => {
const targetArea = document.querySelector('#repo-content-pjax-container div h1')
if (targetArea && targetArea.parentElement && !document.querySelector('#pr-filters-addon')) {
console.debug('Creating filter interface...')
const filterInterface = createFilterInterface()
filterInterface.id = 'pr-filters-addon'
targetArea.parentElement.insertBefore(filterInterface, targetArea)
setTimeout(() => {
updateQueryPreview()
console.debug('Interface initialized successfully')
}, 100)
return true
}
return false
}
if (!checkAndInit()) {
setTimeout(checkAndInit, 1000)
}
}
// Start
if ('loading' === document.readyState) {
document.addEventListener('DOMContentLoaded', init)
} else {
init()
}
})()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment