#!/usr/bin/env bash # Cleanup stale claims for autonomous multi-agent workflow. # # Освобождает issues застрявшие в `status/wip` дольше 4 часов: # - убирает assignee # - меняет label status/wip → status/ready # # Why: если worker crashes или window закрывается во время работы, issue # остаётся wip+assigned forever. Без cleanup queue забивается. # # Run: cron каждые 30 минут (см. .forgejo/workflows/stale-claims.yml). # # Env vars (требуются): # FORGEJO_TOKEN — admin или с write:issue scope на repo # FORGEJO_URL — https://git.gendsgn.ru (без /api/v1) # FORGEJO_REPO — lekss361/gendesign # STALE_HOURS — порог в часах (default 4) # # Exit codes: # 0 — success (even if 0 stale found) # 1 — env vars missing / API error set -euo pipefail : "${FORGEJO_TOKEN:?FORGEJO_TOKEN required}" : "${FORGEJO_URL:?FORGEJO_URL required}" : "${FORGEJO_REPO:?FORGEJO_REPO required}" STALE_HOURS="${STALE_HOURS:-4}" STALE_SECS=$((STALE_HOURS * 3600)) curl_forgejo() { curl -sS -H "Authorization: token $FORGEJO_TOKEN" \ -H "Content-Type: application/json" \ "$FORGEJO_URL/api/v1/$1" "${@:2}" } now_utc_epoch() { python3 -c 'import datetime; print(int(datetime.datetime.now(datetime.UTC).timestamp()))' } iso_to_epoch() { python3 -c "import datetime; print(int(datetime.datetime.fromisoformat('$1'.replace('Z','+00:00')).timestamp()))" } echo "[$(date -Is)] cleanup-stale-claims: scanning status/wip issues (stale threshold: ${STALE_HOURS}h)" NOW=$(now_utc_epoch) CUTOFF=$((NOW - STALE_SECS)) # GET all open issues с label status/wip RESPONSE=$(curl_forgejo "repos/$FORGEJO_REPO/issues?labels=status/wip&state=open&limit=50&page=1") # Парсим JSON echo "$RESPONSE" | python3 <= cutoff: print(f" · #{issue_num} '{title[:50]}' — age {age_hours}h, fresh, skip") continue print(f" ⚠ #{issue_num} '{title[:50]}' — age {age_hours}h, STALE") print(f" assignees: {assignees}") print(f" → releasing claim") # Build commands repo = os.environ["FORGEJO_REPO"] base = os.environ["FORGEJO_URL"] + "/api/v1" token = os.environ["FORGEJO_TOKEN"] auth = ["-H", f"Authorization: token {token}", "-H", "Content-Type: application/json"] try: # 1. Clear assignees subprocess.run( ["curl", "-sS", "-X", "PATCH", *auth, "-d", '{"assignees": []}', f"{base}/repos/{repo}/issues/{issue_num}"], check=True, capture_output=True, ) # 2. Add status/ready label subprocess.run( ["curl", "-sS", "-X", "POST", *auth, "-d", '{"labels": ["status/ready"]}', f"{base}/repos/{repo}/issues/{issue_num}/labels"], check=True, capture_output=True, ) # 3. Remove status/wip label subprocess.run( ["curl", "-sS", "-X", "DELETE", *auth, f"{base}/repos/{repo}/issues/{issue_num}/labels/status%2Fwip"], check=True, capture_output=True, ) # 4. Post comment for trace comment_body = json.dumps({ "body": f"⚠️ Stale claim released: issue stuck in status/wip for {age_hours}h " f"(previously assigned to {', '.join(assignees) or 'none'}). " f"Label transition: status/wip → status/ready. Worker likely crashed." }) subprocess.run( ["curl", "-sS", "-X", "POST", *auth, "-d", comment_body, f"{base}/repos/{repo}/issues/{issue_num}/comments"], check=True, capture_output=True, ) released += 1 except subprocess.CalledProcessError as e: print(f" ✗ FAILED: {e.stderr.decode() if e.stderr else 'unknown'}") errors += 1 print(f"") print(f"[OK] Released {released} stale claim(s), {errors} errors") sys.exit(1 if errors else 0) PYEOF