5656 const repositoryData = repos.map((repo) => ({
5757 cloneUrl: repo.clone_url,
5858 name: repo.name,
59+ sizeKiB: repo.size,
5960 targetVisibility: repo.visibility === 'public' ? 'public' : 'private',
6061 ...gitlabTarget(repo),
6162 }));
@@ -149,6 +150,57 @@ jobs:
149150 curl "${curl_args[@]}" "${url}"
150151 }
151152
153+ wait_for_gitlab_import() {
154+ local project_id="$1"
155+ local source_name="$2"
156+ local deadline="$((SECONDS + 10800))"
157+ local import_error
158+ local import_status
159+ local status
160+
161+ while ((SECONDS < deadline)); do
162+ if ! status="$(gitlab_request GET "${GITLAB_API_URL}/projects/${project_id}/import")"; then
163+ echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab import."
164+ return 1
165+ fi
166+ if [[ "${status}" != "200" ]]; then
167+ if [[ "${status}" == "403" ]]; then
168+ echo "::error title=Mirror failed: ${source_name}::GITLAB_TOKEN needs Import API Read access."
169+ return 1
170+ fi
171+ echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab import (HTTP ${status})."
172+ return 1
173+ fi
174+
175+ import_status="$(jq -er '.import_status' "${response_file}")"
176+ case "${import_status}" in
177+ finished)
178+ echo "GitLab server-side import finished."
179+ return 0
180+ ;;
181+ scheduled | started)
182+ echo "GitLab server-side import status: ${import_status}."
183+ sleep 30
184+ ;;
185+ failed)
186+ import_error="$(jq -r '.import_error // "No import error detail was provided."' "${response_file}")"
187+ import_error="${import_error//$'\r'/ }"
188+ import_error="${import_error//$'\n'/ }"
189+ echo "GitLab import failed: ${import_error}"
190+ echo "::error title=Mirror failed: ${source_name}::GitLab server-side import failed."
191+ return 1
192+ ;;
193+ *)
194+ echo "::error title=Mirror failed: ${source_name}::Unexpected GitLab import status: ${import_status}."
195+ return 1
196+ ;;
197+ esac
198+ done
199+
200+ echo "::error title=Mirror failed: ${source_name}::GitLab server-side import timed out after three hours."
201+ return 1
202+ }
203+
152204 encoded_group="$(jq -rn --arg value "${GITLAB_GROUP}" '$value | @uri')"
153205 if ! status="$(gitlab_request GET "${GITLAB_API_URL}/groups/${encoded_group}")"; then
154206 echo "::error::Unable to query the GitLab group."
@@ -162,54 +214,99 @@ jobs:
162214
163215 github_auth="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 --wrap=0)"
164216 echo "::add-mask::${github_auth}"
217+ github_token_uri="$(jq -rn --arg value "${GITHUB_TOKEN}" '$value | @uri')"
218+ echo "::add-mask::${github_token_uri}"
219+
220+ # GitLab.com limits each Git push to 5 GiB. Seed large empty projects through GitLab's import API,
221+ # using a lower threshold because GitHub's reported repository size is not the resulting pack size.
222+ gitlab_import_threshold_kib="$((4 * 1024 * 1024))"
165223
166224 repository_count="$(jq -er 'length' "${repository_file}")"
225+ successful_repository_count=0
226+ failed_repository_names=()
227+ failed_repository_reasons=()
167228 for ((index = 0; index < repository_count; index++)); do
168229 repository="$(jq -ec ".[${index}]" "${repository_file}")"
169230 repository_number="$((index + 1))"
170231 source_name="$(jq -er '.name' <<< "${repository}")"
171232 source_clone_url="$(jq -er '.cloneUrl' <<< "${repository}")"
233+ source_size_kib="$(jq -er '.sizeKiB' <<< "${repository}")"
172234 target_name="$(jq -er '.targetName' <<< "${repository}")"
173235 target_path="$(jq -er '.targetPath' <<< "${repository}")"
174236 target_visibility="$(jq -er '.targetVisibility' <<< "${repository}")"
175237 description="Mirror of ${source_clone_url}"
176238 project_path="${GITLAB_GROUP}/${target_path}"
177239 encoded_project_path="$(jq -rn --arg value "${project_path}" '$value | @uri')"
240+ repository_log="${temp_root}/repository-${index}.log"
241+
242+ set +e
243+ (
244+ set -euo pipefail
245+ trap 'echo "::endgroup::"' EXIT
246+
247+ echo "::group::[${repository_number}/${repository_count}] ${source_name} -> ${project_path}"
248+ echo "Visibility: ${target_visibility}"
249+ echo "Checking for the GitLab project..."
178250
179- echo "::group::[${repository_number}/${repository_count}] ${source_name} -> ${project_path}"
180- echo "Visibility: ${target_visibility}"
181- echo "Checking for the GitLab project..."
251+ project_created=false
252+ gitlab_import_started=false
182253
183254 if ! status="$(gitlab_request GET "${GITLAB_API_URL}/projects/${encoded_project_path}")"; then
184255 echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab project."
185256 exit 1
186257 fi
187258
188- if [[ "${status}" == "404" ]]; then
189- echo "Project does not exist; creating ${project_path}..."
259+ if [[ "${status}" == "404" ]] || [[ "${status}" == "301" ]]; then
260+ if [[ "${status}" == "301" ]]; then
261+ echo "The exact GitLab path redirects elsewhere; reclaiming ${project_path}."
262+ else
263+ echo "Project does not exist; creating ${project_path}..."
264+ fi
265+
266+ create_import_url=""
267+ if ((source_size_kib >= gitlab_import_threshold_kib)); then
268+ create_import_url="${source_clone_url}"
269+ if [[ "${target_visibility}" == "private" ]]; then
270+ create_import_url="https://x-access-token:${github_token_uri}@${source_clone_url#https://}"
271+ echo "::add-mask::${create_import_url}"
272+ fi
273+ echo "The large repository will be imported as part of project creation."
274+ fi
275+
190276 payload="$(
191277 jq -nc \
278+ --arg description "${description}" \
279+ --arg import_url "${create_import_url}" \
192280 --arg name "${target_name}" \
193281 --arg path "${target_path}" \
194282 --argjson namespace_id "${group_id}" \
195283 --arg visibility "${target_visibility}" \
196284 '{
285+ description: $description,
197286 name: $name,
198287 path: $path,
199288 namespace_id: $namespace_id,
200289 visibility: $visibility,
201290 initialize_with_readme: false
202- }'
291+ } + if $import_url == "" then {} else {import_url: $import_url} end '
203292 )"
204293 if ! status="$(gitlab_request POST "${GITLAB_API_URL}/projects" "${payload}")"; then
205294 echo "::error title=Mirror failed: ${source_name}::Unable to create the GitLab project."
206295 exit 1
207296 fi
208297 if [[ "${status}" != "201" ]]; then
298+ create_error="$(jq -r '.message // "No response message was provided."' "${response_file}")"
299+ create_error="${create_error//$'\r'/ }"
300+ create_error="${create_error//$'\n'/ }"
301+ echo "GitLab project creation response: ${create_error}"
209302 error_message="Unable to create the GitLab project (HTTP ${status})."
210303 echo "::error title=Mirror failed: ${source_name}::${error_message}"
211304 exit 1
212305 fi
306+ project_created=true
307+ if [[ -n "${create_import_url}" ]]; then
308+ gitlab_import_started=true
309+ fi
213310 elif [[ "${status}" != "200" ]]; then
214311 echo "::error title=Mirror failed: ${source_name}::Unable to query the GitLab project (HTTP ${status})."
215312 exit 1
@@ -218,6 +315,13 @@ jobs:
218315 fi
219316
220317 project_id="$(jq -er '.id' "${response_file}")"
318+ if [[ "${project_created}" == "true" ]]; then
319+ project_empty=true
320+ else
321+ project_empty="$(
322+ jq -r '(.empty_repo == true) or (.default_branch == null)' "${response_file}"
323+ )"
324+ fi
221325
222326 # Make a private source private before changing metadata or pushing any Git data.
223327 if [[ "${target_visibility}" == "private" ]]; then
@@ -267,6 +371,75 @@ jobs:
267371
268372 target_clone_url="$(jq -er '.ssh_url_to_repo' "${response_file}")"
269373
374+ gitlab_incremental_seed=false
375+ if [[ "${project_empty}" == "true" ]] && \
376+ ((source_size_kib >= gitlab_import_threshold_kib)) && \
377+ [[ "${gitlab_import_started}" != "true" ]]; then
378+ echo "Empty large project detected (${source_size_kib} KiB reported by GitHub)."
379+ echo "Scheduling a GitLab server-side Git import to avoid the 5 GiB push limit..."
380+ if [[ "${target_visibility}" == "private" ]]; then
381+ import_payload="$(
382+ jq -nc \
383+ --arg import_url "${source_clone_url}" \
384+ --arg import_url_user "x-access-token" \
385+ --arg import_url_password "${GITHUB_TOKEN}" \
386+ '{
387+ import_url: $import_url,
388+ import_url_user: $import_url_user,
389+ import_url_password: $import_url_password
390+ }'
391+ )"
392+ else
393+ import_payload="$(
394+ jq -nc \
395+ --arg import_url "${source_clone_url}" \
396+ '{import_url: $import_url}'
397+ )"
398+ fi
399+
400+ if ! status="$(
401+ gitlab_request POST "${GITLAB_API_URL}/projects/${project_id}/import/git" "${import_payload}"
402+ )"; then
403+ echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import."
404+ exit 1
405+ fi
406+ if [[ "${status}" == "403" ]]; then
407+ echo "::error title=Mirror failed: ${source_name}::GITLAB_TOKEN needs Import API Create access."
408+ exit 1
409+ fi
410+
411+ import_response_message="$(jq -r '.message // "No response message was provided."' "${response_file}")"
412+ import_response_message="${import_response_message//$'\r'/ }"
413+ import_response_message="${import_response_message//$'\n'/ }"
414+ if [[ "${status}" == "409" ]]; then
415+ case "${import_response_message}" in
416+ "Import already in progress")
417+ echo "A GitLab import is already in progress; continuing to monitor it."
418+ gitlab_import_started=true
419+ ;;
420+ "Project already has a repository")
421+ echo "GitLab has repository storage from an earlier push; using incremental seeding."
422+ gitlab_incremental_seed=true
423+ ;;
424+ *)
425+ echo "GitLab import conflict: ${import_response_message}"
426+ echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import."
427+ exit 1
428+ ;;
429+ esac
430+ elif [[ "${status}" != "201" ]] && [[ "${status}" != "202" ]]; then
431+ echo "GitLab import response: ${import_response_message}"
432+ echo "::error title=Mirror failed: ${source_name}::Unable to schedule the GitLab import" \
433+ "(HTTP ${status})."
434+ exit 1
435+ else
436+ gitlab_import_started=true
437+ fi
438+ elif [[ "${project_empty}" != "true" ]] && \
439+ ((source_size_kib >= gitlab_import_threshold_kib)); then
440+ echo "Large project is already populated; using an incremental Git push."
441+ fi
442+
270443 mirror_dir="${temp_root}/repository.git"
271444 echo "Cloning ${source_clone_url} as a mirror..."
272445 if ! git \
@@ -275,6 +448,39 @@ jobs:
275448 echo "::error title=Mirror failed: ${source_name}::Unable to clone the GitHub repository."
276449 exit 1
277450 fi
451+
452+ mapfile -t pull_refs < <(
453+ git -C "${mirror_dir}" for-each-ref --format='%(refname)' refs/pull/
454+ )
455+ if ((${#pull_refs[@]} > 0)); then
456+ echo "Removing ${#pull_refs[@]} GitHub-only pull request refs from the mirror..."
457+ printf 'delete %s\n' "${pull_refs[@]}" | git -C "${mirror_dir}" update-ref --stdin
458+ fi
459+
460+ if [[ "${gitlab_import_started}" == "true" ]]; then
461+ echo "Waiting for the GitLab server-side import to finish..."
462+ if ! wait_for_gitlab_import "${project_id}" "${source_name}"; then
463+ exit 1
464+ fi
465+ fi
466+
467+ if [[ "${gitlab_incremental_seed}" == "true" ]]; then
468+ mapfile -t seed_refs < <(
469+ git -C "${mirror_dir}" for-each-ref --format='%(refname)' refs/heads/ refs/tags/
470+ )
471+ echo "Seeding ${#seed_refs[@]} branches and tags in separate pushes..."
472+ for seed_ref in "${seed_refs[@]}"; do
473+ echo "Seeding ${seed_ref}..."
474+ if ! git \
475+ -C "${mirror_dir}" \
476+ -c core.sshCommand="${gitlab_ssh_command}" \
477+ push --progress "${target_clone_url}" "+${seed_ref}:${seed_ref}"; then
478+ echo "::error title=Mirror failed: ${source_name}::Unable to seed ${seed_ref} on GitLab."
479+ exit 1
480+ fi
481+ done
482+ fi
483+
278484 echo "Pushing the mirror to ${target_clone_url}..."
279485 if ! git \
280486 -C "${mirror_dir}" \
@@ -285,7 +491,51 @@ jobs:
285491 fi
286492 rm -rf "${mirror_dir}"
287493 echo "Completed mirror for ${source_name}."
288- echo "::endgroup::"
494+ ) 2>&1 | tee "${repository_log}"
495+ repository_status="${PIPESTATUS[0]}"
496+ set -e
497+
498+ rm -rf "${temp_root}/repository.git"
499+ if ((repository_status == 0)); then
500+ successful_repository_count="$((successful_repository_count + 1))"
501+ else
502+ error_line="$(grep -E '^::error(::| )' "${repository_log}" | tail -n 1 || true)"
503+ if [[ -n "${error_line}" ]]; then
504+ failure_reason="${error_line##*::}"
505+ else
506+ failure_reason="Repository mirroring failed; inspect its log group."
507+ fi
508+ failure_reason="${failure_reason//$'\r'/ }"
509+ failure_reason="${failure_reason//$'\n'/ }"
510+ failed_repository_names+=("${source_name}")
511+ failed_repository_reasons+=("${failure_reason}")
512+ echo "Recorded mirror failure for ${source_name}; continuing with the next repository."
513+ fi
514+ rm -f "${repository_log}"
289515 done
290516
291- echo "Mirrored ${repository_count} repositories to GitLab."
517+ failed_repository_count="${#failed_repository_names[@]}"
518+ {
519+ echo "## GitHub to GitLab mirror summary"
520+ echo
521+ echo "- Successful: ${successful_repository_count}"
522+ echo "- Failed: ${failed_repository_count}"
523+ if ((failed_repository_count > 0)); then
524+ echo
525+ echo "| Repository | Failure |"
526+ echo "| --- | --- |"
527+ for ((failure_index = 0; failure_index < failed_repository_count; failure_index++)); do
528+ summary_name="${failed_repository_names[$failure_index]//|/\\|}"
529+ summary_reason="${failed_repository_reasons[$failure_index]//|/\\|}"
530+ printf '| %s | %s |\n' "\`${summary_name}\`" "${summary_reason}"
531+ done
532+ fi
533+ } >> "${GITHUB_STEP_SUMMARY}"
534+
535+ if ((failed_repository_count > 0)); then
536+ echo "::error::Failed to mirror ${failed_repository_count} of ${repository_count} repositories." \
537+ "See the step summary."
538+ exit 1
539+ fi
540+
541+ echo "Mirrored all ${repository_count} repositories to GitLab."
0 commit comments