fix(scripts): forgejo timeout coverage for python oracles and create-readme #45

Merged
slaid098 merged 6 commits from fix/scripts/forgejo-timeout-coverage into main 2026-08-11 16:35:48 +03:00
Owner

Что сделано

Покрыты все непокрытые call sites к Forgejo API в Python-оракулах и create-readme (issue #42, Волна 1 после PR#41):

  • pipeline-status.py:_forgejo_request — urlopen(req, timeout=FORGEJO_TIMEOUT_S) + retry-цикл (3×, 1с sleep, skip на первой попытке). Retry на URLError (timeout/connection) и HTTP 5xx; НЕ на 4xx. При исчерпании — return (0, "", "[forgejo] ... failed after N retries").
  • project-status.py:_forgejo_get — тот же паттерн (urlopen timeout + retry).
  • spec-status.py:_forgejo_get — тот же паттерн.
  • pipeline-status.ts, project-status.ts, spec-status.ts — spawnSync(..., { timeout: 60000 }) + обработка status === null (SIGTERM → "timed out (60s)").
  • create-readme.ts — мигрировал прямой fetch() на локальный _fetchWithRetry (AbortSignal.timeout + retry на timeout/5xx, НЕ на 4xx). GET принимает 200/404 (404 = нет README, не ошибка); PUT принимает 200/201.
  • Env vars: OPENCODE_FORGEJO_TIMEOUT (10с), OPENCODE_FORGEJO_RETRY (3), OPENCODE_FORGEJO_RETRY_INTERVAL (1) — Python через _env_int/os.environ.get, TS через _envInt.
  • Логи: Python — print(..., file=sys.stderr) с [forgejo] prefix (только retry/error). TS — process.stderr.write с [forgejo] prefix.
  • Тесты: test_pipeline_status.py — 10 новых тестов (success/no-retry, timeout-retries-fail, timeout-then-success, 5xx-retries-then-success, 4xx-no-retry 404/401, 5xx-exhausted, retry=0, timeout-param). test_pipeline_status_tool.py — 2 новых (timeout=60000 в opts, status=null → readable error). test_create_readme_tool.py — 4 новых (timeout-then-success, 4xx-404-no-retry, 4xx-401-no-retry, 5xx-retry-then-4xx). _ts_loader.mjs — stub поддерживает throw: "timeout" и signal в response.

Почему

PR#41 (issue #40) пофиксил hang в _shared.ts:callForgejo, но аудит выявил непокрытые call sites — тот же класс 5-мин hang'а. Python-оракулы вызывали urlopen без timeout=, TS-обёртки — spawnSync без timeout=. Один hung-вызов побеждал бюджет CI poll loop. Худший кейс теперь ≤32с (10с×3 retry) на любой Forgejo-вызов в оракулах, ≤60с на уровне spawnSync.

Watch out

  • CI poll loop wait_timeout=300 НЕ трогал (by design — ожидание CI билда).
  • create-readme.ts использует локальный _fetchWithRetry вместо callForgejo из _shared.ts: callForgejo возвращает spawnSync-shape ({status:0|1, stdout, stderr}), несовместимый с HTTP-response семантикой create-readme (нужен 404 как не-ошибка для GET, 200/201 для PUT).
  • _sleepSync в create-readme использует busy-wait (spawnSync path синхронный, event loop недоступен). Для retry-интервала 1с это приемлемо.
  • AbortSignal.timeout() требует Node 18+ / Bun — среда opencode поддерживает.
  • mypy: 36 pre-existing ошибок ProjectType valid-type в project-status.py — не связаны с этим PR.
  • Покрытие тестами 80.83% — выше порога 80%.

Pending

  • Волна 2: curl в skills/agents — отдельный issue.
  • Волна 3: git spawnSync в merge-pr/create-pr/commit — отдельный issue.
  • Server-side Forgejo (SQLite, webhook, indexer) — issue #19 в forgejo-infra.

Closes #42

Closes #42

## Что сделано Покрыты все непокрытые call sites к Forgejo API в Python-оракулах и create-readme (issue #42, Волна 1 после PR#41): - **`pipeline-status.py:_forgejo_request`** — `urlopen(req, timeout=FORGEJO_TIMEOUT_S)` + retry-цикл (3×, 1с sleep, skip на первой попытке). Retry на `URLError` (timeout/connection) и HTTP 5xx; НЕ на 4xx. При исчерпании — `return (0, "", "[forgejo] ... failed after N retries")`. - **`project-status.py:_forgejo_get`** — тот же паттерн (urlopen timeout + retry). - **`spec-status.py:_forgejo_get`** — тот же паттерн. - **`pipeline-status.ts`, `project-status.ts`, `spec-status.ts`** — `spawnSync(..., { timeout: 60000 })` + обработка `status === null` (SIGTERM → "timed out (60s)"). - **`create-readme.ts`** — мигрировал прямой `fetch()` на локальный `_fetchWithRetry` (AbortSignal.timeout + retry на timeout/5xx, НЕ на 4xx). GET принимает 200/404 (404 = нет README, не ошибка); PUT принимает 200/201. - **Env vars**: `OPENCODE_FORGEJO_TIMEOUT` (10с), `OPENCODE_FORGEJO_RETRY` (3), `OPENCODE_FORGEJO_RETRY_INTERVAL` (1) — Python через `_env_int`/`os.environ.get`, TS через `_envInt`. - **Логи**: Python — `print(..., file=sys.stderr)` с `[forgejo]` prefix (только retry/error). TS — `process.stderr.write` с `[forgejo]` prefix. - **Тесты**: `test_pipeline_status.py` — 10 новых тестов (success/no-retry, timeout-retries-fail, timeout-then-success, 5xx-retries-then-success, 4xx-no-retry 404/401, 5xx-exhausted, retry=0, timeout-param). `test_pipeline_status_tool.py` — 2 новых (timeout=60000 в opts, status=null → readable error). `test_create_readme_tool.py` — 4 новых (timeout-then-success, 4xx-404-no-retry, 4xx-401-no-retry, 5xx-retry-then-4xx). `_ts_loader.mjs` — stub поддерживает `throw: "timeout"` и `signal` в response. ## Почему PR#41 (issue #40) пофиксил hang в `_shared.ts:callForgejo`, но аудит выявил непокрытые call sites — тот же класс 5-мин hang'а. Python-оракулы вызывали `urlopen` без `timeout=`, TS-обёртки — `spawnSync` без `timeout=`. Один hung-вызов побеждал бюджет CI poll loop. Худший кейс теперь ≤32с (10с×3 retry) на любой Forgejo-вызов в оракулах, ≤60с на уровне spawnSync. ## Watch out - CI poll loop `wait_timeout=300` НЕ трогал (by design — ожидание CI билда). - `create-readme.ts` использует локальный `_fetchWithRetry` вместо `callForgejo` из `_shared.ts`: `callForgejo` возвращает spawnSync-shape (`{status:0|1, stdout, stderr}`), несовместимый с HTTP-response семантикой create-readme (нужен 404 как не-ошибка для GET, 200/201 для PUT). - `_sleepSync` в create-readme использует busy-wait (spawnSync path синхронный, event loop недоступен). Для retry-интервала 1с это приемлемо. - `AbortSignal.timeout()` требует Node 18+ / Bun — среда opencode поддерживает. - mypy: 36 pre-existing ошибок `ProjectType valid-type` в project-status.py — не связаны с этим PR. - Покрытие тестами 80.83% — выше порога 80%. ## Pending - Волна 2: curl в skills/agents — отдельный issue. - Волна 3: git spawnSync в merge-pr/create-pr/commit — отдельный issue. - Server-side Forgejo (SQLite, webhook, indexer) — issue #19 в forgejo-infra. Closes #42 Closes #42
test(scripts): cover forgejo timeout retry and 4xx-no-retry
Some checks failed
CI (always) / bootstrap (pull_request) Successful in 9s
CI / bootstrap (pull_request) Successful in 11s
CI / lint (pull_request) Failing after 28s
CI / typecheck (pull_request) Successful in 27s
CI / complexity (pull_request) Successful in 27s
CI / test (3.13) (pull_request) Successful in 1m36s
7a9d2f1225
fix(ci): ruff UP041/E501/RUF059 in test_pipeline_status forgejo retry tests
Some checks failed
CI (always) / bootstrap (pull_request) Successful in 3s
CI / bootstrap (pull_request) Successful in 6s
CI / typecheck (pull_request) Successful in 32s
CI / complexity (pull_request) Successful in 32s
CI / lint (pull_request) Failing after 32s
CI / test (3.13) (pull_request) Has been cancelled
cd7387c5eb
fix(ci): ruff format E306 blank lines around nested fake_urlopen functions
All checks were successful
CI (always) / bootstrap (pull_request) Successful in 5s
CI / bootstrap (pull_request) Successful in 8s
CI / lint (pull_request) Successful in 41s
CI / typecheck (pull_request) Successful in 50s
CI / complexity (pull_request) Successful in 28s
CI / test (3.13) (pull_request) Successful in 2m18s
a0e83d4dc7
Author
Owner

Code Review Summary

PR добавляет urlopen(timeout=) + retry (3×, 1с sleep, retry на timeout/5xx, no-retry на 4xx) в 3 Python-оракула, spawnSync timeout: 60000 + обработку status === null (SIGTERM) в 3 TS-обёртках, и локальный _fetchWithRetry в create-readme.ts. 16 новых тестов покрывают timeout/retry/4xx-no-retry. Худший кейс latency снижен с 5-мин hang до ≤32с (Python) / ≤60с (spawnSync). Качество реализации высокое, решения задокументированы в PR body.

Положительные моменты

  • Python retry корректен: urlopen(req, timeout=FORGEJO_TIMEOUT_S) + цикл for attempt in range(1, max_attempts + 1) с time.sleep (skip на первой попытке). Retry на URLError (timeout/connection) и HTTP 5xx; 4xx → immediate return. Порядок except-блоков правильный: HTTPError → URLError → OSError (от конкретного к общему). isinstance(e.reason, socket.timeout) корректно различает timeout от connection error.
  • max(1, FORGEJO_RETRY) / Math.max(1, FORGEJO_RETRY) — консистентность между Python и TS: RETRY=0 → 1 попытка, без infinite loop.
  • spawnSync timeout + SIGTERM: timeout: 60000 + if (r.status === null) с проверкой r.signal === "SIGTERM" → читаемое "timed out (60s)". Проверка status === null стоит ДО status !== 0 — порядок правильный. 60s имеет headroom над Python-внутренним 10с×3+2×1=32с.
  • Env vars консистентны: OPENCODE_FORGEJO_TIMEOUT/RETRY/RETRY_INTERVAL читаются во всех 4 файлах (3 Python + create-readme.ts). .env.example документирует worst-case latency 32с.
  • Логи: print(..., file=sys.stderr) / process.stderr.write с [forgejo] prefix, только retry/error (не на success). Не засоряют stdout.
  • CI poll loop wait_timeout=300 не тронут — by design (ожидание CI билда), корректно.
  • Backward compat: возвращаемые значения _forgejo_request/_forgejo_get не изменились ((status, body, err)), существующие вызовы работают без правок.
  • Тесты отличные: 10 Python-тестов покрывают success/no-retry, timeout-retries-fail, timeout-then-success, 5xx-retries-then-success, 4xx-no-retry (404/401), 5xx-exhausted, retry=0, timeout-param-passed. 4 create-readme теста покрывают timeout-then-success, 404-no-retry, 401-no-retry, 5xx-retry-then-4xx. 2 TS-теста проверяют timeout=60000 в opts и status=null → readable error. _ts_loader.mjs расширен поддержкой throw: "timeout"/"abort" и signal в response.
  • PR body качественный: ## Что сделано / ## Почему / ## Watch out / ## Pending заполнены осмысленно. Решение по локальному _fetchWithRetry вместо callForgejo явно обосновано (несовместимость возвращаемого значения: spawnSync-shape vs HTTP-response семантика с 404 как не-ошибкой).

Замечания (info, не блокирующие)

  • .opencode/tools/create-readme.ts:397 [dead code] else if (!getRes.ok && getRes.status === 0) — недостижимая ветка. При timeout getRes.status === 0, и предыдущая проверка getRes.status !== 404 (строка 395) перехватывает его первой, возвращая "HTTP 0: <error text>" вместо более понятного "Forgejo GET failed: <error text>" из строки 398. Ошибка всё равно возвращается (не блокирующее), но сообщение вводит в заблуждение ("HTTP 0" — не HTTP-статус). Fix: переставить проверку status === 0 ДО status !== 404, или удалить dead code и принять "HTTP 0" как есть.

  • Дублирование retry-логики в 3 Python-оракулах [duplication] pipeline-status.py, project-status.py, spec-status.py содержат идентичный retry-цикл ~40 строк (×3 = ~120 строк). Это сознательный компромисс для независимости оракулов (нет общего модуля, только stdlib imports). Можно вынести в общий _forgejo_request helper, но это требует изменения архитектуры (общий модуль или sys.path манипуляция). Не блокирующее — оракулы намеренно standalone.

  • create-readme.ts не переиспользует callForgejo из _shared.ts [duplication] Локальный _fetchWithRetry (~50 строк) дублирует callForgejo (timeout, retry, 4xx-no-retry, [forgejo] логи). PR body объясняет причину: callForgejo возвращает spawnSync-shape {status:0|1, stdout, stderr}, несовместимый с HTTP-response семантикой create-readme (нужен 404 как не-ошибка для GET, 200/201 для PUT). Альтернатива — расширить callForgejo опцией okStatuses: number[] вместо одиночного okStatus. Не блокирующее — решение задокументировано, _sleepSync busy-wait приемлем для 1с интервала.

  • .opencode/scripts/pipeline-status.py:446-448 [style] FORGEJO_TIMEOUT_S/FORGEJO_RETRY/FORGEJO_RETRY_INTERVAL_S определены после _forgejo_request (строка 93), которая их использует. Работает (функция читает globals при вызове, не при определении), но в project-status.py:206 и spec-status.py:131 константы определены ДО _forgejo_get — более чистый top-down порядок. Не блокирующее — стилистическое.

  • tests/test_pipeline_status.py:1462 [test fragility] assert ps.FORGEJO_TIMEOUT_S == 10 зависит от того, что OPENCODE_FORGEJO_TIMEOUT не задан в env pytest (или равен 10). CI не задаёт (docker-compose.yml не содержит), но тест хрупкий. Fix: monkeypatch.delenv("OPENCODE_FORGEJO_TIMEOUT", raising=False) перед вызовом, или assert через _env_int напрямую.

  • Нет теста "все попытки timeout" для create-readme [test coverage] Ветка ошибки timeout (status=0, все retry исчерпаны) в _fetchWithRetry не покрыта. Тест test_remote_create_timeout_then_success проверяет timeout→retry→success, но не timeout→timeout→timeout→fail. Не блокирующее — основной happy/sad path покрыт.

Verdict: APPROVE

## Code Review Summary PR добавляет `urlopen(timeout=)` + retry (3×, 1с sleep, retry на timeout/5xx, no-retry на 4xx) в 3 Python-оракула, `spawnSync timeout: 60000` + обработку `status === null` (SIGTERM) в 3 TS-обёртках, и локальный `_fetchWithRetry` в `create-readme.ts`. 16 новых тестов покрывают timeout/retry/4xx-no-retry. Худший кейс latency снижен с 5-мин hang до ≤32с (Python) / ≤60с (spawnSync). Качество реализации высокое, решения задокументированы в PR body. ### Положительные моменты - **Python retry корректен**: `urlopen(req, timeout=FORGEJO_TIMEOUT_S)` + цикл `for attempt in range(1, max_attempts + 1)` с `time.sleep` (skip на первой попытке). Retry на `URLError` (timeout/connection) и HTTP 5xx; 4xx → immediate return. Порядок except-блоков правильный: `HTTPError` → `URLError` → `OSError` (от конкретного к общему). `isinstance(e.reason, socket.timeout)` корректно различает timeout от connection error. - **`max(1, FORGEJO_RETRY)` / `Math.max(1, FORGEJO_RETRY)`** — консистентность между Python и TS: `RETRY=0` → 1 попытка, без infinite loop. - **spawnSync timeout + SIGTERM**: `timeout: 60000` + `if (r.status === null)` с проверкой `r.signal === "SIGTERM"` → читаемое "timed out (60s)". Проверка `status === null` стоит ДО `status !== 0` — порядок правильный. 60s имеет headroom над Python-внутренним 10с×3+2×1=32с. - **Env vars консистентны**: `OPENCODE_FORGEJO_TIMEOUT/RETRY/RETRY_INTERVAL` читаются во всех 4 файлах (3 Python + create-readme.ts). `.env.example` документирует worst-case latency 32с. - **Логи**: `print(..., file=sys.stderr)` / `process.stderr.write` с `[forgejo]` prefix, только retry/error (не на success). Не засоряют stdout. - **CI poll loop `wait_timeout=300` не тронут** — by design (ожидание CI билда), корректно. - **Backward compat**: возвращаемые значения `_forgejo_request`/`_forgejo_get` не изменились (`(status, body, err)`), существующие вызовы работают без правок. - **Тесты отличные**: 10 Python-тестов покрывают success/no-retry, timeout-retries-fail, timeout-then-success, 5xx-retries-then-success, 4xx-no-retry (404/401), 5xx-exhausted, retry=0, timeout-param-passed. 4 create-readme теста покрывают timeout-then-success, 404-no-retry, 401-no-retry, 5xx-retry-then-4xx. 2 TS-теста проверяют timeout=60000 в opts и status=null → readable error. `_ts_loader.mjs` расширен поддержкой `throw: "timeout"/"abort"` и `signal` в response. - **PR body качественный**: `## Что сделано` / `## Почему` / `## Watch out` / `## Pending` заполнены осмысленно. Решение по локальному `_fetchWithRetry` вместо `callForgejo` явно обосновано (несовместимость возвращаемого значения: spawnSync-shape vs HTTP-response семантика с 404 как не-ошибкой). ### Замечания (info, не блокирующие) - **`.opencode/tools/create-readme.ts:397`** [dead code] `else if (!getRes.ok && getRes.status === 0)` — недостижимая ветка. При timeout `getRes.status === 0`, и предыдущая проверка `getRes.status !== 404` (строка 395) перехватывает его первой, возвращая `"HTTP 0: <error text>"` вместо более понятного `"Forgejo GET failed: <error text>"` из строки 398. Ошибка всё равно возвращается (не блокирующее), но сообщение вводит в заблуждение ("HTTP 0" — не HTTP-статус). Fix: переставить проверку `status === 0` ДО `status !== 404`, или удалить dead code и принять "HTTP 0" как есть. - **Дублирование retry-логики в 3 Python-оракулах** [duplication] `pipeline-status.py`, `project-status.py`, `spec-status.py` содержат идентичный retry-цикл ~40 строк (×3 = ~120 строк). Это сознательный компромисс для независимости оракулов (нет общего модуля, только stdlib imports). Можно вынести в общий `_forgejo_request` helper, но это требует изменения архитектуры (общий модуль или sys.path манипуляция). Не блокирующее — оракулы намеренно standalone. - **`create-readme.ts` не переиспользует `callForgejo` из `_shared.ts`** [duplication] Локальный `_fetchWithRetry` (~50 строк) дублирует `callForgejo` (timeout, retry, 4xx-no-retry, `[forgejo]` логи). PR body объясняет причину: `callForgejo` возвращает spawnSync-shape `{status:0|1, stdout, stderr}`, несовместимый с HTTP-response семантикой create-readme (нужен 404 как не-ошибка для GET, 200/201 для PUT). Альтернатива — расширить `callForgejo` опцией `okStatuses: number[]` вместо одиночного `okStatus`. Не блокирующее — решение задокументировано, `_sleepSync` busy-wait приемлем для 1с интервала. - **`.opencode/scripts/pipeline-status.py:446-448`** [style] `FORGEJO_TIMEOUT_S`/`FORGEJO_RETRY`/`FORGEJO_RETRY_INTERVAL_S` определены после `_forgejo_request` (строка 93), которая их использует. Работает (функция читает globals при вызове, не при определении), но в `project-status.py:206` и `spec-status.py:131` константы определены ДО `_forgejo_get` — более чистый top-down порядок. Не блокирующее — стилистическое. - **`tests/test_pipeline_status.py:1462`** [test fragility] `assert ps.FORGEJO_TIMEOUT_S == 10` зависит от того, что `OPENCODE_FORGEJO_TIMEOUT` не задан в env pytest (или равен 10). CI не задаёт (docker-compose.yml не содержит), но тест хрупкий. Fix: `monkeypatch.delenv("OPENCODE_FORGEJO_TIMEOUT", raising=False)` перед вызовом, или assert через `_env_int` напрямую. - **Нет теста "все попытки timeout" для `create-readme`** [test coverage] Ветка ошибки timeout (status=0, все retry исчерпаны) в `_fetchWithRetry` не покрыта. Тест `test_remote_create_timeout_then_success` проверяет timeout→retry→success, но не timeout→timeout→timeout→fail. Не блокирующее — основной happy/sad path покрыт. ### Verdict: APPROVE
slaid098 deleted branch fix/scripts/forgejo-timeout-coverage 2026-08-11 16:35:48 +03:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slaid098/opencode-config!45
No description provided.