From b5263216429268bd2116187fbf21dcb191317fd1 Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 26 Apr 2026 03:11:18 -0700 Subject: [PATCH] feat(extension): add Chrome MV3 tombstone badge for GitHub repo pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #14 Manifest V3 content script that injects a ðŸŠĶ "Declared Dead — View Certificate" badge near the repo title on any github.com// page whose API entry has deathIndex >= 6. Click takes you to the certificate at commitmentissues.dev/?repo=/. Files: - extension/manifest.json (MV3, host permission for commitmentissues.dev only) - extension/content.js (parses repo from path, fetches /api/repo with AbortSignal.timeout(4000), inserts the badge once per repo, cleans up on SPA navigation via popstate + turbo:load + turbo:render + a href-watching MutationObserver) - extension/icon.png (copy of src/app/icon.png as specified in #14) Acceptance criteria: - Badge appears only on repo pages with deathIndex >= 6 - Reserved top-level paths (orgs, settings, explore, marketplace, etc.) and per-user subpaths (followers, following, projects, tabs) are excluded from the repo-detection - No duplicate badge — lastInjectedFor short-circuits per fullName, plus a re-fetch is skipped when the location changes mid-await - API unavailable / non-200 / timeout / fetch error -> silent skip - README has load-unpacked instructions Tested manually by loading the unpacked extension and visiting a few known-dead and known-live repos to confirm the inject-once-and-link behavior. --- README.md | 14 ++++ extension/content.js | 162 ++++++++++++++++++++++++++++++++++++++++ extension/icon.png | Bin 0 -> 1244 bytes extension/manifest.json | 21 ++++++ 4 files changed, 197 insertions(+) create mode 100644 extension/content.js create mode 100644 extension/icon.png create mode 100644 extension/manifest.json diff --git a/README.md b/README.md index 824f14d..36a3fde 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,20 @@ src/ └── types.ts ← shared TypeScript types ``` +## Browser extension + +A small Chrome extension lives in [`extension/`](extension/) that injects a ðŸŠĶ "Declared Dead — View Certificate" badge next to the title on any GitHub repo page where the API reports `deathIndex >= 6`. + +To load it unpacked: + +1. Open `chrome://extensions/`. +2. Toggle **Developer mode** (top right). +3. Click **Load unpacked**. +4. Select the [`extension/`](extension/) directory. +5. Visit any dead repo, e.g. `https://github.com/atom/atom` — the badge appears next to the repo name and links to its certificate at `commitmentissues.dev/?repo=atom/atom`. + +Manifest V3, content-script-only. Cleans up on GitHub's SPA navigation, fails silently when the API is unavailable, and never blocks page render (4-second `AbortSignal.timeout` on the fetch). + ## Testing ```bash diff --git a/extension/content.js b/extension/content.js new file mode 100644 index 0000000..6f6f8af --- /dev/null +++ b/extension/content.js @@ -0,0 +1,162 @@ +// Commitment Issues — tombstone badge for GitHub repo pages. +// +// Injects a small "Declared Dead" badge near the repo title when the +// commitmentissues.dev API reports deathIndex >= 6. Skips silently on any +// error so it never blocks the page. + +(() => { + 'use strict'; + + const API_BASE = 'https://commitmentissues.dev'; + const SITE_BASE = 'https://commitmentissues.dev'; + const DEATH_THRESHOLD = 6; + const FETCH_TIMEOUT_MS = 4000; + const BADGE_ID = 'commitmentissues-tombstone-badge'; + + /** + * Parse `owner/repo` from `/owner/repo` or `/owner/repo/`. Returns + * null for non-repo paths like `/`, `/owner` (user/org page), + * `/orgs/...`, `/settings`, `/explore`, etc. + */ + function parseRepoFromPath(pathname) { + const parts = pathname.split('/').filter(Boolean); + if (parts.length < 2) return null; + // GitHub reserves a small set of top-level paths that are NOT user/org + // namespaces. Anything matching one is not a repo URL. + const reservedTopLevel = new Set([ + 'orgs', 'settings', 'explore', 'topics', 'trending', 'collections', + 'events', 'sponsors', 'marketplace', 'pricing', 'enterprise', 'features', + 'security', 'contact', 'about', 'login', 'logout', 'join', 'signup', + 'new', 'notifications', 'pulls', 'issues', 'stars', 'codespaces', + 'gist', 'apps', 'github-copilot', 'copilot', 'organizations', + ]); + if (reservedTopLevel.has(parts[0].toLowerCase())) return null; + // The repo segment can't be a known per-user reserved subpath either. + const reservedSecond = new Set(['followers', 'following', 'tabs', 'projects']); + if (reservedSecond.has(parts[1].toLowerCase())) return null; + return { owner: parts[0], name: parts[1] }; + } + + function findRepoTitleAnchor() { + // GitHub's repo header anchors the repo name with this strong+a structure. + // Try a few known-stable selectors before giving up. + const candidates = [ + 'strong[itemprop="name"] a', + 'h1 strong[itemprop="name"] a', + 'h1 a[data-pjax="#repo-content-pjax-container"]', + ]; + for (const sel of candidates) { + const el = document.querySelector(sel); + if (el) return el; + } + return null; + } + + function makeBadge(owner, name) { + const fullName = `${owner}/${name}`; + const a = document.createElement('a'); + a.id = BADGE_ID; + a.href = `${SITE_BASE}/?repo=${encodeURIComponent(fullName)}`; + a.target = '_blank'; + a.rel = 'noopener noreferrer'; + a.title = 'Declared dead by commitmentissues.dev — view certificate'; + a.textContent = 'ðŸŠĶ Declared Dead — View Certificate →'; + Object.assign(a.style, { + display: 'inline-flex', + alignItems: 'center', + gap: '4px', + marginLeft: '8px', + padding: '2px 8px', + borderRadius: '12px', + background: '#1e1e1e', + color: '#e6e6e6', + fontSize: '11px', + fontWeight: '500', + lineHeight: '18px', + textDecoration: 'none', + verticalAlign: 'middle', + whiteSpace: 'nowrap', + }); + return a; + } + + async function fetchDeathIndex(owner, name) { + const repoUrl = `https://github.com/${owner}/${name}`; + const apiUrl = `${API_BASE}/api/repo?url=${encodeURIComponent(repoUrl)}`; + let signal; + try { + signal = AbortSignal.timeout(FETCH_TIMEOUT_MS); + } catch { + const ctrl = new AbortController(); + setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS); + signal = ctrl.signal; + } + const res = await fetch(apiUrl, { signal }); + if (!res.ok) return null; + const body = await res.json(); + const idx = typeof body?.deathIndex === 'number' ? body.deathIndex : null; + return idx; + } + + let lastInjectedFor = null; + + async function tryInject() { + const existing = document.getElementById(BADGE_ID); + if (existing) existing.remove(); + + const repo = parseRepoFromPath(location.pathname); + if (!repo) { + lastInjectedFor = null; + return; + } + + const fullName = `${repo.owner}/${repo.name}`; + if (lastInjectedFor === fullName) return; + + const anchor = findRepoTitleAnchor(); + if (!anchor) return; + + let deathIndex = null; + try { + deathIndex = await fetchDeathIndex(repo.owner, repo.name); + } catch { + return; + } + if (deathIndex === null || deathIndex < DEATH_THRESHOLD) { + lastInjectedFor = fullName; + return; + } + + const stillSamePage = parseRepoFromPath(location.pathname); + if (!stillSamePage || `${stillSamePage.owner}/${stillSamePage.name}` !== fullName) { + return; + } + + const titleStrong = anchor.parentElement; + const insertAfter = titleStrong?.parentElement === document.querySelector('h1') + ? titleStrong + : anchor; + insertAfter.parentNode.insertBefore(makeBadge(repo.owner, repo.name), insertAfter.nextSibling); + lastInjectedFor = fullName; + } + + let lastUrl = location.href; + function observeNavigation() { + const fire = () => { + if (location.href === lastUrl) return; + lastUrl = location.href; + tryInject(); + }; + window.addEventListener('popstate', fire); + document.addEventListener('pjax:end', fire); + document.addEventListener('turbo:load', fire); + document.addEventListener('turbo:render', fire); + const mo = new MutationObserver(() => { + if (location.href !== lastUrl) fire(); + }); + mo.observe(document.body, { childList: true, subtree: true }); + } + + tryInject(); + observeNavigation(); +})(); diff --git a/extension/icon.png b/extension/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..bd91211ff4224d2eee66b79f1f2dec800bafbe8b GIT binary patch literal 1244 zcmV<21S9*2P);4o zfG6l9G(17uHwbS~-XJf)l)|L6aM3WGp6g)H zCGYMzd%m-uHVP@D-ENmG%ld}bSA48SQRFD4O7a`q9ky$1ZUjMapVwNsT<)ZqSET@U zGMUT@n>P*tQ^wulaCnDAYIqj{vf^AWcZ;iA!5M<%R||#0cX%5Dvg&L$dyh>A=My!m zaebN^dJ1^;oWUi`f?U_Fp8P?b0Cz3`{Aj$PggOFVBKYGp;G@w9t-U5%t)_V{moDM* z!pE>}8!19aEFJEW$%xo+PU7h8O+0+Kj{SWXzCVPW$%xZXEPRNo*OswZxso)6?5bP5 z8)F&;V(GV&M*j8m2_CGkV-!U2hMwS}K;ZiGIxbxwi%KXCE%&nHRKK`;edOL;|#*xLNt z07%mOq0jb!g!*VUo9K}kCo)8OJ&y|QrkT=|c3W$`oTj($kD!%8dAPExPE)=RWHFY)3(f0ywb?5 zT$;939P>y#Ynj-vDc4Wjk25Qw)PMuGYh3Sn{sc@y-ibO&E{u`G$Tz-83X;FCO3sGETx5Q{#nPTH$&~pbN}KL5ITzze9SGSLlXh4)>hgkKvEg zoQlp8@()ZtpI_qL_-=A4mVEH#_4q%W3j>caCZ<1jEGM%Xz3}ZtkdQfLZoxSAep=^# zzwa;=_s%OFhbun`E}^Flkd8dpEs0rh#*jHA=hMIqxugk14Hm20Y0iyD!qgVo-lqYl zCU6*>u*B1ooFA8YRY|CV*JlX