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:
parent
297eb29d65
commit
e4dc866e20
3 changed files with 61 additions and 29 deletions
|
|
@ -36,8 +36,11 @@ env vars доступны во **всех** новых shell-сессиях ав
|
||||||
Проверь что выставлены:
|
Проверь что выставлены:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
[System.Environment]::GetEnvironmentVariable("FORGEJO_TOKEN_BACKEND", "User") | Out-Null
|
# GetEnvironmentVariable возвращает $null если var отсутствует — НЕ throws.
|
||||||
if (-not $?) { Write-Error "❌ Запусти setup-bot-env.ps1 сначала" }
|
# Поэтому проверяем результат напрямую (не через $? — он у 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 окно)
|
### Шаг 2 — Env vars + git identity (per окно)
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ on:
|
||||||
- cron: '*/30 * * * *'
|
- cron: '*/30 * * * *'
|
||||||
workflow_dispatch: {} # manual run возможен через UI
|
workflow_dispatch: {} # manual run возможен через UI
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: stale-claims
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cleanup:
|
cleanup:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
#
|
#
|
||||||
# Освобождает issues застрявшие в `status/wip` дольше 4 часов:
|
# Освобождает issues застрявшие в `status/wip` дольше 4 часов:
|
||||||
# - убирает assignee
|
# - убирает assignee
|
||||||
# - меняет label status/wip → status/ready
|
# - меняет label status/wip → status/ready (delete wip → add ready)
|
||||||
#
|
#
|
||||||
# Why: если worker crashes или window закрывается во время работы, issue
|
# Why: если worker crashes или window закрывается во время работы, issue
|
||||||
# остаётся wip+assigned forever. Без cleanup queue забивается.
|
# остаётся wip+assigned forever. Без cleanup queue забивается.
|
||||||
|
|
@ -16,6 +16,11 @@
|
||||||
# FORGEJO_REPO — lekss361/gendesign
|
# FORGEJO_REPO — lekss361/gendesign
|
||||||
# STALE_HOURS — порог в часах (default 4)
|
# 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:
|
# Exit codes:
|
||||||
# 0 — success (even if 0 stale found)
|
# 0 — success (even if 0 stale found)
|
||||||
# 1 — env vars missing / API error
|
# 1 — env vars missing / API error
|
||||||
|
|
@ -26,7 +31,7 @@ set -euo pipefail
|
||||||
: "${FORGEJO_URL:?FORGEJO_URL required}"
|
: "${FORGEJO_URL:?FORGEJO_URL required}"
|
||||||
: "${FORGEJO_REPO:?FORGEJO_REPO required}"
|
: "${FORGEJO_REPO:?FORGEJO_REPO required}"
|
||||||
|
|
||||||
STALE_HOURS="${STALE_HOURS:-4}"
|
export STALE_HOURS="${STALE_HOURS:-4}"
|
||||||
STALE_SECS=$((STALE_HOURS * 3600))
|
STALE_SECS=$((STALE_HOURS * 3600))
|
||||||
|
|
||||||
curl_forgejo() {
|
curl_forgejo() {
|
||||||
|
|
@ -36,34 +41,56 @@ curl_forgejo() {
|
||||||
}
|
}
|
||||||
|
|
||||||
now_utc_epoch() {
|
now_utc_epoch() {
|
||||||
python3 -c 'import datetime; print(int(datetime.datetime.now(datetime.UTC).timestamp()))'
|
python3 -c 'import datetime; print(int(datetime.datetime.now(datetime.timezone.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)"
|
echo "[$(date -Is)] cleanup-stale-claims: scanning status/wip issues (stale threshold: ${STALE_HOURS}h)"
|
||||||
|
|
||||||
NOW=$(now_utc_epoch)
|
NOW=$(now_utc_epoch)
|
||||||
CUTOFF=$((NOW - STALE_SECS))
|
export CUTOFF=$((NOW - STALE_SECS))
|
||||||
|
|
||||||
# GET all open issues с label status/wip
|
# GET all open issues с label status/wip — pagination в Python (более надёжно чем
|
||||||
RESPONSE=$(curl_forgejo "repos/$FORGEJO_REPO/issues?labels=status/wip&state=open&limit=50&page=1")
|
# склеивать 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
|
# Парсим JSON
|
||||||
echo "$RESPONSE" | python3 <<PYEOF
|
cat "$ISSUES_TMP" | python3 <<'PYEOF'
|
||||||
import json, os, subprocess, sys
|
import json, os, subprocess, sys, datetime
|
||||||
|
|
||||||
cutoff = int(os.environ.get("CUTOFF", "0"))
|
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:
|
if not data:
|
||||||
print(f"[OK] 0 wip issues — nothing to do")
|
print("[OK] 0 wip issues — nothing to do")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
released = 0
|
released = 0
|
||||||
errors = 0
|
errors = 0
|
||||||
|
now_epoch = int(datetime.datetime.now(datetime.timezone.utc).timestamp())
|
||||||
|
|
||||||
for issue in data:
|
for issue in data:
|
||||||
issue_num = issue["number"]
|
issue_num = issue["number"]
|
||||||
|
|
@ -71,10 +98,8 @@ for issue in data:
|
||||||
title = issue["title"]
|
title = issue["title"]
|
||||||
assignees = [a["login"] for a in (issue.get("assignees") or [])]
|
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())
|
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
|
age_hours = age_secs // 3600
|
||||||
|
|
||||||
if updated_epoch >= cutoff:
|
if updated_epoch >= cutoff:
|
||||||
|
|
@ -85,7 +110,6 @@ for issue in data:
|
||||||
print(f" assignees: {assignees}")
|
print(f" assignees: {assignees}")
|
||||||
print(f" → releasing claim")
|
print(f" → releasing claim")
|
||||||
|
|
||||||
# Build commands
|
|
||||||
repo = os.environ["FORGEJO_REPO"]
|
repo = os.environ["FORGEJO_REPO"]
|
||||||
base = os.environ["FORGEJO_URL"] + "/api/v1"
|
base = os.environ["FORGEJO_URL"] + "/api/v1"
|
||||||
token = os.environ["FORGEJO_TOKEN"]
|
token = os.environ["FORGEJO_TOKEN"]
|
||||||
|
|
@ -99,20 +123,21 @@ for issue in data:
|
||||||
check=True, capture_output=True,
|
check=True, capture_output=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Add status/ready label
|
# 2. Remove status/wip label FIRST (если падает — issue в neutral state без status/*,
|
||||||
subprocess.run(
|
# что менее опасно чем оба status/ready+status/wip одновременно)
|
||||||
["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(
|
subprocess.run(
|
||||||
["curl", "-sS", "-X", "DELETE", *auth,
|
["curl", "-sS", "-X", "DELETE", *auth,
|
||||||
f"{base}/repos/{repo}/issues/{issue_num}/labels/status%2Fwip"],
|
f"{base}/repos/{repo}/issues/{issue_num}/labels/status%2Fwip"],
|
||||||
check=True, capture_output=True,
|
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
|
# 4. Post comment for trace
|
||||||
comment_body = json.dumps({
|
comment_body = json.dumps({
|
||||||
"body": f"⚠️ Stale claim released: issue stuck in status/wip for {age_hours}h "
|
"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'}")
|
print(f" ✗ FAILED: {e.stderr.decode() if e.stderr else 'unknown'}")
|
||||||
errors += 1
|
errors += 1
|
||||||
|
|
||||||
print(f"")
|
print()
|
||||||
print(f"[OK] Released {released} stale claim(s), {errors} errors")
|
print(f"[OK] Released {released} stale claim(s), {errors} errors")
|
||||||
sys.exit(1 if errors else 0)
|
sys.exit(1 if errors else 0)
|
||||||
PYEOF
|
PYEOF
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue