commit 3a0d6186c94a50232648715f24a3ec550887d45a Author: Jeena Date: Tue Aug 19 10:13:22 2025 +0900 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..a8d332d --- /dev/null +++ b/Pipfile @@ -0,0 +1,13 @@ +[[source]] +name = "pypi" +url = "https://pypi.org/simple" +verify_ssl = true + +[dev-packages] + +[packages] +requests = "*" +python-dotenv = "*" + +[requires] +python_version = "3.11" diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..52266ae --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,148 @@ +{ + "_meta": { + "hash": { + "sha256": "67ce3909f0bc2928002eee2dc1c7699080fb382e69d870314013aadc72d077c2" + }, + "pipfile-spec": 6, + "requires": { + "python_version": "3.11" + }, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "certifi": { + "hashes": [ + "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", + "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" + ], + "markers": "python_version >= '3.7'", + "version": "==2025.8.3" + }, + "charset-normalizer": { + "hashes": [ + "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91", + "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0", + "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", + "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601", + "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", + "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07", + "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c", + "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64", + "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", + "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", + "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432", + "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", + "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", + "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", + "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae", + "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19", + "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", + "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e", + "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4", + "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7", + "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312", + "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", + "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", + "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c", + "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", + "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99", + "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b", + "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", + "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", + "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", + "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", + "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", + "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0", + "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc", + "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", + "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f", + "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a", + "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40", + "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", + "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849", + "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", + "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", + "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05", + "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", + "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c", + "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a", + "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", + "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34", + "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9", + "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", + "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14", + "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30", + "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b", + "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b", + "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942", + "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", + "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", + "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b", + "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", + "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669", + "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0", + "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", + "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", + "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe", + "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", + "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", + "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", + "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2", + "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca", + "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", + "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f", + "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb", + "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", + "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557", + "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", + "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7", + "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72", + "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c", + "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9" + ], + "markers": "python_version >= '3.7'", + "version": "==3.4.3" + }, + "idna": { + "hashes": [ + "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", + "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + ], + "markers": "python_version >= '3.6'", + "version": "==3.10" + }, + "python-dotenv": { + "hashes": [ + "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", + "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab" + ], + "index": "pypi", + "markers": "python_version >= '3.9'", + "version": "==1.1.1" + }, + "requests": { + "hashes": [ + "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", + "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422" + ], + "index": "pypi", + "markers": "python_version >= '3.8'", + "version": "==2.32.4" + }, + "urllib3": { + "hashes": [ + "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", + "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + ], + "markers": "python_version >= '3.9'", + "version": "==2.5.0" + } + }, + "develop": {} +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..fd73ae3 --- /dev/null +++ b/README.md @@ -0,0 +1,59 @@ +# GitHub to Forgejo Migration Script + +This repository contains a Python script to migrate repositories from **GitHub** to **Forgejo**. +It supports private and public repositories owned by the authenticated GitHub user. + +## Setup + +1. Clone this repository: + + ```bash + git clone git@your-forgejo-instance:username/github-to-forgejo.git + cd github-to-forgejo + ``` + +2. Install dependencies with Pipenv: + + ```bash + pipenv install + ``` + +## Authentication + +You need to create **personal access tokens** for both GitHub and Forgejo. + +### GitHub Token + +1. Go to [GitHub Personal Access Tokens](https://github.com/settings/tokens). +2. Create a new **classic** token with the following scopes: + - `repo` (required for private repos) + - `read:project` (if you want project boards) + - `read:org` (optional, if repos are in orgs) + - `read:discussion` (optional, usually not needed) +3. Copy the generated token. + +### Forgejo Token + +1. Log into your Forgejo instance. +2. Go to **Settings → Applications → Manage Access Tokens**. +3. Create a token with these scopes: + - `repository (read/write)` + - `issue (read/write)` + - `misc (read/write)` + - `user (read/write)` +4. Copy the generated token. + +### .env file + +The settings are stored in the .env file. Copy the env-example file and call +it .env + +Put in both tokens and fill in the rest accordingly like usernames and URLs. + +## Usage + +Run the script to migrate repositories: + +```bash +pipenv run python migrate_github_to_forgejo.py +``` diff --git a/env-example b/env-example new file mode 100644 index 0000000..014c873 --- /dev/null +++ b/env-example @@ -0,0 +1,15 @@ +# .env +GITHUB_TOKEN= +GITHUB_USERNAME= + +FORGEJO_TOKEN= +FORGEJO_USERNAME= +FORGEJO_URL= + +MIRROR=False +PRIVATE=True +IMPORT_ISSUES=True +IMPORT_LABELS=True +IMPORT_MILESTONES=True +IMPORT_PR=True +IMPORT_WIKI=True diff --git a/migrate_github_to_forgejo.py b/migrate_github_to_forgejo.py new file mode 100755 index 0000000..7fec648 --- /dev/null +++ b/migrate_github_to_forgejo.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +import requests +import json +from dotenv import load_dotenv +import os + +# ------------------ CONFIG ------------------ +load_dotenv() + +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +GITHUB_USERNAME = os.getenv("GITHUB_USERNAME") + +FORGEJO_TOKEN = os.getenv("FORGEJO_TOKEN") +FORGEJO_USERNAME = os.getenv("FORGEJO_USERNAME") # only for repo path +FORGEJO_URL = os.getenv("FORGEJO_URL") + +# Migration options +def str2bool(v): + return str(v).lower() in ("yes", "true", "1") + +MIRROR = str2bool(os.getenv("MIRROR", "False")) +IMPORT_ISSUES = str2bool(os.getenv("IMPORT_ISSUES", "True")) +IMPORT_LABELS = str2bool(os.getenv("IMPORT_LABELS", "True")) +IMPORT_MILESTONES = str2bool(os.getenv("IMPORT_MILESTONES", "True")) +IMPORT_PR = str2bool(os.getenv("IMPORT_PR", "True")) +IMPORT_WIKI = str2bool(os.getenv("IMPORT_WIKI", "True")) +# -------------------------------------------- + +# Get Forgejo user ID using personal token +def get_forgejo_uid(): + headers = {"Authorization": f"token {FORGEJO_TOKEN}"} + resp = requests.get(f"{FORGEJO_URL}/api/v1/user", headers=headers) + resp.raise_for_status() + return resp.json()["id"] + +# Get GitHub repos including archived status and privacy +def get_github_repos(): + headers = {"Authorization": f"token {GITHUB_TOKEN}"} + repos = [] + page = 1 + while True: + # Use /user/repos for authenticated user and visibility=all + url = f"https://api.github.com/user/repos?per_page=100&page={page}&visibility=all&affiliation=owner" + resp = requests.get(url, headers=headers) + resp.raise_for_status() + data = resp.json() + if not data: + break + for r in data: + repos.append({ + "name": r["name"], + "archived": r["archived"], + "private": r["private"] + }) + page += 1 + return repos + +# Check if repo exists in Forgejo +def get_forgejo_repo(repo_name): + headers = {"Authorization": f"token {FORGEJO_TOKEN}"} + resp = requests.get(f"{FORGEJO_URL}/api/v1/repos/{FORGEJO_USERNAME}/{repo_name}", headers=headers) + if resp.status_code == 200: + return resp.json() + return None + +# Migrate a repo to Forgejo using authenticated clone if private +def migrate_repo(repo_name, uid, private): + # Use HTTPS clone URL for GitHub + clone_url = f"https://{GITHUB_USERNAME}:{GITHUB_TOKEN}@github.com/{GITHUB_USERNAME}/{repo_name}.git" if private else f"https://github.com/{GITHUB_USERNAME}/{repo_name}.git" + headers = {"Authorization": f"token {FORGEJO_TOKEN}", "Content-Type": "application/json"} + payload = { + "clone_addr": clone_url, + "uid": uid, + "repo_name": repo_name, + "mirror": MIRROR, + "private": private, + "auth_username": GITHUB_USERNAME if private else "", + "auth_password": GITHUB_TOKEN if private else "", + "issues": IMPORT_ISSUES, + "labels": IMPORT_LABELS, + "milestones": IMPORT_MILESTONES, + "pull_requests": IMPORT_PR, + "wiki": IMPORT_WIKI + } + resp = requests.post(f"{FORGEJO_URL}/api/v1/repos/migrate", headers=headers, data=json.dumps(payload)) + if resp.status_code == 201: + print(f"[SUCCESS] Migrated {repo_name}") + else: + print(f"[ERROR] Failed to migrate {repo_name}: {resp.status_code} {resp.text}") + +# Update existing repo visibility or archive +def update_repo(repo_name, private, archived): + headers = {"Authorization": f"token {FORGEJO_TOKEN}", "Content-Type": "application/json"} + payload = {"private": private, "archived": archived} + resp = requests.patch(f"{FORGEJO_URL}/api/v1/repos/{FORGEJO_USERNAME}/{repo_name}", headers=headers, data=json.dumps(payload)) + if resp.status_code == 200: + print(f"[UPDATED] {repo_name}: private={private}, archived={archived}") + else: + print(f"[ERROR] Failed to update {repo_name}: {resp.status_code} {resp.text}") + +def main(): + uid = get_forgejo_uid() + repos = get_github_repos() + print(f"Found {len(repos)} repos on GitHub.") + + for repo in repos: + name = repo["name"] + private = repo["private"] + archived = repo["archived"] + + existing = get_forgejo_repo(name) + if existing: + # Update visibility and archive status if needed + update_repo(name, private, archived) + continue + + migrate_repo(name, uid, private) + if archived: + update_repo(name, private, archived) # archive after migration + +if __name__ == "__main__": + main()