mirror of
https://github.com/adamzend/gitea-github-sync.git
synced 2026-10-30 19:01:59 +01:00
Release v1.0.0: synchronize GitHub mirror metadata in Gitea
This commit is contained in:
commit
b0cb018f7d
6 files changed
+920
No files matched your search
@@ -0,0 +1,2 @@
|
||||
* text=auto eol=lf
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1,5 @@
|
||||
gitea-github-sync.conf
|
||||
*.log
|
||||
*.lock
|
||||
*.bak
|
||||
.env
|
||||
@@ -0,0 +1,103 @@
|
||||
# Gitea GitHub Sync
|
||||
|
||||
Enrich all GitHub pull mirrors owned by a Gitea account with repository metadata,
|
||||
topics, issues and comments that native Git mirroring does not synchronize.
|
||||
|
||||
The script discovers repositories automatically and derives each GitHub source
|
||||
from the mirror URL. Source and destination repository names can differ.
|
||||
|
||||
## Features
|
||||
|
||||
- Automatic discovery, including private mirrors visible to the Gitea token.
|
||||
- Description, website, default branch and topic synchronization.
|
||||
- Issue titles, bodies, open/closed states and comment synchronization.
|
||||
- Existing synchronization markers prevent duplicate imports on repeated runs.
|
||||
- Foreground preview, foreground apply and quiet background apply modes.
|
||||
- GitHub rate-limit diagnostics, bounded waits and account-level stop conditions.
|
||||
- Repository isolation, a shared process lock and per-repository summaries.
|
||||
- Optional native Gitea mirror-sync requests.
|
||||
|
||||
## Requirements
|
||||
|
||||
Linux, Bash 4.3+, jq 1.6+, curl 7.55+, util-linux and coreutils.
|
||||
|
||||
On Debian/Ubuntu:
|
||||
|
||||
```bash
|
||||
sudo apt-get install bash curl jq util-linux coreutils
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Use `gitea-github-sync.conf.example` as the template for
|
||||
`/etc/gitea-github-sync.conf`. Update an existing configuration rather than
|
||||
overwriting its tokens. Set your Gitea URL and token, the account owner, and a
|
||||
GitHub token where needed. An empty `GITEA_OWNER` selects the token's account.
|
||||
|
||||
The example contains deployment-specific defaults; replace them for your server.
|
||||
Restrict the configuration to its owner:
|
||||
|
||||
```bash
|
||||
sudo chmod 600 /etc/gitea-github-sync.conf
|
||||
```
|
||||
|
||||
Never commit the populated configuration or tokens to this repository.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Preview without API writes (the default)
|
||||
sudo bash ./gitea-github-sync.sh --dry-run
|
||||
|
||||
# Synchronize in the foreground
|
||||
sudo bash ./gitea-github-sync.sh --apply
|
||||
|
||||
# Synchronize in the background without terminal output
|
||||
sudo bash ./gitea-github-sync.sh --apply-quiet
|
||||
|
||||
# Enrich mirrors without queuing native Git synchronization
|
||||
sudo bash ./gitea-github-sync.sh --apply --no-git
|
||||
```
|
||||
|
||||
Use `--config FILE` for another configuration file and `--help` for all options.
|
||||
Choose only one execution mode.
|
||||
|
||||
Quiet mode appends progress and errors to `/var/log/gitea-github-sync.log`,
|
||||
configurable through `QUIET_LOG_FILE`. Its launcher returns immediately;
|
||||
check the log's final `exit_status` to determine the actual result.
|
||||
|
||||
```bash
|
||||
sudo tail -f /var/log/gitea-github-sync.log
|
||||
```
|
||||
|
||||
**Depending on the number of repositories/mirrors and their issues/comments,
|
||||
execution can take a very long time, potentially hours.** Quiet mode does not
|
||||
speed up synchronization. GitHub limits may require a later run.
|
||||
|
||||
## YunoHost deployment
|
||||
|
||||
The original deployment uses Gitea installed through YunoHost. YunoHost manages
|
||||
its own installation and data directories; repository storage paths can differ
|
||||
from standalone Gitea examples. This script uses HTTP APIs and does not require
|
||||
local repository paths. Include any web path prefix in `GITEA_URL`.
|
||||
|
||||
See the [full English guide](README.txt) for locating the effective configuration,
|
||||
YunoHost path details, all settings, all Bash functions and troubleshooting.
|
||||
|
||||
## Scope and limitations
|
||||
|
||||
Pull requests, releases/assets, labels, milestones, reactions, assignments,
|
||||
original authorship and timestamps are not copied. Source deletions do not delete
|
||||
Gitea records. Unmarked local records are untouched; previous imports without
|
||||
markers require separate mapping. Local edits to synchronized fields are replaced
|
||||
by the GitHub source. Disabled internal issue trackers receive metadata updates
|
||||
only. Native mirror-sync runs asynchronously in Gitea.
|
||||
|
||||
The script has been checked with Bash syntax validation, ShellCheck and local
|
||||
mock API tests, including multiple mirrors, idempotency, throttling and quiet mode.
|
||||
|
||||
## Downloads
|
||||
|
||||
Download the script or the complete bundle from
|
||||
[GitHub Releases](https://github.com/adamzend/gitea-github-sync/releases).
|
||||
The first release is **v1.0.0**.
|
||||
+269
@@ -0,0 +1,269 @@
|
||||
GitHub mirror enrichment for a Gitea account
|
||||
==========================================
|
||||
|
||||
Purpose and workflow
|
||||
--------------------
|
||||
This script complements Gitea's native GitHub pull mirrors. It discovers the
|
||||
account's repositories through the Gitea REST API, identifies GitHub sources,
|
||||
reads the source and destination data, compares their contents, and applies
|
||||
missing or changed metadata, issues and comments. It processes one repository
|
||||
at a time and can optionally queue Gitea's own background Git mirror job.
|
||||
|
||||
IMPORTANT: Depending on the number of repositories/mirrors and the number of
|
||||
issues and comments, execution can take a very long time. Large accounts may
|
||||
require hours. Network latency, API pagination, server load, rate-limit waits
|
||||
and the number of required writes all affect duration. Dry-run also reads the
|
||||
APIs and can take a long time. There is no fixed completion-time guarantee.
|
||||
Quiet mode changes execution/output handling; it does not make the work faster.
|
||||
|
||||
Configuration
|
||||
-------------
|
||||
Linux requirements: Bash >= 4.3, jq >= 1.6, curl >= 7.55, flock (util-linux),
|
||||
coreutils. On Debian/Ubuntu:
|
||||
sudo apt-get install bash curl jq util-linux coreutils
|
||||
|
||||
Save the example configuration as /etc/gitea-github-sync.conf, or update your
|
||||
existing file. Set GITEA_URL, GITEA_TOKEN and GITEA_OWNER. An empty GITEA_OWNER
|
||||
selects the account identified by GITEA_TOKEN. GITHUB_TOKEN is optional for
|
||||
public sources and required for private sources. It is recommended for large
|
||||
scans because of GitHub API rate limits.
|
||||
|
||||
The Gitea token must allow account/repository reads and repository settings,
|
||||
issues and comment writes. Its account must be able to edit imported comments.
|
||||
The configuration is trusted Bash code. Restrict its permissions:
|
||||
sudo chmod 600 /etc/gitea-github-sync.conf
|
||||
|
||||
Execution
|
||||
---------
|
||||
From the directory containing the downloaded script:
|
||||
sudo bash ./gitea-github-sync.sh --dry-run
|
||||
sudo bash ./gitea-github-sync.sh --apply
|
||||
sudo bash ./gitea-github-sync.sh --apply-quiet
|
||||
|
||||
Alternative configuration:
|
||||
bash ./gitea-github-sync.sh --config ./my-config.conf --dry-run
|
||||
|
||||
Dry-run is the default and makes no API writes. --no-git skips mirror-sync
|
||||
requests while still enriching repositories. Non-root runs need LOCK_FILE in
|
||||
a writable, trusted directory. All runs must use the same lock file.
|
||||
No schedule is installed; the script can be invoked by cron or systemd.
|
||||
|
||||
Complete command-line reference
|
||||
-------------------------------
|
||||
--dry-run
|
||||
Foreground preview; reads APIs and reports planned changes. No API writes.
|
||||
This is the default if no execution mode is provided.
|
||||
--apply
|
||||
Foreground synchronization. Logs are printed to the terminal. The command
|
||||
returns when synchronization ends and exposes the final exit status.
|
||||
--apply-quiet
|
||||
Background synchronization with the same changes as --apply. The launcher
|
||||
returns immediately. The script prints nothing to the terminal and appends
|
||||
stdout/stderr, warnings and errors to QUIET_LOG_FILE. nohup protects the worker
|
||||
from ordinary terminal hangups; stdin is disconnected. No extra '&' is needed.
|
||||
Reboot, service/session policies that kill processes, and explicit termination
|
||||
can still stop it. This is not a system service and has no automatic restart.
|
||||
--config FILE
|
||||
Use a different trusted Bash configuration file. Default:
|
||||
/etc/gitea-github-sync.conf (or CONFIG_FILE from the environment).
|
||||
--no-git
|
||||
Do not queue native mirror-sync. Metadata, issue and comment enrichment still
|
||||
runs. It works with any execution mode.
|
||||
--help / -h
|
||||
Display usage without synchronizing anything.
|
||||
|
||||
Choose only one execution mode. For example:
|
||||
sudo bash ./gitea-github-sync.sh --apply-quiet --no-git
|
||||
|
||||
Quiet-mode logs and completion
|
||||
-----------------------------
|
||||
Default log: /var/log/gitea-github-sync.log
|
||||
Override it in the configuration with an absolute path:
|
||||
QUIET_LOG_FILE='/var/log/gitea-github-sync.log'
|
||||
|
||||
The parent directory must already exist and be writable by the executing user.
|
||||
The script creates/appends the log with mode 600. For non-root runs, use a log
|
||||
in a directory owned by that user. The log grows across runs; arrange log
|
||||
rotation separately if needed. It is separate from Gitea's application logs.
|
||||
|
||||
Read progress when desired:
|
||||
sudo tail -n 100 /var/log/gitea-github-sync.log
|
||||
sudo tail -f /var/log/gitea-github-sync.log
|
||||
|
||||
Each worker logs its PID and ends with:
|
||||
Background worker finished: PID=12345, exit_status=0
|
||||
Zero means successful script completion; nonzero means an error or interruption.
|
||||
The launcher's exit status 0 only means the background command was dispatched,
|
||||
not that synchronization succeeded. Check the completion line in the log.
|
||||
If it cannot read configuration or open the log, the launcher fails silently;
|
||||
immediately use 'echo $?' to inspect its nonzero status. An uncatchable kill or
|
||||
power loss can leave the log without a completion line.
|
||||
|
||||
The worker uses the same LOCK_FILE as foreground runs. A second invocation
|
||||
cannot synchronize concurrently; a rejected quiet worker records the lock error
|
||||
in its log. For cron/systemd jobs that need the final exit status, prefer --apply
|
||||
with output managed by the scheduler instead of detaching with --apply-quiet.
|
||||
|
||||
Configuration reference
|
||||
-----------------------
|
||||
GITEA_URL: Base web URL, default https://git.an0n.eu. Include any path prefix,
|
||||
for example https://example.org/gitea. Do not append /api/v1.
|
||||
GITEA_OWNER: Owner to scan; empty means the authenticated token account.
|
||||
GITEA_TOKEN: Required Gitea API token; distinct from the GitHub token.
|
||||
GITHUB_TOKEN: Optional for public GitHub sources, required for private sources.
|
||||
GITHUB_API: GitHub API base, default https://api.github.com. Source discovery
|
||||
currently recognizes GitHub.com URLs, not arbitrary enterprise hosts.
|
||||
GITHUB_API_VERSION: Default 2026-03-10.
|
||||
LOCK_FILE: Default /var/run/gitea-github-sync.lock; shared by all execution modes.
|
||||
MAX_PAGES: Default 10000; maximum pages per paginated API listing.
|
||||
GITHUB_RATE_LIMIT_MAX_WAIT: Default 60; total wait budget in seconds per request.
|
||||
GITHUB_RATE_LIMIT_RETRIES: Default 2; maximum 10; allowed throttling retries.
|
||||
QUIET_LOG_FILE: Default /var/log/gitea-github-sync.log; --apply-quiet output.
|
||||
TMPDIR: Optional temporary-directory base; defaults to /tmp. The script creates
|
||||
a private working directory and removes it on normal exit or handled signals.
|
||||
Legacy GITEA_REPO/GITHUB_OWNER/GITHUB_REPO values do not select a single mirror.
|
||||
|
||||
Gitea installed through YunoHost
|
||||
-------------------------------
|
||||
In the deployment this script was prepared for, Gitea is installed through
|
||||
YunoHost. Its managed installation and data directories differ from paths in
|
||||
generic standalone Gitea guides. Do not assume repositories live in
|
||||
/var/lib/gitea or /home/git/gitea-repositories.
|
||||
|
||||
The YunoHost package's configuration template sets [repository] ROOT to
|
||||
<data_dir>/repositories and APP_DATA_PATH to <data_dir>/data. The exact data_dir,
|
||||
install_dir and service name depend on the installed app instance, package
|
||||
version, and migration history. A second instance can have a different app ID.
|
||||
Inspect the installed service to find its effective configuration:
|
||||
sudo systemctl cat gitea
|
||||
Use the actual instance's service name if it differs. If ExecStart specifies a
|
||||
configuration file, inspect that file. The package installs app.ini at
|
||||
<install_dir>/custom/conf/app.ini; install_dir is the directory containing the
|
||||
Gitea executable in ExecStart. Read [repository] ROOT in the installed app.ini.
|
||||
Do not publish the whole configuration; it contains credentials. YunoHost
|
||||
manages this file and package upgrades may overwrite manual edits.
|
||||
|
||||
No local repository path is needed by this script: it operates exclusively
|
||||
through HTTP APIs and lets Gitea perform Git synchronization. Leave storage
|
||||
directories and their ownership under YunoHost management. The script's own
|
||||
/etc/gitea-github-sync.conf and /var/log/gitea-github-sync.log are independent
|
||||
files, not the YunoHost Gitea application's app.ini or service log.
|
||||
|
||||
If YunoHost serves Gitea below /gitea, use that prefix in GITEA_URL. The API must
|
||||
be reachable through YunoHost's reverse proxy with token authentication. An
|
||||
SSO redirect or access-denied HTML response prevents API use even with a valid
|
||||
Gitea token; check app access settings without making private repositories public.
|
||||
|
||||
Package references:
|
||||
https://github.com/YunoHost-Apps/gitea_ynh/blob/master/conf/app.ini
|
||||
https://github.com/YunoHost-Apps/gitea_ynh/blob/master/manifest.toml
|
||||
https://github.com/YunoHost-Apps/gitea_ynh/blob/master/scripts/install
|
||||
|
||||
Discovery
|
||||
---------
|
||||
All pages of /api/v1/users/{owner}/repos are read. Only repositories owned by
|
||||
that account with mirror=true and a GitHub.com original_url are selected.
|
||||
Private repositories are included when visible to the token. Restricted token
|
||||
access can limit discovery; inaccessible repositories cannot be detected.
|
||||
Collaborator and organization repositories are not implicitly included.
|
||||
|
||||
Supported sources: HTTPS/HTTP GitHub URLs, git@github.com:owner/repo.git and
|
||||
ssh://git@github.com/owner/repo.git, with optional .git suffix and trailing slash.
|
||||
HTTPS credentials and standard SSH port 22 are supported. Embedded credentials
|
||||
are not logged or reused; configure GITHUB_TOKEN separately.
|
||||
Each mirror's source is derived independently; the destination name can differ.
|
||||
Legacy GITEA_REPO, GITHUB_OWNER and GITHUB_REPO settings are ignored.
|
||||
Non-mirrors, non-GitHub sources and repositories owned by others are skipped.
|
||||
Archived GitHub mirrors are reported as failures; remaining repositories run.
|
||||
|
||||
Scope
|
||||
-----
|
||||
The original enrichment scope is preserved: description, website, default
|
||||
branch, topics, issue titles/bodies/states and issue comment bodies. Mirrors
|
||||
without an internal issue tracker receive metadata/topics updates and an
|
||||
explicit warning that issue/comment synchronization was skipped.
|
||||
|
||||
Pull requests, releases/assets, labels, milestones, assignments, reactions,
|
||||
authorship and original timestamps are not copied. Attachments remain source
|
||||
links. Records removed from GitHub are not deleted in Gitea. This is enrichment
|
||||
within the original script's scope, not a complete GitHub migration.
|
||||
|
||||
Existing V2.1 github-sync-id and github-comment-id markers are supported.
|
||||
Unmarked local records remain untouched. Earlier imports without markers need
|
||||
separate mapping to avoid creating additional records. Local edits to marked
|
||||
fields are overwritten by GitHub. Marker names are reserved for the script.
|
||||
|
||||
Errors and repeated runs
|
||||
------------------------
|
||||
Repositories run sequentially with isolated state. A failure stops only the
|
||||
current repository. The final summary reports discovered, selected, succeeded,
|
||||
failed and skipped counts. Exit status is 1 if any selected repository fails.
|
||||
Discovery failures abort before writes. Successful writes are not rolled back.
|
||||
|
||||
After a failure, inspect the log, fix the cause, run dry-run and then apply.
|
||||
POST is never automatically retried after an ambiguous response. Markers are
|
||||
used to reconcile existing records on subsequent runs. Duplicate markers stop
|
||||
the affected repository before its write phase.
|
||||
|
||||
Mirror-sync is asynchronous. A missing default branch is deferred while other
|
||||
fields are updated and mirror-sync is queued (unless --no-git is used). The
|
||||
repository is reported as failed for that run; rerun after Git sync finishes.
|
||||
Accepted mirror-sync requests do not confirm completion of the background job.
|
||||
API reads are not a transactional snapshot; concurrent source changes may
|
||||
require another run.
|
||||
|
||||
GitHub HTTP 403 / 429 / 401
|
||||
-------------------------
|
||||
The log reports whether a GitHub token is configured and includes the API error
|
||||
message. Rate limits are recognized from response headers and API messages.
|
||||
The script respects Retry-After and X-RateLimit-Reset. It retries within the
|
||||
GITHUB_RATE_LIMIT_MAX_WAIT budget (default 60 seconds per request) and
|
||||
GITHUB_RATE_LIMIT_RETRIES (default 2, maximum 10). Longer throttling stops the
|
||||
entire account scan with exit status 1 instead of failing every repository.
|
||||
HTTP 401 also stops the scan: correct GITHUB_TOKEN before rerunning.
|
||||
An ordinary permission-related 403 fails only the affected repository.
|
||||
|
||||
An anonymous quota exhaustion requires waiting for reset or configuring a valid
|
||||
GITHUB_TOKEN. An authenticated quota can also be exhausted. Repeating the same
|
||||
run immediately does not reset a quota. Permission errors require checking
|
||||
token repository access and applicable organization policies. A proxy/firewall
|
||||
may return non-JSON errors. Changing the script alone cannot grant API access.
|
||||
|
||||
Reference: https://docs.github.com/en/rest/using-the-rest-api/troubleshooting-the-rest-api
|
||||
|
||||
Validation
|
||||
----------
|
||||
Bash syntax, ShellCheck and local mock API tests cover account pagination,
|
||||
multiple sources, renamed/private mirrors, isolated issue/comment state,
|
||||
repeat-run idempotency and continuation after repository failures.
|
||||
No production API writes were performed during development.
|
||||
|
||||
Internal function reference
|
||||
---------------------------
|
||||
usage: Prints command-line help.
|
||||
log: Writes timestamped messages, with the current repository label when set.
|
||||
die: Logs an error and exits with failure.
|
||||
on_error: Reports an unexpected failed command's line and exit status.
|
||||
cleanup: Stops/waits for an active repository worker and removes temporary files.
|
||||
finish: Runs cleanup at exit and records the quiet worker's final exit status.
|
||||
response_header: Extracts a response header from the last HTTP response block.
|
||||
request: Sends authenticated API requests, builds JSON request headers, handles
|
||||
HTTP/transport errors and bounded GitHub throttling retries. It never retries
|
||||
a write automatically. A shared stop marker halts scanning on GitHub rate-limit
|
||||
exhaustion or invalid authentication.
|
||||
validate: Checks an API response against a jq validation expression.
|
||||
paginate: Collects every page until an empty page, detects repeated pages and
|
||||
enforces MAX_PAGES even when the server caps the requested page size.
|
||||
marker: Extracts an issue/comment synchronization marker and rejects ambiguity.
|
||||
sync_repository: Runs the read/validation, comparison and update phases in an
|
||||
isolated subshell/directory for one mirror, with per-repository counters.
|
||||
write_api: Sends a Gitea mutation and counts successful write requests.
|
||||
github_source: Parses supported GitHub clone URLs into source owner/repository.
|
||||
|
||||
The main loop discovers and filters repositories, starts one worker at a time,
|
||||
waits for its result, continues after repository-specific errors, and prints the
|
||||
account summary. Quiet launching happens before this loop and reuses the same
|
||||
foreground synchronization engine in a detached process.
|
||||
|
||||
API reference:
|
||||
https://docs.gitea.com/api/1.23/operations/user-list-repos/
|
||||
@@ -0,0 +1,18 @@
|
||||
# Trusted Bash configuration. Save as /etc/gitea-github-sync.conf (chmod 600).
|
||||
GITEA_TOKEN='REPLACE_WITH_GITEA_TOKEN'
|
||||
# Optional for public GitHub repositories; recommended to increase rate limits.
|
||||
GITHUB_TOKEN=''
|
||||
GITEA_URL='https://git.an0n.eu'
|
||||
# Leave empty to discover the account from GITEA_TOKEN, or specify an owner.
|
||||
# All repositories owned by this account are scanned, including private ones
|
||||
# visible to the token. Collaborator repositories owned by others are excluded.
|
||||
GITEA_OWNER='adam'
|
||||
# Source owners and repository names are derived from each mirror's original_url.
|
||||
GITHUB_API='https://api.github.com'
|
||||
GITHUB_API_VERSION='2026-03-10'
|
||||
# Maximum total waiting time per GitHub request; longer limits stop the scan.
|
||||
GITHUB_RATE_LIMIT_MAX_WAIT=60
|
||||
GITHUB_RATE_LIMIT_RETRIES=2
|
||||
LOCK_FILE='/var/run/gitea-github-sync.lock'
|
||||
# Used only by --apply-quiet. Parent directory must already exist and be writable.
|
||||
QUIET_LOG_FILE='/var/log/gitea-github-sync.log'
|
||||
Executable
+523
@@ -0,0 +1,523 @@
|
||||
#!/usr/bin/env bash
|
||||
# GitHub -> Gitea: metadata, topics, issues and issue comments.
|
||||
# Linux, Bash >= 4.3, curl, jq >= 1.6, flock (util-linux).
|
||||
# Worker state is intentionally local to subshells; parent discovery state persists.
|
||||
# shellcheck disable=SC2030,SC2031
|
||||
set -Eeuo pipefail
|
||||
umask 077
|
||||
|
||||
CONFIG_FILE="${CONFIG_FILE:-/etc/gitea-github-sync.conf}"
|
||||
MODE=dry-run
|
||||
NO_GIT=0
|
||||
QUIET=0
|
||||
SELECTED_MODE=
|
||||
# Silence even launcher/configuration errors when quiet mode was requested.
|
||||
# Its caller can still inspect the launcher's exit status.
|
||||
for argument in "$@"; do
|
||||
if [[ "$argument" == --apply-quiet ]]; then exec >/dev/null 2>&1; fi
|
||||
done
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
Usage: $0 [--config FILE] [--dry-run|--apply|--apply-quiet] [--no-git]
|
||||
--dry-run Report changes without writing to APIs (default).
|
||||
--apply Apply changes in the foreground.
|
||||
--apply-quiet Apply in the background; write output to QUIET_LOG_FILE.
|
||||
--config FILE Read a trusted Bash configuration file.
|
||||
--no-git Skip native Gitea mirror-sync requests.
|
||||
--help, -h Show this help.
|
||||
USAGE
|
||||
}
|
||||
log() { printf '[%s] %s%s\n' "$(date '+%F %T')" "${REPO_LABEL:+[$REPO_LABEL] }" "$*" >&2; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
on_error() { local rc=$1 line=$2; log "Failure at line $line (exit $rc); synchronization incomplete."; exit "$rc"; }
|
||||
trap 'on_error "$?" "$LINENO"' ERR
|
||||
while (($#)); do
|
||||
case "$1" in
|
||||
--dry-run|--apply|--apply-quiet)
|
||||
[[ -z "$SELECTED_MODE" || "$SELECTED_MODE" == "$1" ]] || die "Choose only one execution mode"
|
||||
SELECTED_MODE=$1 ;;
|
||||
esac
|
||||
case "$1" in
|
||||
--config) (($# >= 2)) || die "Missing path after --config"; CONFIG_FILE=$2; shift ;;
|
||||
--dry-run) MODE=dry-run; QUIET=0 ;;
|
||||
--apply) MODE=apply; QUIET=0 ;;
|
||||
--apply-quiet) MODE=apply; QUIET=1 ;;
|
||||
--no-git) NO_GIT=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) die "Unknown option: $1" ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
((BASH_VERSINFO[0] > 4 || (BASH_VERSINFO[0] == 4 && BASH_VERSINFO[1] >= 3))) ||
|
||||
die "Bash >= 4.3 is required"
|
||||
readonly MODE NO_GIT QUIET
|
||||
[[ -r "$CONFIG_FILE" ]] || die "Configuration not found: $CONFIG_FILE"
|
||||
# Configuration is trusted shell code; restrict write access to its owner.
|
||||
# shellcheck disable=SC1090
|
||||
source "$CONFIG_FILE"
|
||||
: "${QUIET_LOG_FILE:=/var/log/gitea-github-sync.log}"
|
||||
if ((QUIET)); then
|
||||
command -v nohup >/dev/null || die "Missing command: nohup"
|
||||
[[ "$QUIET_LOG_FILE" == /* ]] || die "QUIET_LOG_FILE must be an absolute path"
|
||||
# Open before spawning so an inaccessible log produces a failed launch.
|
||||
exec >>"$QUIET_LOG_FILE" 2>&1
|
||||
chmod 600 "$QUIET_LOG_FILE"
|
||||
quiet_args=(--config "$CONFIG_FILE" --apply)
|
||||
if ((NO_GIT)); then quiet_args+=(--no-git); fi
|
||||
GITEA_SYNC_QUIET_WORKER=1 nohup "$BASH" "$0" "${quiet_args[@]}" </dev/null &
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${GITEA_SYNC_QUIET_WORKER:-}" == 1 ]]; then
|
||||
# Preserve the final worker result, including failures before acquiring a lock.
|
||||
trap 'rc=$?; log "Background worker finished: PID=$$, exit_status=$rc"' EXIT
|
||||
log "Background worker started: PID=$$"
|
||||
fi
|
||||
: "${GITEA_URL:=https://git.an0n.eu}"
|
||||
: "${GITEA_OWNER:=}"
|
||||
: "${GITHUB_API:=https://api.github.com}"
|
||||
: "${GITHUB_API_VERSION:=2026-03-10}"
|
||||
: "${LOCK_FILE:=/var/run/gitea-github-sync.lock}"
|
||||
: "${MAX_PAGES:=10000}"
|
||||
: "${GITHUB_RATE_LIMIT_MAX_WAIT:=60}"
|
||||
: "${GITHUB_RATE_LIMIT_RETRIES:=2}"
|
||||
[[ -n "${GITEA_TOKEN:-}" ]] || die "GITEA_TOKEN is missing from the configuration"
|
||||
[[ "$MAX_PAGES" =~ ^[1-9][0-9]*$ ]] || die "Invalid MAX_PAGES"
|
||||
for value in "$GITHUB_RATE_LIMIT_MAX_WAIT" "$GITHUB_RATE_LIMIT_RETRIES"; do
|
||||
[[ "$value" =~ ^(0|[1-9][0-9]{0,5})$ ]] || die "Invalid GitHub rate limit setting"
|
||||
done
|
||||
((GITHUB_RATE_LIMIT_RETRIES <= 10)) || die "GITHUB_RATE_LIMIT_RETRIES must not exceed 10"
|
||||
for cmd in curl jq flock mktemp rm cmp cat date mkdir awk sleep; do
|
||||
command -v "$cmd" >/dev/null || die "Missing command: $cmd"
|
||||
done
|
||||
GITEA_URL=${GITEA_URL%/}
|
||||
GITHUB_API=${GITHUB_API%/}
|
||||
[[ "$GITEA_URL" == http://* || "$GITEA_URL" == https://* ]] || die "Invalid GITEA_URL"
|
||||
[[ "$GITHUB_API" == http://* || "$GITHUB_API" == https://* ]] || die "Invalid GITHUB_API"
|
||||
exec 9>"$LOCK_FILE" || die "Cannot open lock: $LOCK_FILE"
|
||||
flock -n 9 || die "Another synchronization is running"
|
||||
WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gitea-github-sync.XXXXXX")
|
||||
cleanup() {
|
||||
if [[ -n "${worker_pid:-}" ]]; then
|
||||
kill -TERM "$worker_pid" 2>/dev/null || true
|
||||
wait "$worker_pid" 2>/dev/null || true
|
||||
fi
|
||||
rm -rf -- "$WORK_DIR"
|
||||
}
|
||||
finish() {
|
||||
local rc=$?
|
||||
cleanup
|
||||
if [[ "${GITEA_SYNC_QUIET_WORKER:-}" == 1 ]]; then
|
||||
log "Background worker finished: PID=$$, exit_status=$rc"
|
||||
fi
|
||||
return "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
RUN_DIR=$WORK_DIR
|
||||
HEADER_DIR=$RUN_DIR
|
||||
|
||||
# Keep credentials out of curl's command line. curl >= 7.55 supports -H @file.
|
||||
printf 'Authorization: token %s\nAccept: application/json\n' "$GITEA_TOKEN" > "$WORK_DIR/gt.headers"
|
||||
printf 'Accept: application/vnd.github+json\nX-GitHub-Api-Version: %s\n' "$GITHUB_API_VERSION" > "$WORK_DIR/gh.headers"
|
||||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
|
||||
printf 'Authorization: Bearer %s\n' "$GITHUB_TOKEN" >> "$WORK_DIR/gh.headers"
|
||||
log "GitHub authentication: token configured"
|
||||
else
|
||||
log "WARNING: GitHub authentication: anonymous. Set GITHUB_TOKEN in the configuration for a higher API quota"
|
||||
fi
|
||||
|
||||
response_header() {
|
||||
awk -v name="$2" '
|
||||
/^HTTP\// { value="" }
|
||||
tolower($1)==tolower(name ":") {
|
||||
sub(/^[^:]*:[[:space:]]*/, ""); sub(/\r$/, ""); value=$0
|
||||
}
|
||||
END { print value }' "$1"
|
||||
}
|
||||
|
||||
# GETs may retry. Never automatically repeat a mutation: a lost response can
|
||||
# follow a successful POST, and retrying it would create duplicates.
|
||||
request() {
|
||||
local service=$1 method=$2 url=$3 output=$4 payload=${5:-} allow_missing=${6:-0} status
|
||||
local headers="$WORK_DIR/response.headers" message remaining reset retry_after wait_seconds
|
||||
local attempt=0 waited=0 now reset_wait
|
||||
local -a args=(-q --silent --show-error --connect-timeout 15 --max-time 120
|
||||
--user-agent 'gitea-github-sync/3.1' -D "$headers"
|
||||
-H "@$HEADER_DIR/$service.headers" -X "$method" -o "$output" -w '%{http_code}')
|
||||
[[ "$method" == GET || "$MODE" == apply ]] || die "Write request blocked in dry-run"
|
||||
# GitHub throttling must be inspected before any retry, including HTTP 429.
|
||||
if [[ "$method" == GET && "$service" != gh ]]; then
|
||||
args+=(--retry 3 --retry-delay 2 --retry-max-time 180)
|
||||
fi
|
||||
if [[ -n "$payload" ]]; then
|
||||
args+=(-H 'Content-Type: application/json' --data-binary "@$payload")
|
||||
fi
|
||||
while :; do
|
||||
status=$(curl "${args[@]}" "$url") || die "Transport error: $method $url; run dry-run before retrying APPLY"
|
||||
if [[ "$status" == 2[0-9][0-9] ]]; then return 0; fi
|
||||
if [[ "$status" == 404 && "$allow_missing" == 1 ]]; then return 4; fi
|
||||
message=$(jq -r 'if type=="object" and (.message|type)=="string" then
|
||||
.message | gsub("[[:cntrl:]]"; " ") | .[:1000]
|
||||
else "No JSON error message in response" end' "$output" 2>/dev/null) || message='Non-JSON error response (possibly proxy or firewall)'
|
||||
message=${message//"$GITEA_TOKEN"/[REDACTED]}
|
||||
if [[ -n "${GITHUB_TOKEN:-}" ]]; then message=${message//"$GITHUB_TOKEN"/[REDACTED]}; fi
|
||||
log "HTTP $status: $method $url; API message: $message"
|
||||
remaining=$(response_header "$headers" x-ratelimit-remaining)
|
||||
reset=$(response_header "$headers" x-ratelimit-reset)
|
||||
retry_after=$(response_header "$headers" retry-after)
|
||||
if [[ "$service" == gh && ( "$status" == 403 || "$status" == 429 ) ]] &&
|
||||
[[ "$remaining" == 0 || "$status" == 429 || -n "$retry_after" || "${message,,}" == *"rate limit"* ]]; then
|
||||
log "GitHub rate limit: remaining=${remaining:-unknown}, reset_epoch=${reset:-unknown}, retry_after=${retry_after:-absent}"
|
||||
now=$(date +%s)
|
||||
wait_seconds=$((60 * (2 ** attempt)))
|
||||
if [[ "$retry_after" =~ ^[0-9]{1,9}$ ]]; then
|
||||
wait_seconds=$((10#$retry_after))
|
||||
elif [[ -n "$retry_after" ]]; then
|
||||
reset_wait=$(date -d "$retry_after" +%s 2>/dev/null) || reset_wait=$((now + 60))
|
||||
wait_seconds=$((reset_wait - now))
|
||||
fi
|
||||
if [[ "$remaining" == 0 && "$reset" =~ ^[0-9]{1,12}$ ]]; then
|
||||
reset_wait=$((10#$reset - now + 1))
|
||||
if [[ -z "$retry_after" ]] || ((reset_wait > wait_seconds)); then wait_seconds=$reset_wait; fi
|
||||
log "GitHub quota resets at $(date -u -d "@$reset" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || printf '%s' "$reset")"
|
||||
fi
|
||||
if ((wait_seconds < 1)); then wait_seconds=1; fi
|
||||
if [[ "$method" == GET ]] && ((attempt < GITHUB_RATE_LIMIT_RETRIES && waited + wait_seconds <= GITHUB_RATE_LIMIT_MAX_WAIT)); then
|
||||
log "Waiting $wait_seconds seconds before retrying GitHub (attempt $((attempt + 1)))"
|
||||
sleep "$wait_seconds"
|
||||
waited=$((waited + wait_seconds))
|
||||
attempt=$((attempt + 1))
|
||||
continue
|
||||
fi
|
||||
printf '%s\n' 'GitHub rate limit reached; wait for reset or correct GITHUB_TOKEN before rerunning' > "$RUN_DIR/github-stop"
|
||||
die "GitHub rate limit wait/retry budget exceeded; stopping account scan"
|
||||
fi
|
||||
if [[ "$service" == gh && "$status" == 401 ]]; then
|
||||
printf '%s\n' 'GitHub authentication failed; replace or correct GITHUB_TOKEN' > "$RUN_DIR/github-stop"
|
||||
elif [[ "$service" == gh && "$status" == 403 ]]; then
|
||||
log "GitHub access denied without a rate-limit signal. Check token repository access, organization/SSO policy, and any proxy/firewall"
|
||||
fi
|
||||
die "Request failed; see API message above"
|
||||
done
|
||||
}
|
||||
validate() { jq -e "$2" "$1" >/dev/null || die "Invalid JSON data: ${1##*/}"; }
|
||||
paginate() {
|
||||
local service=$1 url=$2 output=$3 page count
|
||||
: > "$output"
|
||||
for ((page=1; page<=MAX_PAGES; page++)); do
|
||||
request "$service" GET "$url&page=$page" "$WORK_DIR/page.json"
|
||||
validate "$WORK_DIR/page.json" 'type == "array" and all(.[]; type == "object")'
|
||||
count=$(jq length "$WORK_DIR/page.json")
|
||||
((count > 0)) || return 0
|
||||
if ((page > 1)) && cmp -s "$WORK_DIR/page.json" "$WORK_DIR/previous-page.json"; then
|
||||
die "API returned a repeated page: $url"
|
||||
fi
|
||||
cat "$WORK_DIR/page.json" > "$WORK_DIR/previous-page.json"
|
||||
jq -c '.[]' "$WORK_DIR/page.json" >> "$output"
|
||||
# Continue to an empty page: servers may cap the requested page size.
|
||||
done
|
||||
die "MAX_PAGES exceeded for $url"
|
||||
}
|
||||
marker() {
|
||||
# Existing V2.1 markers are supported. Ambiguous markers stop the run.
|
||||
jq -r --arg kind "$2" '
|
||||
[(.body // "") | scan("<!-- " + $kind + ":([0-9]+) -->") | .[0]] |
|
||||
if length > 1 then error("Multiple markers in one record")
|
||||
else .[0] // "" end' "$1"
|
||||
}
|
||||
issue_schema='def pos: type == "number" and . > 0 and floor == .;
|
||||
all(.[]; (.id|pos) and (.number|pos) and (.title|type=="string") and
|
||||
(.body == null or (.body|type=="string")) and (.state=="open" or .state=="closed"))'
|
||||
comment_schema='def pos: type == "number" and . > 0 and floor == .;
|
||||
all(.[]; (.id|pos) and (.body == null or (.body|type=="string")))'
|
||||
|
||||
# Run each repository in its own subshell and directory. The parent waits for
|
||||
# every worker and continues after a failure without disabling worker errexit.
|
||||
sync_repository() (
|
||||
set -Eeuo pipefail
|
||||
local entry=$1 repo_id source current_source
|
||||
local GITEA_OWNER GITEA_REPO GITHUB_OWNER GITHUB_REPO WORK_DIR
|
||||
repo_id=$(jq -r .id <<< "$entry")
|
||||
GITEA_OWNER=$(jq -r .owner.login <<< "$entry")
|
||||
GITEA_REPO=$(jq -r .name <<< "$entry")
|
||||
REPO_LABEL="$GITEA_OWNER/$GITEA_REPO"
|
||||
source=$(github_source "$(jq -r .original_url <<< "$entry")")
|
||||
IFS=$'\t' read -r GITHUB_OWNER GITHUB_REPO <<< "$source"
|
||||
WORK_DIR="$RUN_DIR/repo-$repo_id"
|
||||
mkdir -- "$WORK_DIR"
|
||||
# The parent owns cleanup; do not remove shared credentials from a worker.
|
||||
trap - EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
GT="$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO"
|
||||
GH="$GITHUB_API/repos/$GITHUB_OWNER/$GITHUB_REPO"
|
||||
log "GitHub $GITHUB_OWNER/$GITHUB_REPO -> Gitea $GITEA_OWNER/$GITEA_REPO; mode $MODE"
|
||||
request gt GET "$GT" "$WORK_DIR/gt-repo.json"
|
||||
request gh GET "$GH" "$WORK_DIR/gh-repo.json"
|
||||
for service in gt gh; do
|
||||
validate "$WORK_DIR/$service-repo.json" 'type=="object" and (.id|type=="number") and
|
||||
(.default_branch|type=="string") and (.description==null or (.description|type=="string")) and
|
||||
(.website==null or (.website|type=="string")) and (.homepage==null or (.homepage|type=="string"))'
|
||||
done
|
||||
validate "$WORK_DIR/gt-repo.json" '.archived != true and .mirror == true'
|
||||
is_mirror=true
|
||||
current_source=$(github_source "$(jq -r '.original_url // ""' "$WORK_DIR/gt-repo.json")")
|
||||
[[ "${current_source,,}" == "${source,,}" ]] || die "Mirror source changed after discovery; rerun discovery"
|
||||
sync_issues=$(jq -r '.has_issues == true and .external_tracker == null' "$WORK_DIR/gt-repo.json")
|
||||
if [[ "$sync_issues" != true ]]; then
|
||||
log "WARNING: Internal issues are disabled; synchronizing metadata and topics only"
|
||||
fi
|
||||
request gh GET "$GH/topics" "$WORK_DIR/gh-topics.json"
|
||||
request gt GET "$GT/topics" "$WORK_DIR/gt-topics.json"
|
||||
for service in gh gt; do
|
||||
validate "$WORK_DIR/$service-topics.json" '(.topics // .names) | type=="array" and all(.[]; type=="string")'
|
||||
done
|
||||
jq '{topics:(.names | sort | unique)}' "$WORK_DIR/gh-topics.json" > "$WORK_DIR/topics-payload.json"
|
||||
jq '{topics:((.topics // .names) | sort | unique)}' "$WORK_DIR/gt-topics.json" > "$WORK_DIR/topics-old.json"
|
||||
jq '{description:(.description // ""), website:(.homepage // ""), default_branch}' "$WORK_DIR/gh-repo.json" > "$WORK_DIR/repo-payload.json"
|
||||
jq '{description:(.description // ""), website:(.website // ""), default_branch}' "$WORK_DIR/gt-repo.json" > "$WORK_DIR/repo-old.json"
|
||||
|
||||
# Complete the read/validation phase before any writes.
|
||||
log "Reading issues and comments (excluding pull requests)..."
|
||||
if [[ "$sync_issues" == true ]]; then
|
||||
paginate gh "$GH/issues?state=all&sort=created&direction=asc&per_page=100" "$WORK_DIR/gh-all.jsonl"
|
||||
paginate gt "$GT/issues?state=all&type=issues&limit=50" "$WORK_DIR/gt-all.jsonl"
|
||||
else
|
||||
: > "$WORK_DIR/gh-all.jsonl"
|
||||
: > "$WORK_DIR/gt-all.jsonl"
|
||||
fi
|
||||
for service in gh gt; do
|
||||
jq -s '[.[] | select(.pull_request == null)] | sort_by(.number)' "$WORK_DIR/$service-all.jsonl" > "$WORK_DIR/$service-issues.json"
|
||||
validate "$WORK_DIR/$service-issues.json" "$issue_schema"
|
||||
jq -c '.[]' "$WORK_DIR/$service-issues.json" > "$WORK_DIR/$service-issues.jsonl"
|
||||
done
|
||||
declare -A gh_numbers=() gt_numbers=() gt_comment_ids=() seen_gh=()
|
||||
while IFS= read -r record; do
|
||||
printf '%s\n' "$record" > "$WORK_DIR/record.json"
|
||||
id=$(jq -r .id "$WORK_DIR/record.json")
|
||||
number=$(jq -r .number "$WORK_DIR/record.json")
|
||||
[[ -z "${seen_gh[$id]+x}" && -z "${gh_numbers[$number]+x}" ]] || die "Duplicate GitHub issue $id"
|
||||
seen_gh[$id]=1
|
||||
gh_numbers[$number]=$id
|
||||
done < "$WORK_DIR/gh-issues.jsonl"
|
||||
while IFS= read -r record; do
|
||||
printf '%s\n' "$record" > "$WORK_DIR/record.json"
|
||||
id=$(marker "$WORK_DIR/record.json" github-sync-id)
|
||||
[[ -n "$id" ]] || continue
|
||||
[[ -z "${gt_numbers[$id]+x}" ]] || die "Duplicate github-sync-id:$id in Gitea"
|
||||
number=$(jq -r .number "$WORK_DIR/record.json")
|
||||
gt_numbers[$id]=$number
|
||||
printf '%s\n' "$record" > "$WORK_DIR/gt-issue-$id.json"
|
||||
paginate gt "$GT/issues/$number/comments?limit=50" "$WORK_DIR/comments.jsonl"
|
||||
jq -s '.' "$WORK_DIR/comments.jsonl" > "$WORK_DIR/comments.json"
|
||||
validate "$WORK_DIR/comments.json" "$comment_schema"
|
||||
while IFS= read -r comment; do
|
||||
printf '%s\n' "$comment" > "$WORK_DIR/record.json"
|
||||
cid=$(marker "$WORK_DIR/record.json" github-comment-id)
|
||||
[[ -n "$cid" ]] || continue
|
||||
key="$number:$cid"
|
||||
[[ -z "${gt_comment_ids[$key]+x}" ]] || die "Duplicate github-comment-id:$cid in Gitea #$number"
|
||||
gt_comment_ids[$key]=$(jq -r .id "$WORK_DIR/record.json")
|
||||
printf '%s\n' "$comment" > "$WORK_DIR/gt-comment-$number-$cid.json"
|
||||
done < "$WORK_DIR/comments.jsonl"
|
||||
done < "$WORK_DIR/gt-issues.jsonl"
|
||||
if [[ "$sync_issues" == true ]]; then
|
||||
paginate gh "$GH/issues/comments?sort=created&direction=asc&per_page=100" "$WORK_DIR/gh-comments.jsonl"
|
||||
else
|
||||
: > "$WORK_DIR/gh-comments.jsonl"
|
||||
fi
|
||||
jq -s '.' "$WORK_DIR/gh-comments.jsonl" > "$WORK_DIR/gh-comments.json"
|
||||
validate "$WORK_DIR/gh-comments.json" "$comment_schema and all(.[]; (.issue_url|type==\"string\"))"
|
||||
declare -A seen_comments=()
|
||||
while IFS= read -r record; do
|
||||
printf '%s\n' "$record" > "$WORK_DIR/record.json"
|
||||
number=$(jq -r '.issue_url | split("/") | last' "$WORK_DIR/record.json")
|
||||
[[ "$number" =~ ^[1-9][0-9]*$ ]] || die "Invalid issue number in GitHub comment"
|
||||
[[ -n "${gh_numbers[$number]+x}" ]] || continue
|
||||
cid=$(jq -r .id "$WORK_DIR/record.json")
|
||||
[[ -z "${seen_comments[$cid]+x}" ]] || die "Duplicate GitHub comment $cid"
|
||||
seen_comments[$cid]=1
|
||||
printf '%s\n' "$record" >> "$WORK_DIR/gh-comments-$number.jsonl"
|
||||
done < "$WORK_DIR/gh-comments.jsonl"
|
||||
|
||||
# Payloads are built from files, avoiding shell argument limits and retaining
|
||||
# Unicode and trailing newlines. Ignore source copies of our reserved markers.
|
||||
# shellcheck disable=SC2016
|
||||
body_filter='def marked($kind; $id):
|
||||
(. // "" | gsub("<!-- " + $kind + ":[0-9]+ -->"; "")) as $b |
|
||||
$b + (if $b=="" then "" else "\n\n" end) + "<!-- " + $kind + ":" + ($id|tostring) + " -->";'
|
||||
issues_create=0 issues_update=0 issues_same=0
|
||||
comments_create=0 comments_update=0 comments_same=0
|
||||
mutations=0
|
||||
deferred_branch=0
|
||||
write_api() {
|
||||
request gt "$1" "$2" "$WORK_DIR/response.json" "${3:-}"
|
||||
mutations=$((mutations + 1))
|
||||
}
|
||||
if ! cmp -s "$WORK_DIR/repo-old.json" "$WORK_DIR/repo-payload.json"; then
|
||||
log "Repository metadata: update planned"
|
||||
# Mirror sync is asynchronous. Do not set a branch that does not exist yet.
|
||||
branch=$(jq -r .default_branch "$WORK_DIR/repo-payload.json")
|
||||
old_branch=$(jq -r .default_branch "$WORK_DIR/repo-old.json")
|
||||
if [[ "$branch" != "$old_branch" ]]; then
|
||||
log "Default branch must exist in Gitea: $branch"
|
||||
encoded=$(jq -rn --arg v "$branch" '$v|@uri')
|
||||
if request gt GET "$GT/branches/$encoded" "$WORK_DIR/branch.json" '' 1; then
|
||||
validate "$WORK_DIR/branch.json" '(.name|type=="string")'
|
||||
else
|
||||
log "Branch is not available in Gitea; deferring default_branch. Retry after mirror-sync completes."
|
||||
jq 'del(.default_branch)' "$WORK_DIR/repo-payload.json" > "$WORK_DIR/repo-without-branch.json"
|
||||
cat "$WORK_DIR/repo-without-branch.json" > "$WORK_DIR/repo-payload.json"
|
||||
deferred_branch=1
|
||||
fi
|
||||
fi
|
||||
if [[ "$MODE" == apply ]]; then write_api PATCH "$GT" "$WORK_DIR/repo-payload.json"; fi
|
||||
fi
|
||||
if ! cmp -s "$WORK_DIR/topics-old.json" "$WORK_DIR/topics-payload.json"; then
|
||||
log "Repository topics: update planned"
|
||||
if [[ "$MODE" == apply ]]; then write_api PUT "$GT/topics" "$WORK_DIR/topics-payload.json"; fi
|
||||
fi
|
||||
while IFS= read -r record; do
|
||||
printf '%s\n' "$record" > "$WORK_DIR/source.json"
|
||||
id=$(jq -r .id "$WORK_DIR/source.json")
|
||||
number=$(jq -r .number "$WORK_DIR/source.json")
|
||||
target=${gt_numbers[$id]:-}
|
||||
jq "$body_filter"'{title, body:(.body | marked("github-sync-id"; $id)), state}' \
|
||||
--argjson id "$id" "$WORK_DIR/source.json" > "$WORK_DIR/issue-payload.json"
|
||||
if [[ -z "$target" ]]; then
|
||||
issues_create=$((issues_create + 1))
|
||||
log "CREATE issue GitHub #$number"
|
||||
if [[ "$MODE" == apply ]]; then
|
||||
jq '{title, body, closed:(.state=="closed")}' "$WORK_DIR/issue-payload.json" > "$WORK_DIR/create.json"
|
||||
write_api POST "$GT/issues" "$WORK_DIR/create.json"
|
||||
validate "$WORK_DIR/response.json" '(.number|type=="number" and .>0 and floor==.)'
|
||||
target=$(jq -r .number "$WORK_DIR/response.json")
|
||||
gt_numbers[$id]=$target
|
||||
fi
|
||||
else
|
||||
jq '{title, body:(.body // ""), state}' "$WORK_DIR/gt-issue-$id.json" > "$WORK_DIR/old.json"
|
||||
if cmp -s "$WORK_DIR/old.json" "$WORK_DIR/issue-payload.json"; then
|
||||
issues_same=$((issues_same + 1))
|
||||
else
|
||||
issues_update=$((issues_update + 1))
|
||||
log "UPDATE issue Gitea #$target <- GitHub #$number"
|
||||
if [[ "$MODE" == apply ]]; then write_api PATCH "$GT/issues/$target" "$WORK_DIR/issue-payload.json"; fi
|
||||
fi
|
||||
fi
|
||||
[[ -f "$WORK_DIR/gh-comments-$number.jsonl" ]] || continue
|
||||
while IFS= read -r comment; do
|
||||
printf '%s\n' "$comment" > "$WORK_DIR/source.json"
|
||||
cid=$(jq -r .id "$WORK_DIR/source.json")
|
||||
jq "$body_filter"'{body:(.body | marked("github-comment-id"; $id))}' \
|
||||
--argjson id "$cid" "$WORK_DIR/source.json" > "$WORK_DIR/comment-payload.json"
|
||||
key="$target:$cid"
|
||||
existing=${gt_comment_ids[$key]:-}
|
||||
if [[ -z "$existing" ]]; then
|
||||
comments_create=$((comments_create + 1))
|
||||
log "CREATE GitHub comment $cid on issue #$number"
|
||||
if [[ "$MODE" == apply ]]; then write_api POST "$GT/issues/$target/comments" "$WORK_DIR/comment-payload.json"; fi
|
||||
else
|
||||
jq '{body:(.body // "")}' "$WORK_DIR/gt-comment-$target-$cid.json" > "$WORK_DIR/old.json"
|
||||
if cmp -s "$WORK_DIR/old.json" "$WORK_DIR/comment-payload.json"; then
|
||||
comments_same=$((comments_same + 1))
|
||||
else
|
||||
comments_update=$((comments_update + 1))
|
||||
log "UPDATE Gitea comment $existing <- GitHub $cid"
|
||||
if [[ "$MODE" == apply ]]; then write_api PATCH "$GT/issues/comments/$existing" "$WORK_DIR/comment-payload.json"; fi
|
||||
fi
|
||||
fi
|
||||
done < "$WORK_DIR/gh-comments-$number.jsonl"
|
||||
done < "$WORK_DIR/gh-issues.jsonl"
|
||||
if ((NO_GIT)); then
|
||||
log "Mirror-sync skipped (--no-git)"
|
||||
elif [[ "$is_mirror" != true ]]; then
|
||||
log "Mirror-sync skipped: repository is not a mirror"
|
||||
elif [[ "$MODE" == apply ]]; then
|
||||
write_api POST "$GT/mirror-sync"
|
||||
log "Mirror-sync queued; check completion in Gitea"
|
||||
else
|
||||
log "DRY-RUN: mirror-sync would be queued"
|
||||
fi
|
||||
log "Issues: create=$issues_create, update=$issues_update, unchanged=$issues_same"
|
||||
log "Comments: create=$comments_create, update=$comments_update, unchanged=$comments_same"
|
||||
if [[ "$MODE" == apply ]] && ((deferred_branch)); then
|
||||
die "Default branch update pending. Successful writes: $mutations. Wait for Git synchronization and rerun."
|
||||
fi
|
||||
log "Completed $MODE; successful write requests: $mutations"
|
||||
|
||||
)
|
||||
|
||||
# Parse only GitHub.com clone URLs. Do not log original_url: it can contain
|
||||
# embedded credentials. Destination names may differ from source names.
|
||||
github_source() {
|
||||
jq -rn --arg url "$1" '
|
||||
($url | capture("(?i)^(?:https?://(?:[^/@]+@)?github\\.com(?::443)?/|ssh://(?:git@)?github\\.com(?::22)?/|git@github\\.com:)(?<owner>[a-z0-9_.-]+)/(?<repo>[a-z0-9_.-]+)/?$")?) as $m |
|
||||
if $m == null then "" else
|
||||
($m.repo | sub("(?i)\\.git$"; "")) as $repo |
|
||||
if $m.owner == "." or $m.owner == ".." or $repo == "" or $repo == "." or $repo == ".."
|
||||
then "" else [$m.owner, $repo] | @tsv end
|
||||
end'
|
||||
}
|
||||
|
||||
if [[ -n "${GITEA_REPO:-}${GITHUB_OWNER:-}${GITHUB_REPO:-}" ]]; then
|
||||
log "Legacy GITEA_REPO/GITHUB_OWNER/GITHUB_REPO settings are ignored; repositories are discovered automatically"
|
||||
fi
|
||||
if [[ -z "$GITEA_OWNER" ]]; then
|
||||
request gt GET "$GITEA_URL/api/v1/user" "$WORK_DIR/user.json"
|
||||
validate "$WORK_DIR/user.json" '(.login|type=="string" and length>0)'
|
||||
GITEA_OWNER=$(jq -r .login "$WORK_DIR/user.json")
|
||||
fi
|
||||
[[ "$GITEA_OWNER" =~ ^[A-Za-z0-9_.-]+$ && "$GITEA_OWNER" != . && "$GITEA_OWNER" != .. ]] || die "Invalid GITEA_OWNER"
|
||||
log "Discovering repositories owned by $GITEA_OWNER"
|
||||
paginate gt "$GITEA_URL/api/v1/users/$GITEA_OWNER/repos?limit=50" "$WORK_DIR/repositories.jsonl"
|
||||
jq -s '.' "$WORK_DIR/repositories.jsonl" > "$WORK_DIR/repositories.json"
|
||||
validate "$WORK_DIR/repositories.json" 'all(.[];
|
||||
(.id|type=="number" and .>0 and floor==.) and
|
||||
(.name|type=="string" and test("^[A-Za-z0-9_.-]+$") and .!="." and .!="..") and
|
||||
(.owner.login|type=="string" and test("^[A-Za-z0-9_.-]+$") and .!="." and .!="..") and
|
||||
(.mirror|type=="boolean") and (.original_url==null or (.original_url|type=="string")))'
|
||||
declare -A discovered_ids=()
|
||||
discovered=0 selected=0 skipped=0 succeeded=0 failed=0
|
||||
while IFS= read -r entry; do
|
||||
repo_id=$(jq -r .id <<< "$entry")
|
||||
[[ -z "${discovered_ids[$repo_id]+x}" ]] || continue
|
||||
discovered_ids[$repo_id]=1
|
||||
discovered=$((discovered + 1))
|
||||
full_name=$(jq -r '.owner.login + "/" + .name' <<< "$entry")
|
||||
owner=$(jq -r .owner.login <<< "$entry")
|
||||
if [[ "${owner,,}" != "${GITEA_OWNER,,}" ]] || [[ $(jq -r .mirror <<< "$entry") != true ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
log "SKIP $full_name: not a pull mirror owned by $GITEA_OWNER"
|
||||
continue
|
||||
fi
|
||||
source=$(github_source "$(jq -r '.original_url // ""' <<< "$entry")")
|
||||
if [[ -z "$source" ]]; then
|
||||
skipped=$((skipped + 1))
|
||||
log "SKIP $full_name: no supported GitHub.com source URL"
|
||||
continue
|
||||
fi
|
||||
selected=$((selected + 1))
|
||||
if [[ $(jq -r '.archived // false' <<< "$entry") == true ]]; then
|
||||
failed=$((failed + 1))
|
||||
log "FAILED $full_name: archived GitHub mirror cannot be updated"
|
||||
continue
|
||||
fi
|
||||
sync_repository "$entry" &
|
||||
worker_pid=$!
|
||||
if wait "$worker_pid"; then
|
||||
succeeded=$((succeeded + 1))
|
||||
log "OK $full_name"
|
||||
else
|
||||
failed=$((failed + 1))
|
||||
log "FAILED $full_name"
|
||||
fi
|
||||
worker_pid=
|
||||
if [[ -f "$RUN_DIR/github-stop" ]]; then
|
||||
log "Account scan aborted: $(cat "$RUN_DIR/github-stop"). Remaining repositories were not attempted"
|
||||
break
|
||||
fi
|
||||
done < "$WORK_DIR/repositories.jsonl"
|
||||
log "Repositories: discovered=$discovered, selected=$selected, succeeded=$succeeded, failed=$failed, skipped=$skipped"
|
||||
((failed == 0)) || exit 1
|
||||
log "Account synchronization completed ($MODE)"
|
||||
Reference in new issue
Block a user