Compare commits
1 Commits
claude/iss
...
fix/loop-g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f13f540b3 |
@@ -1,50 +0,0 @@
|
|||||||
# ── Gitea app.ini Hardening Patch ────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# Apply these changes to /etc/gitea/app.ini (or custom/conf/app.ini)
|
|
||||||
# AFTER running setup-gitea-tls.sh, or apply manually.
|
|
||||||
#
|
|
||||||
# The deploy script handles DOMAIN, ROOT_URL, HTTP_ADDR, and COOKIE_SECURE
|
|
||||||
# automatically. This file documents the FULL recommended hardening config
|
|
||||||
# from the security audit (#971).
|
|
||||||
#
|
|
||||||
# ── Instructions ────────────────────────────────────────────────────────────
|
|
||||||
#
|
|
||||||
# 1. Back up your current app.ini:
|
|
||||||
# cp /etc/gitea/app.ini /etc/gitea/app.ini.bak
|
|
||||||
#
|
|
||||||
# 2. Apply each section below by editing app.ini.
|
|
||||||
#
|
|
||||||
# 3. Restart Gitea:
|
|
||||||
# systemctl restart gitea
|
|
||||||
# # or: docker restart gitea
|
|
||||||
|
|
||||||
# ── [server] section ───────────────────────────────────────────────────────
|
|
||||||
# These are set automatically by setup-gitea-tls.sh:
|
|
||||||
#
|
|
||||||
# DOMAIN = git.alexanderwhitestone.com
|
|
||||||
# HTTP_ADDR = 127.0.0.1
|
|
||||||
# HTTP_PORT = 3000
|
|
||||||
# PROTOCOL = http
|
|
||||||
# ROOT_URL = https://git.alexanderwhitestone.com/
|
|
||||||
#
|
|
||||||
# Additionally recommended:
|
|
||||||
# ENABLE_PPROF = false
|
|
||||||
# OFFLINE_MODE = true
|
|
||||||
|
|
||||||
# ── [security] section ─────────────────────────────────────────────────────
|
|
||||||
# INSTALL_LOCK = true
|
|
||||||
# SECRET_KEY = <generate with: gitea generate secret SECRET_KEY>
|
|
||||||
# REVERSE_PROXY_TRUST_LOCAL = true
|
|
||||||
# COOKIE_SECURE = true (set by deploy script)
|
|
||||||
# SET_COOKIE_HTTP_ONLY = true
|
|
||||||
|
|
||||||
# ── [service] section ──────────────────────────────────────────────────────
|
|
||||||
# DISABLE_REGISTRATION = true
|
|
||||||
# ALLOW_ONLY_EXTERNAL_REGISTRATION = false
|
|
||||||
# SHOW_REGISTRATION_BUTTON = false
|
|
||||||
# ENABLE_REVERSE_PROXY_AUTHENTICATION = false
|
|
||||||
# REQUIRE_SIGNIN_VIEW = true
|
|
||||||
|
|
||||||
# ── [repository] section ───────────────────────────────────────────────────
|
|
||||||
# FORCE_PRIVATE = true
|
|
||||||
# DEFAULT_PRIVATE = private
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
# ── Gitea Reverse Proxy — TLS via Let's Encrypt ─────────────────────────────
|
|
||||||
#
|
|
||||||
# Install path: /etc/nginx/sites-available/gitea
|
|
||||||
# Symlink: ln -s /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# - DNS A record: git.alexanderwhitestone.com -> 143.198.27.163
|
|
||||||
# - certbot + python3-certbot-nginx installed
|
|
||||||
# - Certificate obtained via: certbot --nginx -d git.alexanderwhitestone.com
|
|
||||||
#
|
|
||||||
# After certbot runs, it will auto-modify the ssl lines below.
|
|
||||||
# This config is the pre-certbot template that certbot enhances.
|
|
||||||
|
|
||||||
# ── HTTP → HTTPS redirect ───────────────────────────────────────────────────
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name git.alexanderwhitestone.com;
|
|
||||||
|
|
||||||
# Let's Encrypt ACME challenge
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
return 301 https://$host$request_uri;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── HTTPS — reverse proxy to Gitea ──────────────────────────────────────────
|
|
||||||
server {
|
|
||||||
listen 443 ssl http2;
|
|
||||||
listen [::]:443 ssl http2;
|
|
||||||
server_name git.alexanderwhitestone.com;
|
|
||||||
|
|
||||||
# ── TLS (managed by certbot) ────────────────────────────────────────────
|
|
||||||
ssl_certificate /etc/letsencrypt/live/git.alexanderwhitestone.com/fullchain.pem;
|
|
||||||
ssl_certificate_key /etc/letsencrypt/live/git.alexanderwhitestone.com/privkey.pem;
|
|
||||||
|
|
||||||
# ── TLS hardening ───────────────────────────────────────────────────────
|
|
||||||
ssl_protocols TLSv1.2 TLSv1.3;
|
|
||||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
|
||||||
ssl_prefer_server_ciphers on;
|
|
||||||
ssl_session_cache shared:SSL:10m;
|
|
||||||
ssl_session_timeout 10m;
|
|
||||||
|
|
||||||
# ── Security headers ────────────────────────────────────────────────────
|
|
||||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
|
||||||
add_header X-Content-Type-Options nosniff always;
|
|
||||||
add_header X-Frame-Options SAMEORIGIN always;
|
|
||||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
|
||||||
|
|
||||||
# ── Proxy to Gitea ──────────────────────────────────────────────────────
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
|
||||||
|
|
||||||
# WebSocket support (for live updates)
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
|
|
||||||
# Large repo pushes
|
|
||||||
client_max_body_size 512m;
|
|
||||||
|
|
||||||
# Timeouts for large git operations
|
|
||||||
proxy_connect_timeout 300;
|
|
||||||
proxy_send_timeout 300;
|
|
||||||
proxy_read_timeout 300;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Gitea TLS Setup — Nginx + Let's Encrypt ─────────────────────────────────
|
|
||||||
#
|
|
||||||
# Sets up a reverse proxy with automatic TLS for the Gitea instance.
|
|
||||||
#
|
|
||||||
# Prerequisites:
|
|
||||||
# - Ubuntu/Debian server with root access
|
|
||||||
# - DNS A record pointing to this server's IP
|
|
||||||
# - Gitea running on localhost:3000
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# sudo bash deploy/setup-gitea-tls.sh git.alexanderwhitestone.com
|
|
||||||
# sudo bash deploy/setup-gitea-tls.sh git.alexanderwhitestone.com --email admin@alexanderwhitestone.com
|
|
||||||
#
|
|
||||||
# What it does:
|
|
||||||
# 1. Installs Nginx + Certbot
|
|
||||||
# 2. Deploys the Nginx reverse proxy config
|
|
||||||
# 3. Obtains a Let's Encrypt TLS certificate
|
|
||||||
# 4. Patches Gitea app.ini for HTTPS
|
|
||||||
# 5. Blocks direct access to port 3000
|
|
||||||
# 6. Restarts services
|
|
||||||
|
|
||||||
BOLD='\033[1m'
|
|
||||||
GREEN='\033[0;32m'
|
|
||||||
YELLOW='\033[1;33m'
|
|
||||||
RED='\033[0;31m'
|
|
||||||
CYAN='\033[0;36m'
|
|
||||||
NC='\033[0m'
|
|
||||||
|
|
||||||
info() { echo -e "${GREEN}[+]${NC} $1"; }
|
|
||||||
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
|
|
||||||
error() { echo -e "${RED}[x]${NC} $1"; }
|
|
||||||
step() { echo -e "\n${BOLD}── $1 ──${NC}"; }
|
|
||||||
|
|
||||||
# ── Parse arguments ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
DOMAIN=""
|
|
||||||
EMAIL=""
|
|
||||||
GITEA_INI="/etc/gitea/app.ini"
|
|
||||||
DRY_RUN=false
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
|
||||||
case $1 in
|
|
||||||
--email) EMAIL="$2"; shift 2 ;;
|
|
||||||
--ini) GITEA_INI="$2"; shift 2 ;;
|
|
||||||
--dry-run) DRY_RUN=true; shift ;;
|
|
||||||
-*) error "Unknown option: $1"; exit 1 ;;
|
|
||||||
*) DOMAIN="$1"; shift ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -z "$DOMAIN" ]; then
|
|
||||||
error "Usage: $0 <domain> [--email you@example.com] [--ini /path/to/app.ini]"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$EMAIL" ]; then
|
|
||||||
EMAIL="admin@${DOMAIN#*.}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e "${CYAN}${BOLD}"
|
|
||||||
echo " ╔══════════════════════════════════════════╗"
|
|
||||||
echo " ║ Gitea TLS Setup ║"
|
|
||||||
echo " ║ Nginx + Let's Encrypt ║"
|
|
||||||
echo " ╚══════════════════════════════════════════╝"
|
|
||||||
echo -e "${NC}"
|
|
||||||
echo " Domain: $DOMAIN"
|
|
||||||
echo " Email: $EMAIL"
|
|
||||||
echo " Gitea INI: $GITEA_INI"
|
|
||||||
echo " Dry run: $DRY_RUN"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Preflight checks ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
if [ "$(id -u)" -ne 0 ]; then
|
|
||||||
error "This script must be run as root (or with sudo)"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Verify DNS resolves to this server
|
|
||||||
step "Checking DNS"
|
|
||||||
RESOLVED_IP=$(dig +short "$DOMAIN" 2>/dev/null | head -1)
|
|
||||||
LOCAL_IP=$(curl -4sf https://ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')
|
|
||||||
|
|
||||||
if [ -z "$RESOLVED_IP" ]; then
|
|
||||||
error "DNS record for $DOMAIN not found."
|
|
||||||
error "Create an A record pointing $DOMAIN to $LOCAL_IP first."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$RESOLVED_IP" != "$LOCAL_IP" ]; then
|
|
||||||
warn "DNS for $DOMAIN resolves to $RESOLVED_IP but this server is $LOCAL_IP"
|
|
||||||
warn "Let's Encrypt will fail if DNS doesn't point here. Continue anyway? [y/N]"
|
|
||||||
read -r CONTINUE
|
|
||||||
if [ "$CONTINUE" != "y" ] && [ "$CONTINUE" != "Y" ]; then
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
info "DNS OK: $DOMAIN -> $RESOLVED_IP"
|
|
||||||
|
|
||||||
# Verify Gitea is running
|
|
||||||
step "Checking Gitea"
|
|
||||||
if curl -sf http://127.0.0.1:3000/ > /dev/null 2>&1; then
|
|
||||||
info "Gitea is running on localhost:3000"
|
|
||||||
else
|
|
||||||
warn "Gitea not responding on localhost:3000 — continuing anyway"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if $DRY_RUN; then
|
|
||||||
info "Dry run — would install nginx, certbot, configure TLS for $DOMAIN"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 1: Install Nginx + Certbot ────────────────────────────────────────
|
|
||||||
|
|
||||||
step "Installing Nginx + Certbot"
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq nginx certbot python3-certbot-nginx
|
|
||||||
info "Nginx + Certbot installed"
|
|
||||||
|
|
||||||
# ── Step 2: Deploy Nginx config ────────────────────────────────────────────
|
|
||||||
|
|
||||||
step "Deploying Nginx Configuration"
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
NGINX_CONF="$SCRIPT_DIR/nginx-gitea.conf"
|
|
||||||
|
|
||||||
if [ ! -f "$NGINX_CONF" ]; then
|
|
||||||
error "nginx-gitea.conf not found at $NGINX_CONF"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Install config (replacing domain if different)
|
|
||||||
sed "s/git\.alexanderwhitestone\.com/$DOMAIN/g" "$NGINX_CONF" \
|
|
||||||
> /etc/nginx/sites-available/gitea
|
|
||||||
|
|
||||||
ln -sf /etc/nginx/sites-available/gitea /etc/nginx/sites-enabled/gitea
|
|
||||||
|
|
||||||
# Remove default site if it conflicts
|
|
||||||
if [ -L /etc/nginx/sites-enabled/default ]; then
|
|
||||||
rm /etc/nginx/sites-enabled/default
|
|
||||||
info "Removed default Nginx site"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Test config (will fail on missing cert — that's expected pre-certbot)
|
|
||||||
# First deploy without SSL, get cert, then enable SSL
|
|
||||||
cat > /etc/nginx/sites-available/gitea <<PRESSL
|
|
||||||
# Temporary HTTP-only config for certbot initial setup
|
|
||||||
server {
|
|
||||||
listen 80;
|
|
||||||
listen [::]:80;
|
|
||||||
server_name $DOMAIN;
|
|
||||||
|
|
||||||
location /.well-known/acme-challenge/ {
|
|
||||||
root /var/www/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
proxy_pass http://127.0.0.1:3000;
|
|
||||||
proxy_set_header Host \$host;
|
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PRESSL
|
|
||||||
|
|
||||||
nginx -t && systemctl reload nginx
|
|
||||||
info "Temporary HTTP proxy deployed"
|
|
||||||
|
|
||||||
# ── Step 3: Obtain TLS Certificate ─────────────────────────────────────────
|
|
||||||
|
|
||||||
step "Obtaining TLS Certificate"
|
|
||||||
certbot --nginx \
|
|
||||||
-d "$DOMAIN" \
|
|
||||||
--email "$EMAIL" \
|
|
||||||
--agree-tos \
|
|
||||||
--non-interactive \
|
|
||||||
--redirect
|
|
||||||
|
|
||||||
info "TLS certificate obtained and Nginx configured"
|
|
||||||
|
|
||||||
# Now deploy the full config (certbot may have already modified it, but
|
|
||||||
# let's ensure our hardened version is in place)
|
|
||||||
sed "s/git\.alexanderwhitestone\.com/$DOMAIN/g" "$NGINX_CONF" \
|
|
||||||
> /etc/nginx/sites-available/gitea
|
|
||||||
|
|
||||||
nginx -t && systemctl reload nginx
|
|
||||||
info "Full TLS proxy config deployed"
|
|
||||||
|
|
||||||
# ── Step 4: Patch Gitea app.ini ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
step "Patching Gitea Configuration"
|
|
||||||
if [ -f "$GITEA_INI" ]; then
|
|
||||||
# Backup first
|
|
||||||
cp "$GITEA_INI" "${GITEA_INI}.bak.$(date +%Y%m%d%H%M%S)"
|
|
||||||
info "Backed up app.ini"
|
|
||||||
|
|
||||||
# Patch server section
|
|
||||||
sed -i "s|^DOMAIN\s*=.*|DOMAIN = $DOMAIN|" "$GITEA_INI"
|
|
||||||
sed -i "s|^ROOT_URL\s*=.*|ROOT_URL = https://$DOMAIN/|" "$GITEA_INI"
|
|
||||||
sed -i "s|^HTTP_ADDR\s*=.*|HTTP_ADDR = 127.0.0.1|" "$GITEA_INI"
|
|
||||||
|
|
||||||
# Enable secure cookies
|
|
||||||
if grep -q "^COOKIE_SECURE" "$GITEA_INI"; then
|
|
||||||
sed -i "s|^COOKIE_SECURE\s*=.*|COOKIE_SECURE = true|" "$GITEA_INI"
|
|
||||||
else
|
|
||||||
sed -i "/^\[security\]/a COOKIE_SECURE = true" "$GITEA_INI"
|
|
||||||
fi
|
|
||||||
|
|
||||||
info "Gitea config patched: DOMAIN=$DOMAIN, ROOT_URL=https://$DOMAIN/, HTTP_ADDR=127.0.0.1"
|
|
||||||
else
|
|
||||||
warn "Gitea config not found at $GITEA_INI"
|
|
||||||
warn "Update manually:"
|
|
||||||
warn " DOMAIN = $DOMAIN"
|
|
||||||
warn " ROOT_URL = https://$DOMAIN/"
|
|
||||||
warn " HTTP_ADDR = 127.0.0.1"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 5: Block direct port 3000 access ───────────────────────────────────
|
|
||||||
|
|
||||||
step "Blocking Direct Port 3000 Access"
|
|
||||||
if command -v ufw &> /dev/null; then
|
|
||||||
ufw deny 3000/tcp 2>/dev/null || true
|
|
||||||
info "Port 3000 blocked via ufw"
|
|
||||||
else
|
|
||||||
# Use iptables as fallback
|
|
||||||
iptables -A INPUT -p tcp --dport 3000 -j DROP 2>/dev/null || true
|
|
||||||
info "Port 3000 blocked via iptables (not persistent — install ufw for persistence)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Ensure HTTP/HTTPS are allowed
|
|
||||||
if command -v ufw &> /dev/null; then
|
|
||||||
ufw allow 80/tcp 2>/dev/null || true
|
|
||||||
ufw allow 443/tcp 2>/dev/null || true
|
|
||||||
ufw allow 22/tcp 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 6: Restart Gitea ───────────────────────────────────────────────────
|
|
||||||
|
|
||||||
step "Restarting Gitea"
|
|
||||||
if systemctl is-active --quiet gitea; then
|
|
||||||
systemctl restart gitea
|
|
||||||
info "Gitea restarted"
|
|
||||||
elif docker ps --format '{{.Names}}' | grep -q gitea; then
|
|
||||||
docker restart "$(docker ps --format '{{.Names}}' | grep gitea | head -1)"
|
|
||||||
info "Gitea container restarted"
|
|
||||||
else
|
|
||||||
warn "Could not auto-restart Gitea — restart it manually"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Step 7: Verify ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
step "Verifying Deployment"
|
|
||||||
sleep 3
|
|
||||||
|
|
||||||
# Check HTTPS
|
|
||||||
if curl -sf "https://$DOMAIN" > /dev/null 2>&1; then
|
|
||||||
info "HTTPS is working: https://$DOMAIN"
|
|
||||||
else
|
|
||||||
warn "HTTPS check failed — may need a moment to propagate"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check HSTS
|
|
||||||
HSTS=$(curl -sI "https://$DOMAIN" 2>/dev/null | grep -i "strict-transport-security" || true)
|
|
||||||
if [ -n "$HSTS" ]; then
|
|
||||||
info "HSTS header present: $HSTS"
|
|
||||||
else
|
|
||||||
warn "HSTS header not detected — check Nginx config"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check HTTP redirect
|
|
||||||
HTTP_STATUS=$(curl -sI "http://$DOMAIN" 2>/dev/null | head -1 | awk '{print $2}')
|
|
||||||
if [ "$HTTP_STATUS" = "301" ]; then
|
|
||||||
info "HTTP->HTTPS redirect working (301)"
|
|
||||||
else
|
|
||||||
warn "HTTP redirect returned $HTTP_STATUS (expected 301)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Summary ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo -e "${GREEN}${BOLD}"
|
|
||||||
echo " ╔══════════════════════════════════════════╗"
|
|
||||||
echo " ║ Gitea TLS Setup Complete! ║"
|
|
||||||
echo " ╚══════════════════════════════════════════╝"
|
|
||||||
echo -e "${NC}"
|
|
||||||
echo ""
|
|
||||||
echo " Gitea: https://$DOMAIN"
|
|
||||||
echo ""
|
|
||||||
echo " Certbot auto-renewal is enabled by default."
|
|
||||||
echo " Test it: certbot renew --dry-run"
|
|
||||||
echo ""
|
|
||||||
echo " To check status:"
|
|
||||||
echo " nginx -t # test config"
|
|
||||||
echo " systemctl status nginx # proxy status"
|
|
||||||
echo " certbot certificates # TLS cert info"
|
|
||||||
echo ""
|
|
||||||
@@ -17,23 +17,8 @@ REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||||||
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||||
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
|
SUMMARY_FILE = REPO_ROOT / ".loop" / "retro" / "summary.json"
|
||||||
|
|
||||||
|
GITEA_API = "http://localhost:3000/api/v1"
|
||||||
def _get_gitea_api() -> str:
|
REPO_SLUG = "rockachopa/Timmy-time-dashboard"
|
||||||
"""Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default."""
|
|
||||||
# Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility)
|
|
||||||
api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API")
|
|
||||||
if api_url:
|
|
||||||
return api_url
|
|
||||||
# Check ~/.hermes/gitea_api file
|
|
||||||
api_file = Path.home() / ".hermes" / "gitea_api"
|
|
||||||
if api_file.exists():
|
|
||||||
return api_file.read_text().strip()
|
|
||||||
# Default fallback
|
|
||||||
return "http://localhost:3000/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
GITEA_API = _get_gitea_api()
|
|
||||||
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
|
|
||||||
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
||||||
|
|
||||||
TAG_RE = re.compile(r"\[([^\]]+)\]")
|
TAG_RE = re.compile(r"\[([^\]]+)\]")
|
||||||
|
|||||||
@@ -30,22 +30,7 @@ IDLE_STATE_FILE = REPO_ROOT / ".loop" / "idle_state.json"
|
|||||||
CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json"
|
CYCLE_RESULT_FILE = REPO_ROOT / ".loop" / "cycle_result.json"
|
||||||
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
||||||
|
|
||||||
|
GITEA_API = os.environ.get("GITEA_API", "http://143.198.27.163:3000/api/v1")
|
||||||
def _get_gitea_api() -> str:
|
|
||||||
"""Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default."""
|
|
||||||
# Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility)
|
|
||||||
api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API")
|
|
||||||
if api_url:
|
|
||||||
return api_url
|
|
||||||
# Check ~/.hermes/gitea_api file
|
|
||||||
api_file = Path.home() / ".hermes" / "gitea_api"
|
|
||||||
if api_file.exists():
|
|
||||||
return api_file.read_text().strip()
|
|
||||||
# Default fallback
|
|
||||||
return "http://localhost:3000/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
GITEA_API = _get_gitea_api()
|
|
||||||
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
|
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
|
||||||
|
|
||||||
# Default cycle duration in seconds (5 min); stale threshold = 2× this
|
# Default cycle duration in seconds (5 min); stale threshold = 2× this
|
||||||
@@ -202,11 +187,7 @@ def load_queue() -> list[dict]:
|
|||||||
# Persist the cleaned queue so stale entries don't recur
|
# Persist the cleaned queue so stale entries don't recur
|
||||||
_save_cleaned_queue(data, open_numbers)
|
_save_cleaned_queue(data, open_numbers)
|
||||||
return ready
|
return ready
|
||||||
except json.JSONDecodeError as exc:
|
except (json.JSONDecodeError, OSError):
|
||||||
print(f"[loop-guard] WARNING: Corrupt queue.json ({exc}) — returning empty queue")
|
|
||||||
return []
|
|
||||||
except OSError as exc:
|
|
||||||
print(f"[loop-guard] WARNING: Cannot read queue.json ({exc}) — returning empty queue")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,28 +20,11 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# ── Config ──────────────────────────────────────────────────────────────
|
# ── Config ──────────────────────────────────────────────────────────────
|
||||||
|
GITEA_API = os.environ.get("GITEA_API", "http://143.198.27.163:3000/api/v1")
|
||||||
|
|
||||||
def _get_gitea_api() -> str:
|
|
||||||
"""Read Gitea API URL from env var, then ~/.hermes/gitea_api file, then default."""
|
|
||||||
# Check env vars first (TIMMY_GITEA_API is preferred, GITEA_API for compatibility)
|
|
||||||
api_url = os.environ.get("TIMMY_GITEA_API") or os.environ.get("GITEA_API")
|
|
||||||
if api_url:
|
|
||||||
return api_url
|
|
||||||
# Check ~/.hermes/gitea_api file
|
|
||||||
api_file = Path.home() / ".hermes" / "gitea_api"
|
|
||||||
if api_file.exists():
|
|
||||||
return api_file.read_text().strip()
|
|
||||||
# Default fallback
|
|
||||||
return "http://localhost:3000/api/v1"
|
|
||||||
|
|
||||||
|
|
||||||
GITEA_API = _get_gitea_api()
|
|
||||||
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
|
REPO_SLUG = os.environ.get("REPO_SLUG", "rockachopa/Timmy-time-dashboard")
|
||||||
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
TOKEN_FILE = Path.home() / ".hermes" / "gitea_token"
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json"
|
QUEUE_FILE = REPO_ROOT / ".loop" / "queue.json"
|
||||||
QUEUE_BACKUP_FILE = REPO_ROOT / ".loop" / "queue.json.bak"
|
|
||||||
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
|
RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "triage.jsonl"
|
||||||
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
|
QUARANTINE_FILE = REPO_ROOT / ".loop" / "quarantine.json"
|
||||||
CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
CYCLE_RETRO_FILE = REPO_ROOT / ".loop" / "retro" / "cycles.jsonl"
|
||||||
@@ -343,36 +326,31 @@ def run_triage() -> list[dict]:
|
|||||||
ready = [s for s in scored if s["ready"]]
|
ready = [s for s in scored if s["ready"]]
|
||||||
not_ready = [s for s in scored if not s["ready"]]
|
not_ready = [s for s in scored if not s["ready"]]
|
||||||
|
|
||||||
# Save backup before writing (if current file exists and is valid)
|
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
backup_file = QUEUE_FILE.with_suffix(".json.bak")
|
||||||
|
|
||||||
|
# Backup existing queue before overwriting
|
||||||
if QUEUE_FILE.exists():
|
if QUEUE_FILE.exists():
|
||||||
try:
|
try:
|
||||||
json.loads(QUEUE_FILE.read_text()) # Validate current file
|
backup_file.write_text(QUEUE_FILE.read_text())
|
||||||
QUEUE_BACKUP_FILE.write_text(QUEUE_FILE.read_text())
|
except OSError:
|
||||||
except (json.JSONDecodeError, OSError):
|
pass
|
||||||
pass # Current file is corrupt, don't overwrite backup
|
|
||||||
|
|
||||||
# Write new queue file
|
# Write and validate
|
||||||
QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
queue_json = json.dumps(ready, indent=2) + "\n"
|
||||||
QUEUE_FILE.write_text(json.dumps(ready, indent=2) + "\n")
|
QUEUE_FILE.write_text(queue_json)
|
||||||
|
|
||||||
# Validate the write by re-reading and parsing
|
# Validate by re-reading — restore backup on corruption
|
||||||
try:
|
try:
|
||||||
json.loads(QUEUE_FILE.read_text())
|
validated = json.loads(QUEUE_FILE.read_text())
|
||||||
except (json.JSONDecodeError, OSError) as exc:
|
if not isinstance(validated, list):
|
||||||
print(f"[triage] ERROR: queue.json validation failed: {exc}", file=sys.stderr)
|
raise ValueError("queue.json is not a list")
|
||||||
# Restore from backup if available
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
if QUEUE_BACKUP_FILE.exists():
|
print(f"[triage] ERROR: queue.json validation failed: {e}", file=sys.stderr)
|
||||||
try:
|
if backup_file.exists():
|
||||||
backup_data = QUEUE_BACKUP_FILE.read_text()
|
print("[triage] Restoring from backup", file=sys.stderr)
|
||||||
json.loads(backup_data) # Validate backup
|
QUEUE_FILE.write_text(backup_file.read_text())
|
||||||
QUEUE_FILE.write_text(backup_data)
|
|
||||||
print(f"[triage] Restored queue.json from backup")
|
|
||||||
except (json.JSONDecodeError, OSError) as restore_exc:
|
|
||||||
print(f"[triage] ERROR: Backup restore failed: {restore_exc}", file=sys.stderr)
|
|
||||||
# Write empty list as last resort
|
|
||||||
QUEUE_FILE.write_text("[]\n")
|
|
||||||
else:
|
else:
|
||||||
# No backup, write empty list
|
|
||||||
QUEUE_FILE.write_text("[]\n")
|
QUEUE_FILE.write_text("[]\n")
|
||||||
|
|
||||||
# Write retro entry
|
# Write retro entry
|
||||||
|
|||||||
@@ -56,13 +56,11 @@ async def self_modify_queue(request: Request):
|
|||||||
|
|
||||||
@router.get("/swarm/mission-control", response_class=HTMLResponse)
|
@router.get("/swarm/mission-control", response_class=HTMLResponse)
|
||||||
async def mission_control(request: Request):
|
async def mission_control(request: Request):
|
||||||
"""Render the swarm mission control dashboard page."""
|
|
||||||
return templates.TemplateResponse(request, "mission_control.html", {})
|
return templates.TemplateResponse(request, "mission_control.html", {})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/bugs", response_class=HTMLResponse)
|
@router.get("/bugs", response_class=HTMLResponse)
|
||||||
async def bugs_page(request: Request):
|
async def bugs_page(request: Request):
|
||||||
"""Render the bug tracking page."""
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"bugs.html",
|
"bugs.html",
|
||||||
@@ -77,19 +75,16 @@ async def bugs_page(request: Request):
|
|||||||
|
|
||||||
@router.get("/self-coding", response_class=HTMLResponse)
|
@router.get("/self-coding", response_class=HTMLResponse)
|
||||||
async def self_coding(request: Request):
|
async def self_coding(request: Request):
|
||||||
"""Render the self-coding automation status page."""
|
|
||||||
return templates.TemplateResponse(request, "self_coding.html", {"stats": {}})
|
return templates.TemplateResponse(request, "self_coding.html", {"stats": {}})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/hands", response_class=HTMLResponse)
|
@router.get("/hands", response_class=HTMLResponse)
|
||||||
async def hands_page(request: Request):
|
async def hands_page(request: Request):
|
||||||
"""Render the hands (automation executions) page."""
|
|
||||||
return templates.TemplateResponse(request, "hands.html", {"executions": []})
|
return templates.TemplateResponse(request, "hands.html", {"executions": []})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/creative/ui", response_class=HTMLResponse)
|
@router.get("/creative/ui", response_class=HTMLResponse)
|
||||||
async def creative_ui(request: Request):
|
async def creative_ui(request: Request):
|
||||||
"""Render the creative UI playground page."""
|
|
||||||
return templates.TemplateResponse(request, "creative.html", {})
|
return templates.TemplateResponse(request, "creative.html", {})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -145,7 +145,6 @@ async def tasks_page(request: Request):
|
|||||||
|
|
||||||
@router.get("/tasks/pending", response_class=HTMLResponse)
|
@router.get("/tasks/pending", response_class=HTMLResponse)
|
||||||
async def tasks_pending(request: Request):
|
async def tasks_pending(request: Request):
|
||||||
"""Return HTMX partial for pending approval tasks."""
|
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
|
"SELECT * FROM tasks WHERE status='pending_approval' ORDER BY created_at DESC"
|
||||||
@@ -165,7 +164,6 @@ async def tasks_pending(request: Request):
|
|||||||
|
|
||||||
@router.get("/tasks/active", response_class=HTMLResponse)
|
@router.get("/tasks/active", response_class=HTMLResponse)
|
||||||
async def tasks_active(request: Request):
|
async def tasks_active(request: Request):
|
||||||
"""Return HTMX partial for active (approved/running/paused) tasks."""
|
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
|
"SELECT * FROM tasks WHERE status IN ('approved','running','paused') ORDER BY created_at DESC"
|
||||||
@@ -185,7 +183,6 @@ async def tasks_active(request: Request):
|
|||||||
|
|
||||||
@router.get("/tasks/completed", response_class=HTMLResponse)
|
@router.get("/tasks/completed", response_class=HTMLResponse)
|
||||||
async def tasks_completed(request: Request):
|
async def tasks_completed(request: Request):
|
||||||
"""Return HTMX partial for completed/vetoed/failed tasks (last 50)."""
|
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
rows = db.execute(
|
rows = db.execute(
|
||||||
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
|
"SELECT * FROM tasks WHERE status IN ('completed','vetoed','failed') ORDER BY completed_at DESC LIMIT 50"
|
||||||
@@ -244,31 +241,26 @@ async def create_task_form(
|
|||||||
|
|
||||||
@router.post("/tasks/{task_id}/approve", response_class=HTMLResponse)
|
@router.post("/tasks/{task_id}/approve", response_class=HTMLResponse)
|
||||||
async def approve_task(request: Request, task_id: str):
|
async def approve_task(request: Request, task_id: str):
|
||||||
"""Approve a pending task and move it to active queue."""
|
|
||||||
return await _set_status(request, task_id, "approved")
|
return await _set_status(request, task_id, "approved")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/veto", response_class=HTMLResponse)
|
@router.post("/tasks/{task_id}/veto", response_class=HTMLResponse)
|
||||||
async def veto_task(request: Request, task_id: str):
|
async def veto_task(request: Request, task_id: str):
|
||||||
"""Veto a task, marking it as rejected."""
|
|
||||||
return await _set_status(request, task_id, "vetoed")
|
return await _set_status(request, task_id, "vetoed")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/pause", response_class=HTMLResponse)
|
@router.post("/tasks/{task_id}/pause", response_class=HTMLResponse)
|
||||||
async def pause_task(request: Request, task_id: str):
|
async def pause_task(request: Request, task_id: str):
|
||||||
"""Pause a running or approved task."""
|
|
||||||
return await _set_status(request, task_id, "paused")
|
return await _set_status(request, task_id, "paused")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/cancel", response_class=HTMLResponse)
|
@router.post("/tasks/{task_id}/cancel", response_class=HTMLResponse)
|
||||||
async def cancel_task(request: Request, task_id: str):
|
async def cancel_task(request: Request, task_id: str):
|
||||||
"""Cancel a task (marks as vetoed)."""
|
|
||||||
return await _set_status(request, task_id, "vetoed")
|
return await _set_status(request, task_id, "vetoed")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/{task_id}/retry", response_class=HTMLResponse)
|
@router.post("/tasks/{task_id}/retry", response_class=HTMLResponse)
|
||||||
async def retry_task(request: Request, task_id: str):
|
async def retry_task(request: Request, task_id: str):
|
||||||
"""Retry a failed/vetoed task by moving it back to approved."""
|
|
||||||
return await _set_status(request, task_id, "approved")
|
return await _set_status(request, task_id, "approved")
|
||||||
|
|
||||||
|
|
||||||
@@ -279,7 +271,6 @@ async def modify_task(
|
|||||||
title: str = Form(...),
|
title: str = Form(...),
|
||||||
description: str = Form(""),
|
description: str = Form(""),
|
||||||
):
|
):
|
||||||
"""Update task title and description."""
|
|
||||||
with _get_db() as db:
|
with _get_db() as db:
|
||||||
db.execute(
|
db.execute(
|
||||||
"UPDATE tasks SET title=?, description=? WHERE id=?",
|
"UPDATE tasks SET title=?, description=? WHERE id=?",
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
"""Tests for load_queue corrupt JSON handling in loop_guard.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import scripts.loop_guard as lg
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate(tmp_path, monkeypatch):
|
|
||||||
"""Redirect loop_guard paths to tmp_path for isolation."""
|
|
||||||
monkeypatch.setattr(lg, "QUEUE_FILE", tmp_path / "queue.json")
|
|
||||||
monkeypatch.setattr(lg, "IDLE_STATE_FILE", tmp_path / "idle_state.json")
|
|
||||||
monkeypatch.setattr(lg, "CYCLE_RESULT_FILE", tmp_path / "cycle_result.json")
|
|
||||||
monkeypatch.setattr(lg, "GITEA_API", "http://test:3000/api/v1")
|
|
||||||
monkeypatch.setattr(lg, "REPO_SLUG", "owner/repo")
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_queue_missing_file(tmp_path):
|
|
||||||
"""Missing queue file returns empty list."""
|
|
||||||
result = lg.load_queue()
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_queue_valid_data(tmp_path):
|
|
||||||
"""Valid queue.json returns ready items."""
|
|
||||||
data = [
|
|
||||||
{"issue": 1, "title": "Ready issue", "ready": True},
|
|
||||||
{"issue": 2, "title": "Not ready", "ready": False},
|
|
||||||
]
|
|
||||||
lg.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lg.QUEUE_FILE.write_text(json.dumps(data, indent=2))
|
|
||||||
|
|
||||||
result = lg.load_queue()
|
|
||||||
assert len(result) == 1
|
|
||||||
assert result[0]["issue"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_queue_corrupt_json_logs_warning(tmp_path, capsys):
|
|
||||||
"""Corrupt queue.json returns empty list and logs warning."""
|
|
||||||
lg.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lg.QUEUE_FILE.write_text("not valid json {{{")
|
|
||||||
|
|
||||||
result = lg.load_queue()
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "WARNING" in captured.out
|
|
||||||
assert "Corrupt queue.json" in captured.out
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_queue_not_a_list(tmp_path):
|
|
||||||
"""Queue.json that is not a list returns empty list."""
|
|
||||||
lg.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lg.QUEUE_FILE.write_text(json.dumps({"not": "a list"}))
|
|
||||||
|
|
||||||
result = lg.load_queue()
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_queue_no_ready_items(tmp_path):
|
|
||||||
"""Queue with no ready items returns empty list."""
|
|
||||||
data = [
|
|
||||||
{"issue": 1, "title": "Not ready 1", "ready": False},
|
|
||||||
{"issue": 2, "title": "Not ready 2", "ready": False},
|
|
||||||
]
|
|
||||||
lg.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lg.QUEUE_FILE.write_text(json.dumps(data, indent=2))
|
|
||||||
|
|
||||||
result = lg.load_queue()
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_load_queue_oserror_logs_warning(tmp_path, monkeypatch, capsys):
|
|
||||||
"""OSError when reading queue.json returns empty list and logs warning."""
|
|
||||||
lg.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
lg.QUEUE_FILE.write_text("[]")
|
|
||||||
|
|
||||||
# Mock Path.read_text to raise OSError
|
|
||||||
original_read_text = Path.read_text
|
|
||||||
|
|
||||||
def mock_read_text(self, *args, **kwargs):
|
|
||||||
if self.name == "queue.json":
|
|
||||||
raise OSError("Permission denied")
|
|
||||||
return original_read_text(self, *args, **kwargs)
|
|
||||||
|
|
||||||
monkeypatch.setattr(Path, "read_text", mock_read_text)
|
|
||||||
|
|
||||||
result = lg.load_queue()
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "WARNING" in captured.out
|
|
||||||
assert "Cannot read queue.json" in captured.out
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
"""Tests for queue.json validation and backup in triage_score.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import scripts.triage_score as ts
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
|
||||||
def _isolate(tmp_path, monkeypatch):
|
|
||||||
"""Redirect triage_score paths to tmp_path for isolation."""
|
|
||||||
monkeypatch.setattr(ts, "QUEUE_FILE", tmp_path / "queue.json")
|
|
||||||
monkeypatch.setattr(ts, "QUEUE_BACKUP_FILE", tmp_path / "queue.json.bak")
|
|
||||||
monkeypatch.setattr(ts, "RETRO_FILE", tmp_path / "retro" / "triage.jsonl")
|
|
||||||
monkeypatch.setattr(ts, "QUARANTINE_FILE", tmp_path / "quarantine.json")
|
|
||||||
monkeypatch.setattr(ts, "CYCLE_RETRO_FILE", tmp_path / "retro" / "cycles.jsonl")
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_created_on_write(tmp_path):
|
|
||||||
"""When writing queue.json, a backup should be created from previous valid file."""
|
|
||||||
# Create initial valid queue file
|
|
||||||
initial_data = [{"issue": 1, "title": "Test", "ready": True}]
|
|
||||||
ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_FILE.write_text(json.dumps(initial_data))
|
|
||||||
|
|
||||||
# Write new data
|
|
||||||
new_data = [{"issue": 2, "title": "New", "ready": True}]
|
|
||||||
ts.QUEUE_FILE.write_text(json.dumps(new_data, indent=2) + "\n")
|
|
||||||
|
|
||||||
# Manually run the backup logic as run_triage would
|
|
||||||
if ts.QUEUE_FILE.exists():
|
|
||||||
try:
|
|
||||||
json.loads(ts.QUEUE_FILE.read_text())
|
|
||||||
ts.QUEUE_BACKUP_FILE.write_text(ts.QUEUE_FILE.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Both files should exist with same content
|
|
||||||
assert ts.QUEUE_BACKUP_FILE.exists()
|
|
||||||
assert json.loads(ts.QUEUE_BACKUP_FILE.read_text()) == new_data
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_queue_restored_from_backup(tmp_path, capsys):
|
|
||||||
"""If queue.json is corrupt, it should be restored from backup."""
|
|
||||||
# Create a valid backup
|
|
||||||
valid_data = [{"issue": 1, "title": "Backup", "ready": True}]
|
|
||||||
ts.QUEUE_BACKUP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_BACKUP_FILE.write_text(json.dumps(valid_data, indent=2) + "\n")
|
|
||||||
|
|
||||||
# Create a corrupt queue file
|
|
||||||
ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_FILE.write_text("not valid json {{{")
|
|
||||||
|
|
||||||
# Run validation and restore logic
|
|
||||||
try:
|
|
||||||
json.loads(ts.QUEUE_FILE.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
if ts.QUEUE_BACKUP_FILE.exists():
|
|
||||||
try:
|
|
||||||
backup_data = ts.QUEUE_BACKUP_FILE.read_text()
|
|
||||||
json.loads(backup_data) # Validate backup
|
|
||||||
ts.QUEUE_FILE.write_text(backup_data)
|
|
||||||
print("[triage] Restored queue.json from backup")
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
ts.QUEUE_FILE.write_text("[]\n")
|
|
||||||
else:
|
|
||||||
ts.QUEUE_FILE.write_text("[]\n")
|
|
||||||
|
|
||||||
# Queue should be restored from backup
|
|
||||||
assert json.loads(ts.QUEUE_FILE.read_text()) == valid_data
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "Restored queue.json from backup" in captured.out
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_queue_no_backup_writes_empty_list(tmp_path):
|
|
||||||
"""If queue.json is corrupt and no backup exists, write empty list."""
|
|
||||||
# Ensure no backup exists
|
|
||||||
assert not ts.QUEUE_BACKUP_FILE.exists()
|
|
||||||
|
|
||||||
# Create a corrupt queue file
|
|
||||||
ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_FILE.write_text("not valid json {{{")
|
|
||||||
|
|
||||||
# Run validation and restore logic
|
|
||||||
try:
|
|
||||||
json.loads(ts.QUEUE_FILE.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
if ts.QUEUE_BACKUP_FILE.exists():
|
|
||||||
try:
|
|
||||||
backup_data = ts.QUEUE_BACKUP_FILE.read_text()
|
|
||||||
json.loads(backup_data)
|
|
||||||
ts.QUEUE_FILE.write_text(backup_data)
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
ts.QUEUE_FILE.write_text("[]\n")
|
|
||||||
else:
|
|
||||||
ts.QUEUE_FILE.write_text("[]\n")
|
|
||||||
|
|
||||||
# Should have empty list
|
|
||||||
assert json.loads(ts.QUEUE_FILE.read_text()) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_corrupt_backup_writes_empty_list(tmp_path):
|
|
||||||
"""If both queue.json and backup are corrupt, write empty list."""
|
|
||||||
# Create a corrupt backup
|
|
||||||
ts.QUEUE_BACKUP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_BACKUP_FILE.write_text("also corrupt backup")
|
|
||||||
|
|
||||||
# Create a corrupt queue file
|
|
||||||
ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_FILE.write_text("not valid json {{{")
|
|
||||||
|
|
||||||
# Run validation and restore logic
|
|
||||||
try:
|
|
||||||
json.loads(ts.QUEUE_FILE.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
if ts.QUEUE_BACKUP_FILE.exists():
|
|
||||||
try:
|
|
||||||
backup_data = ts.QUEUE_BACKUP_FILE.read_text()
|
|
||||||
json.loads(backup_data)
|
|
||||||
ts.QUEUE_FILE.write_text(backup_data)
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
ts.QUEUE_FILE.write_text("[]\n")
|
|
||||||
else:
|
|
||||||
ts.QUEUE_FILE.write_text("[]\n")
|
|
||||||
|
|
||||||
# Should have empty list
|
|
||||||
assert json.loads(ts.QUEUE_FILE.read_text()) == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_valid_queue_not_corrupt_no_backup_overwrite(tmp_path):
|
|
||||||
"""Don't overwrite backup if current queue.json is corrupt."""
|
|
||||||
# Create a valid backup
|
|
||||||
valid_backup = [{"issue": 99, "title": "Old Backup", "ready": True}]
|
|
||||||
ts.QUEUE_BACKUP_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_BACKUP_FILE.write_text(json.dumps(valid_backup, indent=2) + "\n")
|
|
||||||
|
|
||||||
# Create a corrupt queue file
|
|
||||||
ts.QUEUE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
ts.QUEUE_FILE.write_text("corrupt data")
|
|
||||||
|
|
||||||
# Try to save backup (should skip because current is corrupt)
|
|
||||||
if ts.QUEUE_FILE.exists():
|
|
||||||
try:
|
|
||||||
json.loads(ts.QUEUE_FILE.read_text()) # This will fail
|
|
||||||
ts.QUEUE_BACKUP_FILE.write_text(ts.QUEUE_FILE.read_text())
|
|
||||||
except (json.JSONDecodeError, OSError):
|
|
||||||
pass # Should hit this branch
|
|
||||||
|
|
||||||
# Backup should still have original valid data
|
|
||||||
assert json.loads(ts.QUEUE_BACKUP_FILE.read_text()) == valid_backup
|
|
||||||
|
|
||||||
|
|
||||||
def test_backup_path_configuration():
|
|
||||||
"""Ensure backup file path is properly configured relative to queue file."""
|
|
||||||
assert ts.QUEUE_BACKUP_FILE.parent == ts.QUEUE_FILE.parent
|
|
||||||
assert ts.QUEUE_BACKUP_FILE.name == "queue.json.bak"
|
|
||||||
assert ts.QUEUE_FILE.name == "queue.json"
|
|
||||||
Reference in New Issue
Block a user