I keep 5–10 GitHub PR tabs open at any given time. The problem: I can't tell which ones need my attention without clicking into each one.
So I built a small Chrome extension that solves exactly this — it prefixes the tab title with the current PR status, updated every 120 seconds.
What it looks like
| Symbol | Meaning |
|---|---|
| 🟢 | Approved — at least one approval, no pending changes |
| 🔴 | Changes requested |
| 🟡 | Awaiting review |
| 🟣 | Merged |
| ⚫ | Closed |
| 💬N | N total comments |
Example tab title: 🟢 💬3 | Fix login bug · Pull Request #42 · acme/app
How it works
The extension has two main parts: a content script and a service worker.
The content script runs on every github.com/*/*/pull/* page. It reads the PR number from the URL, sends a message to the service worker, and applies the status prefix to document.title.
function parseURL() {
const m = location.pathname.match(/^\/([^/]+)\/([^/]+)\/pull\/(\d+)/);
if (!m) return null;
return { owner: m[1], repo: m[2], number: parseInt(m[3], 10) };
}
The service worker handles the GitHub API call and caches results for 60 seconds to avoid hammering the API:
async function fetchPR(owner, repo, number, token) {
const base = `https://api.github.com/repos/${owner}/${repo}/pulls/${number}`;
const [pull, reviews] = await Promise.all([
ghFetch(base, token),
ghFetch(`${base}/reviews?per_page=100`, token)
]);
const state = computeState(pull, reviews);
const comments = (pull.comments || 0) + (pull.review_comments || 0);
return { state, comments };
}
Handling GitHub's SPA navigation
GitHub is a single-page app — navigating between PRs doesn't reload the page. I handle this with three event listeners plus a 1-second interval fallback:
document.addEventListener('turbo:load', update);
document.addEventListener('pjax:end', update);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') update();
});
// Fallback for SPA URL changes that don't fire events
let lastHref = location.href;
setInterval(() => {
if (location.href !== lastHref) {
lastHref = location.href;
update();
}
}, 1000);
Review state logic
The trickiest part: computing the "real" review state from GitHub's API. The API returns all reviews including dismissed ones and multiple reviews from the same person. I keep only the latest verdict per reviewer:
function computeState(pull, reviews) {
if (pull.merged_at) return 'merged';
if (pull.state === 'closed') return 'closed';
const sorted = [...reviews].sort((a, b) =>
Date.parse(a.submitted_at) - Date.parse(b.submitted_at)
);
const byUser = {};
for (const r of sorted) {
if (r.state === 'DISMISSED') continue;
if (r.state === 'APPROVED' || r.state === 'CHANGES_REQUESTED') {
byUser[r.user.login] = r.state;
}
}
const verdicts = Object.values(byUser);
if (verdicts.includes('CHANGES_REQUESTED')) return 'changes_requested';
if (verdicts.includes('APPROVED')) return 'approved';
return 'open';
}
Security notes
- Token stored in
chrome.storage.local(not synced to Google account) -
host_permissionsrestricted togithub.com/*/*/pull/*only - Input validation on all message parameters before URL interpolation
- No backend, no telemetry, no external servers
Stack
- Manifest V3
- Content script + service worker
- GitHub REST API (fine-grained PAT,
Pull requests: Read-only) -
chrome.storage.localfor token persistence - ~300 lines total
Get the source
The full source code is available for €5 on Gumroad:
👉 https://wultimaprojects.gumroad.com/l/xghli
Includes README with setup instructions and PAT generation guide.
Happy to answer questions or take feedback in the comments.
Top comments (0)