Merge pull request 'fix: fix: disinto init must be fully idempotent — safe to re-run on existing factory (#239)' (#264) from fix/issue-239 into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
commit
410a5ee948
3 changed files with 299 additions and 73 deletions
269
bin/disinto
269
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,39 @@ 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
|
||||
# Use bot user's own Basic Auth (we just set the password above)
|
||||
local existing_token_ids
|
||||
existing_token_ids=$(curl -sf \
|
||||
-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=""
|
||||
|
||||
# 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 \
|
||||
-u "${bot_user}:${bot_pass}" \
|
||||
"${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 +898,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 +910,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 +1026,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 '${org_name}/${ops_name}' (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 +1208,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 +1469,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 +1482,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 +1592,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 +1609,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 +2192,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 +2260,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."
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
||||
|
|
@ -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"),
|
||||
|
|
@ -149,6 +150,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 +296,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
|
||||
|
|
@ -303,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)
|
||||
|
|
@ -460,6 +497,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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -174,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 ==="
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue