gendesign/scripts/cleanup-stale-claims.sh
lekss361 297eb29d65 feat(claude): env-vars refactor + stale-claim cron (Phase 2 finalization)
#11: slash-commands и _autonomous_pickup.md теперь читают persistent User-scope
env vars напрямую (FORGEJO_TOKEN_<ROLE>) вместо file-based из ~/.claude/secrets/.

Один раз: scripts/setup-bot-env.ps1 (запускается user'ом локально, кладёт
токены из vault в Windows User registry). После этого окна grab credentials
через [System.Environment]::GetEnvironmentVariable() — никаких файлов.

Все 5 work-as-*.md обновлены (analyst/backend/frontend/reviewer/qa).

#12: stale-claim cleanup — cron каждые 30 минут.

- scripts/cleanup-stale-claims.sh — bash, parses Forgejo API + Python для JSON
- .forgejo/workflows/stale-claims.yml — cron schedule `*/30 * * * *`

Освобождает issues застрявшие в status/wip >4h:
  - assignees=[]
  - +status/ready -status/wip
  - comment "stale claim released, worker likely crashed"

Использует secret FORGEJO_BOT_QA_TOKEN (минимум прав = write:issue).

Setup: secret нужно завести в Forgejo Actions UI после merge —
git.gendsgn.ru/lekss361/gendesign/-/settings/actions/secrets.

Refs: PRs #608, #609. Closes TODO с PR #608 (stale-claim cron).
2026-05-28 00:24:45 +03:00

136 lines
4.6 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <<PYEOF
import json, os, subprocess, sys
cutoff = int(os.environ.get("CUTOFF", "0"))
data = json.loads(sys.stdin.read())
if not data:
print(f"[OK] 0 wip issues — nothing to do")
sys.exit(0)
released = 0
errors = 0
for issue in data:
issue_num = issue["number"]
updated_at = issue["updated_at"]
title = issue["title"]
assignees = [a["login"] for a in (issue.get("assignees") or [])]
# ISO to epoch
import datetime
updated_epoch = int(datetime.datetime.fromisoformat(updated_at.replace("Z", "+00:00")).timestamp())
age_secs = int(datetime.datetime.now(datetime.UTC).timestamp()) - updated_epoch
age_hours = age_secs // 3600
if updated_epoch >= 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