diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index dc9aecc..fbe0a8a 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -2,6 +2,11 @@ name: Mirror to Gitea on: workflow_dispatch: + inputs: + notification_test: + description: Send a test notification without running the mirror + type: boolean + default: false schedule: - cron: '0 */4 * * *' # Run every 4 hours @@ -18,8 +23,20 @@ jobs: node-version: 20 - name: Run Mirror Script + id: sync + if: ${{ !inputs.notification_test }} run: node mirror.js env: GH_PAT: ${{ secrets.GH_PAT }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} 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 }} diff --git a/.github/workflows/rename-gone-mirrors.yml b/.github/workflows/rename-gone-mirrors.yml index 0901755..6cf3fa0 100644 --- a/.github/workflows/rename-gone-mirrors.yml +++ b/.github/workflows/rename-gone-mirrors.yml @@ -15,8 +15,18 @@ jobs: with: node-version: 20 - name: Rename mirrors missing on GitHub + id: sync run: node rename-gone-mirrors.js env: GH_PAT: ${{ secrets.GH_PAT }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} 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 }} diff --git a/mirror.js b/mirror.js index a94e256..1391f65 100644 --- a/mirror.js +++ b/mirror.js @@ -1,5 +1,10 @@ const syncOrgAvatar = require('./sync-org-avatar'); +const { writeFileSync } = require('node:fs'); 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 headers = { GH: { Authorization: `token ${GH_PAT}`, Accept: 'application/vnd.github+json' }, @@ -44,19 +49,28 @@ const getPages = async (url) => { let avatarFailures = 0, repoFailures = 0; for (const org of ghOrgs) { + facts.orgsChecked++; console.log(`Checking Org: ${org.login}`); try { const gOrg = await req(`${G_API}/orgs/${org.login}`, headers.GT, 'GET', null, true); if (!gOrg) { console.log(`Creating Org: ${org.login}`); 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') { console.log(`Updating Org Visibility: ${org.login}`); await req(`${G_API}/orgs/${org.login}`, headers.GT, 'PATCH', { visibility: 'public' }); + facts.orgVisibilityChanged++; + facts.changes.push(`Made org ${org.login} public`); } 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) { avatarFailures++; 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...`); + facts.reposFound = allRepos.length; for (const r of allRepos) { try { const owner = r.owner.login; @@ -96,22 +111,30 @@ const getPages = async (url) => { mirror_prune: true }; 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) { 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 }); + facts.repoVisibilityChanged++; + facts.changes.push(`${owner}/${r.name} is now ${r.private ? 'private' : 'public'}`); } } catch (e) { repoFailures++; console.error(`Failed to mirror ${r.owner.login}/${r.name}:`, e.message); } } + facts.failures = avatarFailures + repoFailures; if (avatarFailures || repoFailures) { console.error(`Sync finished with ${avatarFailures} avatar failure(s) and ${repoFailures} repository failure(s).`); process.exitCode = 1; } } catch (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.'); })(); diff --git a/notify.js b/notify.js new file mode 100644 index 0000000..63cb582 --- /dev/null +++ b/notify.js @@ -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; +}); diff --git a/rename-gone-mirrors.js b/rename-gone-mirrors.js index 3eff1b1..a45b388 100644 --- a/rename-gone-mirrors.js +++ b/rename-gone-mirrors.js @@ -1,3 +1,4 @@ +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'); @@ -8,6 +9,7 @@ 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, { @@ -86,6 +88,7 @@ const alreadyGone = (name, original) => 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([ @@ -101,6 +104,7 @@ const alreadyGone = (name, original) => } 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; } @@ -109,13 +113,14 @@ const alreadyGone = (name, original) => } } - let renamed = 0, failures = 0; 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) || 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; 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'); occupied.delete(key(repo.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}`); } catch (error) { - failures++; + facts.failures++; console.error(`Failed to check or rename ${owner}/${repo.name}: ${error.message}`); } } } - console.log(`Renamed ${renamed} missing GitHub mirror(s); ${failures} failure(s).`); - if (failures) process.exitCode = 1; + 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)); });