Add the GitHub and Gitea steps and split the script into library files

Dry run by default: the script reads from both hosts (tokens, owners,
names, license, SSH) and prints a plan; --apply creates the repositories
and the Gitea -> GitHub push mirror after a final yes. Choosing GitHub
applies the AGPL-3.0 license to the Gitea repository. A failed step is
reported with what exists and how to continue; nothing is ever deleted.

create-project.sh is now the entry point; the work lives in src/lib/, one
responsibility per file. .gitignore gets !src/lib (the Python template
ignores any lib/ folder). Tests grow to 599 checks, with a stub curl and
ssh, and guards for the file structure.

Task: MIL-002#1
Task: MIL-002#2
Task: MIL-002#3
Task: MIL-002#4
Task: MIL-002#5
Task: MIL-002#6
Refs #9
Refs #10
Refs #11
Refs #12
Refs #13
Refs #14

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 15:29:10 +08:00
co-authored by Claude Sonnet 5.5
parent 903c948d85
commit 736cdb42fc
30 changed files with 2269 additions and 668 deletions
+61
View File
@@ -0,0 +1,61 @@
# shellcheck shell=bash
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
# api.sh - Calls to the GitHub and Gitea APIs and the reporting of a refused call.
#
# Part of create-project.sh: sourced by it, never run on its own.
#
# Provides: host_label, api_call, server_message, fail_request, expect_status
host_label() {
case "$1" in
gitea) printf 'Gitea' ;;
github) printf 'GitHub' ;;
*) die "internal error: unknown host" ;;
esac
}
# api_call HOST METHOD PATH [BODY]: HOST is gitea or github. A network
# failure ends the run; the HTTP status is left in HTTP_STATUS.
api_call() {
local host="$1" method="$2" path="$3" body="${4:-}"
case "$host" in
gitea)
http_request "$method" "${CONFIG[GITEA_API_URL]}$path" token \
"${CREDENTIALS[GITEA_TOKEN]}" "$body" || die "$HTTP_ERROR"
;;
github)
http_request "$method" "${CONFIG[GITHUB_API_URL]}$path" bearer \
"${CREDENTIALS[GITHUB_PAT]}" "$body" || die "$HTTP_ERROR"
;;
*) die "internal error: unknown host" ;;
esac
}
# server_message: the "message" of the last response, cleaned and shortened.
server_message() {
local message
# A response that is not JSON must not stop the error report.
message="$(json_get "$HTTP_BODY_FILE" message 2>/dev/null || true)"
message="${message//[[:cntrl:]]/ }"
printf '%s' "${message:0:160}"
}
fail_request() {
local detail message
detail="$(describe_http_status "$HTTP_STATUS")"
message="$(server_message)"
die "$1: $detail${message:+ (the server says: $message)}"
}
# expect_status CONTEXT CODE...: go on if the last status is one of CODE,
# otherwise stop with CONTEXT and the server's own words.
expect_status() {
local context="$1" code
shift
for code in "$@"; do
if [[ $HTTP_STATUS == "$code" ]]; then
return 0
fi
done
fail_request "$context"
}