initial commit

This commit is contained in:
cupcakearmy 2023-03-09 02:09:52 +01:00
commit 2e3993c0ee
No known key found for this signature in database
GPG Key ID: 3235314B4D31232F
19 changed files with 869 additions and 0 deletions

5
.dockerignore Normal file
View File

@ -0,0 +1,5 @@
*
!package.json
!pnpm-lock.yaml
!src
!tsconfig.json

5
.env.sample Normal file
View File

@ -0,0 +1,5 @@
GITHUB_TOKEN=
GITHUB_SCOPE=
GITEA_HOST=
GITEA_TOKEN=

43
.github/workflows/docker.yaml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Publish Docker image
on:
release:
types: [published]
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
install: true
- name: Docker Labels
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/${{ github.repository }}
# This assumes your repository is also github.com/foo/bar
# You could also use ghcr.io/foo/some-package as long as you are the user/org "foo"
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Log in to the Container registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
data
config
.env

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
18.15

19
Dockerfile Normal file
View File

@ -0,0 +1,19 @@
FROM node:18.15-alpine as base
RUN npm -g install pnpm@7
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
FROM base as build
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
FROM base as runner
RUN pnpm install --frozen-lockfile --prod
COPY --from=build /app/dist /app/dist
CMD ["pnpm", "run", "start"]

5
README.md Normal file
View File

@ -0,0 +1,5 @@
# Backup Github repos to Gitea
## Known limitations
- Issues, PR, etc. can be imported, but [not for a mirror](https://github.com/go-gitea/gitea/issues/18369)

17
docker-compose.yaml Normal file
View File

@ -0,0 +1,17 @@
version: "3.8"
services:
job:
build: .
env_file: .env
server:
image: gitea/gitea:1
environment:
- USER_UID=1000
- USER_GID=1000
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"version": "1.0.0-rc.0",
"type": "module",
"scripts": {
"start": "node .",
"build": "tsc",
"dev": "tsc -w"
},
"main": "./dist/index.js",
"dependencies": {
"axios": "^1.3.4",
"dotenv": "^16.0.3",
"node-cron": "^3.0.2",
"winston": "^3.8.2"
},
"devDependencies": {
"@types/node": "18",
"@types/node-cron": "^3.0.7",
"typescript": "^4.9.5"
}
}

293
pnpm-lock.yaml Normal file
View File

@ -0,0 +1,293 @@
lockfileVersion: 5.4
specifiers:
'@types/node': '18'
'@types/node-cron': ^3.0.7
axios: ^1.3.4
dotenv: ^16.0.3
node-cron: ^3.0.2
typescript: ^4.9.5
winston: ^3.8.2
dependencies:
axios: 1.3.4
dotenv: 16.0.3
node-cron: 3.0.2
winston: 3.8.2
devDependencies:
'@types/node': 18.14.6
'@types/node-cron': 3.0.7
typescript: 4.9.5
packages:
/@colors/colors/1.5.0:
resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==}
engines: {node: '>=0.1.90'}
dev: false
/@dabh/diagnostics/2.0.3:
resolution: {integrity: sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==}
dependencies:
colorspace: 1.1.4
enabled: 2.0.0
kuler: 2.0.0
dev: false
/@types/node-cron/3.0.7:
resolution: {integrity: sha512-9PuLtBboc/+JJ7FshmJWv769gDonTpItN0Ol5TMwclpSQNjVyB2SRxSKBcTtbSysSL5R7Oea06kTTFNciCoYwA==}
dev: true
/@types/node/18.14.6:
resolution: {integrity: sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA==}
dev: true
/@types/triple-beam/1.3.2:
resolution: {integrity: sha512-txGIh+0eDFzKGC25zORnswy+br1Ha7hj5cMVwKIU7+s0U2AxxJru/jZSMU6OC9MJWP6+pc/hc6ZjyZShpsyY2g==}
dev: false
/async/3.2.4:
resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==}
dev: false
/asynckit/0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: false
/axios/1.3.4:
resolution: {integrity: sha512-toYm+Bsyl6VC5wSkfkbbNB6ROv7KY93PEBBL6xyDczaIHasAiv4wPqQ/c4RjoQzipxRD2W5g21cOqQulZ7rHwQ==}
dependencies:
follow-redirects: 1.15.2
form-data: 4.0.0
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
dev: false
/color-convert/1.9.3:
resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==}
dependencies:
color-name: 1.1.3
dev: false
/color-name/1.1.3:
resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==}
dev: false
/color-name/1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
dev: false
/color-string/1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
dev: false
/color/3.2.1:
resolution: {integrity: sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==}
dependencies:
color-convert: 1.9.3
color-string: 1.9.1
dev: false
/colorspace/1.1.4:
resolution: {integrity: sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==}
dependencies:
color: 3.2.1
text-hex: 1.0.0
dev: false
/combined-stream/1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: false
/delayed-stream/1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: false
/dotenv/16.0.3:
resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==}
engines: {node: '>=12'}
dev: false
/enabled/2.0.0:
resolution: {integrity: sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==}
dev: false
/fecha/4.2.3:
resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==}
dev: false
/fn.name/1.1.0:
resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==}
dev: false
/follow-redirects/1.15.2:
resolution: {integrity: sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dev: false
/form-data/4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
dev: false
/inherits/2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
dev: false
/is-arrayish/0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
dev: false
/is-stream/2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
dev: false
/kuler/2.0.0:
resolution: {integrity: sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==}
dev: false
/logform/2.5.1:
resolution: {integrity: sha512-9FyqAm9o9NKKfiAKfZoYo9bGXXuwMkxQiQttkT4YjjVtQVIQtK6LmVtlxmCaFswo6N4AfEkHqZTV0taDtPotNg==}
dependencies:
'@colors/colors': 1.5.0
'@types/triple-beam': 1.3.2
fecha: 4.2.3
ms: 2.1.3
safe-stable-stringify: 2.4.2
triple-beam: 1.3.0
dev: false
/mime-db/1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: false
/mime-types/2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: false
/ms/2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: false
/node-cron/3.0.2:
resolution: {integrity: sha512-iP8l0yGlNpE0e6q1o185yOApANRe47UPbLf4YxfbiNHt/RU5eBcGB/e0oudruheSf+LQeDMezqC5BVAb5wwRcQ==}
engines: {node: '>=6.0.0'}
dependencies:
uuid: 8.3.2
dev: false
/one-time/1.0.0:
resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==}
dependencies:
fn.name: 1.1.0
dev: false
/proxy-from-env/1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
dev: false
/readable-stream/3.6.1:
resolution: {integrity: sha512-+rQmrWMYGA90yenhTYsLWAsLsqVC8osOw6PKE1HDYiO0gdPeKe/xDHNzIAIn4C91YQ6oenEhfYqqc1883qHbjQ==}
engines: {node: '>= 6'}
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
dev: false
/safe-buffer/5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
dev: false
/safe-stable-stringify/2.4.2:
resolution: {integrity: sha512-gMxvPJYhP0O9n2pvcfYfIuYgbledAOJFcqRThtPRmjscaipiwcwPPKLytpVzMkG2HAN87Qmo2d4PtGiri1dSLA==}
engines: {node: '>=10'}
dev: false
/simple-swizzle/0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
dependencies:
is-arrayish: 0.3.2
dev: false
/stack-trace/0.0.10:
resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==}
dev: false
/string_decoder/1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
dependencies:
safe-buffer: 5.2.1
dev: false
/text-hex/1.0.0:
resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==}
dev: false
/triple-beam/1.3.0:
resolution: {integrity: sha512-XrHUvV5HpdLmIj4uVMxHggLbFSZYIn7HEWsqePZcI50pco+MPqJ50wMGY794X7AOOhxOBAjbkqfAbEe/QMp2Lw==}
dev: false
/typescript/4.9.5:
resolution: {integrity: sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==}
engines: {node: '>=4.2.0'}
hasBin: true
dev: true
/util-deprecate/1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
dev: false
/uuid/8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
dev: false
/winston-transport/4.5.0:
resolution: {integrity: sha512-YpZzcUzBedhlTAfJg6vJDlyEai/IFMIVcaEZZyl3UXIl4gmqRpU7AE89AHLkbzLUsv0NVmw7ts+iztqKxxPW1Q==}
engines: {node: '>= 6.4.0'}
dependencies:
logform: 2.5.1
readable-stream: 3.6.1
triple-beam: 1.3.0
dev: false
/winston/3.8.2:
resolution: {integrity: sha512-MsE1gRx1m5jdTTO9Ld/vND4krP2To+lgDoMEHGGa4HIlAUyXJtfc7CxQcGXVyz2IBpw5hbFkj2b/AtUdQwyRew==}
engines: {node: '>= 12.0.0'}
dependencies:
'@colors/colors': 1.5.0
'@dabh/diagnostics': 2.0.3
async: 3.2.4
is-stream: 2.0.1
logform: 2.5.1
one-time: 1.0.0
readable-stream: 3.6.1
safe-stable-stringify: 2.4.2
stack-trace: 0.0.10
triple-beam: 1.3.0
winston-transport: 4.5.0
dev: false

79
src/api/gitea.ts Normal file
View File

@ -0,0 +1,79 @@
import axios, { AxiosError } from 'axios'
import { Config } from '../config.js'
import { logger } from '../logger.js'
import { ListRepositoriesResponse } from './gitea.types.js'
const Base = axios.create({
baseURL: new URL('/api/v1', Config.gitea.host).href,
headers: {
Authorization: `token ${Config.gitea.token}`,
},
})
export type MirrorOptions = {
private: boolean
auth_token: string
clone_addr: string
repo_name: string
}
export async function mirror(options: MirrorOptions) {
try {
logger.debug('Mirroring repository', options)
const response = await Base({
url: '/repos/migrate',
method: 'POST',
data: {
...options,
lfs: true,
mirror: true,
service: 'github',
wiki: true,
// These don't work for now on mirrored repos.
// https://github.com/go-gitea/gitea/issues/18369
issues: true,
labels: true,
milestones: true,
pull_requests: true,
releases: true,
},
})
logger.debug('Mirrored repository', { data: response.data })
return response.data
} catch (e) {
if (e instanceof AxiosError) {
logger.error('Error mirroring repository', e.response?.data)
} else {
logger.error('Unknown error', e)
}
}
}
export async function listRepositories(page: number, limit: number) {
const response = await Base<ListRepositoriesResponse>({
url: '/user/repos',
method: 'GET',
params: {
page,
limit,
},
})
return response.data
}
export async function listAllRepositories() {
logger.debug('Listing all repositories in Gitea')
const limit = 50
const repos: ListRepositoriesResponse = []
let page = 1
while (true) {
const response = await listRepositories(page, limit)
repos.push(...response)
if (response.length < limit) break
page++
}
logger.debug('Listed all repositories in Gitea', { data: repos })
return repos
}

88
src/api/gitea.types.ts Normal file
View File

@ -0,0 +1,88 @@
export type ListRepositoriesResponse = Repository[]
export interface Repository {
id: number
owner: Owner
name: string
full_name: string
description: string
empty: boolean
private: boolean
fork: boolean
template: boolean
parent: null
mirror: boolean
size: number
language: string
languages_url: string
html_url: string
ssh_url: string
clone_url: string
original_url: string
website: string
stars_count: number
forks_count: number
watchers_count: number
open_issues_count: number
open_pr_counter: number
release_counter: number
default_branch: string
archived: boolean
created_at: Date
updated_at: Date
permissions: Permissions
has_issues: boolean
internal_tracker: InternalTracker
has_wiki: boolean
has_pull_requests: boolean
has_projects: boolean
ignore_whitespace_conflicts: boolean
allow_merge_commits: boolean
allow_rebase: boolean
allow_rebase_explicit: boolean
allow_squash_merge: boolean
allow_rebase_update: boolean
default_delete_branch_after_merge: boolean
default_merge_style: string
avatar_url: string
internal: boolean
mirror_interval: string
mirror_updated: Date
repo_transfer: null
}
export interface InternalTracker {
enable_time_tracker: boolean
allow_only_contributors_to_track_time: boolean
enable_issue_dependencies: boolean
}
export interface Owner {
id: number
login: string
login_name: string
full_name: string
email: string
avatar_url: string
language: string
is_admin: boolean
last_login: Date
created: Date
restricted: boolean
active: boolean
prohibit_login: boolean
location: string
website: string
description: string
visibility: string
followers_count: number
following_count: number
starred_repos_count: number
username: string
}
export interface Permissions {
admin: boolean
push: boolean
pull: boolean
}

39
src/api/github.ts Normal file
View File

@ -0,0 +1,39 @@
import axios from 'axios'
import { Config } from '../config.js'
import type { ListRepositoriesResponse } from './github.types.js'
const Base = axios.create({
baseURL: 'https://api.github.com',
headers: {
Authorization: `Bearer ${Config.github.token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
})
async function listRepos(page: number, limit: number) {
const response = await Base<ListRepositoriesResponse>({
url: `user/repos`,
method: 'GET',
params: {
page,
per_page: limit,
affiliation: 'owner',
},
})
return response.data
}
export async function listAllRepositories() {
const limit = 100
const repos: ListRepositoriesResponse = []
let page = 1
while (true) {
const response = await listRepos(page, limit)
repos.push(...response)
if (response.length < limit) break
page++
}
return repos
}

113
src/api/github.types.ts Normal file
View File

@ -0,0 +1,113 @@
export type ListRepositoriesResponse = Repository[]
export interface Repository {
id: number
node_id: string
name: string
full_name: string
private: boolean
owner: Owner
html_url: string
description: string
fork: boolean
url: string
forks_url: string
keys_url: string
collaborators_url: string
teams_url: string
hooks_url: string
issue_events_url: string
events_url: string
assignees_url: string
branches_url: string
tags_url: string
blobs_url: string
git_tags_url: string
git_refs_url: string
trees_url: string
statuses_url: string
languages_url: string
stargazers_url: string
contributors_url: string
subscribers_url: string
subscription_url: string
commits_url: string
git_commits_url: string
comments_url: string
issue_comment_url: string
contents_url: string
compare_url: string
merges_url: string
archive_url: string
downloads_url: string
issues_url: string
pulls_url: string
milestones_url: string
notifications_url: string
labels_url: string
releases_url: string
deployments_url: string
created_at: Date
updated_at: Date
pushed_at: Date
git_url: string
ssh_url: string
clone_url: string
svn_url: string
homepage: string
size: number
stargazers_count: number
watchers_count: number
language: string
has_issues: boolean
has_projects: boolean
has_downloads: boolean
has_wiki: boolean
has_pages: boolean
has_discussions: boolean
forks_count: number
mirror_url: null
archived: boolean
disabled: boolean
open_issues_count: number
license: null
allow_forking: boolean
is_template: boolean
web_commit_signoff_required: boolean
topics: string[]
visibility: string
forks: number
open_issues: number
watchers: number
default_branch: string
permissions: Permissions
}
export interface Owner {
login: string
id: number
node_id: string
avatar_url: string
gravatar_id: string
url: string
html_url: string
followers_url: string
following_url: string
gists_url: string
starred_url: string
subscriptions_url: string
organizations_url: string
repos_url: string
events_url: string
received_events_url: string
type: string
site_admin: boolean
}
export interface Permissions {
admin: boolean
maintain: boolean
push: boolean
triage: boolean
pull: boolean
}

49
src/config.ts Normal file
View File

@ -0,0 +1,49 @@
import { config } from 'dotenv'
config()
function getEnv(key: string, fallback: string, parse?: undefined, validator?: (s: string) => boolean): string
function getEnv<T>(key: string, fallback: T, parse: (value: string) => T, validator?: (T: string) => boolean): T
function getEnv<T>(
key: string,
fallback: T,
parse?: (value: string) => T,
validator?: (s: string | T) => boolean
): T | string {
const value = process.env[key]
const parsed = value === undefined ? fallback : parse ? parse(value) : value
if (validator && !validator(parsed)) {
console.error(`Invalid or missing value for ${key}: ${value}`)
process.exit(1)
}
return parsed
}
function parseBoolean(value: string): boolean {
value = value.toLowerCase()
const truthy = ['true', 'yes', '1', 'y']
return truthy.includes(value)
}
function isPresent(s: string): boolean {
return s.length > 0
}
function simple(key: string) {
return getEnv(key, '', undefined, isPresent)
}
export const Config = {
logging: {
level: getEnv('LOG_LEVEL', 'info'),
},
github: {
scope: simple('GITHUB_SCOPE'),
token: simple('GITHUB_TOKEN'),
},
gitea: {
host: simple('GITEA_HOST'),
token: simple('GITEA_TOKEN'),
},
version: getEnv('npm_package_version', 'unknown'),
}

51
src/core.ts Normal file
View File

@ -0,0 +1,51 @@
import { listAllRepositories as giteaRepos, mirror, MirrorOptions } from './api/gitea.js'
import { listAllRepositories as githubRepos } from './api/github.js'
import { Config } from './config.js'
import { logger } from './logger.js'
let running = false
export async function sync() {
if (running) {
logger.info('Already running, skipping')
return
}
try {
logger.info('Starting sync')
const syncedRepos = await giteaRepos()
const toSync = await githubRepos()
logger.debug('Loaded repos', { remote: toSync.length, local: syncedRepos.length })
for (const repo of toSync) {
const sameName = syncedRepos.find((r) => r.name === repo.name || r.original_url === repo.clone_url)
if (sameName) {
if (sameName.original_url === repo.clone_url) {
logger.info('Already synced, skipping', { name: repo.name })
} else {
logger.error('Repo with same name but different url', {
name: repo.name,
url: repo.clone_url,
original_url: sameName.original_url,
})
}
continue
}
const options: MirrorOptions = {
repo_name: repo.name,
clone_addr: repo.clone_url,
private: repo.private,
auth_token: Config.github.token,
}
logger.info('Mirroring repository', options)
await mirror(options)
logger.info('Mirrored repository', { name: repo.name })
}
logger.info('Finished sync')
} catch (e) {
logger.error(e)
logger.error('Failed to sync')
} finally {
running = false
}
}

10
src/index.ts Normal file
View File

@ -0,0 +1,10 @@
import cron from 'node-cron'
import { Config } from './config.js'
import { sync } from './core.js'
import { logger } from './logger.js'
logger.info(`Mirror manager - ${Config.version}`, { version: Config.version })
await sync()
cron.schedule('0/5 * * * *', sync)

12
src/logger.ts Normal file
View File

@ -0,0 +1,12 @@
import winston from 'winston'
import { Config } from './config.js'
export const logger = winston.createLogger({
level: Config.logging.level,
transports: [
new winston.transports.Console({
format: winston.format.combine(winston.format.timestamp(), winston.format.colorize(), winston.format.simple()),
}),
],
})

12
tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "es2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
"module": "ES2022" /* Specify what module code is generated. */,
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
"outDir": "./dist" /* Specify an output folder for all emitted files. */,
"esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
"strict": true /* Enable all strict type-checking options. */,
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}