Created
September 13, 2024 23:12
-
-
Save esauvisky/2fd4643b8367d7ec2116b56ac5d5d746 to your computer and use it in GitHub Desktop.
fork finder
This file contains 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 GitHub Fork Commit Search | |
// @namespace http://tampermonkey.net/ | |
// @version 0.9 | |
// @description Search commits across selected forks of a GitHub project | |
// @match https://github.com/* | |
// @grant GM_addStyle | |
// @require https://code.jquery.com/jquery-3.6.0.min.js | |
// @require https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.29.1/moment.min.js | |
// ==/UserScript== | |
(function () { | |
'use strict'; | |
// Add custom styles | |
GM_addStyle(` | |
.custom-box { | |
color: var(--fgColor-muted, var(--color-fg-muted, #848d97)); | |
font-size: 12px; | |
border-collapse: separate; | |
border-spacing: 0px; | |
border-width: 1px; | |
border-style: solid; | |
border-image: initial; | |
border-color: var(--borderColor-default, var(--color-border-default, #30363d)); | |
border-radius: 6px; | |
margin-top: var(--base-size-16, 16px) !important; | |
} | |
.fgColor-default { | |
color: var(--fgColor-default, var(--color-fg-default, #c9d1d9)); | |
} | |
.sortable { | |
position: relative; | |
cursor: pointer; | |
} | |
.sortable.active { | |
padding-right: var(--base-size-8) !important; | |
} | |
.sortable.active.lastcolumn { | |
padding-right: var(--base-size-20) !important; | |
} | |
.sortable.active::after { | |
position: absolute; | |
right: 50%; | |
bottom: calc(50% - calc(var(--control-xlarge-size, 48px) / 2 - 2px)); | |
width: 100%; | |
height: 2px; | |
content: ""; | |
background-color: var(--fgColor-default, var(--color-fg-default, #c9d1d9)); | |
border-radius: 0px; | |
transform: translate(50%, -50%); | |
} | |
.sortable.asc::before { | |
content: "▲"; | |
position: absolute; | |
right: 5px; | |
} | |
.sortable.desc::before { | |
content: "▼"; | |
position: absolute; | |
right: 5px; | |
} | |
.loading { | |
font-size: 14px; | |
color: var(--color-fg-muted, #848d97); | |
display: flex; | |
align-items: center; | |
justify-content: center; | |
padding: 10px; | |
} | |
.loading-spinner { | |
border: 4px solid rgba(255, 255, 255, 0.3); | |
border-top: 4px solid var(--color-fg-default, #c9d1d9); | |
border-radius: 50%; | |
width: 20px; | |
height: 20px; | |
animation: spin 1s linear infinite; | |
margin-right: 8px; | |
} | |
@keyframes spin { | |
0% { | |
transform: rotate(0deg); | |
} | |
100% { | |
transform: rotate(360deg); | |
} | |
}`); | |
// Check if we're on a GitHub repository page | |
if (!window.location.pathname.match(/^\/[^/]+\/[^/]+$/)) return; | |
// Extract owner and repo from the URL | |
const [, owner, repo] = window.location.pathname.split('/'); | |
// Create and append the search UI | |
const searchUI = ` | |
<div class="custom-box mb-3"> | |
<div class="Box-header"> | |
<h2 class="Box-title"> | |
Forks | |
</h2> | |
<button id="api-key-button" | |
style="float: right; font-size: 10px; padding: 2px 5px; margin-top: -5px; cursor: pointer;" | |
class="btn-link">API Key</button> | |
</div> | |
<div class="Box-body"> | |
<div id="loading-indicator" class="loading"> | |
<div class="loading-spinner"></div> Loading forks... | |
</div> | |
<form id="fork-search-form" class="d-flex flex-items-center mb-3" style="display: none;"> | |
<input type="text" id="fork-search-input" class="form-control flex-auto mr-2" | |
placeholder="Search commit messages" disabled> | |
<button id="fork-search-button" type="submit" class="btn btn-primary" disabled>Search</button> | |
</form> | |
<div id="fork-search-results"></div> | |
<div class="js-details-container Details"> | |
<div class="js-details-content"> | |
<table id="forkTable" class="width-full custom-box"> | |
<thead> | |
<tr class="react-directory-row"> | |
<th class="react-directory-row-name-cell-large-screen" | |
style="padding-left: var(--base-size-16); text-align: left; padding-top: 3px;"> | |
<input type="checkbox" id="select-all-forks"> | |
</th> | |
<th class="react-directory-row-name-cell-large-screen sortable fgColor-default" | |
data-sort="name" style="text-align: left; padding-left: var(--base-size-16);">Fork</th> | |
<th class="react-directory-row-commit-cell sortable" data-sort="stars" | |
style="text-align: center;">Stars</th> | |
<th class="react-directory-row-commit-cell sortable" data-sort="forks" | |
style="text-align: center;">Forks</th> | |
<th class="react-directory-row-commit-cell sortable" data-sort="issues" | |
style="text-align: center;">Issues</th> | |
<th class="react-directory-row-commit-cell sortable lastcolumn" data-sort="pushed" | |
style="text-align: right; padding-right: var(--base-size-16);">Last Push</th> | |
</tr> | |
</thead> | |
<tbody></tbody> | |
</table> | |
</div> | |
</div> | |
<div id="pagination" class="mt-3 d-flex justify-content-between align-items-center"></div> | |
<div id="commitResults" class="mt-3"></div> | |
</div> | |
</div>`; | |
// Insert the search UI before the repos-overview | |
$('.Layout-main').prepend(searchUI); | |
let currentPage = 1; | |
const perPage = 10; | |
let currentSort = { column: 'stars', direction: 'desc' }; | |
let selectedForks = new Set(); | |
let totalForks = 0; | |
let allForks = []; | |
let fetchingForks = false; // Track the fetching state | |
let githubToken = localStorage.getItem('githubToken'); | |
async function fetchForks(repo, page = 1) { | |
const headers = githubToken ? { 'Authorization': `token ${githubToken}` } : {}; | |
const response = await fetch(`https://api.github.com/repos/${repo}/forks?sort=stargazers&per_page=100&page=${page}`, { headers }); | |
if (!response.ok) { | |
const errorData = await response.json(); | |
throw new Error(JSON.stringify(errorData)); | |
} | |
const data = await response.json(); | |
const linkHeader = response.headers.get('link'); // Get the Link header | |
return { data, linkHeader }; // Return both the data and the link header | |
} | |
async function fetchCommits(repo) { | |
const headers = githubToken ? { 'Authorization': `token ${githubToken}` } : {}; | |
const response = await fetch(`https://api.github.com/repos/${repo}/commits?per_page=100`, { headers }); | |
if (!response.ok) { | |
const errorData = await response.json(); | |
throw new Error(JSON.stringify(errorData)); | |
} | |
return await response.json(); | |
} | |
async function fetchAndDisplayForks(repo) { | |
if (fetchingForks) return; // Prevent concurrent fetches | |
fetchingForks = true; // Set fetching state to true | |
let page = 1; | |
totalForks = 0; | |
allForks = []; // Clear previous forks to prevent duplicates | |
$('#loading-indicator').show(); // Show the loading indicator | |
$('#fork-search-form, #forkTable, #pagination').hide(); // Hide the table and form | |
async function loadNextPage(url) { | |
try { | |
const { data: forks, linkHeader } = await fetchForks(repo, page); | |
if (forks.length > 0) { | |
allForks = allForks.concat(forks); | |
allForks = removeDuplicates(allForks); // Remove duplicates | |
totalForks = allForks.length; // Update total forks after removing duplicates | |
sortForks(currentSort.column, currentSort.direction); | |
updateTable(allForks.slice((currentPage - 1) * perPage, currentPage * perPage)); | |
updatePagination(); | |
if (linkHeader && linkHeader.includes('rel="next"')) { | |
const nextUrl = linkHeader.match(/<([^>]+)>;\s*rel="next"/)[1]; | |
page++; // Increment the page number | |
loadNextPage(nextUrl); // Fetch the next page | |
} else { | |
fetchingForks = false; // No more forks to fetch | |
$('#loading-indicator').hide(); // Hide loading indicator | |
$('#fork-search-form, #forkTable, #pagination').show(); // Show the table and form | |
} | |
} else { | |
fetchingForks = false; // No more forks to fetch | |
$('#loading-indicator').hide(); // Hide loading indicator | |
$('#fork-search-form, #forkTable, #pagination').show(); // Show the table and form | |
} | |
} catch (error) { | |
let errorMessage = 'An error occurred while fetching forks.'; | |
try { | |
const errorData = JSON.parse(error.message); // Parse the JSON error message | |
if (errorData.message) { | |
errorMessage = `${errorData.message} <a href="${errorData.documentation_url}" target="_blank">Learn more</a>`; | |
} | |
} catch (parseError) { | |
console.error('Error parsing error response:', parseError); | |
} | |
$('#fork-search-results').html(`<div class="flash flash-error">${errorMessage}</div>`); | |
fetchingForks = false; // Reset fetching state in case of error | |
$('#loading-indicator').hide(); // Hide loading indicator | |
$('#fork-search-form, #forkTable, #pagination').show(); // Show the table and form | |
} | |
} | |
// Start fetching the first page | |
loadNextPage(`https://api.github.com/repos/${repo}/forks?sort=stargazers&per_page=100&page=1`); | |
} | |
async function searchCommits() { | |
// Disable the search form and button | |
$('#fork-search-input, #fork-search-button').prop('disabled', true); | |
const query = $('#fork-search-input').val().trim().toLowerCase(); | |
let commitResults = ''; | |
for (const fork of selectedForks) { | |
try { | |
const commits = await fetchCommits(fork); | |
const filteredCommits = commits.filter(commit => | |
commit.commit.message.split('\n')[0].toLowerCase().includes(query) | |
); | |
if (filteredCommits.length === 0) continue; | |
commitResults += `<h4>${fork}</h4><ul style="list-style: auto; padding-left: var(--base-size-24);">`; | |
filteredCommits.forEach(commit => { | |
commitResults += ` | |
<li style="font-size: 14px;"> | |
<div class="react-directory-row-name-cell-large-screen"> | |
<a href="${commit.html_url}" class="Link--secondary"> | |
<span>${commit.sha.substring(0, 7)}</span> | |
<span>${commit.commit.message.split('\n')[0]}</span> | |
</a> | |
</div> | |
</li>`; | |
}); | |
commitResults += '</ul>'; | |
} catch (error) { | |
try { | |
const errorData = JSON.parse(error.message); // Parse the JSON error message | |
commitResults += `<p class="color-fg-danger">Error fetching commits for ${fork}: ${errorData.message} <a href="${errorData.documentation_url}" target="_blank">Learn more</a></p>`; | |
} catch (parseError) { | |
commitResults += `<p class="color-fg-danger">Error fetching commits for ${fork}: ${error.message}</p>`; | |
} | |
} | |
} | |
// Display the search results | |
$('#commitResults').html(commitResults); | |
// Re-enable the search form and button | |
$('#fork-search-input, #fork-search-button').prop('disabled', false); | |
} | |
function updateTable(data) { | |
const tableBody = $('#forkTable tbody'); | |
tableBody.empty(); | |
data.forEach((fork) => { | |
const row = ` | |
<tr class="react-directory-row"> | |
<td class="react-directory-row-name-cell-large-screen"> | |
<input type="checkbox" class="fork-checkbox" data-repo="${fork.full_name}" ${selectedForks.has(fork.full_name) ? 'checked' : '' } style="vertical-align: middle;"> | |
</td> | |
<td class="react-directory-row-commit-cell"> | |
<div class="react-directory-commit-message"> | |
<a href="${fork.html_url}" class="Link--primary"> | |
<img src="${fork.owner.avatar_url | |
}" width="20" height="20" class="avatar avatar-user" style="margin-right: 5px;" /> | |
${fork.full_name} | |
</a> | |
</div> | |
</td> | |
<td class="react-directory-row-commit-cell" style="text-align: center; padding-left: 0;">${fork.stargazers_count}</td> | |
<td class="react-directory-row-commit-cell" style="text-align: center; padding-left: 0;">${fork.forks_count}</td> | |
<td class="react-directory-row-commit-cell" style="text-align: center; padding-left: 0;">${fork.open_issues_count}</td> | |
<td> | |
<div class="react-directory-commit-age"> | |
<relative-time datetime="${fork.pushed_at}">${moment( | |
fork.pushed_at | |
).fromNow()}</relative-time> | |
</div> | |
</td> | |
</tr>`; | |
tableBody.append(row); | |
}); | |
} | |
function updatePagination() { | |
const pagination = $('#pagination'); | |
pagination.empty(); | |
const pageControls = $(` | |
<div style="display: flex; align-items: center; width: 100%;"> | |
<div style="flex: 1;"> | |
${currentPage > 1 ? '<button id="prevPage" class="btn btn-sm">Previous</button>' : ''} | |
</div> | |
<div style="flex: 1; text-align: center;"> | |
Page ${currentPage} of ${Math.ceil(totalForks / perPage)} (${totalForks} total forks) | |
</div> | |
<div style="flex: 1; text-align: right;"> | |
${(currentPage * perPage < totalForks) ? '<button id="nextPage" class="btn btn-sm">Next</button>' : '' } </div> | |
</div>`); | |
pagination.append(pageControls); | |
} | |
function sortForks(column, direction) { | |
allForks.sort((a, b) => { | |
let aValue, bValue; | |
switch (column) { | |
case 'name': | |
aValue = a.full_name.toLowerCase(); | |
bValue = b.full_name.toLowerCase(); | |
break; | |
case 'stars': | |
aValue = a.stargazers_count; | |
bValue = b.stargazers_count; | |
break; | |
case 'forks': | |
aValue = a.forks_count; | |
bValue = b.forks_count; | |
break; | |
case 'issues': | |
aValue = a.open_issues_count; | |
bValue = b.open_issues_count; | |
break; | |
case 'pushed': | |
aValue = new Date(a.pushed_at); | |
bValue = new Date(b.pushed_at); | |
break; | |
default: | |
return 0; | |
} | |
if (aValue < bValue) return direction === 'asc' ? -1 : 1; | |
if (aValue > bValue) return direction === 'asc' ? 1 : -1; | |
return 0; | |
}); | |
} | |
function sortTable(column) { | |
if (fetchingForks) return; // Prevent sorting while fetching | |
currentSort.column = column; | |
currentSort.direction = | |
currentSort.direction === 'asc' ? 'desc' : 'asc'; | |
$('.sortable').removeClass('active asc desc'); | |
$(`th[data-sort="${column}"]`).addClass(`active ${currentSort.direction}`); | |
sortForks(column, currentSort.direction); | |
currentPage = 1; | |
updateTable(allForks.slice((currentPage - 1) * perPage, currentPage * perPage)); // Re-render the table after sorting | |
updatePagination(); // Update pagination after sorting | |
} | |
// Function to handle token input | |
function promptForToken() { | |
const token = prompt('Please enter your GitHub personal access token:'); | |
if (token) { | |
localStorage.setItem('githubToken', token); | |
githubToken = token; | |
alert('Token saved successfully!'); | |
} else { | |
alert('No token entered. You can still proceed with limited API access.'); | |
} | |
} | |
function removeDuplicates(forks) { | |
const uniqueForks = []; | |
const forkNames = new Set(); | |
for (const fork of forks) { | |
if (!forkNames.has(fork.full_name)) { | |
uniqueForks.push(fork); | |
forkNames.add(fork.full_name); | |
} | |
} | |
return uniqueForks; | |
} | |
// Event listeners | |
$('#api-key-button').on('click', function () { | |
promptForToken(); | |
}); | |
$('#fork-search-form').on('submit', function (e) { | |
e.preventDefault(); | |
searchCommits(); | |
}); | |
$(document).on('change', '.fork-checkbox', function () { | |
const repo = $(this).data('repo'); | |
if (this.checked) { | |
selectedForks.add(repo); | |
} else { | |
selectedForks.delete(repo); | |
} | |
const anyChecked = selectedForks.size > 0; | |
$('#fork-search-input, #fork-search-button').prop('disabled', !anyChecked); | |
}); | |
$('#pagination').on('click', '#prevPage', function () { | |
if (currentPage > 1) { | |
currentPage--; | |
updateTable(allForks.slice((currentPage - 1) * perPage, currentPage * perPage)); | |
updatePagination(); | |
} | |
}); | |
$('#pagination').on('click', '#nextPage', function () { | |
currentPage++; | |
updateTable(allForks.slice((currentPage - 1) * perPage, currentPage * perPage)); | |
updatePagination(); | |
}); | |
$('#select-all-forks').on('change', function () { | |
const isChecked = $(this).is(':checked'); | |
$('.fork-checkbox').prop('checked', isChecked); | |
if (isChecked) { | |
allForks.forEach((fork) => selectedForks.add(fork.full_name)); | |
} else { | |
selectedForks.clear(); | |
} | |
$('#fork-search-input, #fork-search-button').prop('disabled', !isChecked); | |
}); | |
$('#forkTable thead').on('click', '.sortable', function () { | |
const column = $(this).data('sort'); | |
sortTable(column); | |
}); | |
// Auto-fill and search if on a repository page | |
const repoMatch = window.location.pathname.match(/^\/([^/]+\/[^/]+)/); | |
if (repoMatch) { | |
const repo = repoMatch[1]; | |
fetchAndDisplayForks(repo); | |
// Initial sort will be applied in fetchAndDisplayForks | |
} | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment