From 979e1210b4f6ae04466ce60ae52790f81124d9fb Mon Sep 17 00:00:00 2001 From: Agent Date: Sun, 5 Apr 2026 21:15:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20fix:=20disinto=20init=20must=20be=20?= =?UTF-8?q?fully=20idempotent=20=E2=80=94=20safe=20to=20re-run=20on=20exis?= =?UTF-8?q?ting=20factory=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/disinto | 268 +++++++++++++++++++++++++++++++----------- tests/mock-forgejo.py | 55 ++++++++- tests/smoke-init.sh | 3 +- 3 files changed, 253 insertions(+), 73 deletions(-) diff --git a/bin/disinto b/bin/disinto index 942b4d0..1d2ecd1 100755 --- a/bin/disinto +++ b/bin/disinto @@ -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." diff --git a/tests/mock-forgejo.py b/tests/mock-forgejo.py index d8be511..4691072 100755 --- a/tests/mock-forgejo.py +++ b/tests/mock-forgejo.py @@ -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) diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index a6721f2..d60aed1 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -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 -- 2.49.1 From a5c34a5ebabcc69823f2779ee29bc4300a48e31c Mon Sep 17 00:00:00 2001 From: Agent Date: Sun, 5 Apr 2026 22:07:53 +0000 Subject: [PATCH 2/2] fix: address PR #264 review feedback - Fix token cleanup to use bot user's Basic Auth instead of admin token (prevents silent failures when admin token auth is rejected) - Fix error message to reference correct variable (org_name/ops_name) - Add idempotency test to smoke-init.sh (runs init twice) --- bin/disinto | 7 ++++--- tests/mock-forgejo.py | 33 +++++++++++++++++++++++++++++++++ tests/smoke-init.sh | 12 ++++++++++++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/bin/disinto b/bin/disinto index 1d2ecd1..4d27e38 100755 --- a/bin/disinto +++ b/bin/disinto @@ -875,9 +875,10 @@ setup_forge() { # 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 + # Use bot user's own Basic Auth (we just set the password above) local existing_token_ids existing_token_ids=$(curl -sf \ - -H "Authorization: token ${admin_token}" \ + -u "${bot_user}:${bot_pass}" \ "${forge_url}/api/v1/users/${bot_user}/tokens" 2>/dev/null \ | jq -r '.[].id // empty' 2>/dev/null) || existing_token_ids="" @@ -885,7 +886,7 @@ setup_forge() { if [ -n "$existing_token_ids" ]; then while IFS= read -r tid; do [ -n "$tid" ] && curl -sf -X DELETE \ - -H "Authorization: token ${admin_token}" \ + -u "${bot_user}:${bot_pass}" \ "${forge_url}/api/v1/users/${bot_user}/tokens/${tid}" >/dev/null 2>&1 || true done <<< "$existing_token_ids" fi @@ -1071,7 +1072,7 @@ setup_ops_repo() { 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 + echo "Error: failed to create ops repo '${org_name}/${ops_name}' (HTTP ${http_code})" >&2 return 1 fi fi diff --git a/tests/mock-forgejo.py b/tests/mock-forgejo.py index 4691072..c65b522 100755 --- a/tests/mock-forgejo.py +++ b/tests/mock-forgejo.py @@ -135,6 +135,7 @@ class ForgejoHandler(BaseHTTPRequestHandler): # Users patterns (r"^users/([^/]+)$", f"handle_{method}_users_username"), (r"^users/([^/]+)/tokens$", f"handle_{method}_users_username_tokens"), + (r"^users/([^/]+)/tokens/([^/]+)$", f"handle_{method}_users_username_tokens_token_id"), (r"^users/([^/]+)/repos$", f"handle_{method}_users_username_repos"), # Repos patterns (r"^repos/([^/]+)/([^/]+)$", f"handle_{method}_repos_owner_repo"), @@ -307,6 +308,38 @@ class ForgejoHandler(BaseHTTPRequestHandler): tokens = [t for t in state["tokens"].values() if t.get("username") == username] json_response(self, 200, tokens) + def handle_DELETE_users_username_tokens_token_id(self, query): + """DELETE /api/v1/users/{username}/tokens/{id}""" + # Support both token auth and basic auth + username = require_token(self) + if not username: + username = require_basic_auth(self) + if not username: + json_response(self, 401, {"message": "invalid authentication"}) + return + + parts = self.path.split("/") + if len(parts) >= 8: + token_id_str = parts[7] + else: + json_response(self, 404, {"message": "token not found"}) + return + + # Find and delete token by ID + deleted = False + for tok_sha1, tok in list(state["tokens"].items()): + if tok.get("id") == int(token_id_str) and tok.get("username") == username: + del state["tokens"][tok_sha1] + deleted = True + break + + if deleted: + self.send_response(204) + self.send_header("Content-Length", 0) + self.end_headers() + else: + json_response(self, 404, {"message": "token not found"}) + def handle_POST_users_username_tokens(self, query): """POST /api/v1/users/{username}/tokens""" username = require_basic_auth(self) diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index d60aed1..a8371bd 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -175,6 +175,18 @@ else fail "disinto init exited non-zero" fi +# ── Idempotency test: run init again ─────────────────────────────────────── +echo "=== Idempotency test: running disinto init again ===" +if bash "${FACTORY_ROOT}/bin/disinto" init \ + "${TEST_SLUG}" \ + --bare --yes \ + --forge-url "$FORGE_URL" \ + --repo-root "/tmp/smoke-test-repo"; then + pass "disinto init (re-run) completed successfully" +else + fail "disinto init (re-run) exited non-zero" +fi + # ── 4. Verify Forgejo state ───────────────────────────────────────────────── echo "=== 4/6 Verifying Forgejo state ===" -- 2.49.1