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
+31 -18
View File
@@ -4,43 +4,47 @@
# (MIL-001 Go/No-Go criteria 2 to 4). Sourced by run-tests.sh.
# shellcheck disable=SC2016 # snippet and fixture text is literal on purpose
readonly ANSWERS_GITHUB=$'my-app\nA test app\n\nTirSystem\ny\nacme-org\n\nn\n'
readonly ANSWERS_GITEA_ONLY=$'my-app\n\n\nTirSystem\nn\n\nn\n'
# listing: all files under the work directory except the test's own captures.
listing() {
find "$WORK" -type f ! -name out.txt ! -name err.txt ! -name snippet.sh \
! -name 'curl.*' | sort
! -name 'curl.*' ! -name 'routes*' ! -name 'ssh.args' | sort
}
test_full_run_with_github() {
write_fixtures
write_curl_stub
setup_hosts
local before after
before="$(listing)"
run_cli "$ANSWERS_GITHUB" --config "$WORK/config.env" --env "$WORK/.env"
after="$(listing)"
assert_status "full run" 0 "$STATUS"
assert_contains "summary" "$OUT" "nothing has been created yet"
assert_contains "dry run" "$OUT" "Dry run: nothing was created"
assert_contains "plan" "$OUT" "create (private) with the AGPL-3.0 license"
assert_contains "Gitea link derived from config" "$OUT" "https://git.example.test/TirSystem/my-app"
assert_contains "GitHub link uses the chosen organization" "$OUT" "https://github.com/acme-org/my-app"
assert_contains "AGPL noted" "$OUT" "AGPL license applied"
assert_contains "credential state" "$OUT" "GITEA_TOKEN set, GITHUB_PAT set"
assert_not_contains "no Gitea token in output" "$OUT$ERR" "$FAKE_GITEA_TOKEN"
assert_not_contains "no GitHub token in output" "$OUT$ERR" "$FAKE_GITHUB_PAT"
assert_file_missing "no network call" "$WORK/curl.args"
assert_contains "reads from Gitea" "$(calls)" "GET https://git.example.test/api/v1/user"
assert_contains "reads from GitHub" "$(calls)" "GET https://api.github.com/user"
assert_not_contains "a dry run only reads" "$(calls)" "POST"
assert_not_contains "a dry run never deletes" "$(calls)" "DELETE"
assert_eq "no file created or changed" "$before" "$after"
assert_eq "temporary files removed" "" "$(find "$WORK/tmp" -mindepth 1)"
}
test_full_run_without_github() {
write_fixtures
setup_hosts
printf 'GITEA_TOKEN=%s\n' "$FAKE_GITEA_TOKEN" >"$WORK/.env"
run_cli "$ANSWERS_GITEA_ONLY" --config "$WORK/config.env" --env "$WORK/.env"
assert_status "Gitea-only run needs no GitHub credentials" 0 "$STATUS"
assert_contains "GitHub not used" "$OUT" "GitHub : not used"
assert_not_contains "no AGPL line" "$OUT" "AGPL"
assert_contains "GITHUB_PAT not set" "$OUT" "GITHUB_PAT not set"
assert_not_contains "no call to GitHub" "$(calls)" "api.github.com"
assert_not_contains "no license lookup without GitHub" "$(calls)" "/licenses"
assert_contains "plan says GitHub is not used" "$OUT" "GitHub repository : not used"
}
test_github_chosen_without_credentials_fails() {
@@ -99,11 +103,11 @@ test_usage_errors() {
assert_contains "help shows exit codes" "$OUT" "Exit codes"
run_cli "" --version
assert_status "version" 0 "$STATUS"
assert_contains "version output" "$OUT" "RepoFoundry 0.1.0"
assert_contains "version output" "$OUT" "RepoFoundry 0.2.0"
}
test_warns_when_env_is_not_ignored_by_git() {
write_fixtures
setup_hosts
git init -q "$WORK/repo"
cp "$WORK/.env" "$WORK/repo/.env"
cp "$WORK/config.env" "$WORK/repo/config.env"
@@ -117,12 +121,20 @@ test_warns_when_env_is_not_ignored_by_git() {
test_script_uses_no_unsafe_constructs() {
local code
code="$(grep -vE '^[[:space:]]*#' "$SCRIPT")"
code="$(cat "$SCRIPT" "$SRC_DIR"/lib/*.sh | grep -vE '^[[:space:]]*#')"
assert_not_contains "no rm -rf" "$code" "rm -rf"
assert_not_contains "no rm -r" "$code" "rm -r "
assert_not_contains "no eval" "$code" "eval "
assert_not_contains "no source" "$code" "source "
assert_not_contains "no dot-source" "$code" $'\n. '
# Only the script's own library files are sourced, by a fixed path; a
# configuration file never is.
local line
while IFS= read -r line; do
check
if [[ $line != 'source "$SCRIPT_DIR/lib/'*'.sh"' ]]; then
fail "unexpected source line: $line"
fi
done < <(grep -hE '(^|[[:space:];])source ' <<<"$code")
check
if grep -Eq '^[[:space:]]*set -[A-Za-z]*x' <<<"$code"; then
fail "the script turns tracing on"
@@ -133,9 +145,9 @@ test_script_uses_no_unsafe_constructs() {
test_tracing_does_not_leak_secrets() {
# bash -x would print every assignment and command, secrets included, so
# the script switches tracing off and says so.
write_fixtures
setup_hosts
STATUS=0
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" -x "$SCRIPT" \
--config "$WORK/config.env" --env "$WORK/.env" <<<"$ANSWERS_GITHUB" \
>"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
assert_status "run under bash -x" 0 "$STATUS"
@@ -157,7 +169,7 @@ test_termination_removes_temp_files() {
if ! mkfifo "$WORK/in" 2>/dev/null; then
return 0
fi
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$SCRIPT" \
--config "$WORK/config.env" --env "$WORK/.env" <"$WORK/in" \
>/dev/null 2>&1 &
local pid=$! tries=0
@@ -182,19 +194,20 @@ test_script_lives_in_src() {
test_default_files_are_in_the_project_root() {
# A project copy: script in src/, config.env and .env one level up.
write_fixtures
setup_hosts
mkdir -p "$WORK/project/src"
cp "$SCRIPT" "$WORK/project/src/create-project.sh"
cp -R "$SRC_DIR/lib" "$WORK/project/src/lib"
cp "$WORK/config.env" "$WORK/project/config.env"
cp "$WORK/.env" "$WORK/project/.env"
STATUS=0
PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
<<<"$ANSWERS_GITHUB" >"$WORK/out.txt" 2>"$WORK/err.txt" || STATUS=$?
assert_status "run with the default files" 0 "$STATUS"
assert_contains "found config.env in the project root" "$(cat "$WORK/out.txt")" "https://git.example.test/TirSystem/my-app"
# Run from another directory: the defaults follow the script, not the cwd.
STATUS=0
(cd "$WORK" && PATH="$WORK/bin:$PATH" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
(cd "$WORK" && PATH="$WORK/bin:$PATH" STUB_DIR="$WORK" TMPDIR="$WORK/tmp" "$BASH" "$WORK/project/src/create-project.sh" \
<<<"$ANSWERS_GITEA_ONLY" >"$WORK/out.txt" 2>"$WORK/err.txt") || STATUS=$?
assert_status "run from another directory" 0 "$STATUS"
}