Files
gitea-github-cron/rename-gone-mirrors.js
T
multipleof4andClaude Opus 5.5 318384f234 Rename and freeze mirrors of moved GitHub repos
GitHub redirects transferred/renamed repos, so the existence check
passed for them. Treat a redirect to a different full_name as gone,
and disable periodic sync on renamed mirrors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 09:04:47 -07:00

162 lines
7.5 KiB
JavaScript

const { writeFileSync } = require('node:fs');
const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env;
if (![GH_PAT, GITEA_TOKEN, GITEA_URL].every(Boolean)) {
throw new Error('GH_PAT, GITEA_TOKEN, and GITEA_URL are required');
}
const gitea = `https://${GITEA_URL.replace(/^https?:\/\//, '').replace(/\/$/, '')}/api/v1`;
const github = 'https://api.github.com';
const ghHeaders = { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' };
const gtHeaders = { Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' };
const key = value => value.toLowerCase();
const facts = { orgsChecked: 0, skippedOrgs: 0, mirrorsFound: 0, renamed: 0, failures: 0, changes: [] };
const request = async (url, headers, method = 'GET', body, missingOK = false) => {
const res = await fetch(url, {
method, headers, body: body && JSON.stringify(body), signal: AbortSignal.timeout(30_000)
});
if (res.headers.has('x-github-sso')) throw new Error(`GitHub SSO authorization required for ${url}`);
if (res.status === 404 && missingOK) return null;
if (!res.ok) throw new Error(`${method} ${url}: ${res.status} ${(await res.text()).slice(0, 300)}`);
return method === 'GET' || method === 'PATCH' ? res.json() : res;
};
const pages = async (url, headers, sizeKey) => {
const all = [];
for (let page = 1; page <= 1000; page++) {
const next = new URL(url);
next.searchParams.set(sizeKey, '100');
next.searchParams.set('page', String(page));
const batch = await request(next, headers);
if (!Array.isArray(batch)) throw new Error(`Expected an array from ${next}`);
if (!batch.length) return all;
all.push(...batch);
}
throw new Error(`Pagination limit reached for ${url}`);
};
const source = repo => {
if (!repo.mirror || !repo.original_url) return null;
try {
const url = new URL(repo.original_url.replace(/^git@github\.com:/i, 'https://github.com/'));
if (url.hostname !== 'github.com') return null;
const parts = url.pathname.replace(/\.git\/?$/i, '').split('/').filter(Boolean);
if (parts.length !== 2 || key(parts[0]) !== key(repo.owner.login)) return null;
return { owner: parts[0], name: parts[1] };
} catch {
return null;
}
};
const goneName = (name, number) => {
const suffix = `-gone${number}`;
return `${name.slice(0, 100 - suffix.length)}${suffix}`;
};
const alreadyGone = (name, original) =>
key(name) !== key(original) && /-gone[1-9]\d*$/i.test(name);
(async () => {
const auth = await fetch(`${github}/user`, { headers: ghHeaders, signal: AbortSignal.timeout(30_000) });
if (auth.headers.has('x-github-sso')) throw new Error('GH_PAT needs GitHub SSO authorization');
if (!auth.ok) throw new Error(`GitHub authentication failed: ${auth.status}`);
const scopes = auth.headers.get('x-oauth-scopes')?.split(',').map(scope => scope.trim()) || [];
if (!scopes.includes('repo')) {
throw new Error('GH_PAT must be a classic GitHub token with repo scope to check private mirrors safely');
}
const ghUser = await auth.json();
const [gtUser, ghOrgs, gtOrgs, ghPersonal, gtPersonal] = await Promise.all([
request(`${gitea}/user`, gtHeaders),
pages(`${github}/user/orgs`, ghHeaders, 'per_page'),
pages(`${gitea}/user/orgs`, gtHeaders, 'limit'),
pages(`${github}/user/repos?affiliation=owner&visibility=all`, ghHeaders, 'per_page'),
pages(`${gitea}/user/repos`, gtHeaders, 'limit')
]);
if (key(gtUser.login) !== key(ghUser.login)) {
throw new Error(`GitHub user ${ghUser.login} does not match Gitea user ${gtUser.login}`);
}
const personal = ghPersonal.filter(repo => key(repo.owner.login) === key(ghUser.login));
if (!Number.isInteger(ghUser.public_repos) || !Number.isInteger(ghUser.owned_private_repos)) {
throw new Error('GitHub did not report both public and owned private repository counts');
}
const expected = ghUser.public_repos + ghUser.owned_private_repos;
if (personal.length < expected) {
throw new Error(`GitHub returned ${personal.length} personal repos, but reports ${expected}; refusing to rename`);
}
const ghOrgNames = new Set(ghOrgs.map(org => key(org.login)));
const groups = [{ owner: gtUser.login, repos: gtPersonal, ghRepos: personal }];
for (const org of gtOrgs) {
facts.orgsChecked++;
const owner = org.username || org.name;
if (ghOrgNames.has(key(owner))) {
const [ghOrg, ghRepos, gtRepos] = await Promise.all([
request(`${github}/orgs/${encodeURIComponent(owner)}`, ghHeaders),
pages(`${github}/orgs/${encodeURIComponent(owner)}/repos?type=all`, ghHeaders, 'per_page'),
pages(`${gitea}/orgs/${encodeURIComponent(owner)}/repos`, gtHeaders, 'limit')
]);
const expected = ghOrg.public_repos + ghOrg.total_private_repos;
if (Number.isFinite(expected) && ghRepos.length < expected) {
throw new Error(`GitHub returned ${ghRepos.length} repos for ${owner}, but reports ${expected}; refusing to rename`);
}
groups.push({ owner, repos: gtRepos, ghRepos });
} else {
const ghOrg = await request(`${github}/orgs/${encodeURIComponent(owner)}`, ghHeaders, 'GET', null, true);
if (ghOrg) {
facts.skippedOrgs++;
console.log(`Skipping ${owner}: GitHub organization exists but is not listed for this token`);
continue;
}
const gtRepos = await pages(`${gitea}/orgs/${encodeURIComponent(owner)}/repos`, gtHeaders, 'limit');
groups.push({ owner, repos: gtRepos, ghRepos: [] });
}
}
for (const { owner, repos, ghRepos } of groups) {
const live = new Set(ghRepos.map(repo => key(repo.name)));
const occupied = new Set(repos.map(repo => key(repo.name)));
for (const repo of repos) {
const remote = source(repo);
if (!remote || key(repo.owner.login) !== key(owner)) continue;
facts.mirrorsFound++;
if (live.has(key(remote.name))) continue;
if (alreadyGone(repo.name, remote.name)) continue;
try {
// GitHub redirects transferred/renamed repos, so a different full_name means it moved
const current = await request(
`${github}/repos/${encodeURIComponent(remote.owner)}/${encodeURIComponent(remote.name)}`,
ghHeaders, 'GET', null, true
);
if (current && key(current.full_name) === key(`${remote.owner}/${remote.name}`)) continue;
let number = 1, name;
do name = goneName(repo.name, number++); while (occupied.has(key(name)));
const updated = await request(
`${gitea}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo.name)}`,
gtHeaders, 'PATCH', { name, mirror_interval: '0' }
);
if (key(updated.name) !== key(name)) throw new Error('Gitea did not return the requested name');
occupied.delete(key(repo.name));
occupied.add(key(name));
facts.renamed++;
const note = current ? ` (moved to ${current.full_name})` : '';
facts.changes.push(`${owner}/${repo.name} → ${owner}/${name}${note}`);
console.log(`Renamed ${owner}/${repo.name} to ${owner}/${name}${note}`);
} catch (error) {
facts.failures++;
console.error(`Failed to check or rename ${owner}/${repo.name}: ${error.message}`);
}
}
}
console.log(`Renamed ${facts.renamed} missing GitHub mirror(s); ${facts.failures} failure(s).`);
if (facts.failures) process.exitCode = 1;
})().catch(error => {
console.error(`Fatal: ${error.message}`);
facts.fatal = true;
process.exitCode = 1;
}).finally(() => {
if (process.env.GITHUB_ACTIONS) writeFileSync('rename-facts.json', JSON.stringify(facts));
});