Skip to content

Commit bc744a9

Browse files
committed
Harden repo-intel against edge cases in URLs, refs, and avatars
- Replace inline `onerror=` JS on avatar imgs with a delegated error handler that reads data-attributes. Inline handlers go through HTML entity decoding before JS parsing, so a contributor whose name starts with a single quote could close the string literal and break (or inject into) the fallback construction. - Guard the GraphQL `defaultBranchRef` for empty repos and exit with a clean message instead of TypeError on the chained access. - Sort commits in `apply_filters` and the timeline by parsed instant rather than lexicographically; ISO strings with mixed offsets sort inconsistently with UTC order. - URL-encode branch path segments in heatmap and author links so branches like `feature/foo` keep their slashes while `&`, `?`, etc. get escaped. - Fix the `-o, --output` help text to match the actual default (`/tmp/<owner>--<repo>.html`).
1 parent f5903d2 commit bc744a9

3 files changed

Lines changed: 62 additions & 18 deletions

File tree

src/repo-intel/repo-intel.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
When omitted, the current working directory is used as a local git repo.
1818
1919
Options:
20-
-o, --output PATH Write the dashboard to PATH instead of /tmp/<repo-name>.html.
20+
-o, --output PATH Write the dashboard to PATH instead of /tmp/<owner>--<repo>.html.
2121
--no-open Don't open the result in a browser.
2222
--no-cache Ignore the local cache and re-fetch all commits.
2323
--commits SPEC Filter commits by position. SPEC is either N (last N
@@ -64,7 +64,7 @@
6464
import urllib.request
6565
import webbrowser
6666
from collections import defaultdict
67-
from datetime import datetime
67+
from datetime import datetime, timezone
6868
from pathlib import Path
6969

7070
TEMPLATE = "__TEMPLATE_PLACEHOLDER__"
@@ -442,8 +442,11 @@ def collect_remote(slug, no_cache=False, commits_filter=None, since=None, until=
442442
sys.exit(f"Repository not found or inaccessible: {slug}")
443443
repo_name = repo_node["name"]
444444
repo_url = repo_node["url"]
445-
default_branch = repo_node["defaultBranchRef"].get("name") or default_branch
446-
history = repo_node["defaultBranchRef"]["target"]["history"]
445+
branch_ref = repo_node.get("defaultBranchRef")
446+
if not branch_ref or not branch_ref.get("target"):
447+
sys.exit(f"error: {slug} has no commits on its default branch")
448+
default_branch = branch_ref.get("name") or default_branch
449+
history = branch_ref["target"]["history"]
447450
for n in history["nodes"]:
448451
if n["oid"] in cached_oids:
449452
hit_cache = True
@@ -497,7 +500,16 @@ def in_range(m):
497500
return bool(d) and (not since or d >= since) and (not until or d <= until)
498501
commits_meta = {h: m for h, m in commits_meta.items() if in_range(m)}
499502
if commits_filter:
500-
ordered = sorted(commits_meta, key=lambda h: commits_meta[h].get("iso") or "")
503+
epoch = datetime(1970, 1, 1, tzinfo=timezone.utc)
504+
def _ts(h):
505+
iso = commits_meta[h].get("iso") or ""
506+
if not iso:
507+
return epoch
508+
try:
509+
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
510+
except ValueError:
511+
return epoch
512+
ordered = sorted(commits_meta, key=_ts)
501513
if commits_filter[0] == "last":
502514
keep = set(ordered[-commits_filter[1]:])
503515
else:

src/repo-intel/template.html

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -304,9 +304,9 @@ <h1 id="title"></h1>
304304
let monthHtml = '<div class="heatmap-months">'; monthSpans.forEach(ms => { monthHtml += `<span style="flex:${ms.count} ${ms.count} 0;text-align:left;">${ms.label}</span>`; }); monthHtml += '</div>';
305305
let dayLabelsHtml = '<div class="heatmap-day-labels">'; ['Mon','','Wed','','Fri','',''].forEach(l => { dayLabelsHtml += `<span>${l}</span>`; }); dayLabelsHtml += '</div>';
306306
const baseUrl = D.githubBaseUrl;
307-
const branch = D.defaultBranch || 'main';
307+
const branchPath = (D.defaultBranch || 'main').split('/').map(encodeURIComponent).join('/');
308308
let gridHtml = '<div class="heatmap">';
309-
weeksArr.forEach((week, wi) => { gridHtml += '<div class="heatmap-week">'; if (wi === 0 && week[0].dow > 0) for (let i = 0; i < week[0].dow; i++) gridHtml += '<div class="heatmap-cell" style="visibility:hidden;"></div>'; week.forEach(day => { const bg = cellColor(day.count); const href = baseUrl ? `${baseUrl}/commits/${branch}?after=&since=${day.key}&until=${day.key}` : '#'; gridHtml += `<a href="${href}" target="_blank" class="heatmap-cell" style="background:${bg};" data-date="${day.key}" data-count="${day.count}" data-color="${bg}"></a>`; }); if (wi === weeksArr.length - 1 && week[week.length - 1].dow < 6) for (let i = week[week.length - 1].dow + 1; i <= 6; i++) gridHtml += '<div class="heatmap-cell" style="visibility:hidden;"></div>'; gridHtml += '</div>'; });
309+
weeksArr.forEach((week, wi) => { gridHtml += '<div class="heatmap-week">'; if (wi === 0 && week[0].dow > 0) for (let i = 0; i < week[0].dow; i++) gridHtml += '<div class="heatmap-cell" style="visibility:hidden;"></div>'; week.forEach(day => { const bg = cellColor(day.count); const href = baseUrl ? `${baseUrl}/commits/${branchPath}?after=&since=${day.key}&until=${day.key}` : '#'; gridHtml += `<a href="${href}" target="_blank" class="heatmap-cell" style="background:${bg};" data-date="${day.key}" data-count="${day.count}" data-color="${bg}"></a>`; }); if (wi === weeksArr.length - 1 && week[week.length - 1].dow < 6) for (let i = week[week.length - 1].dow + 1; i <= 6; i++) gridHtml += '<div class="heatmap-cell" style="visibility:hidden;"></div>'; gridHtml += '</div>'; });
310310
gridHtml += '</div>';
311311
const legendHtml = `<div class="heatmap-legend"><span>Less</span><div class="heatmap-cell" style="background:var(--bg-empty-cell);"></div><div class="heatmap-cell" style="background:${shade(0.25)};"></div><div class="heatmap-cell" style="background:${shade(0.5)};"></div><div class="heatmap-cell" style="background:${shade(0.75)};"></div><div class="heatmap-cell" style="background:${color};"></div><span>More</span></div>`;
312312
container.innerHTML = `<div class="heatmap-wrap">${monthHtml}<div class="heatmap-grid">${dayLabelsHtml}${gridHtml}</div>${legendHtml}</div>`;
@@ -359,10 +359,12 @@ <h1 id="title"></h1>
359359
// === COMMIT TIMELINE ===
360360
function escapeHtml(s) { return String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m])); }
361361

362+
// GitHub branch paths preserve slashes (`feature/foo`), so encode segment-wise.
363+
function encodeBranch(b) { return (b || 'main').split('/').map(encodeURIComponent).join('/'); }
364+
362365
function authorUrl(c) {
363366
if (!D.githubBaseUrl) return '#';
364-
const branch = D.defaultBranch || 'main';
365-
return `${D.githubBaseUrl}/commits/${branch}?author=${encodeURIComponent(c.email)}`;
367+
return `${D.githubBaseUrl}/commits/${encodeBranch(D.defaultBranch)}?author=${encodeURIComponent(c.email)}`;
366368
}
367369

368370
const authorPopover = document.createElement('div');
@@ -375,14 +377,29 @@ <h1 id="title"></h1>
375377
return m ? '@' + m[1] : '';
376378
}
377379

380+
// Reads data-bg/data-initial from the failed <img> and swaps in a styled fallback.
381+
// Doing this from a real event listener avoids the inline-onerror double-decoding
382+
// trap where a name starting with a single quote would break the JS string literal.
383+
function installAvatarFallback(img) {
384+
if (!img) return;
385+
img.addEventListener('error', () => {
386+
const tag = img.dataset.fallbackTag || 'div';
387+
const fb = document.createElement(tag);
388+
fb.className = img.dataset.fallbackClass || '';
389+
if (img.dataset.bg) fb.style.background = img.dataset.bg;
390+
if (img.dataset.initial) fb.textContent = img.dataset.initial;
391+
img.replaceWith(fb);
392+
});
393+
}
394+
378395
function showAuthorPopover(idx, el) {
379396
const c = contributors[idx];
380397
if (!c) return;
381398
const av = c.avatarUrl;
382399
const handle = githubHandleFromAvatar(av);
383400
const initial = (c.name || '?').trim().charAt(0).toUpperCase();
384401
const avatarHtml = av
385-
? `<img class="lp-avatar" src="${escapeHtml(av)}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'lp-avatar-fallback',style:'background:${clr(idx)}',textContent:'${escapeHtml(initial)}'}))">`
402+
? `<img class="lp-avatar" src="${escapeHtml(av)}" alt="" data-fallback-class="lp-avatar-fallback" data-bg="${escapeHtml(clr(idx))}" data-initial="${escapeHtml(initial)}">`
386403
: `<div class="lp-avatar-fallback" style="background:${clr(idx)}">${escapeHtml(initial)}</div>`;
387404
const net = c.added - c.deleted;
388405
const netHtml = net > 0
@@ -396,6 +413,7 @@ <h1 id="title"></h1>
396413
`<div class="lp-stats">${fmt(c.commits)} commits · ${c.activeDays} active day${c.activeDays === 1 ? '' : 's'}</div>` +
397414
`<div class="lp-stats"><span class="add">+${fmt(c.added)}</span> <span class="del">-${fmt(c.deleted)}</span> (net ${netHtml})</div>` +
398415
`<div class="lp-period">${c.first}${c.last}</div>`;
416+
installAvatarFallback(authorPopover.querySelector('.lp-avatar'));
399417

400418
const rect = el.getBoundingClientRect();
401419
authorPopover.style.left = (rect.right + 12) + 'px';
@@ -459,7 +477,8 @@ <h1 id="title"></h1>
459477
});
460478

461479
// One-time precomputation. r/y depend on zoom so they're computed in drawCanvas.
462-
const sorted = commits.slice().sort((a, b) => a.d.localeCompare(b.d));
480+
// Sort by parsed UTC instant — lex-sort on ISO mis-orders across timezone offsets.
481+
const sorted = commits.slice().sort((a, b) => new Date(a.d) - new Date(b.d));
463482
const dayStackTmp = new Map();
464483
const drawables = [];
465484
sorted.forEach(c => {
@@ -884,12 +903,13 @@ <h1 id="title"></h1>
884903
const timeStr = dt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
885904
const av = author.avatarUrl;
886905
const avatarHtml = av
887-
? `<img class="tt-avatar" src="${escapeHtml(av)}" alt="" onerror="this.replaceWith(Object.assign(document.createElement('span'),{className:'tt-dot',style:'background:${hit.color}'}))">`
906+
? `<img class="tt-avatar" src="${escapeHtml(av)}" alt="" data-fallback-tag="span" data-fallback-class="tt-dot" data-bg="${escapeHtml(hit.color)}">`
888907
: `<span class="tt-dot" style="background:${hit.color}"></span>`;
889908
tooltip.innerHTML =
890909
`<div class="tt-author-row">${avatarHtml}<span class="tt-author">${escapeHtml(author.name)}</span></div>` +
891910
`<div class="tt-subject">${escapeHtml(c.s || '')}</div>` +
892911
`<div class="tt-meta">${dateStr} ${timeStr} · <span style="color:${colorAdded}">+${fmt(c.a || 0)}</span> <span style="color:${colorDeleted}">-${fmt(c.l || 0)}</span> · <span class="tt-hash">${c.h}</span></div>`;
912+
installAvatarFallback(tooltip.querySelector('.tt-avatar'));
893913
tooltip.style.left = clientX + 'px';
894914
tooltip.style.top = clientY + 'px';
895915
tooltip.classList.add('visible');

stow/bin/repo-intel

Lines changed: 18 additions & 6 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)