fix: fix: disinto init must be fully idempotent — safe to re-run on existing factory (#239)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/pr/smoke-init Pipeline was successful

This commit is contained in:
Agent 2026-04-05 21:15:25 +00:00
parent dcf348e486
commit 979e1210b4
3 changed files with 253 additions and 73 deletions

View file

@ -177,6 +177,12 @@ generate_compose() {
local forge_port="${1:-3000}"
local compose_file="${FACTORY_ROOT}/docker-compose.yml"
# Check if compose file already exists
if [ -f "$compose_file" ]; then
echo "Compose: ${compose_file} (already exists, skipping)"
return 0
fi
cat > "$compose_file" <<'COMPOSEEOF'
# docker-compose.yml — generated by disinto init
# Brings up Forgejo, Woodpecker, and the agent runtime.
@ -818,9 +824,15 @@ setup_forge() {
bot_pass="bot-$(head -c 16 /dev/urandom | base64 | tr -dc 'a-zA-Z0-9' | head -c 20)"
token_var="${bot_token_vars[$bot_user]}"
if ! curl -sf --max-time 5 \
# Check if bot user exists
local user_exists=false
if curl -sf --max-time 5 \
-H "Authorization: token ${admin_token}" \
"${forge_url}/api/v1/users/${bot_user}" >/dev/null 2>&1; then
user_exists=true
fi
if [ "$user_exists" = false ]; then
echo "Creating bot user: ${bot_user}"
local create_output
if ! create_output=$(_forgejo_exec forgejo admin user create \
@ -846,10 +858,38 @@ setup_forge() {
echo "Error: bot user '${bot_user}' not found after creation" >&2
exit 1
fi
echo " ${bot_user} user created"
else
echo " ${bot_user} user exists (resetting password for token generation)"
# User exists but may not have a known password.
# Use admin API to reset the password so we can generate a new token.
_forgejo_exec forgejo admin user change-password \
--username "${bot_user}" \
--password "${bot_pass}" \
--must-change-password=false || {
echo "Error: failed to reset password for existing bot user '${bot_user}'" >&2
exit 1
}
fi
# Generate token via API (basic auth as the bot user — Forgejo requires
# basic auth on POST /users/{username}/tokens, token auth is rejected)
# First, try to delete existing tokens to avoid name collision
local existing_token_ids
existing_token_ids=$(curl -sf \
-H "Authorization: token ${admin_token}" \
"${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \
| jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids=""
# Delete any existing tokens for this user
if [ -n "$existing_token_ids" ]; then
while IFS= read -r tid; do
[ -n "$tid" ] && curl -sf -X DELETE \
-H "Authorization: token ${admin_token}" \
"${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true
done <<< "$existing_token_ids"
fi
token=$(curl -sf -X POST \
-u "${bot_user}:${bot_pass}" \
-H "Content-Type: application/json" \
@ -857,16 +897,6 @@ setup_forge() {
-d "{\"name\":\"disinto-${bot_user}-token\",\"scopes\":[\"all\"]}" 2>/dev/null \
| jq -r '.sha1 // empty') || token=""
if [ -z "$token" ]; then
# Token name collision — create with timestamp suffix
token=$(curl -sf -X POST \
-u "${bot_user}:${bot_pass}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/users/${bot_user}/tokens" \
-d "{\"name\":\"disinto-${bot_user}-$(date +%s)\",\"scopes\":[\"all\"]}" 2>/dev/null \
| jq -r '.sha1 // empty') || token=""
fi
if [ -z "$token" ]; then
echo "Error: failed to create API token for '${bot_user}'" >&2
exit 1
@ -879,7 +909,7 @@ setup_forge() {
printf '%s=%s\n' "$token_var" "$token" >> "$env_file"
fi
export "${token_var}=${token}"
echo " ${bot_user} token saved (${token_var})"
echo " ${bot_user} token generated and saved (${token_var})"
# Backwards-compat aliases for dev-bot and review-bot
if [ "$bot_user" = "dev-bot" ]; then
@ -995,76 +1025,125 @@ setup_ops_repo() {
echo ""
echo "── Ops repo setup ─────────────────────────────────────"
# Check if ops repo already exists on Forgejo
if curl -sf --max-time 5 \
-H "Authorization: token ${FORGE_TOKEN}" \
"${forge_url}/api/v1/repos/${ops_slug}" >/dev/null 2>&1; then
echo "Ops repo: ${ops_slug} (already exists on Forgejo)"
else
# Create ops repo under org (or human user if org creation failed)
if ! curl -sf -X POST \
# Determine the actual ops repo location by searching across possible namespaces
# This handles cases where the repo was created under a different namespace
# due to past bugs (e.g., dev-bot/disinto-ops instead of disinto-admin/disinto-ops)
local actual_ops_slug=""
local -a possible_namespaces=( "$org_name" "dev-bot" "disinto-admin" )
local http_code
for ns in "${possible_namespaces[@]}"; do
slug="${ns}/${ops_name}"
if curl -sf --max-time 5 \
-H "Authorization: token ${FORGE_TOKEN}" \
"${forge_url}/api/v1/repos/${slug}" >/dev/null 2>&1; then
actual_ops_slug="$slug"
echo "Ops repo: ${slug} (found at ${slug})"
break
fi
done
# If not found, try to create it in the configured namespace
if [ -z "$actual_ops_slug" ]; then
echo "Creating ops repo in namespace: ${org_name}"
# Create org if it doesn't exist
curl -sf -X POST \
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/orgs" \
-d "{\"username\":\"${org_name}\",\"visibility\":\"public\"}" >/dev/null 2>&1 || true
if curl -sf -X POST \
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/orgs/${org_name}/repos" \
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" >/dev/null 2>&1; then
# Fallback: use admin API to create repo under the target namespace.
# POST /api/v1/users/{username}/repos creates under the authenticated user,
# not under {username}. The admin API POST /api/v1/admin/users/{username}/repos
# explicitly creates in the target user's namespace regardless of who is authed.
curl -sf -X POST \
actual_ops_slug="${org_name}/${ops_name}"
echo "Ops repo: ${actual_ops_slug} created on Forgejo"
else
# Fallback: use admin API to create repo under the target namespace
http_code=$(curl -s -o /dev/null -w "%{http_code}" \
-X POST \
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/admin/users/${org_name}/repos" \
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" >/dev/null 2>&1 || true
-d "{\"name\":\"${ops_name}\",\"auto_init\":true,\"default_branch\":\"${primary_branch}\",\"description\":\"Operational data for ${org_name}/${ops_name%-ops}\"}" 2>/dev/null || echo "0")
if [ "$http_code" = "201" ]; then
actual_ops_slug="${org_name}/${ops_name}"
echo "Ops repo: ${actual_ops_slug} created on Forgejo (via admin API)"
else
echo "Error: failed to create ops repo '${actual_ops_slug}' (HTTP ${http_code})" >&2
return 1
fi
fi
fi
# Add all bot users as collaborators with appropriate permissions
# vault branch protection (#77) requires:
# - Admin-only merge to main (enforced by admin_enforced: true)
# - Bots can push branches and create PRs, but cannot merge
local bot_user bot_perm
declare -A bot_permissions=(
[dev-bot]="write"
[review-bot]="read"
[planner-bot]="write"
[gardener-bot]="write"
[vault-bot]="write"
[supervisor-bot]="read"
[predictor-bot]="read"
[architect-bot]="write"
)
for bot_user in "${!bot_permissions[@]}"; do
bot_perm="${bot_permissions[$bot_user]}"
curl -sf -X PUT \
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/repos/${ops_slug}/collaborators/${bot_user}" \
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1 || true
done
# Configure collaborators on the ops repo
local bot_user bot_perm
declare -A bot_permissions=(
[dev-bot]="write"
[review-bot]="read"
[planner-bot]="write"
[gardener-bot]="write"
[vault-bot]="write"
[supervisor-bot]="read"
[predictor-bot]="read"
[architect-bot]="write"
)
# Add disinto-admin as admin collaborator
curl -sf -X PUT \
# Add all bot users as collaborators with appropriate permissions
# vault branch protection (#77) requires:
# - Admin-only merge to main (enforced by admin_enforced: true)
# - Bots can push branches and create PRs, but cannot merge
for bot_user in "${!bot_permissions[@]}"; do
bot_perm="${bot_permissions[$bot_user]}"
if curl -sf -X PUT \
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/repos/${ops_slug}/collaborators/disinto-admin" \
-d '{"permission":"admin"}' >/dev/null 2>&1 || true
"${forge_url}/api/v1/repos/${actual_ops_slug}/collaborators/${bot_user}" \
-d "{\"permission\":\"${bot_perm}\"}" >/dev/null 2>&1; then
echo " + ${bot_user} = ${bot_perm} collaborator"
else
echo " ! ${bot_user} = ${bot_perm} (already set or failed)"
fi
done
echo "Ops repo: ${ops_slug} created on Forgejo"
# Add disinto-admin as admin collaborator
if curl -sf -X PUT \
-H "Authorization: token ${admin_token:-${FORGE_TOKEN}}" \
-H "Content-Type: application/json" \
"${forge_url}/api/v1/repos/${actual_ops_slug}/collaborators/disinto-admin" \
-d '{"permission":"admin"}' >/dev/null 2>&1; then
echo " + disinto-admin = admin collaborator"
else
echo " ! disinto-admin = admin (already set or failed)"
fi
# Clone ops repo locally if not present
if [ ! -d "${ops_root}/.git" ]; then
local auth_url
auth_url=$(printf '%s' "$forge_url" | sed "s|://|://dev-bot:${FORGE_TOKEN}@|")
local clone_url="${auth_url}/${ops_slug}.git"
local clone_url="${auth_url}/${actual_ops_slug}.git"
echo "Cloning: ops repo -> ${ops_root}"
git clone --quiet "$clone_url" "$ops_root" 2>/dev/null || {
if git clone --quiet "$clone_url" "$ops_root" 2>/dev/null; then
echo "Ops repo: ${actual_ops_slug} cloned successfully"
else
echo "Initializing: ops repo at ${ops_root}"
mkdir -p "$ops_root"
git -C "$ops_root" init --initial-branch="${primary_branch}" -q
}
# Set remote to the actual ops repo location
git -C "$ops_root" remote add origin "${forge_url}/${actual_ops_slug}.git"
echo "Ops repo: ${actual_ops_slug} initialized locally"
fi
else
echo "Ops repo: ${ops_root} (already exists locally)"
# Verify remote is correct
local current_remote
current_remote=$(git -C "$ops_root" remote get-url origin 2>/dev/null || true)
local expected_remote="${forge_url}/${actual_ops_slug}.git"
if [ -n "$current_remote" ] && [ "$current_remote" != "$expected_remote" ]; then
echo " Fixing: remote URL from ${current_remote} to ${expected_remote}"
git -C "$ops_root" remote set-url origin "$expected_remote"
fi
fi
# Seed directory structure
@ -1128,10 +1207,13 @@ OPSEOF
git -C "$ops_root" commit -m "chore: seed ops repo structure" -q
# Push if remote exists
if git -C "$ops_root" remote get-url origin >/dev/null 2>&1; then
git -C "$ops_root" push origin "${primary_branch}" -q 2>/dev/null || true
if git -C "$ops_root" push origin "${primary_branch}" -q 2>/dev/null; then
echo "Seeded: ops repo with initial structure"
else
echo "Warning: failed to push seed content to ops repo" >&2
fi
fi
fi
echo "Seeded: ops repo with initial structure"
fi
}
@ -1386,9 +1468,11 @@ create_labels() {
| grep -o '"name":"[^"]*"' | cut -d'"' -f4) || existing=""
local name color
local created=0 skipped=0 failed=0
for name in backlog in-progress blocked tech-debt underspecified vision action bug-report prediction/unreviewed prediction/dismissed prediction/actioned; do
if echo "$existing" | grep -qx "$name"; then
echo " . ${name} (already exists)"
skipped=$((skipped + 1))
continue
fi
color="${labels[$name]}"
@ -1397,11 +1481,15 @@ create_labels() {
-H "Content-Type: application/json" \
"${api}/labels" \
-d "{\"name\":\"${name}\",\"color\":\"${color}\"}" >/dev/null 2>&1; then
echo " + ${name}"
echo " + ${name} (created)"
created=$((created + 1))
else
echo " ! ${name} (failed to create)"
failed=$((failed + 1))
fi
done
echo "Labels: ${created} created, ${skipped} skipped, ${failed} failed"
}
# Generate a minimal VISION.md template in the target project.
@ -1503,6 +1591,14 @@ install_cron() {
echo "$cron_block"
echo ""
# Check if cron entries already exist
local current_crontab
current_crontab=$(crontab -l 2>/dev/null || true)
if echo "$current_crontab" | grep -q "# disinto: ${name}"; then
echo "Cron: skipped (entries for ${name} already installed)"
return
fi
if [ "$auto_yes" = false ] && [ -t 0 ]; then
read -rp "Install these cron entries? [y/N] " confirm
if [[ ! "$confirm" =~ ^[Yy] ]]; then
@ -1512,8 +1608,12 @@ install_cron() {
fi
# Append to existing crontab
{ crontab -l 2>/dev/null || true; printf '%s\n' "$cron_block"; } | crontab -
echo "Cron entries installed"
if { crontab -l 2>/dev/null || true; printf '%s\n' "$cron_block"; } | crontab -; then
echo "Cron entries installed for ${name}"
else
echo "Error: failed to install cron entries" >&2
return 1
fi
}
# Set up Woodpecker CI to use Forgejo as its forge backend.
@ -2091,17 +2191,36 @@ p.write_text(text)
if [ -n "${MIRROR_NAMES:-}" ]; then
echo "Mirrors: setting up remotes"
local mname murl
local mirrors_ok=true
for mname in $MIRROR_NAMES; do
murl=$(eval "echo \"\$MIRROR_$(echo "$mname" | tr '[:lower:]' '[:upper:]')\"") || true
[ -z "$murl" ] && continue
git -C "$repo_root" remote add "$mname" "$murl" 2>/dev/null \
|| git -C "$repo_root" remote set-url "$mname" "$murl" 2>/dev/null || true
echo " + ${mname} -> ${murl}"
if git -C "$repo_root" remote get-url "$mname" >/dev/null 2>&1; then
if git -C "$repo_root" remote set-url "$mname" "$murl"; then
echo " + ${mname} -> ${murl} (updated)"
else
echo " ! ${mname} -> ${murl} (failed to update URL)"
mirrors_ok=false
fi
else
if git -C "$repo_root" remote add "$mname" "$murl"; then
echo " + ${mname} -> ${murl} (added)"
else
echo " ! ${mname} -> ${murl} (failed to add remote)"
mirrors_ok=false
fi
fi
done
# Initial sync: push current primary branch to mirrors
source "${FACTORY_ROOT}/lib/mirrors.sh"
export PROJECT_REPO_ROOT="$repo_root"
mirror_push
if [ "$mirrors_ok" = true ]; then
source "${FACTORY_ROOT}/lib/mirrors.sh"
export PROJECT_REPO_ROOT="$repo_root"
if mirror_push; then
echo "Mirrors: initial sync complete"
else
echo "Warning: mirror push failed" >&2
fi
fi
fi
# Encrypt secrets if SOPS + age are available
@ -2140,9 +2259,16 @@ p.write_text(text)
# Activate default agents (zero-cost when idle — they only invoke Claude
# when there is actual work, so an empty project burns no LLM tokens)
mkdir -p "${FACTORY_ROOT}/state"
touch "${FACTORY_ROOT}/state/.dev-active"
touch "${FACTORY_ROOT}/state/.reviewer-active"
touch "${FACTORY_ROOT}/state/.gardener-active"
# State files are idempotent — create if missing, skip if present
for state_file in ".dev-active" ".reviewer-active" ".gardener-active"; do
if [ -f "${FACTORY_ROOT}/state/${state_file}" ]; then
echo "State: ${state_file} (already active)"
else
touch "${FACTORY_ROOT}/state/${state_file}"
echo "State: ${state_file} (created)"
fi
done
echo ""
echo "Done. Project ${project_name} is ready."

View file

@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Mock Forgejo API server for CI smoke tests.
Implements 15 Forgejo API endpoints that disinto init calls.
Implements 16 Forgejo API endpoints that disinto init calls.
State stored in-memory (dicts), responds instantly.
"""
@ -149,6 +149,7 @@ class ForgejoHandler(BaseHTTPRequestHandler):
# Admin patterns
(r"^admin/users$", f"handle_{method}_admin_users"),
(r"^admin/users/([^/]+)$", f"handle_{method}_admin_users_username"),
(r"^admin/users/([^/]+)/repos$", f"handle_{method}_admin_users_username_repos"),
# Org patterns
(r"^orgs$", f"handle_{method}_orgs"),
]
@ -294,7 +295,10 @@ class ForgejoHandler(BaseHTTPRequestHandler):
def handle_GET_users_username_tokens(self, query):
"""GET /api/v1/users/{username}/tokens"""
# Support both token auth (for listing own tokens) and basic auth (for admin listing)
username = require_token(self)
if not username:
username = require_basic_auth(self)
if not username:
json_response(self, 401, {"message": "invalid authentication"})
return
@ -460,6 +464,55 @@ class ForgejoHandler(BaseHTTPRequestHandler):
state["repos"][key] = repo
json_response(self, 201, repo)
def handle_POST_admin_users_username_repos(self, query):
"""POST /api/v1/admin/users/{username}/repos
Admin API to create a repo under a specific user namespace.
This allows creating repos in any user's namespace when authenticated as admin.
"""
require_token(self)
parts = self.path.split("/")
if len(parts) >= 6:
target_user = parts[4]
else:
json_response(self, 400, {"message": "username required"})
return
if target_user not in state["users"]:
json_response(self, 404, {"message": "user not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
data = json.loads(body) if body else {}
repo_name = data.get("name")
if not repo_name:
json_response(self, 400, {"message": "name is required"})
return
repo_id = next_ids["repos"]
next_ids["repos"] += 1
key = f"{target_user}/{repo_name}"
repo = {
"id": repo_id,
"full_name": key,
"name": repo_name,
"owner": {"id": state["users"][target_user]["id"], "login": target_user},
"empty": not data.get("auto_init", False),
"default_branch": data.get("default_branch", "main"),
"description": data.get("description", ""),
"private": data.get("private", False),
"html_url": f"https://example.com/{key}",
"ssh_url": f"git@example.com:{key}.git",
"clone_url": f"https://example.com/{key}.git",
"created_at": "2026-04-01T00:00:00Z",
}
state["repos"][key] = repo
json_response(self, 201, repo)
def handle_POST_user_repos(self, query):
"""POST /api/v1/user/repos"""
require_token(self)

View file

@ -15,7 +15,8 @@
set -euo pipefail
FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FORGE_URL="${FORGE_URL:-http://localhost:3000}"
# Always use localhost for mock Forgejo (in case FORGE_URL is set from docker-compose)
export FORGE_URL="http://localhost:3000"
MOCK_BIN="/tmp/smoke-mock-bin"
TEST_SLUG="smoke-org/smoke-repo"
FAILED=0