Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions web/api/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export const GET = handle(async req => {
const key = cityKey(target, all);
const meta = await readMeta(key).catch(() => null);
const name = target.kind === 'owner' ? target.login : `${target.owner}/${target.name}`;
// a repo GitHub moved (facebook/react is react/react now) opens under its current name, as on GitHub
if (meta && meta.target.toLowerCase() !== name.toLowerCase()) {
return new Response(null, { status: 307, headers: { Location: `/${meta.target}${all ? '?all=1' : ''}`, 'Cache-Control': 'public, s-maxage=300' } });
}
const shown = meta?.target ?? name;
const path = `/${shown}${all ? '?all=1' : ''}`;
const title = `${shown}${all ? ' (whole history)' : ''} · commit city`;
Expand Down
18 changes: 15 additions & 3 deletions web/lib/city.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface Plan {
p: Discovery['profile']; c: number | null; x: number; o: [number, number];
b?: [string, number][]; // the biggest repos skipped as too big to replay (counted in o), named on the city page
g?: Discovery['github']; // a person's public commits as GitHub counts them, for the city's reconciliation note
a?: string; // the key this build was asked for, when GitHub has moved the repo since: its page redirects
r: { n: string; f: boolean; c: number; h: string; sh?: string[]; gen?: number }[]; // gen: an older record still in use
}

Expand Down Expand Up @@ -115,9 +116,9 @@ export async function usableGen(name: string, head: string, headTime: number, wh

export async function startBuild(target: Target, token: string, opts: { whole?: boolean } = {}) {
const whole = !!opts.whole && target.kind === 'repo';
const key = cityKey(target, whole);
let key = cityKey(target, whole);
const owner = target.kind === 'owner' ? target.login : target.owner;
const settings = await readSettings(owner, true);
let settings = await readSettings(owner, true);
if (settings.hidden) throw new HttpError(403, 'this city is hidden by its owner', { status: 'hidden' });
let d: Discovery;
try {
Expand All @@ -127,6 +128,16 @@ export async function startBuild(target: Target, token: string, opts: { whole?:
if (e instanceof RateLimited) throw new HttpError(503, 'GitHub API quota is used up for now; sign in with GitHub to build with your own quota', { status: 'rate_limited', resetAt: e.resetAt });
throw e;
}
// a repo GitHub moved (facebook/react is react/react now) is built under its current name, so there is one city
// per repo; finish() leaves the old name a meta that redirects there. Whoever owns the repo now may hide it.
let alias: string | undefined;
if (target.kind === 'repo' && d.target.toLowerCase() !== cityKey(target)) {
const [movedOwner, movedName] = d.target.split('/');
alias = key;
key = cityKey({ kind: 'repo', owner: movedOwner, name: movedName }, whole);
settings = await readSettings(movedOwner, true);
if (settings.hidden) throw new HttpError(403, 'this city is hidden by its owner', { status: 'hidden' });
}
const excluded = new Set((settings.exclude ?? []).map(s => s.toLowerCase()));
const repos = d.repos.filter(r => !excluded.has(r.name.toLowerCase()));
// nothing left to replay because it is all too big (torvalds/linux, vercel/next.js): say so now, instead of
Expand All @@ -141,7 +152,7 @@ export async function startBuild(target: Target, token: string, opts: { whole?:
const plan: Plan = {
k: key, kind: d.kind, mode: d.mode, s: d.start, n: d.now, p: d.profile, c: d.contributions, x: d.restricted,
o: [d.omittedRepos, d.omittedCommits], ...(named.length ? { b: named.map(s => [s.name, s.commits] as [string, number]) } : {}),
...(d.github ? { g: d.github } : {}),
...(d.github ? { g: d.github } : {}), ...(alias ? { a: alias } : {}),
r: repos.map((r, i) => ({ n: r.name, f: r.fork, c: r.commits, h: r.head, ...(r.shas ? { sh: r.shas } : {}),
...(gens[i] && gens[i] !== REPLAY_GEN ? { gen: gens[i] } : {}) })),
};
Expand Down Expand Up @@ -204,6 +215,7 @@ export async function finishBuild(ticketToken: string, planToken: string): Promi
const meta = summarize(payload, { v, key: p.k, target: t.t, kind: p.kind, url });
await putJSON(skyPath(p.k, v), skyline(payload)).catch(e => console.error('sky', e));
await putJSON(metaPath(p.k), meta, { overwrite: true, maxAge: 60 });
if (p.a) await putJSON(metaPath(p.a), meta, { overwrite: true, maxAge: 60 }).catch(e => console.error('moved', e));
await addToGallery(meta).catch(e => console.error('gallery', e));
return meta;
}
Expand Down
4 changes: 4 additions & 0 deletions web/public/s/city.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,10 @@
try {
st = await call('/api/build/start', { t: CC.target, all: !!CC.all });
} catch (e) { return fail(e); }
// GitHub moved this repo: the city is built, and lives, under its current name
if (isRepo && st.target.toLowerCase() !== CC.target.toLowerCase() && history.replaceState) {
history.replaceState(null, '', `/${st.target}${CC.all ? '?all=1' : ''}${location.hash}`);
}
const who = st.kind === 'org' ? `the ${st.profile.login} org` : st.kind === 'repo' ? st.target : st.profile.name || st.profile.login;
const jobs = st.repos.map(r => ({ ...r, s: r.cached ? 'hit' : 'wait', note: r.cached ? 'cached' : `${fmt(r.commits)} commits` }));
for (const s of st.skipped || []) jobs.push({ name: s.name, s: 'bad', note: s.reason, skipped: true, commits: s.commits });
Expand Down
14 changes: 9 additions & 5 deletions web/scripts/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; ch
'.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', '.gz': 'application/gzip', '.txt': 'text/plain; charset=utf-8',
'.ico': 'image/x-icon', '.xml': 'application/xml', '.webmanifest': 'application/manifest+json', '.woff2': 'font/woff2', '.webp': 'image/webp', '.mp4': 'video/mp4' };

const rewrites = config.rewrites.map(r => { const keys = []; return { re: pathToRegexp(r.source, keys), keys, dest: r.destination }; });
const compile = rules => (rules || []).map(r => { const keys = []; return { re: pathToRegexp(r.source, keys), keys, dest: r.destination }; });
const redirects = compile(config.redirects), rewrites = compile(config.rewrites);
const fill = (r, m) => { let dest = r.dest; r.keys.forEach((k, i) => { dest = dest.replaceAll(`:${k.name}`, m[i + 1] ?? ''); }); return dest; };
const headerRules = (config.headers || []).map(h => ({ re: pathToRegexp(h.source), headers: h.headers }));

const loaded = new Map();
Expand Down Expand Up @@ -63,7 +65,11 @@ async function serve(req, res) {
res.writeHead(200, { 'Content-Type': TYPES[path.extname(f)] || 'application/octet-stream', 'Access-Control-Allow-Origin': '*' });
return fs.createReadStream(f).pipe(res);
}
// filesystem first, as on Vercel
// redirects, then the filesystem, then rewrites, as on Vercel
for (const r of redirects) {
const m = r.re.exec(p);
if (m) { res.writeHead(307, { Location: fill(r, m) + url.search }); return res.end(); }
}
const sf = staticFile(p);
if (sf) {
res.writeHead(200, { 'Content-Type': TYPES[path.extname(sf)] || 'application/octet-stream', ...extra });
Expand All @@ -75,9 +81,7 @@ async function serve(req, res) {
for (const r of rewrites) {
const m = r.re.exec(p);
if (!m) continue;
let dest = r.dest;
r.keys.forEach((k, i) => { dest = dest.replaceAll(`:${k.name}`, m[i + 1]); });
const du = new URL(dest, url);
const du = new URL(fill(r, m), url);
for (const [k, v] of url.searchParams) if (!du.searchParams.has(k)) du.searchParams.set(k, v);
url.pathname = du.pathname; url.search = du.search;
fn = path.join(root, du.pathname + '.ts');
Expand Down
3 changes: 2 additions & 1 deletion web/test/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ test('finish step: records of another version are never drawn; meta and gallery
const ticket = sign({ k: 'me', t: 'me', s: start, w: false, r: [['me/alpha', r.head(), 0], ['me/beta', 'b'.repeat(40), 0]] }, 600);
const plan = sign({
k: 'me', kind: 'org', mode: 'all', s: start, n: T0 + 30 * DAY, p: { login: 'me', name: 'Me', avatar: '', url: '', createdAt: '' },
c: null, x: 0, o: [1, 900], b: [['me/huge', 900]], g: { commits: 950, capped: false },
c: null, x: 0, o: [1, 900], b: [['me/huge', 900]], g: { commits: 950, capped: false }, a: 'old-me',
r: [{ n: 'me/alpha', f: false, c: 7, h: r.head() }, { n: 'me/beta', f: false, c: 5, h: 'b'.repeat(40) }],
}, 600);
const meta = await finishBuild(ticket, plan);
Expand All @@ -143,6 +143,7 @@ test('finish step: records of another version are never drawn; meta and gallery
assert.deepEqual([payload.totals.omitted_repos, payload.totals.omitted_commits], [2, 905]);
assert.deepEqual(payload.totals.too_big, [{ repo: 'me/huge', commits: 900 }]);
assert.deepEqual(payload.totals.github, { commits: 950 }); // GitHub's count rides along; capped only when true
assert.equal((await getJSON<{ v: number }>('meta/old-me.json'))?.v, meta.v); // the name the build was asked for points here
});

test('a generation-1 record stays in use only where the merged-branch fix cannot change it', async () => {
Expand Down
32 changes: 32 additions & 0 deletions web/test/page.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { GET } from '../api/page';
import { putJSON, setStore, type Store } from '../lib/store';

class MemStore implements Store {
blobs = new Map<string, Buffer>();
async get(p: string) { return this.blobs.get(p) ?? null; }
async exists(p: string) { return this.blobs.has(p); }
async stat() { return null; }
async put(p: string, data: Buffer) { this.blobs.set(p, data); return `mem:/${p}`; }
url(p: string) { return `mem:/${p}`; }
async list() { return []; }
async del(p: string) { this.blobs.delete(p); }
}

const meta = (key: string, target: string) => ({ v: 1, key, target, kind: 'repo', login: target, name: target, avatar: '', url: '', generated: '',
repos: 1, commits: 685, files: 1, lines: 1, window_start: '2025-10-11', who: { human: 685, bot: 0, agent: 0, agents: [] } });

test('a repo GitHub moved opens under its current name; others stay put', async () => {
setStore(new MemStore());
await putJSON('meta/facebook/react.json', meta('facebook/react', 'react/react'));
await putJSON('meta/react/react.json', meta('react/react', 'react/react'));
const moved = await GET(new Request('https://commit.city/api/page?t=facebook/react'));
assert.equal(moved.status, 307);
assert.equal(moved.headers.get('location'), '/react/react');
const whole = await GET(new Request('https://commit.city/api/page?t=facebook/react&all=1'));
assert.equal(whole.status, 200); // no whole-history city of it yet: the build page, not a redirect
const here = await GET(new Request('https://commit.city/api/page?t=React/React'));
assert.equal(here.status, 200); // a different case is the same name
assert.match(await here.text(), /<title>react\/react · commit city<\/title>/);
});
45 changes: 45 additions & 0 deletions web/test/routes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { test } from 'node:test';
import { type Key, pathToRegexp } from 'path-to-regexp';
import { REPO_ROOT } from './helpers';

// vercel.json's redirects, then its rewrites, applied the way Vercel and scripts/dev.mjs apply them
const config = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'web', 'vercel.json'), 'utf8')) as {
redirects: { source: string; destination: string }[]; rewrites: { source: string; destination: string }[];
};
const compile = (rules: { source: string; destination: string }[]) => rules.map(r => { const keys: Key[] = []; return { re: pathToRegexp(r.source, keys), keys, dest: r.destination }; });
const redirects = compile(config.redirects), rewrites = compile(config.rewrites);
function route(p: string): string | null {
for (const [kind, rules] of [['redirect', redirects], ['rewrite', rewrites]] as const) {
for (const r of rules) {
const m = r.re.exec(p);
if (!m) continue;
let dest = r.dest;
r.keys.forEach((k, i) => { dest = dest.replaceAll(`:${k.name}`, m[i + 1] ?? ''); });
return `${kind} ${dest}`;
}
}
return null;
}

test('a GitHub URL with commit.city swapped in lands on the city', () => {
const cases: [string, string | null][] = [
['/torvalds', 'rewrite /api/page?t=torvalds'],
['/vercel/next.js', 'rewrite /api/page?t=vercel/next.js'],
['/vercel/ai/pulls', 'redirect /vercel/ai'],
['/vercel/ai/tree/main/packages/ai', 'redirect /vercel/ai'],
['/vercel/next.js/commits/canary', 'redirect /vercel/next.js'],
['/vercel/ai.git', 'redirect /vercel/ai'],
['/orgs/vercel', 'redirect /vercel'],
['/orgs/vercel/repositories', 'redirect /vercel'],
// the site's own paths are left alone
['/api/build/repo', null],
['/s/f/inter.woff2', null],
['/v/app.a63cdb5695.js', null],
['/explore', null],
['/about', null],
];
for (const [p, want] of cases) assert.equal(route(p), want, p);
});
5 changes: 5 additions & 0 deletions web/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
"api/build/finish.ts": { "maxDuration": 120 },
"api/og.ts": { "maxDuration": 30, "includeFiles": "fonts/**" }
},
"redirects": [
{ "source": "/orgs/:org([A-Za-z0-9][A-Za-z0-9-]{0,38})/:rest*", "destination": "/:org", "permanent": false },
{ "source": "/:owner((?!(?:api|v|s|u|fonts|local-store|explore|events|readme|about|404|index)(?:/|$))[A-Za-z0-9][A-Za-z0-9-]{0,38})/:repo([A-Za-z0-9._-]{1,96})\\.git", "destination": "/:owner/:repo", "permanent": false },
{ "source": "/:owner((?!(?:api|v|s|u|fonts|local-store|explore|events|readme|about|404|index)(?:/|$))[A-Za-z0-9][A-Za-z0-9-]{0,38})/:repo([A-Za-z0-9._-]{1,100})/:rest+", "destination": "/:owner/:repo", "permanent": false }
],
"rewrites": [
{ "source": "/api/auth/:op(login|callback|logout)", "destination": "/api/account?op=:op" },
{ "source": "/api/:op(me|settings)", "destination": "/api/account?op=:op" },
Expand Down
Loading