Adds a floating widget to Nixpkgs PR pages to track which branches the PR has been merged into.
// ==UserScript==
// @name Nixpkgs PR Branch Tracker
// @namespace http://tampermonkey.net/
// @version 1.1
// @description Adds a floating widget to Nixpkgs PR pages to track which branches the PR has been merged into.
// @license WTFPL
// @author shouya
// @match https://github.com/NixOS/nixpkgs/pull/*
// @grant GM_setValue
// @grant GM_getValue
// @connect api.github.com
// @run-at document-end
// ==/UserScript==
(function() {
'use strict';
const REPO = 'NixOS/nixpkgs';
const BRANCHES_TO_CHECK = [
"master",
"nixpkgs-unstable",
"nixos-unstable",
];
// --- UTILITY FUNCTIONS ---
async function saveToken(token) {
await GM_setValue('github_token', token);
}
async function getToken() {
return await GM_getValue('github_token', '');
}
async function getAuthHeaders() {
const token = await getToken();
if (token) {
return {
'Authorization': `token ${token}`,
'Accept': 'application/vnd.github.v3+json',
};
}
return { 'Accept': 'application/vnd.github.v3+json' };
}
async function fetchPRData(prId) {
const url = `https://api.github.com/repos/${REPO}/pulls/${prId}`;
const headers = await getAuthHeaders();
const response = await fetch(url, { headers });
if (response.status === 404) {
return { error: 'PR not found.' };
}
if (response.status === 403) {
return { error: 'API rate limit exceeded. Please set a token.' };
}
if (response.status === 401) {
return { error: 'Invalid token. Please correct it.' };
}
const data = await response.json();
if (!data.merged) {
return { error: 'PR is not merged.' };
}
return {
mergeCommitSha: data.merge_commit_sha,
baseBranch: data.base?.ref,
};
}
async function isCommitInBranch(branch, commitSha) {
const url = `https://api.github.com/repos/${REPO}/compare/${branch}...${commitSha}`;
const headers = await getAuthHeaders();
const response = await fetch(url, { headers });
if (response.status === 404) {
// This happens if the commit is too old or the branch is brand new
return false;
}
const data = await response.json();
// "behind" means the branch is ahead of the commit, i.e., the commit is an ancestor.
// "identical" means the branch HEAD is exactly at this commit.
return data.status === 'identical' || data.status === 'behind';
}
// --- UI CREATION ---
function createWidget() {
const widget = document.createElement('div');
widget.id = 'nixpkgs-tracker-widget';
widget.innerHTML = `
<div class="header">
<strong>Nixpkgs Branch Status</strong>
</div>
<div id="tracker-status-list"></div>
<details>
<summary>Configure Token</summary>
<div class="token-area">
<input type="password" id="tracker-token-input" placeholder="Set GitHub PAT"/>
<button id="tracker-token-save">Save</button>
</div>
</details>
<div id="tracker-main-status" class="status-message">Loading PR data...</div>
`;
document.body.appendChild(widget);
// Add styles
const style = document.createElement('style');
style.innerHTML = `
#nixpkgs-tracker-widget {
position: fixed;
bottom: 20px;
right: 20px;
width: 250px;
background-color: #f6f8fa;
border: 1px solid #d1d5da;
border-radius: 6px;
padding: 12px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 13px;
z-index: 9999;
box-shadow: 0 1px 15px rgba(27,31,35,.15);
color: #24292e;
}
#nixpkgs-tracker-widget .header {
font-size: 14px;
border-bottom: 1px solid #e1e4e8;
padding-bottom: 8px;
margin-bottom: 8px;
}
#nixpkgs-tracker-widget .branch-status {
display: flex;
justify-content: space-between;
padding: 4px 0;
}
#nixpkgs-tracker-widget .status-message {
margin-top: 8px;
font-style: italic;
color: #586069;
}
#nixpkgs-tracker-widget .token-area {
margin-top: 10px;
display: flex;
}
#nixpkgs-tracker-widget #tracker-token-input {
flex-grow: 1;
min-width: 0;
padding: 3px 6px;
border: 1px solid #d1d5da;
border-radius: 4px 0 0 4px;
}
#nixpkgs-tracker-widget #tracker-token-save {
padding: 3px 8px;
border: 1px solid #1b1f2326;
border-left: 0;
background-color: #fafbfc;
cursor: pointer;
border-radius: 0 4px 4px 0;
}
#nixpkgs-tracker-widget #tracker-token-save:hover {
background-color: #f3f4f6;
}
`;
document.head.appendChild(style);
// Add event listener for the save button
document.getElementById('tracker-token-save').addEventListener('click', async () => {
const tokenInput = document.getElementById('tracker-token-input');
const token = tokenInput.value;
await saveToken(token);
tokenInput.value = '';
tokenInput.placeholder = 'Token saved!';
setTimeout(() => {
tokenInput.placeholder = 'Set GitHub PAT';
// Reload checks with the new token
runChecks();
}, 2000);
});
}
function updateStatus(branchName, status, message = '') {
const element = document.getElementById(`status-${branchName}`);
if (element) {
let symbol = '⏳'; // Loading
if (status === 'merged') symbol = '✅';
if (status === 'unmerged') symbol = '❌';
element.innerHTML = `<span>${branchName}</span><span>${symbol} ${message}</span>`;
}
}
function setMainStatus(message) {
document.getElementById('tracker-main-status').textContent = message;
}
// --- MAIN LOGIC ---
async function runChecks() {
const pathParts = window.location.pathname.split('/');
const prId = pathParts[4];
if (!prId || !/^\d+$/.test(prId)) {
setMainStatus('Not a valid PR page.');
return;
}
// Initialize UI for branches
const statusList = document.getElementById('tracker-status-list');
statusList.innerHTML = ''; // Clear previous results on re-run
setMainStatus('Loading PR data...');
BRANCHES_TO_CHECK.forEach(branch => {
const el = document.createElement('div');
el.className = 'branch-status';
el.id = `status-${branch}`;
el.innerHTML = `<span>${branch}</span><span>⏳ Loading...</span>`;
statusList.appendChild(el);
});
const prData = await fetchPRData(prId);
if (prData.error) {
setMainStatus(prData.error);
// Clear the loading state for branches
BRANCHES_TO_CHECK.forEach(branch => {
document.getElementById(`status-${branch}`).innerHTML = `<span>${branch}</span><span>-</span>`;
});
return;
}
setMainStatus(`Checking commit ${prData.mergeCommitSha.slice(0, 7)}...`);
// Check all branches concurrently
const checks = BRANCHES_TO_CHECK.map(async (branch) => {
try {
const isMerged = await isCommitInBranch(branch, prData.mergeCommitSha);
updateStatus(branch, isMerged ? 'merged' : 'unmerged', isMerged ? 'Merged' : 'Not Merged');
} catch (e) {
updateStatus(branch, 'error', 'Error');
console.error(`Error checking branch ${branch}:`, e);
}
});
await Promise.all(checks);
setMainStatus('Checks complete.');
}
// --- SCRIPT EXECUTION ---
createWidget();
runChecks();
})();