#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).
136 lines
4.6 KiB
Bash
136 lines
4.6 KiB
Bash
#!/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
|