mirror of
https://github.com/multipleof4/gitea-github-cron.git
synced 2026-10-08 21:35:21 +00:00
Notify ntfy with workflow facts and test dispatch
This commit is contained in:
@@ -2,6 +2,11 @@ name: Mirror to Gitea
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
notification_test:
|
||||||
|
description: Send a test notification without running the mirror
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 */4 * * *' # Run every 4 hours
|
- cron: '0 */4 * * *' # Run every 4 hours
|
||||||
|
|
||||||
@@ -18,8 +23,20 @@ jobs:
|
|||||||
node-version: 20
|
node-version: 20
|
||||||
|
|
||||||
- name: Run Mirror Script
|
- name: Run Mirror Script
|
||||||
|
id: sync
|
||||||
|
if: ${{ !inputs.notification_test }}
|
||||||
run: node mirror.js
|
run: node mirror.js
|
||||||
env:
|
env:
|
||||||
GH_PAT: ${{ secrets.GH_PAT }}
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
GITEA_URL: ${{ secrets.GITEA_URL }}
|
GITEA_URL: ${{ secrets.GITEA_URL }}
|
||||||
|
|
||||||
|
- name: Notify via ntfy
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: node notify.js
|
||||||
|
env:
|
||||||
|
NTFY_TOPIC_URL: ${{ secrets.NTFY_TOPIC_URL }}
|
||||||
|
WORKFLOW_KIND: mirror
|
||||||
|
STEP_OUTCOME: ${{ steps.sync.outcome }}
|
||||||
|
TEST_NOTIFICATION: ${{ inputs.notification_test }}
|
||||||
|
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
|||||||
@@ -15,8 +15,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
- name: Rename mirrors missing on GitHub
|
- name: Rename mirrors missing on GitHub
|
||||||
|
id: sync
|
||||||
run: node rename-gone-mirrors.js
|
run: node rename-gone-mirrors.js
|
||||||
env:
|
env:
|
||||||
GH_PAT: ${{ secrets.GH_PAT }}
|
GH_PAT: ${{ secrets.GH_PAT }}
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
GITEA_URL: ${{ secrets.GITEA_URL }}
|
GITEA_URL: ${{ secrets.GITEA_URL }}
|
||||||
|
|
||||||
|
- name: Notify via ntfy
|
||||||
|
if: ${{ always() }}
|
||||||
|
run: node notify.js
|
||||||
|
env:
|
||||||
|
NTFY_TOPIC_URL: ${{ secrets.NTFY_TOPIC_URL }}
|
||||||
|
WORKFLOW_KIND: rename
|
||||||
|
STEP_OUTCOME: ${{ steps.sync.outcome }}
|
||||||
|
RUN_URL: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
const syncOrgAvatar = require('./sync-org-avatar');
|
const syncOrgAvatar = require('./sync-org-avatar');
|
||||||
|
const { writeFileSync } = require('node:fs');
|
||||||
const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env;
|
const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env;
|
||||||
|
const facts = {
|
||||||
|
orgsChecked: 0, orgsCreated: 0, orgVisibilityChanged: 0, avatarsUpdated: 0,
|
||||||
|
reposFound: 0, mirrorsCreated: 0, repoVisibilityChanged: 0, failures: 0, changes: []
|
||||||
|
};
|
||||||
const G_API = `https://${GITEA_URL.replace(/^https?:\/\//, '')}/api/v1`;
|
const G_API = `https://${GITEA_URL.replace(/^https?:\/\//, '')}/api/v1`;
|
||||||
const headers = {
|
const headers = {
|
||||||
GH: { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' },
|
GH: { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' },
|
||||||
@@ -44,19 +49,28 @@ const getPages = async (url) => {
|
|||||||
let avatarFailures = 0, repoFailures = 0;
|
let avatarFailures = 0, repoFailures = 0;
|
||||||
|
|
||||||
for (const org of ghOrgs) {
|
for (const org of ghOrgs) {
|
||||||
|
facts.orgsChecked++;
|
||||||
console.log(`Checking Org: ${org.login}`);
|
console.log(`Checking Org: ${org.login}`);
|
||||||
try {
|
try {
|
||||||
const gOrg = await req(`${G_API}/orgs/${org.login}`, headers.GT, 'GET', null, true);
|
const gOrg = await req(`${G_API}/orgs/${org.login}`, headers.GT, 'GET', null, true);
|
||||||
if (!gOrg) {
|
if (!gOrg) {
|
||||||
console.log(`Creating Org: ${org.login}`);
|
console.log(`Creating Org: ${org.login}`);
|
||||||
await req(`${G_API}/orgs`, headers.GT, 'POST', { username: org.login, visibility: 'public' });
|
await req(`${G_API}/orgs`, headers.GT, 'POST', { username: org.login, visibility: 'public' });
|
||||||
|
facts.orgsCreated++;
|
||||||
|
facts.changes.push(`Created org ${org.login}`);
|
||||||
} else if (gOrg.visibility !== 'public') {
|
} else if (gOrg.visibility !== 'public') {
|
||||||
console.log(`Updating Org Visibility: ${org.login}`);
|
console.log(`Updating Org Visibility: ${org.login}`);
|
||||||
await req(`${G_API}/orgs/${org.login}`, headers.GT, 'PATCH', { visibility: 'public' });
|
await req(`${G_API}/orgs/${org.login}`, headers.GT, 'PATCH', { visibility: 'public' });
|
||||||
|
facts.orgVisibilityChanged++;
|
||||||
|
facts.changes.push(`Made org ${org.login} public`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (await syncOrgAvatar(org, G_API, headers.GT)) console.log(`Updated Org Avatar: ${org.login}`);
|
if (await syncOrgAvatar(org, G_API, headers.GT)) {
|
||||||
|
facts.avatarsUpdated++;
|
||||||
|
facts.changes.push(`Updated avatar for ${org.login}`);
|
||||||
|
console.log(`Updated Org Avatar: ${org.login}`);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
avatarFailures++;
|
avatarFailures++;
|
||||||
console.error(`Failed to sync avatar for ${org.login}:`, e.message);
|
console.error(`Failed to sync avatar for ${org.login}:`, e.message);
|
||||||
@@ -70,6 +84,7 @@ const getPages = async (url) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Processing ${allRepos.length} repositories...`);
|
console.log(`Processing ${allRepos.length} repositories...`);
|
||||||
|
facts.reposFound = allRepos.length;
|
||||||
for (const r of allRepos) {
|
for (const r of allRepos) {
|
||||||
try {
|
try {
|
||||||
const owner = r.owner.login;
|
const owner = r.owner.login;
|
||||||
@@ -96,22 +111,30 @@ const getPages = async (url) => {
|
|||||||
mirror_prune: true
|
mirror_prune: true
|
||||||
};
|
};
|
||||||
await req(`${G_API}/repos/migrate`, headers.GT, 'POST', payload);
|
await req(`${G_API}/repos/migrate`, headers.GT, 'POST', payload);
|
||||||
|
facts.mirrorsCreated++;
|
||||||
|
facts.changes.push(`Mirrored ${owner}/${r.name}`);
|
||||||
} else if (exists.private !== r.private) {
|
} else if (exists.private !== r.private) {
|
||||||
console.log(`Updating visibility: ${owner}/${r.name} → ${r.private ? 'private' : 'public'}`);
|
console.log(`Updating visibility: ${owner}/${r.name} → ${r.private ? 'private' : 'public'}`);
|
||||||
await req(`${G_API}/repos/${owner}/${r.name}`, headers.GT, 'PATCH', { private: r.private });
|
await req(`${G_API}/repos/${owner}/${r.name}`, headers.GT, 'PATCH', { private: r.private });
|
||||||
|
facts.repoVisibilityChanged++;
|
||||||
|
facts.changes.push(`${owner}/${r.name} is now ${r.private ? 'private' : 'public'}`);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
repoFailures++;
|
repoFailures++;
|
||||||
console.error(`Failed to mirror ${r.owner.login}/${r.name}:`, e.message);
|
console.error(`Failed to mirror ${r.owner.login}/${r.name}:`, e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
facts.failures = avatarFailures + repoFailures;
|
||||||
if (avatarFailures || repoFailures) {
|
if (avatarFailures || repoFailures) {
|
||||||
console.error(`Sync finished with ${avatarFailures} avatar failure(s) and ${repoFailures} repository failure(s).`);
|
console.error(`Sync finished with ${avatarFailures} avatar failure(s) and ${repoFailures} repository failure(s).`);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Fatal Error:', err);
|
console.error('Fatal Error:', err);
|
||||||
process.exit(1);
|
facts.fatal = true;
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (process.env.GITHUB_ACTIONS) writeFileSync('mirror-facts.json', JSON.stringify(facts));
|
||||||
}
|
}
|
||||||
if (!process.exitCode) console.log('Sync Complete.');
|
if (!process.exitCode) console.log('Sync Complete.');
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
const { readFileSync } = require('node:fs');
|
||||||
|
const { NTFY_TOPIC_URL, RUN_URL, STEP_OUTCOME, TEST_NOTIFICATION, WORKFLOW_KIND } = process.env;
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
mirror: ['Mirror sync', 'mirror-facts.json', [
|
||||||
|
['Organizations checked', 'orgsChecked'],
|
||||||
|
['GitHub repositories found', 'reposFound'],
|
||||||
|
['Organizations created', 'orgsCreated'],
|
||||||
|
['Org visibility changes', 'orgVisibilityChanged'],
|
||||||
|
['Org avatars updated', 'avatarsUpdated'],
|
||||||
|
['New mirrors', 'mirrorsCreated'],
|
||||||
|
['Repo visibility changes', 'repoVisibilityChanged'],
|
||||||
|
['Failures', 'failures']
|
||||||
|
]],
|
||||||
|
rename: ['Missing-mirror check', 'rename-facts.json', [
|
||||||
|
['Gitea organizations checked', 'orgsChecked'],
|
||||||
|
['GitHub mirrors found', 'mirrorsFound'],
|
||||||
|
['Renamed mirrors', 'renamed'],
|
||||||
|
['Organizations skipped', 'skippedOrgs'],
|
||||||
|
['Failures', 'failures']
|
||||||
|
]]
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
if (!NTFY_TOPIC_URL || !labels[WORKFLOW_KIND]) throw new Error('Notification URL or workflow kind missing');
|
||||||
|
const test = TEST_NOTIFICATION === 'true';
|
||||||
|
const [label, file, fields] = labels[WORKFLOW_KIND];
|
||||||
|
const title = test ? 'Gitea backup notification test' : `${label}: ${STEP_OUTCOME === 'success' ? 'succeeded' : 'failed'}`;
|
||||||
|
const lines = test ? ['Notification-only test. No mirror or rename ran.'] : [];
|
||||||
|
|
||||||
|
if (!test) {
|
||||||
|
try {
|
||||||
|
const facts = JSON.parse(readFileSync(file, 'utf8'));
|
||||||
|
lines.push(...fields.map(([name, key]) => `${name}: ${facts[key] ?? 0}`));
|
||||||
|
const changes = facts.changes || [];
|
||||||
|
if (changes.length) lines.push('Changes:', ...changes.slice(0, 10));
|
||||||
|
if (changes.length > 10) lines.push(`...and ${changes.length - 10} more; see run logs.`);
|
||||||
|
if (facts.fatal) lines.push('Fatal error; see run logs.');
|
||||||
|
} catch {
|
||||||
|
lines.push('No run summary available; see run logs.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RUN_URL) lines.push(`Run: ${RUN_URL}`);
|
||||||
|
const res = await fetch(NTFY_TOPIC_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Priority: '2', Title: title, ...(RUN_URL && { Click: RUN_URL }) },
|
||||||
|
body: lines.join('\n'),
|
||||||
|
signal: AbortSignal.timeout(30_000)
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`ntfy returned HTTP ${res.status}`);
|
||||||
|
console.log(`ntfy notification sent: ${title}`);
|
||||||
|
})().catch(error => {
|
||||||
|
console.error(`Notification failed: ${error.message}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
+15
-6
@@ -1,3 +1,4 @@
|
|||||||
|
const { writeFileSync } = require('node:fs');
|
||||||
const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env;
|
const { GH_PAT, GITEA_TOKEN, GITEA_URL } = process.env;
|
||||||
if (![GH_PAT, GITEA_TOKEN, GITEA_URL].every(Boolean)) {
|
if (![GH_PAT, GITEA_TOKEN, GITEA_URL].every(Boolean)) {
|
||||||
throw new Error('GH_PAT, GITEA_TOKEN, and GITEA_URL are required');
|
throw new Error('GH_PAT, GITEA_TOKEN, and GITEA_URL are required');
|
||||||
@@ -8,6 +9,7 @@ const github = 'https://api.github.com';
|
|||||||
const ghHeaders = { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' };
|
const ghHeaders = { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' };
|
||||||
const gtHeaders = { Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' };
|
const gtHeaders = { Authorization: `token ${GITEA_TOKEN}`, 'Content-Type': 'application/json' };
|
||||||
const key = value => value.toLowerCase();
|
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 request = async (url, headers, method = 'GET', body, missingOK = false) => {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -86,6 +88,7 @@ const alreadyGone = (name, original) =>
|
|||||||
const ghOrgNames = new Set(ghOrgs.map(org => key(org.login)));
|
const ghOrgNames = new Set(ghOrgs.map(org => key(org.login)));
|
||||||
const groups = [{ owner: gtUser.login, repos: gtPersonal, ghRepos: personal }];
|
const groups = [{ owner: gtUser.login, repos: gtPersonal, ghRepos: personal }];
|
||||||
for (const org of gtOrgs) {
|
for (const org of gtOrgs) {
|
||||||
|
facts.orgsChecked++;
|
||||||
const owner = org.username || org.name;
|
const owner = org.username || org.name;
|
||||||
if (ghOrgNames.has(key(owner))) {
|
if (ghOrgNames.has(key(owner))) {
|
||||||
const [ghOrg, ghRepos, gtRepos] = await Promise.all([
|
const [ghOrg, ghRepos, gtRepos] = await Promise.all([
|
||||||
@@ -101,6 +104,7 @@ const alreadyGone = (name, original) =>
|
|||||||
} else {
|
} else {
|
||||||
const ghOrg = await request(`${github}/orgs/${encodeURIComponent(owner)}`, ghHeaders, 'GET', null, true);
|
const ghOrg = await request(`${github}/orgs/${encodeURIComponent(owner)}`, ghHeaders, 'GET', null, true);
|
||||||
if (ghOrg) {
|
if (ghOrg) {
|
||||||
|
facts.skippedOrgs++;
|
||||||
console.log(`Skipping ${owner}: GitHub organization exists but is not listed for this token`);
|
console.log(`Skipping ${owner}: GitHub organization exists but is not listed for this token`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -109,13 +113,14 @@ const alreadyGone = (name, original) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let renamed = 0, failures = 0;
|
|
||||||
for (const { owner, repos, ghRepos } of groups) {
|
for (const { owner, repos, ghRepos } of groups) {
|
||||||
const live = new Set(ghRepos.map(repo => key(repo.name)));
|
const live = new Set(ghRepos.map(repo => key(repo.name)));
|
||||||
const occupied = new Set(repos.map(repo => key(repo.name)));
|
const occupied = new Set(repos.map(repo => key(repo.name)));
|
||||||
for (const repo of repos) {
|
for (const repo of repos) {
|
||||||
const remote = source(repo);
|
const remote = source(repo);
|
||||||
if (!remote || key(repo.owner.login) !== key(owner) || live.has(key(remote.name))) continue;
|
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;
|
if (alreadyGone(repo.name, remote.name)) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -134,17 +139,21 @@ const alreadyGone = (name, original) =>
|
|||||||
if (key(updated.name) !== key(name)) throw new Error('Gitea did not return the requested name');
|
if (key(updated.name) !== key(name)) throw new Error('Gitea did not return the requested name');
|
||||||
occupied.delete(key(repo.name));
|
occupied.delete(key(repo.name));
|
||||||
occupied.add(key(name));
|
occupied.add(key(name));
|
||||||
renamed++;
|
facts.renamed++;
|
||||||
|
facts.changes.push(`${owner}/${repo.name} → ${owner}/${name}`);
|
||||||
console.log(`Renamed ${owner}/${repo.name} to ${owner}/${name}`);
|
console.log(`Renamed ${owner}/${repo.name} to ${owner}/${name}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures++;
|
facts.failures++;
|
||||||
console.error(`Failed to check or rename ${owner}/${repo.name}: ${error.message}`);
|
console.error(`Failed to check or rename ${owner}/${repo.name}: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log(`Renamed ${renamed} missing GitHub mirror(s); ${failures} failure(s).`);
|
console.log(`Renamed ${facts.renamed} missing GitHub mirror(s); ${facts.failures} failure(s).`);
|
||||||
if (failures) process.exitCode = 1;
|
if (facts.failures) process.exitCode = 1;
|
||||||
})().catch(error => {
|
})().catch(error => {
|
||||||
console.error(`Fatal: ${error.message}`);
|
console.error(`Fatal: ${error.message}`);
|
||||||
|
facts.fatal = true;
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
|
}).finally(() => {
|
||||||
|
if (process.env.GITHUB_ACTIONS) writeFileSync('rename-facts.json', JSON.stringify(facts));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user