Skip to content

Commit b526321

Browse files
committed
feat(extension): add Chrome MV3 tombstone badge for GitHub repo pages
Closes #14 Manifest V3 content script that injects a 🪦 "Declared Dead — View Certificate" badge near the repo title on any github.com/<owner>/<repo> page whose API entry has deathIndex >= 6. Click takes you to the certificate at commitmentissues.dev/?repo=<owner>/<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.
1 parent ad83046 commit b526321

4 files changed

Lines changed: 197 additions & 0 deletions

File tree

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,20 @@ src/
174174
└── types.ts ← shared TypeScript types
175175
```
176176

177+
## Browser extension
178+
179+
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`.
180+
181+
To load it unpacked:
182+
183+
1. Open `chrome://extensions/`.
184+
2. Toggle **Developer mode** (top right).
185+
3. Click **Load unpacked**.
186+
4. Select the [`extension/`](extension/) directory.
187+
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`.
188+
189+
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).
190+
177191
## Testing
178192

179193
```bash

extension/content.js

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// Commitment Issues — tombstone badge for GitHub repo pages.
2+
//
3+
// Injects a small "Declared Dead" badge near the repo title when the
4+
// commitmentissues.dev API reports deathIndex >= 6. Skips silently on any
5+
// error so it never blocks the page.
6+
7+
(() => {
8+
'use strict';
9+
10+
const API_BASE = 'https://commitmentissues.dev';
11+
const SITE_BASE = 'https://commitmentissues.dev';
12+
const DEATH_THRESHOLD = 6;
13+
const FETCH_TIMEOUT_MS = 4000;
14+
const BADGE_ID = 'commitmentissues-tombstone-badge';
15+
16+
/**
17+
* Parse `owner/repo` from `/owner/repo` or `/owner/repo/<rest>`. Returns
18+
* null for non-repo paths like `/`, `/owner` (user/org page),
19+
* `/orgs/...`, `/settings`, `/explore`, etc.
20+
*/
21+
function parseRepoFromPath(pathname) {
22+
const parts = pathname.split('/').filter(Boolean);
23+
if (parts.length < 2) return null;
24+
// GitHub reserves a small set of top-level paths that are NOT user/org
25+
// namespaces. Anything matching one is not a repo URL.
26+
const reservedTopLevel = new Set([
27+
'orgs', 'settings', 'explore', 'topics', 'trending', 'collections',
28+
'events', 'sponsors', 'marketplace', 'pricing', 'enterprise', 'features',
29+
'security', 'contact', 'about', 'login', 'logout', 'join', 'signup',
30+
'new', 'notifications', 'pulls', 'issues', 'stars', 'codespaces',
31+
'gist', 'apps', 'github-copilot', 'copilot', 'organizations',
32+
]);
33+
if (reservedTopLevel.has(parts[0].toLowerCase())) return null;
34+
// The repo segment can't be a known per-user reserved subpath either.
35+
const reservedSecond = new Set(['followers', 'following', 'tabs', 'projects']);
36+
if (reservedSecond.has(parts[1].toLowerCase())) return null;
37+
return { owner: parts[0], name: parts[1] };
38+
}
39+
40+
function findRepoTitleAnchor() {
41+
// GitHub's repo header anchors the repo name with this strong+a structure.
42+
// Try a few known-stable selectors before giving up.
43+
const candidates = [
44+
'strong[itemprop="name"] a',
45+
'h1 strong[itemprop="name"] a',
46+
'h1 a[data-pjax="#repo-content-pjax-container"]',
47+
];
48+
for (const sel of candidates) {
49+
const el = document.querySelector(sel);
50+
if (el) return el;
51+
}
52+
return null;
53+
}
54+
55+
function makeBadge(owner, name) {
56+
const fullName = `${owner}/${name}`;
57+
const a = document.createElement('a');
58+
a.id = BADGE_ID;
59+
a.href = `${SITE_BASE}/?repo=${encodeURIComponent(fullName)}`;
60+
a.target = '_blank';
61+
a.rel = 'noopener noreferrer';
62+
a.title = 'Declared dead by commitmentissues.dev — view certificate';
63+
a.textContent = '🪦 Declared Dead — View Certificate →';
64+
Object.assign(a.style, {
65+
display: 'inline-flex',
66+
alignItems: 'center',
67+
gap: '4px',
68+
marginLeft: '8px',
69+
padding: '2px 8px',
70+
borderRadius: '12px',
71+
background: '#1e1e1e',
72+
color: '#e6e6e6',
73+
fontSize: '11px',
74+
fontWeight: '500',
75+
lineHeight: '18px',
76+
textDecoration: 'none',
77+
verticalAlign: 'middle',
78+
whiteSpace: 'nowrap',
79+
});
80+
return a;
81+
}
82+
83+
async function fetchDeathIndex(owner, name) {
84+
const repoUrl = `https://github.com/${owner}/${name}`;
85+
const apiUrl = `${API_BASE}/api/repo?url=${encodeURIComponent(repoUrl)}`;
86+
let signal;
87+
try {
88+
signal = AbortSignal.timeout(FETCH_TIMEOUT_MS);
89+
} catch {
90+
const ctrl = new AbortController();
91+
setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
92+
signal = ctrl.signal;
93+
}
94+
const res = await fetch(apiUrl, { signal });
95+
if (!res.ok) return null;
96+
const body = await res.json();
97+
const idx = typeof body?.deathIndex === 'number' ? body.deathIndex : null;
98+
return idx;
99+
}
100+
101+
let lastInjectedFor = null;
102+
103+
async function tryInject() {
104+
const existing = document.getElementById(BADGE_ID);
105+
if (existing) existing.remove();
106+
107+
const repo = parseRepoFromPath(location.pathname);
108+
if (!repo) {
109+
lastInjectedFor = null;
110+
return;
111+
}
112+
113+
const fullName = `${repo.owner}/${repo.name}`;
114+
if (lastInjectedFor === fullName) return;
115+
116+
const anchor = findRepoTitleAnchor();
117+
if (!anchor) return;
118+
119+
let deathIndex = null;
120+
try {
121+
deathIndex = await fetchDeathIndex(repo.owner, repo.name);
122+
} catch {
123+
return;
124+
}
125+
if (deathIndex === null || deathIndex < DEATH_THRESHOLD) {
126+
lastInjectedFor = fullName;
127+
return;
128+
}
129+
130+
const stillSamePage = parseRepoFromPath(location.pathname);
131+
if (!stillSamePage || `${stillSamePage.owner}/${stillSamePage.name}` !== fullName) {
132+
return;
133+
}
134+
135+
const titleStrong = anchor.parentElement;
136+
const insertAfter = titleStrong?.parentElement === document.querySelector('h1')
137+
? titleStrong
138+
: anchor;
139+
insertAfter.parentNode.insertBefore(makeBadge(repo.owner, repo.name), insertAfter.nextSibling);
140+
lastInjectedFor = fullName;
141+
}
142+
143+
let lastUrl = location.href;
144+
function observeNavigation() {
145+
const fire = () => {
146+
if (location.href === lastUrl) return;
147+
lastUrl = location.href;
148+
tryInject();
149+
};
150+
window.addEventListener('popstate', fire);
151+
document.addEventListener('pjax:end', fire);
152+
document.addEventListener('turbo:load', fire);
153+
document.addEventListener('turbo:render', fire);
154+
const mo = new MutationObserver(() => {
155+
if (location.href !== lastUrl) fire();
156+
});
157+
mo.observe(document.body, { childList: true, subtree: true });
158+
}
159+
160+
tryInject();
161+
observeNavigation();
162+
})();

extension/icon.png

1.21 KB
Loading

extension/manifest.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Commitment Issues Tombstone Badge",
4+
"version": "0.1.0",
5+
"description": "Shows a tombstone badge on GitHub repo pages when commitmentissues.dev has marked them dead.",
6+
"icons": {
7+
"32": "icon.png",
8+
"128": "icon.png"
9+
},
10+
"permissions": [],
11+
"host_permissions": [
12+
"https://commitmentissues.dev/*"
13+
],
14+
"content_scripts": [
15+
{
16+
"matches": ["https://github.com/*"],
17+
"js": ["content.js"],
18+
"run_at": "document_idle"
19+
}
20+
]
21+
}

0 commit comments

Comments
 (0)