fixup(claude): address review-bot blockers on PR #610

🔴 Critical (3):

1. cleanup-stale-claims.sh: export CUTOFF + STALE_HOURS
   Без `export` Python heredoc child process получал "0" вместо реального
   cutoff → cron всегда видел все issues как fresh → ничего не освобождал.
   Plus added abort при CUTOFF=0 в Python heredoc для defense-in-depth.

2. cleanup-stale-claims.sh: datetime.UTC → datetime.timezone.utc
   datetime.UTC требует Python 3.11+. Forgejo runner ubuntu-latest имеет 3.10.
   datetime.timezone.utc works в 3.8+.

3. cleanup-stale-claims.sh: invert label order — DELETE wip FIRST, потом
   POST ready. Если step 2 (add ready) fails — issue в neutral state без
   status/*, менее опасно чем оба status/ready+status/wip одновременно.

🟠 + 🟡 + 🟢 (5):

4. Added pagination loop (defensive cap 10 pages × 50 = 500 issues), но
   реализована через temp file + Python parsing (не bash JSON concat).

5. Removed dead code: iso_to_epoch() shell helper не использовался.

6. _autonomous_pickup.md: fix broken `Out-Null | if (-not $?)` pattern.
   GetEnvironmentVariable НЕ throws при missing — возвращает $null. $? у
   Get* всегда $true. Заменено на прямую проверку результата.

7. stale-claims.yml: добавлен concurrency group чтобы overlapping runs
   не stomp'ились (хотя 5-min timeout делает overlap unlikely, defensive).

8. Documented updated_at != heartbeat assumption в header script'а.

Не сделано (low/optional):

- Setup script tokens в memory (UX trade-off — не critical)
- Token suffix в console output (минор info leak в shell history)

Refs: PR #610 review by review-bot (sha=297eb29, verdict=changes)
This commit is contained in:
lekss361 2026-05-28 00:33:27 +03:00
parent 297eb29d65
commit e4dc866e20
3 changed files with 61 additions and 29 deletions

View file

@ -36,8 +36,11 @@ env vars доступны во **всех** новых shell-сессиях ав
Проверь что выставлены:
```powershell
[System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_BACKEND", "User") | Out-Null
if (-not $?) { Write-Error "❌ Запусти setup-bot-env.ps1 сначала" }
# GetEnvironmentVariable возвращает $null если var отсутствует — НЕ throws.
# Поэтому проверяем результат напрямую (не через $? — он у Get* всегда $true).
if (-not [System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_BACKEND", "User")) {
Write-Error "❌ FORGEJO_TOKEN_BACKEND не выставлен. Запусти scripts/setup-bot-env.ps1 сначала"
}
```
### Шаг 2 — Env vars + git identity (per окно)

View file

@ -15,6 +15,10 @@ on:
- cron: '*/30 * * * *'
workflow_dispatch: {} # manual run возможен через UI
concurrency:
group: stale-claims
cancel-in-progress: false
jobs:
cleanup:
runs-on: ubuntu-latest

View file

@ -3,7 +3,7 @@
#
# Освобождает issues застрявшие в `status/wip` дольше 4 часов:
# - убирает assignee
# - меняет label status/wip → status/ready
# - меняет label status/wip → status/ready (delete wip → add ready)
#
# Why: если worker crashes или window закрывается во время работы, issue
# остаётся wip+assigned forever. Без cleanup queue забивается.
@ -16,6 +16,11 @@
# FORGEJO_REPO — lekss361/gendesign
# STALE_HOURS — порог в часах (default 4)
#
# Assumption: issue.updated_at = последний touch (label/comment/assignee
# change). Worker делающий task >4h без касаний issue будет считаться stale.
# Для текущих small tasks 4h enough; в Phase 4 можно добавить heartbeat
# comment'ы от worker'а.
#
# Exit codes:
# 0 — success (even if 0 stale found)
# 1 — env vars missing / API error
@ -26,7 +31,7 @@ set -euo pipefail
: "${FORGEJO_URL:?FORGEJO_URL required}"
: "${FORGEJO_REPO:?FORGEJO_REPO required}"
STALE_HOURS="${STALE_HOURS:-4}"
export STALE_HOURS="${STALE_HOURS:-4}"
STALE_SECS=$((STALE_HOURS * 3600))
curl_forgejo() {
@ -36,34 +41,56 @@ curl_forgejo() {
}
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()))"
python3 -c 'import datetime; print(int(datetime.datetime.now(datetime.timezone.utc).timestamp()))'
}
echo "[$(date -Is)] cleanup-stale-claims: scanning status/wip issues (stale threshold: ${STALE_HOURS}h)"
NOW=$(now_utc_epoch)
CUTOFF=$((NOW - STALE_SECS))
export 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")
# GET all open issues с label status/wip — pagination в Python (более надёжно чем
# склеивать JSON arrays в bash). Defensive cap 10 pages × 50 = 500.
ISSUES_TMP=$(mktemp)
trap 'rm -f "$ISSUES_TMP"' EXIT
for PAGE in 1 2 3 4 5 6 7 8 9 10; do
PAGE_RESP=$(curl_forgejo "repos/$FORGEJO_REPO/issues?labels=status/wip&state=open&limit=50&page=$PAGE")
echo "$PAGE_RESP" >> "$ISSUES_TMP"
echo "---PAGE_END---" >> "$ISSUES_TMP"
PAGE_LEN=$(echo "$PAGE_RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))')
if [ "$PAGE_LEN" -lt 50 ]; then
break
fi
done
# Парсим JSON
echo "$RESPONSE" | python3 <<PYEOF
import json, os, subprocess, sys
cat "$ISSUES_TMP" | python3 <<'PYEOF'
import json, os, subprocess, sys, datetime
cutoff = int(os.environ.get("CUTOFF", "0"))
data = json.loads(sys.stdin.read())
if cutoff == 0:
print("[ERROR] CUTOFF env var не выставлен или равен 0 — abort", file=sys.stderr)
sys.exit(1)
# Read pagination output — multiple JSON arrays separated by "---PAGE_END---"
raw = sys.stdin.read()
pages = [p.strip() for p in raw.split("---PAGE_END---") if p.strip()]
data = []
for p in pages:
try:
data.extend(json.loads(p))
except json.JSONDecodeError:
print(f"[WARN] failed to parse page (len={len(p)}), skipping", file=sys.stderr)
if not data:
print(f"[OK] 0 wip issues — nothing to do")
print("[OK] 0 wip issues — nothing to do")
sys.exit(0)
released = 0
errors = 0
now_epoch = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
for issue in data:
issue_num = issue["number"]
@ -71,10 +98,8 @@ for issue in data:
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_secs = now_epoch - updated_epoch
age_hours = age_secs // 3600
if updated_epoch >= cutoff:
@ -85,7 +110,6 @@ for issue in data:
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"]
@ -99,20 +123,21 @@ for issue in data:
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
# 2. Remove status/wip label FIRST (если падает — issue в neutral state без status/*,
# что менее опасно чем оба status/ready+status/wip одновременно)
subprocess.run(
["curl", "-sS", "-X", "DELETE", *auth,
f"{base}/repos/{repo}/issues/{issue_num}/labels/status%2Fwip"],
check=True, capture_output=True,
)
# 3. 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,
)
# 4. Post comment for trace
comment_body = json.dumps({
"body": f"⚠️ Stale claim released: issue stuck in status/wip for {age_hours}h "
@ -130,7 +155,7 @@ for issue in data:
print(f" ✗ FAILED: {e.stderr.decode() if e.stderr else 'unknown'}")
errors += 1
print(f"")
print()
print(f"[OK] Released {released} stale claim(s), {errors} errors")
sys.exit(1 if errors else 0)
PYEOF