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:
@@ -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"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# apply.sh - The only code that changes anything: confirmations and the apply flow.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: create_all, confirm_reuse, apply_plan
|
||||
|
||||
create_all() {
|
||||
IS_CREATION_STARTED=1
|
||||
if ((PROJECT[has_github])); then
|
||||
create_repository github
|
||||
fi
|
||||
create_repository gitea
|
||||
if ((PROJECT[has_github])); then
|
||||
configure_mirror
|
||||
fi
|
||||
}
|
||||
|
||||
confirm_reuse() {
|
||||
local host
|
||||
for host in github gitea; do
|
||||
if ! is_reused "$host"; then
|
||||
continue
|
||||
fi
|
||||
prompt_yes_no "The $(host_label "$host") repository $(repo_url "$host") already exists and has no real content. Reuse it" n
|
||||
if ! ((REPLY)); then
|
||||
die "stopped: choose another name or remove the existing repository"
|
||||
fi
|
||||
done
|
||||
if ((${STATE[reuse_gitea]:-0})) && ((PROJECT[has_github])) &&
|
||||
[[ ${STATE[gitea_repo]} == empty ]]; then
|
||||
warn "the empty Gitea repository is reused as it is: the $AGPL_LICENSE_KEY license is not added to it"
|
||||
fi
|
||||
}
|
||||
|
||||
# apply_plan: the only place that changes anything on GitHub or Gitea.
|
||||
apply_plan() {
|
||||
prompt_yes_no "Create these now" n
|
||||
if ! ((REPLY)); then
|
||||
say "Nothing was created."
|
||||
return 0
|
||||
fi
|
||||
confirm_reuse
|
||||
create_all
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# cli.sh - The command line: usage text and option parsing.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: usage, usage_error, parse_args
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: ${0##*/} [--apply] [--config FILE] [--env FILE]
|
||||
${0##*/} --help | --version
|
||||
EOF
|
||||
}
|
||||
|
||||
usage_error() {
|
||||
printf 'error: %s\n' "$1" >&2
|
||||
usage >&2
|
||||
exit "$EXIT_USAGE"
|
||||
}
|
||||
|
||||
parse_args() {
|
||||
while (($# > 0)); do
|
||||
case "$1" in
|
||||
--apply)
|
||||
IS_APPLY=1
|
||||
shift
|
||||
;;
|
||||
--config)
|
||||
(($# >= 2)) || usage_error "--config needs a file"
|
||||
CONFIG_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--env)
|
||||
(($# >= 2)) || usage_error "--env needs a file"
|
||||
ENV_FILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h | --help)
|
||||
sed -n '2,/^set -Eeuo/p' "$SCRIPT_FILE" | sed -e '$d' -e 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
--version)
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
exit 0
|
||||
;;
|
||||
*) usage_error "unknown option: $1" ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# config.sh - Reading and checking config.env and .env (parsed, never sourced).
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: unquote_value, parse_env_file, parse_env_entry, validate_config, validate_credentials, require_github_credentials, warn_if_env_unsafe, load_configuration
|
||||
|
||||
# unquote_value RAW: strip matching quotes (or a trailing " # comment" on an
|
||||
# unquoted value) and return the value in REPLY. Fails on unbalanced quotes.
|
||||
unquote_value() {
|
||||
local raw quote
|
||||
raw="$(trim "$1")"
|
||||
quote="${raw:0:1}"
|
||||
if [[ $quote == '"' || $quote == "'" ]]; then
|
||||
[[ ${#raw} -ge 2 && ${raw: -1} == "$quote" ]] || return 1
|
||||
raw="${raw:1:${#raw}-2}"
|
||||
[[ $raw != *"$quote"* ]] || return 1
|
||||
else
|
||||
raw="${raw%%[[:space:]]#*}"
|
||||
raw="$(trim "$raw")"
|
||||
[[ $raw != *'"'* && $raw != *"'"* ]] || return 1
|
||||
fi
|
||||
REPLY="$raw"
|
||||
}
|
||||
|
||||
# parse_env_file FILE ALLOWED_ARRAY TARGET_ARRAY
|
||||
# Read KEY=VALUE lines without source or eval. Only keys named in
|
||||
# ALLOWED_ARRAY are accepted; they are stored in the associative array
|
||||
# TARGET_ARRAY. Messages name the key and the line, never the value.
|
||||
parse_env_file() {
|
||||
local file="$1" line key line_number=0
|
||||
local pattern='^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=(.*)$'
|
||||
[[ -f $file && -r $file ]] || die "cannot read '$file'"
|
||||
# shellcheck disable=SC2094 # the loop body only uses $file in messages
|
||||
while IFS= read -r line || [[ -n $line ]]; do
|
||||
line_number=$((line_number + 1))
|
||||
if ((line_number == 1)); then
|
||||
line="${line#$'\xEF\xBB\xBF'}" # byte order mark from some Windows editors
|
||||
fi
|
||||
line="$(trim "${line%$'\r'}")"
|
||||
if [[ -z $line || $line == \#* ]]; then
|
||||
continue
|
||||
fi
|
||||
[[ $line =~ $pattern ]] ||
|
||||
die "$file line $line_number: expected KEY=VALUE"
|
||||
key="${BASH_REMATCH[1]}"
|
||||
parse_env_entry "$file" "$line_number" "$key" "${BASH_REMATCH[2]}" \
|
||||
"$2" "$3"
|
||||
done <"$file"
|
||||
}
|
||||
|
||||
# parse_env_entry FILE LINE KEY RAW_VALUE ALLOWED_ARRAY TARGET_ARRAY
|
||||
parse_env_entry() {
|
||||
local file="$1" line_number="$2" key="$3" raw="$4"
|
||||
local -n allowed_keys="$5"
|
||||
local -n target_map="$6"
|
||||
local value
|
||||
if ! in_list "$key" "${allowed_keys[@]}"; then
|
||||
if in_list "$key" "${CREDENTIAL_KEYS[@]}"; then
|
||||
die "$file line $line_number: '$key' is a credential; keep it in the .env file only"
|
||||
fi
|
||||
die "$file line $line_number: unknown key '$key'"
|
||||
fi
|
||||
if [[ -n ${target_map[$key]+set} ]]; then
|
||||
die "$file line $line_number: '$key' is set twice"
|
||||
fi
|
||||
unquote_value "$raw" ||
|
||||
die "$file line $line_number: unbalanced or misplaced quotes"
|
||||
value="$REPLY"
|
||||
if has_control_character "$value"; then
|
||||
die "$file line $line_number: '$key' contains a control character"
|
||||
fi
|
||||
if ((${#value} > MAX_VALUE_LENGTH)); then
|
||||
die "$file line $line_number: '$key' is too long"
|
||||
fi
|
||||
# shellcheck disable=SC2004 # target_map is an associative array: $key is a string
|
||||
target_map[$key]="$value"
|
||||
}
|
||||
|
||||
validate_config() {
|
||||
local key url
|
||||
if [[ -z ${CONFIG[GITEA_URL]:-} ]]; then
|
||||
die "GITEA_URL is missing in $CONFIG_FILE (see config.env.example)"
|
||||
fi
|
||||
CONFIG[GITHUB_API_URL]="${CONFIG[GITHUB_API_URL]:-https://api.github.com}"
|
||||
CONFIG[GITHUB_WEB_URL]="${CONFIG[GITHUB_WEB_URL]:-https://github.com}"
|
||||
for key in GITHUB_API_URL GITHUB_WEB_URL GITEA_URL; do
|
||||
url="$(normalize_url "${CONFIG[$key]}")"
|
||||
is_valid_base_url "$url" ||
|
||||
die "$key in $CONFIG_FILE must be an https URL without credentials, query or fragment"
|
||||
CONFIG[$key]="$url"
|
||||
done
|
||||
CONFIG[GITEA_API_URL]="$(normalize_url "${CONFIG[GITEA_API_URL]:-${CONFIG[GITEA_URL]}/api/v1}")"
|
||||
is_valid_base_url "${CONFIG[GITEA_API_URL]}" ||
|
||||
die "GITEA_API_URL in $CONFIG_FILE must be an https URL without credentials, query or fragment"
|
||||
CONFIG[GITEA_SSH_PORT]="${CONFIG[GITEA_SSH_PORT]:-$DEFAULT_SSH_PORT}"
|
||||
is_valid_port "${CONFIG[GITEA_SSH_PORT]}" ||
|
||||
die "GITEA_SSH_PORT in $CONFIG_FILE must be a port number from 1 to 65535"
|
||||
CONFIG[MIRROR_INTERVAL]="${CONFIG[MIRROR_INTERVAL]:-$DEFAULT_MIRROR_INTERVAL}"
|
||||
is_valid_interval "${CONFIG[MIRROR_INTERVAL]}" ||
|
||||
die "MIRROR_INTERVAL in $CONFIG_FILE must look like 10m0s or 8h0m0s"
|
||||
}
|
||||
|
||||
validate_credentials() {
|
||||
if [[ -z ${CREDENTIALS[GITEA_TOKEN]:-} ]]; then
|
||||
die "GITEA_TOKEN is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
# Register secrets first so that no later message can show them.
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITEA_TOKEN]}")
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]]; then
|
||||
SECRET_VALUES+=("${CREDENTIALS[GITHUB_PAT]}")
|
||||
fi
|
||||
is_valid_token "${CREDENTIALS[GITEA_TOKEN]}" ||
|
||||
die "GITEA_TOKEN in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
if [[ -n ${CREDENTIALS[GITHUB_PAT]:-} ]] &&
|
||||
! is_valid_token "${CREDENTIALS[GITHUB_PAT]}"; then
|
||||
die "GITHUB_PAT in $ENV_FILE is not a valid token (8 to 255 letters, digits or _ . ~ + / = -)"
|
||||
fi
|
||||
if [[ -n ${CREDENTIALS[GITHUB_USER]:-} ]] &&
|
||||
! is_valid_github_owner "${CREDENTIALS[GITHUB_USER]}"; then
|
||||
die "GITHUB_USER in $ENV_FILE is not a valid GitHub account name"
|
||||
fi
|
||||
}
|
||||
|
||||
# GitHub credentials are only needed when the Maintainer chose GitHub.
|
||||
require_github_credentials() {
|
||||
local key
|
||||
for key in GITHUB_PAT GITHUB_USER; do
|
||||
if [[ -z ${CREDENTIALS[$key]:-} ]]; then
|
||||
die "GitHub was chosen but $key is missing in $ENV_FILE (see .env.example)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
warn_if_env_unsafe() {
|
||||
local file="$1" dir mode
|
||||
case "$(uname -s 2>/dev/null || true)" in
|
||||
MINGW* | MSYS* | CYGWIN*) ;;
|
||||
*)
|
||||
mode="$(stat -c '%a' -- "$file" 2>/dev/null ||
|
||||
stat -f '%Lp' -- "$file" 2>/dev/null || true)"
|
||||
if [[ -n $mode ]] && (((8#$mode & 8#077) != 0)); then
|
||||
warn "$file is readable by other users (mode $mode); run: chmod 600 $file"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
dir="."
|
||||
if [[ $file == */* ]]; then
|
||||
dir="${file%/*}"
|
||||
fi
|
||||
if git -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
|
||||
! git -C "$dir" check-ignore -q -- "$file"; then
|
||||
warn "$file is not ignored by git; add it to .gitignore before committing"
|
||||
fi
|
||||
}
|
||||
|
||||
load_configuration() {
|
||||
parse_env_file "$CONFIG_FILE" CONFIG_KEYS CONFIG
|
||||
validate_config
|
||||
parse_env_file "$ENV_FILE" CREDENTIAL_KEYS CREDENTIALS
|
||||
validate_credentials
|
||||
warn_if_env_unsafe "$ENV_FILE"
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
# shellcheck shell=bash
|
||||
# constants.sh - Constants and the shared state of a run.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# The state variables are read and written by the other library files; this
|
||||
# is the one place that declares them.
|
||||
# shellcheck disable=SC2034 # read and written by the other library files
|
||||
|
||||
readonly PROJECT_NAME="${REPOFOUNDRY_NAME:-RepoFoundry}"
|
||||
readonly VERSION="0.2.0"
|
||||
readonly EXIT_FAILURE=1
|
||||
readonly EXIT_USAGE=2
|
||||
readonly MAX_VALUE_LENGTH=2048
|
||||
readonly MAX_DESCRIPTION_LENGTH=350
|
||||
readonly HTTP_TIMEOUT_SECONDS=30
|
||||
readonly DEFAULT_MIRROR_INTERVAL="10m0s"
|
||||
readonly DEFAULT_SSH_PORT=10022
|
||||
readonly AGPL_LICENSE_KEY="AGPL-3.0"
|
||||
readonly DEFAULT_BRANCH="main"
|
||||
readonly PLAN_STEPS=("GitHub repository" "Gitea repository" "Push mirror")
|
||||
# shellcheck disable=SC2034 # read through namerefs (parse_env_file)
|
||||
readonly CONFIG_KEYS=(GITHUB_API_URL GITHUB_WEB_URL GITEA_URL GITEA_API_URL
|
||||
GITEA_SSH_PORT MIRROR_INTERVAL)
|
||||
readonly CREDENTIAL_KEYS=(GITHUB_PAT GITHUB_USER GITEA_TOKEN)
|
||||
|
||||
CONFIG_FILE="$PROJECT_ROOT/config.env"
|
||||
ENV_FILE="$PROJECT_ROOT/.env"
|
||||
TMP_DIR=""
|
||||
HAS_JQ=0
|
||||
HTTP_STATUS=0
|
||||
HTTP_BODY_FILE=""
|
||||
HTTP_ERROR=""
|
||||
REPLY=""
|
||||
IS_APPLY=0
|
||||
IS_CREATION_STARTED=0
|
||||
SECRET_VALUES=()
|
||||
TEMP_FILES=()
|
||||
declare -A CONFIG=()
|
||||
declare -A CREDENTIALS=()
|
||||
declare -A PROJECT=()
|
||||
# Facts found by the preflight checks (logins, owner kinds, repository state).
|
||||
declare -A STATE=()
|
||||
# Outcome of each step in PLAN_STEPS, for the final report.
|
||||
declare -A STEP_STATUS=()
|
||||
declare -A STEP_DETAIL=()
|
||||
@@ -0,0 +1,28 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# hosts.sh - Names and links of the repositories on each host.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_reused, repo_owner, repo_url
|
||||
|
||||
# is_reused HOST: succeed if the existing repository on HOST will be reused.
|
||||
is_reused() {
|
||||
[[ ${STATE[reuse_$1]:-0} == 1 ]]
|
||||
}
|
||||
|
||||
repo_owner() {
|
||||
if [[ $1 == gitea ]]; then
|
||||
printf '%s' "${PROJECT[gitea_owner]}"
|
||||
else
|
||||
printf '%s' "${PROJECT[github_owner]}"
|
||||
fi
|
||||
}
|
||||
|
||||
repo_url() {
|
||||
if [[ $1 == gitea ]]; then
|
||||
printf '%s/%s/%s' "${CONFIG[GITEA_URL]}" "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
else
|
||||
printf '%s/%s/%s' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# http.sh - The one safe place that runs curl: tokens stay off the command line.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: describe_http_status, describe_curl_error, http_request
|
||||
|
||||
describe_http_status() {
|
||||
case "$1" in
|
||||
401) printf 'authentication failed: the token is missing, expired or invalid' ;;
|
||||
403) printf 'the token is valid but not allowed to do this (check its scopes)' ;;
|
||||
404) printf 'not found (check the name, the owner and the token access)' ;;
|
||||
409 | 422) printf 'rejected (the name may already exist or be invalid)' ;;
|
||||
429) printf 'rate limited; wait and try again' ;;
|
||||
5??) printf 'the server reported an error; try again later' ;;
|
||||
*) printf 'unexpected HTTP status %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
describe_curl_error() {
|
||||
case "$1" in
|
||||
6) printf 'could not resolve the host name' ;;
|
||||
7) printf 'could not connect' ;;
|
||||
28) printf 'the request timed out' ;;
|
||||
35 | 51 | 58 | 60) printf 'the TLS connection failed' ;;
|
||||
*) printf 'curl failed with exit code %s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# http_request METHOD URL SCHEME TOKEN [BODY]
|
||||
# SCHEME is "token" (Gitea) or "bearer" (GitHub). The token goes into a
|
||||
# private curl config file, never onto the command line where other users
|
||||
# could see it. Redirects are not followed, so the token is only ever sent
|
||||
# to the host named in URL. On success HTTP_STATUS and HTTP_BODY_FILE are
|
||||
# set; on a network failure the function returns 1 with HTTP_ERROR set.
|
||||
# shellcheck disable=SC2034 # HTTP_* are results read by the callers
|
||||
http_request() {
|
||||
local method="$1" url="$2" scheme="$3" token="$4" body="${5:-}"
|
||||
local header config_file body_file out_file host curl_status=0
|
||||
local data_args=()
|
||||
[[ $method =~ ^(GET|POST|PUT|PATCH|DELETE)$ ]] ||
|
||||
die "internal error: unsupported HTTP method"
|
||||
is_valid_request_url "$url" ||
|
||||
die "refusing to call an invalid or non-https URL"
|
||||
is_valid_token "$token" || die "refusing to send a malformed token"
|
||||
case "$scheme" in
|
||||
token) header="Authorization: token $token" ;;
|
||||
bearer) header="Authorization: Bearer $token" ;;
|
||||
*) die "internal error: unknown authentication scheme" ;;
|
||||
esac
|
||||
make_temp_file
|
||||
config_file="$REPLY"
|
||||
make_temp_file
|
||||
out_file="$REPLY"
|
||||
{
|
||||
printf 'url = "%s"\n' "$url"
|
||||
printf 'request = "%s"\n' "$method"
|
||||
printf 'header = "%s"\n' "$header"
|
||||
printf 'header = "Accept: application/json"\n'
|
||||
printf 'header = "User-Agent: %s/%s"\n' "$PROJECT_NAME" "$VERSION"
|
||||
} >"$config_file"
|
||||
if [[ -n $body ]]; then
|
||||
make_temp_file
|
||||
body_file="$REPLY"
|
||||
printf '%s' "$body" >"$body_file"
|
||||
printf 'header = "Content-Type: application/json"\n' >>"$config_file"
|
||||
data_args=(--data-binary "@$body_file")
|
||||
fi
|
||||
# curl's own error text is dropped: the exit code is mapped to a message
|
||||
# that never contains the request.
|
||||
HTTP_STATUS="$(curl --silent --max-time "$HTTP_TIMEOUT_SECONDS" \
|
||||
--connect-timeout 10 --output "$out_file" --write-out '%{http_code}' \
|
||||
--config "$config_file" "${data_args[@]}" 2>/dev/null)" || curl_status=$?
|
||||
# The configuration file holds the token and the body may hold another one
|
||||
# (the mirror password): remove both now instead of at exit.
|
||||
rm -f -- "$config_file" ${body_file:+"$body_file"}
|
||||
if ((curl_status != 0)); then
|
||||
host="${url#https://}"
|
||||
host="${host%%/*}"
|
||||
HTTP_STATUS=0
|
||||
HTTP_ERROR="could not reach $host: $(describe_curl_error "$curl_status")"
|
||||
return 1
|
||||
fi
|
||||
HTTP_BODY_FILE="$out_file"
|
||||
HTTP_ERROR=""
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# json.sh - Reading and writing the small amount of JSON the script needs.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: json_escape, json_get, json_has_value, json_values
|
||||
|
||||
# json_escape TEXT: escape TEXT for use inside a JSON string.
|
||||
json_escape() {
|
||||
local text="$1"
|
||||
text="${text//\\/\\\\}"
|
||||
text="${text//\"/\\\"}"
|
||||
text="${text//$'\n'/\\n}"
|
||||
text="${text//$'\r'/\\r}"
|
||||
text="${text//$'\t'/\\t}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# json_get FILE KEY: print the string, number or boolean value of KEY.
|
||||
# With jq only the top-level key is read. Without jq the first occurrence of
|
||||
# the key anywhere in the file is used, which is enough for the flat fields
|
||||
# the GitHub and Gitea APIs return (name, id, html_url, ...).
|
||||
json_get() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
if ((HAS_JQ)); then
|
||||
# jq on Windows ends lines with CRLF; strip the CR so values stay clean.
|
||||
jq -r --arg key "$key" \
|
||||
'if has($key) and .[$key] != null then .[$key] | tostring else empty end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\(\"[^\"]*\"\|[0-9][0-9]*\|true\|false\)" "$file" || true; } |
|
||||
head -n 1 |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
fi
|
||||
}
|
||||
|
||||
# json_has_value FILE KEY VALUE: succeed if the file holds "KEY": "VALUE",
|
||||
# written with or without a space after the colon.
|
||||
json_has_value() {
|
||||
local file="$1" key="$2" value="$3"
|
||||
grep -Fq "\"$key\":\"$value\"" "$file" ||
|
||||
grep -Fq "\"$key\": \"$value\"" "$file"
|
||||
}
|
||||
|
||||
# json_values FILE KEY: print every string value of KEY, one per line.
|
||||
json_values() {
|
||||
local file="$1" key="$2"
|
||||
[[ $key =~ ^[A-Za-z0-9_]+$ ]] || die "internal error: invalid JSON key"
|
||||
# grep exits 1 when the key is absent; that is not an error here.
|
||||
{ grep -o "\"$key\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$file" || true; } |
|
||||
sed -e 's/^[^:]*:[[:space:]]*//' -e 's/^"\(.*\)"$/\1/'
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# mirror.sh - The Gitea to GitHub push mirror: create, verify, first sync.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: mirror_field, mirror_path, mirror_address, mirror_exists, create_mirror, verify_mirror, request_first_sync, configure_mirror
|
||||
|
||||
# mirror_field FILE ADDRESS FIELD: print FIELD of the push mirror whose
|
||||
# remote_address is ADDRESS. Without jq the first mirror in the list is used,
|
||||
# which is the only one on a repository this script has just created.
|
||||
mirror_field() {
|
||||
local file="$1" address="$2" field="$3"
|
||||
if ((HAS_JQ)); then
|
||||
jq -r --arg address "$address" --arg field "$field" \
|
||||
'[.[] | select(.remote_address == $address)][0]
|
||||
| if . == null or .[$field] == null then empty else .[$field] | tostring end' \
|
||||
"$file" | tr -d '\r'
|
||||
else
|
||||
json_get "$file" "$field"
|
||||
fi
|
||||
}
|
||||
|
||||
mirror_path() {
|
||||
printf '/repos/%s/%s/push_mirrors' "${PROJECT[gitea_owner]}" "${PROJECT[name]}"
|
||||
}
|
||||
|
||||
mirror_address() {
|
||||
printf '%s/%s/%s.git' "${CONFIG[GITHUB_WEB_URL]}" "${PROJECT[github_owner]}" "${PROJECT[name]}"
|
||||
}
|
||||
|
||||
# mirror_exists ADDRESS: succeed if Gitea already pushes to ADDRESS.
|
||||
mirror_exists() {
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
expect_status "cannot list the push mirrors of the Gitea repository" 200
|
||||
json_has_value "$HTTP_BODY_FILE" remote_address "$1"
|
||||
}
|
||||
|
||||
create_mirror() {
|
||||
local address="$1" body
|
||||
# The GitHub token is the password of the mirror; the body file that holds
|
||||
# it is removed as soon as the request has been sent.
|
||||
body="$(printf '{"remote_address":"%s","remote_username":"%s","remote_password":"%s","interval":"%s","sync_on_commit":true}' \
|
||||
"$address" "${STATE[github_login]}" "${CREDENTIALS[GITHUB_PAT]}" \
|
||||
"${CONFIG[MIRROR_INTERVAL]}")"
|
||||
api_call gitea POST "$(mirror_path)" "$body"
|
||||
expect_status "cannot create the push mirror (push mirrors may be switched off on the Gitea server, the interval may be shorter than the server allows, or the GitHub token may not push to the new repository)" 200 201
|
||||
}
|
||||
|
||||
# verify_mirror ADDRESS: read the mirror back and report what Gitea applied.
|
||||
verify_mirror() {
|
||||
local address="$1" on_commit
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
expect_status "cannot read the push mirror back from Gitea" 200
|
||||
json_has_value "$HTTP_BODY_FILE" remote_address "$address" ||
|
||||
die "Gitea did not list the push mirror after creating it"
|
||||
on_commit="$(mirror_field "$HTTP_BODY_FILE" "$address" sync_on_commit)"
|
||||
if [[ $on_commit != true ]]; then
|
||||
warn "Gitea did not apply sync_on_commit (a known server issue): the mirror syncs every ${CONFIG[MIRROR_INTERVAL]}, not on every commit. Switch it on in the repository settings if you need it."
|
||||
STEP_DETAIL["Push mirror"]="(syncs every ${CONFIG[MIRROR_INTERVAL]}, not on every commit)"
|
||||
fi
|
||||
}
|
||||
|
||||
# request_first_sync ADDRESS: ask Gitea for a first push and report an error
|
||||
# it records. A failure here is a warning: the mirror retries by itself.
|
||||
request_first_sync() {
|
||||
local address="$1" last_error
|
||||
api_call gitea POST "$(mirror_path)-sync"
|
||||
if [[ $HTTP_STATUS != 200 && $HTTP_STATUS != 204 ]]; then
|
||||
warn "could not ask Gitea for the first sync (HTTP $HTTP_STATUS); it runs at the next interval"
|
||||
return 0
|
||||
fi
|
||||
sleep "${REPOFOUNDRY_SYNC_WAIT:-3}"
|
||||
api_call gitea GET "$(mirror_path)"
|
||||
if [[ $HTTP_STATUS == 200 ]]; then
|
||||
last_error="$(mirror_field "$HTTP_BODY_FILE" "$address" last_error)"
|
||||
if [[ -n $last_error ]]; then
|
||||
warn "the first mirror sync reported: ${last_error:0:200}"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
configure_mirror() {
|
||||
local label="Push mirror" address
|
||||
address="$(mirror_address)"
|
||||
begin_step "$label"
|
||||
if mirror_exists "$address"; then
|
||||
finish_step "$label" "reused" "Gitea -> $address"
|
||||
else
|
||||
create_mirror "$address"
|
||||
finish_step "$label" "created" "Gitea -> $address"
|
||||
verify_mirror "$address"
|
||||
fi
|
||||
request_first_sync "$address"
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# output.sh - Messages for the user: output, warnings, errors, and redaction of secrets.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: redact, say, warn, die, on_error
|
||||
|
||||
# redact TEXT: print TEXT with every known secret value replaced.
|
||||
redact() {
|
||||
local text="$1" secret
|
||||
for secret in "${SECRET_VALUES[@]}"; do
|
||||
if [[ -n $secret ]]; then
|
||||
text="${text//"$secret"/[redacted]}"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
say() {
|
||||
printf '%s\n' "$(redact "$*")"
|
||||
}
|
||||
|
||||
warn() {
|
||||
printf 'warning: %s\n' "$(redact "$*")" >&2
|
||||
}
|
||||
|
||||
# die [--code N] MESSAGE: print "error: MESSAGE" and exit (default code 1).
|
||||
die() {
|
||||
local code=$EXIT_FAILURE
|
||||
if [[ ${1:-} == --code ]]; then
|
||||
code="$2"
|
||||
shift 2
|
||||
fi
|
||||
printf 'error: %s\n' "$(redact "$*")" >&2
|
||||
exit "$code"
|
||||
}
|
||||
|
||||
# on_error LINE: report an unexpected failure without echoing the command,
|
||||
# because a command line could contain a value that must stay private.
|
||||
on_error() {
|
||||
printf 'error: unexpected failure near line %s of %s\n' "$1" "${0##*/}" >&2
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# plan.sh - Printing what the script is about to do.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: print_plan
|
||||
|
||||
print_plan() {
|
||||
local gitea_action github_action origin_note
|
||||
say ""
|
||||
say "Plan:"
|
||||
if ((STATE[reuse_gitea])); then
|
||||
gitea_action="reuse the existing repository (you will be asked to confirm)"
|
||||
elif ((PROJECT[has_github])); then
|
||||
gitea_action="create (${PROJECT[visibility]}) with the $AGPL_LICENSE_KEY license"
|
||||
else
|
||||
gitea_action="create (${PROJECT[visibility]}), empty"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s %s' "Gitea repository" "$gitea_action" "$(repo_url gitea)")"
|
||||
if ((PROJECT[has_github])); then
|
||||
if ((STATE[reuse_github])); then
|
||||
github_action="reuse the existing empty repository (you will be asked to confirm)"
|
||||
else
|
||||
github_action="create (${PROJECT[visibility]}), empty"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s %s' "GitHub repository" "$github_action" "$(repo_url github)")"
|
||||
say "$(printf ' %-18s: %s' "Push mirror" "Gitea -> GitHub every ${CONFIG[MIRROR_INTERVAL]}")"
|
||||
else
|
||||
say "$(printf ' %-18s: %s' "GitHub repository" "not used")"
|
||||
say "$(printf ' %-18s: %s' "Push mirror" "not used")"
|
||||
fi
|
||||
if ((STATE[is_ssh_ok])); then
|
||||
origin_note="SSH (the SSH test passed)"
|
||||
else
|
||||
origin_note="HTTPS (SSH test: ${STATE[ssh_note]})"
|
||||
fi
|
||||
say "$(printf ' %-18s: %s' "Local origin" "will use $origin_note, in a later phase")"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# preflight.sh - Read-only checks of both hosts before anything is created.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: check_gitea_organization, check_gitea_license, inspect_repository, preflight_gitea, check_github_organization, preflight_github, test_gitea_ssh, decide_existing_repositories, run_preflight
|
||||
|
||||
check_gitea_organization() {
|
||||
local org="$1" login="$2"
|
||||
api_call gitea GET "/orgs/$org"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
die "Gitea owner '$org' is neither your account ($login) nor an organization the token can see"
|
||||
fi
|
||||
expect_status "cannot look up the Gitea organization '$org'" 200
|
||||
api_call gitea GET "/users/$login/orgs/$org/permissions"
|
||||
expect_status "cannot read your permissions in the Gitea organization '$org'" 200
|
||||
if [[ $(json_get "$HTTP_BODY_FILE" can_create_repository) != true ]]; then
|
||||
die "you may not create repositories in the Gitea organization '$org'"
|
||||
fi
|
||||
}
|
||||
|
||||
check_gitea_license() {
|
||||
api_call gitea GET /licenses
|
||||
expect_status "cannot list the licenses of the Gitea server" 200
|
||||
json_has_value "$HTTP_BODY_FILE" key "$AGPL_LICENSE_KEY" ||
|
||||
die "the Gitea server does not offer the $AGPL_LICENSE_KEY license"
|
||||
}
|
||||
|
||||
# inspect_repository HOST: record in STATE[HOST_repo] whether the repository
|
||||
# is free (does not exist), empty, license_only or not_empty.
|
||||
inspect_repository() {
|
||||
local host="$1" owner name names
|
||||
owner="$(repo_owner "$host")"
|
||||
name="${PROJECT[name]}"
|
||||
api_call "$host" GET "/repos/$owner/$name"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
STATE[${host}_repo]="free"
|
||||
return 0
|
||||
fi
|
||||
expect_status "cannot look up the $(host_label "$host") repository $owner/$name" 200
|
||||
api_call "$host" GET "/repos/$owner/$name/contents"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
STATE[${host}_repo]="empty"
|
||||
return 0
|
||||
fi
|
||||
expect_status "cannot read the contents of the $(host_label "$host") repository $owner/$name" 200
|
||||
names="$(json_values "$HTTP_BODY_FILE" name)"
|
||||
case "$names" in
|
||||
"") STATE[${host}_repo]="empty" ;;
|
||||
LICENSE) STATE[${host}_repo]="license_only" ;;
|
||||
*) STATE[${host}_repo]="not_empty" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
preflight_gitea() {
|
||||
local owner="${PROJECT[gitea_owner]}" login
|
||||
api_call gitea GET /user
|
||||
expect_status "Gitea rejected the token" 200
|
||||
login="$(json_get "$HTTP_BODY_FILE" login)"
|
||||
[[ -n $login ]] || die "Gitea did not say which account the token belongs to"
|
||||
STATE[gitea_login]="$login"
|
||||
if is_same_name "$owner" "$login"; then
|
||||
STATE[gitea_owner_kind]="user"
|
||||
else
|
||||
check_gitea_organization "$owner" "$login"
|
||||
STATE[gitea_owner_kind]="organization"
|
||||
fi
|
||||
if ((PROJECT[has_github])); then
|
||||
check_gitea_license
|
||||
fi
|
||||
inspect_repository gitea
|
||||
}
|
||||
|
||||
check_github_organization() {
|
||||
local org="$1" login="$2"
|
||||
api_call github GET "/user/memberships/orgs/$org"
|
||||
if [[ $HTTP_STATUS == 404 ]]; then
|
||||
die "GitHub owner '$org' is neither your account ($login) nor an organization you belong to (or the token lacks the read:org scope)"
|
||||
fi
|
||||
expect_status "cannot read your membership of the GitHub organization '$org'" 200
|
||||
if [[ $(json_get "$HTTP_BODY_FILE" state) != active ]]; then
|
||||
die "your membership of the GitHub organization '$org' is not active"
|
||||
fi
|
||||
}
|
||||
|
||||
preflight_github() {
|
||||
local owner="${PROJECT[github_owner]}" configured login
|
||||
configured="${CREDENTIALS[GITHUB_USER]:-}"
|
||||
api_call github GET /user
|
||||
expect_status "GitHub rejected the token" 200
|
||||
login="$(json_get "$HTTP_BODY_FILE" login)"
|
||||
[[ -n $login ]] || die "GitHub did not say which account the token belongs to"
|
||||
STATE[github_login]="$login"
|
||||
if [[ -n $configured ]] && ! is_same_name "$configured" "$login"; then
|
||||
warn "GITHUB_USER is '$configured' but the token belongs to '$login'; the mirror will use '$login'"
|
||||
fi
|
||||
if is_same_name "$owner" "$login"; then
|
||||
STATE[github_owner_kind]="user"
|
||||
else
|
||||
check_github_organization "$owner" "$login"
|
||||
STATE[github_owner_kind]="organization"
|
||||
fi
|
||||
inspect_repository github
|
||||
}
|
||||
|
||||
# The SSH result decides later whether origin uses SSH or HTTPS. Without ssh
|
||||
# or without access it is simply "not passed"; it never stops the run.
|
||||
test_gitea_ssh() {
|
||||
local host port output status=0
|
||||
host="${CONFIG[GITEA_URL]#https://}"
|
||||
host="${host%%/*}"
|
||||
host="${host%%:*}"
|
||||
port="${CONFIG[GITEA_SSH_PORT]}"
|
||||
STATE[is_ssh_ok]=0
|
||||
STATE[ssh_note]="failed (check your SSH key and that $host:$port is reachable)"
|
||||
if ! command -v ssh >/dev/null 2>&1; then
|
||||
STATE[ssh_note]="not tested (ssh is not installed)"
|
||||
return 0
|
||||
fi
|
||||
output="$(ssh -p "$port" -o BatchMode=yes -o ConnectTimeout=5 \
|
||||
-o StrictHostKeyChecking=yes -T "git@$host" 2>&1)" || status=$?
|
||||
if ((status == 0)) || [[ $output == *"successfully authenticated"* ]]; then
|
||||
STATE[is_ssh_ok]=1
|
||||
STATE[ssh_note]="passed"
|
||||
fi
|
||||
}
|
||||
|
||||
# decide_existing_repositories: a repository that already exists may only be
|
||||
# reused when it is empty (Gitea: or holds just the license this script adds).
|
||||
decide_existing_repositories() {
|
||||
local host owner state
|
||||
for host in gitea github; do
|
||||
STATE[reuse_$host]=0
|
||||
if [[ $host == github ]] && ! ((PROJECT[has_github])); then
|
||||
continue
|
||||
fi
|
||||
owner="$(repo_owner "$host")"
|
||||
state="${STATE[${host}_repo]}"
|
||||
case "$state" in
|
||||
free) ;;
|
||||
empty) STATE[reuse_$host]=1 ;;
|
||||
license_only)
|
||||
if [[ $host == gitea ]] && ((PROJECT[has_github])); then
|
||||
STATE[reuse_$host]=1
|
||||
else
|
||||
die "the $(host_label "$host") repository $owner/${PROJECT[name]} already exists and has content; choose another name or remove it first"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
die "the $(host_label "$host") repository $owner/${PROJECT[name]} already exists and has content; choose another name or remove it first"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
run_preflight() {
|
||||
say ""
|
||||
say "Checking the hosts (read-only requests)..."
|
||||
preflight_gitea
|
||||
if ((PROJECT[has_github])); then
|
||||
preflight_github
|
||||
fi
|
||||
test_gitea_ssh
|
||||
decide_existing_repositories
|
||||
say "All checks passed."
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# project.sh - The details of the project being created: asking for them and showing them.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: collect_project_details, yes_no, credential_state, print_summary
|
||||
|
||||
collect_project_details() {
|
||||
prompt_value "Repository name" "" is_valid_repo_name \
|
||||
"use letters, digits, '.', '_' or '-' (at most 100), not ending in .git"
|
||||
PROJECT[name]="$REPLY"
|
||||
prompt_value "Description (optional)" "" is_valid_description \
|
||||
"at most $MAX_DESCRIPTION_LENGTH characters and no control characters"
|
||||
PROJECT[description]="$REPLY"
|
||||
prompt_choice "Visibility" private private public
|
||||
PROJECT[visibility]="$REPLY"
|
||||
prompt_value "Gitea owner (user or organization)" "" is_valid_gitea_owner \
|
||||
"use letters, digits, '.', '_' or '-' (at most 39)"
|
||||
PROJECT[gitea_owner]="$REPLY"
|
||||
prompt_yes_no "Also create a GitHub repository (applies the AGPL license)" y
|
||||
PROJECT[has_github]="$REPLY"
|
||||
PROJECT[github_owner]=""
|
||||
if ((PROJECT[has_github])); then
|
||||
prompt_value "GitHub owner (user or organization)" \
|
||||
"${CREDENTIALS[GITHUB_USER]:-}" is_valid_github_owner \
|
||||
"use letters, digits or '-' (at most 39)"
|
||||
PROJECT[github_owner]="$REPLY"
|
||||
fi
|
||||
prompt_value "Local directory" "./${PROJECT[name]}" is_valid_directory \
|
||||
"must not be empty, start with '-' or contain control characters"
|
||||
PROJECT[directory]="$REPLY"
|
||||
prompt_yes_no "Enable the plan gate" n
|
||||
PROJECT[is_plan_gate_enabled]="$REPLY"
|
||||
}
|
||||
|
||||
yes_no() {
|
||||
if (($1)); then
|
||||
printf 'yes'
|
||||
else
|
||||
printf 'no'
|
||||
fi
|
||||
}
|
||||
|
||||
credential_state() {
|
||||
if [[ -n ${CREDENTIALS[$1]:-} ]]; then
|
||||
printf 'set'
|
||||
else
|
||||
printf 'not set'
|
||||
fi
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
say ""
|
||||
say "$PROJECT_NAME $VERSION"
|
||||
say "Collected details:"
|
||||
say " Repository : ${PROJECT[name]} (${PROJECT[visibility]})"
|
||||
say " Description : ${PROJECT[description]:-(none)}"
|
||||
say " Gitea : ${CONFIG[GITEA_URL]}/${PROJECT[gitea_owner]}/${PROJECT[name]}"
|
||||
if ((PROJECT[has_github])); then
|
||||
say " GitHub : ${CONFIG[GITHUB_WEB_URL]}/${PROJECT[github_owner]}/${PROJECT[name]} (AGPL license applied)"
|
||||
else
|
||||
say " GitHub : not used"
|
||||
fi
|
||||
say " Directory : ${PROJECT[directory]}"
|
||||
say " Plan gate : $(yes_no "${PROJECT[is_plan_gate_enabled]}")"
|
||||
say "Credentials : GITEA_TOKEN $(credential_state GITEA_TOKEN)," \
|
||||
"GITHUB_PAT $(credential_state GITHUB_PAT)"
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# prompts.sh - Interactive questions with validation of every answer.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: prompt_value, prompt_choice, prompt_yes_no
|
||||
|
||||
# prompt_value LABEL DEFAULT VALIDATOR HINT: ask until VALIDATOR accepts the
|
||||
# answer; the accepted answer is returned in REPLY.
|
||||
prompt_value() {
|
||||
local label="$1" default="$2" validator="$3" hint="$4" answer
|
||||
while true; do
|
||||
if [[ -n $default ]]; then
|
||||
printf '%s [%s]: ' "$label" "$default" >&2
|
||||
else
|
||||
printf '%s: ' "$label" >&2
|
||||
fi
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
if "$validator" "$answer"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: $hint"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_choice LABEL DEFAULT CHOICE...: the answer is returned in REPLY.
|
||||
prompt_choice() {
|
||||
local label="$1" default="$2" answer
|
||||
shift 2
|
||||
while true; do
|
||||
printf '%s (%s) [%s]: ' "$label" "$(IFS=/ && echo "$*")" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
answer="${answer,,}"
|
||||
if in_list "$answer" "$@"; then
|
||||
REPLY="$answer"
|
||||
return 0
|
||||
fi
|
||||
warn "invalid $label: choose one of $*"
|
||||
done
|
||||
}
|
||||
|
||||
# prompt_yes_no LABEL DEFAULT: DEFAULT is y or n; REPLY is 1 (yes) or 0 (no).
|
||||
prompt_yes_no() {
|
||||
local label="$1" default="$2" answer
|
||||
while true; do
|
||||
printf '%s (y/n) [%s]: ' "$label" "$default" >&2
|
||||
IFS= read -r answer || die "no input available for '$label'"
|
||||
answer="$(trim "$answer")"
|
||||
answer="${answer:-$default}"
|
||||
case "${answer,,}" in
|
||||
y | yes)
|
||||
REPLY=1
|
||||
return 0
|
||||
;;
|
||||
n | no)
|
||||
REPLY=0
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
warn "invalid $label: answer y or n"
|
||||
done
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# repositories.sh - Creating the GitHub and Gitea repositories.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: repo_body, create_repository
|
||||
|
||||
# repo_body HOST: the JSON body that creates the repository on HOST.
|
||||
repo_body() {
|
||||
local host="$1" description private=true extra=""
|
||||
description="$(json_escape "${PROJECT[description]}")"
|
||||
if [[ ${PROJECT[visibility]} != private ]]; then
|
||||
private=false
|
||||
fi
|
||||
if [[ $host == github ]]; then
|
||||
printf '{"name":"%s","description":"%s","private":%s,"auto_init":false}' \
|
||||
"${PROJECT[name]}" "$description" "$private"
|
||||
return 0
|
||||
fi
|
||||
if ((PROJECT[has_github])); then
|
||||
extra=',"auto_init":true,"license":"'"$AGPL_LICENSE_KEY"'"'
|
||||
else
|
||||
extra=',"auto_init":false'
|
||||
fi
|
||||
printf '{"name":"%s","description":"%s","private":%s,"default_branch":"%s"%s}' \
|
||||
"${PROJECT[name]}" "$description" "$private" "$DEFAULT_BRANCH" "$extra"
|
||||
}
|
||||
|
||||
# create_repository HOST: create the repository, or reuse the existing one.
|
||||
create_repository() {
|
||||
local host="$1" label owner path
|
||||
label="$(host_label "$host") repository"
|
||||
owner="$(repo_owner "$host")"
|
||||
if is_reused "$host"; then
|
||||
finish_step "$label" "reused" "$(repo_url "$host")"
|
||||
return 0
|
||||
fi
|
||||
begin_step "$label"
|
||||
path="/user/repos"
|
||||
if [[ ${STATE[${host}_owner_kind]} == organization ]]; then
|
||||
path="/orgs/$owner/repos"
|
||||
fi
|
||||
api_call "$host" POST "$path" "$(repo_body "$host")"
|
||||
expect_status "cannot create the $label" 201
|
||||
finish_step "$label" "created" "$(repo_url "$host")"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# steps.sh - The outcome of each step and the final report of a run.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: init_steps, begin_step, finish_step, report_outcome
|
||||
|
||||
init_steps() {
|
||||
local label
|
||||
for label in "${PLAN_STEPS[@]}"; do
|
||||
STEP_STATUS[$label]="not attempted"
|
||||
STEP_DETAIL[$label]=""
|
||||
done
|
||||
if ! ((PROJECT[has_github])); then
|
||||
STEP_STATUS["GitHub repository"]="not used"
|
||||
STEP_STATUS["Push mirror"]="not used"
|
||||
fi
|
||||
}
|
||||
|
||||
# begin_step LABEL marks the step failed until finish_step says otherwise, so
|
||||
# any stop in the middle of a step is reported as a failure of that step.
|
||||
begin_step() {
|
||||
STEP_STATUS[$1]="FAILED"
|
||||
STEP_DETAIL[$1]=""
|
||||
}
|
||||
|
||||
finish_step() {
|
||||
STEP_STATUS[$1]="$2"
|
||||
STEP_DETAIL[$1]="${3:-}"
|
||||
}
|
||||
|
||||
report_outcome() {
|
||||
local code="$1" label
|
||||
say ""
|
||||
if ((code == 0)); then
|
||||
say "Done. This is what exists now:"
|
||||
else
|
||||
say "The run stopped before it finished. This is what exists now:"
|
||||
fi
|
||||
for label in "${PLAN_STEPS[@]}"; do
|
||||
say "$(printf ' %-18s: %s' "$label" "${STEP_STATUS[$label]}${STEP_DETAIL[$label]:+ ${STEP_DETAIL[$label]}}")"
|
||||
done
|
||||
if ((code == 0)); then
|
||||
say "The local project with the framework is created by a later phase."
|
||||
else
|
||||
say "To continue: fix the problem named above and run the same command again with --apply."
|
||||
say "A repository this run created is still empty (or holds only the license), so the next run offers to reuse it."
|
||||
say "Nothing is deleted automatically. To start over, delete the repositories above in the web interface."
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# temp.sh - Private temporary files and their cleanup (never a recursive delete).
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: cleanup, setup_temp_dir, make_temp_file
|
||||
|
||||
# Files are removed one by one and the directory with rmdir: a recursive
|
||||
# delete is never needed and never used.
|
||||
cleanup() {
|
||||
local file
|
||||
for file in "${TEMP_FILES[@]}"; do
|
||||
rm -f -- "$file"
|
||||
done
|
||||
if [[ -n $TMP_DIR && -d $TMP_DIR ]]; then
|
||||
# rmdir fails only if something unexpected is left inside; leave it
|
||||
# rather than delete files this script did not create.
|
||||
rmdir -- "$TMP_DIR" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
setup_temp_dir() {
|
||||
TMP_DIR="$(umask 077 && mktemp -d "${TMPDIR:-/tmp}/repofoundry.XXXXXX")"
|
||||
}
|
||||
|
||||
# make_temp_file: create a private file in TMP_DIR and return it in REPLY.
|
||||
make_temp_file() {
|
||||
REPLY="$(umask 077 && mktemp "$TMP_DIR/file.XXXXXX")"
|
||||
TEMP_FILES+=("$REPLY")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# tools.sh - Checking that the required tools are installed.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: check_tools
|
||||
|
||||
check_tools() {
|
||||
local tool
|
||||
local missing=()
|
||||
for tool in git curl mktemp; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
missing+=("$tool")
|
||||
fi
|
||||
done
|
||||
if ((${#missing[@]} > 0)); then
|
||||
die "required tool(s) not found: ${missing[*]}. Install them and try again."
|
||||
fi
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
HAS_JQ=1
|
||||
else
|
||||
HAS_JQ=0
|
||||
warn "jq not found; using the built-in JSON reader (install jq for stricter parsing)"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# util.sh - Small string and list helpers with no knowledge of the project.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: trim, in_list, has_control_character, is_same_name
|
||||
|
||||
trim() {
|
||||
local text="$1"
|
||||
text="${text#"${text%%[![:space:]]*}"}"
|
||||
text="${text%"${text##*[![:space:]]}"}"
|
||||
printf '%s' "$text"
|
||||
}
|
||||
|
||||
# in_list NEEDLE ITEM...: succeed if NEEDLE equals one of the items.
|
||||
in_list() {
|
||||
local needle="$1" item
|
||||
shift
|
||||
for item in "$@"; do
|
||||
if [[ $item == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
has_control_character() {
|
||||
[[ $1 == *[[:cntrl:]]* ]]
|
||||
}
|
||||
|
||||
is_same_name() {
|
||||
[[ ${1,,} == "${2,,}" ]]
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2004,SC2034,SC2154 # shared state and arrays are declared in constants.sh
|
||||
# validate.sh - Validators for names, URLs, tokens, ports and intervals.
|
||||
#
|
||||
# Part of create-project.sh: sourced by it, never run on its own.
|
||||
#
|
||||
# Provides: is_valid_repo_name, is_valid_gitea_owner, is_valid_github_owner, is_valid_description, is_valid_directory, is_valid_base_url, is_valid_request_url, is_valid_token, is_valid_port, is_valid_interval, normalize_url
|
||||
|
||||
is_valid_repo_name() {
|
||||
local name="$1"
|
||||
[[ $name =~ ^[A-Za-z0-9._-]{1,100}$ ]] || return 1
|
||||
[[ $name != . && $name != .. && $name != *.git ]]
|
||||
}
|
||||
|
||||
is_valid_gitea_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,38}$ ]]
|
||||
}
|
||||
|
||||
is_valid_github_owner() {
|
||||
[[ $1 =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ ]]
|
||||
}
|
||||
|
||||
is_valid_description() {
|
||||
((${#1} <= MAX_DESCRIPTION_LENGTH)) && ! has_control_character "$1"
|
||||
}
|
||||
|
||||
is_valid_directory() {
|
||||
local path="$1"
|
||||
[[ -n $path && ${#path} -le 4096 && $path != -* ]] &&
|
||||
! has_control_character "$path"
|
||||
}
|
||||
|
||||
# https URL without user info, query or fragment, so it can never carry a
|
||||
# credential.
|
||||
is_valid_base_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Like is_valid_base_url but a query string is allowed (for API requests).
|
||||
is_valid_request_url() {
|
||||
local pattern='^https://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~%+/-]*)?(\?[A-Za-z0-9._~%+=&,-]*)?$'
|
||||
[[ $1 =~ $pattern ]]
|
||||
}
|
||||
|
||||
# Access tokens: no quotes, backslashes or whitespace, so a token cannot
|
||||
# break out of the curl configuration it is written to.
|
||||
is_valid_token() {
|
||||
[[ $1 =~ ^[A-Za-z0-9_.~+/=-]{8,255}$ ]]
|
||||
}
|
||||
|
||||
is_valid_port() {
|
||||
[[ $1 =~ ^[0-9]{1,5}$ ]] && ((10#$1 >= 1 && 10#$1 <= 65535))
|
||||
}
|
||||
|
||||
# A Go duration such as 10m0s or 8h0m0s, the form Gitea expects.
|
||||
is_valid_interval() {
|
||||
[[ -n $1 && $1 =~ ^([0-9]+h)?([0-9]+m)?([0-9]+s)?$ ]]
|
||||
}
|
||||
|
||||
normalize_url() {
|
||||
local url="$1"
|
||||
while [[ $url == */ ]]; do
|
||||
url="${url%/}"
|
||||
done
|
||||
printf '%s' "$url"
|
||||
}
|
||||
Reference in New Issue
Block a user