fix(pipeline-status): use statusCheckRollup instead of actions/runs CI filter (#122)

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-07-29 05:29:10 +03:00 committed by GitHub
parent c9533050b8
commit d724e29f96
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 429 additions and 238 deletions

View file

@ -3,8 +3,8 @@
Reads facts from GitHub (gh CLI), git, and memory files to deterministically Reads facts from GitHub (gh CLI), git, and memory files to deterministically
derive the current pipeline phase of a PR no state file, like ``git status`` derive the current pipeline phase of a PR no state file, like ``git status``
for the PR pipeline. CI gate via Actions API (read-only): CI blocks MERGE for the PR pipeline. CI gate via ``gh pr view --json statusCheckRollup`` (read-only):
(transitive guard first phase blocks all subsequent phases). CI blocks MERGE (transitive guard first phase blocks all subsequent phases).
Usage: Usage:
python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status python3 config/scripts/pipeline-status.py <PR_NUMBER> # single PR status
@ -14,7 +14,7 @@ Seven phases:
1. ISSUE GitHub issue exists and linked via Closes/Fixes #N 1. ISSUE GitHub issue exists and linked via Closes/Fixes #N
2. IMPLEMENT PR exists + handoff file docs/handoff/pr-N-slug.md in diff 2. IMPLEMENT PR exists + handoff file docs/handoff/pr-N-slug.md in diff
3. DOCS handoff valid (4 sections) + mandatory ADR 3. DOCS handoff valid (4 sections) + mandatory ADR
4. CI latest CI run on PR branch completed & success 4. CI all checks on PR head SHA completed & success (statusCheckRollup)
5. REVIEW APPROVE found in PR comments 5. REVIEW APPROVE found in PR comments
6. MERGE PR state is MERGED 6. MERGE PR state is MERGED
7. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md 7. MEMORY PR#N distilled into repos/{host}/{org}/{repo}.md
@ -355,116 +355,84 @@ def check_project_map() -> PhaseResult:
def check_ci(pr_number: int) -> PhaseResult: def check_ci(pr_number: int) -> PhaseResult:
"""Phase 4: CI — latest CI run on PR branch completed & success. """Phase 4: CI — all checks on PR head SHA completed & success.
Uses Actions API (read-only, ``Actions: read`` scope). Polls until Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL
``status == completed`` or ``CI_WAIT_TIMEOUT`` elapsed. One tool call workflows for the PR head SHA (CI, CI (always), ADR check, etc.).
final status (DONE on green, NOT_DONE on failure, AMBIGUOUS on This handles docs-only PRs where ``ci.yml`` has ``paths-ignore`` and
timeout / API error). only ``always-ci.yml`` runs.
Edge cases (no polling):
- API error (rc != 0, e.g. 403) сразу AMBIGUOUS (retries won't help).
- no runs (jq null) short retry ``CI_NO_RUNS_RETRY`` times with
``CI_NO_RUNS_INTERVAL`` (CI may not be registered right after push),
then AMBIGUOUS.
- conclusion != success сразу NOT_DONE (fix the failure, don't wait).
- status in (in_progress, queued, ...) polling loop with
``sleep(CI_POLL_INTERVAL)`` + re-query until completed or timeout.
""" """
head_branch, branch_error = _get_pr_head_branch(pr_number)
if branch_error is not None or head_branch is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, branch_error or "head_branch is None")
config = _load_ci_config() config = _load_ci_config()
return _run_ci_loop(head_branch, config) return _run_ci_loop(pr_number, config)
def _run_ci_loop(head_branch: str, config: CiPollConfig) -> PhaseResult: def _run_ci_loop(pr_number: int, config: CiPollConfig) -> PhaseResult:
"""Initial CI query + edge-case dispatch + delegate to poll/no-runs helpers.""" """Initial CI query + edge-case dispatch + delegate to poll/no-checks helpers."""
kind, runs_str, err = _query_ci_run(head_branch) kind, json_str, err = _query_ci_rollup(pr_number)
if kind == "error": if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err) return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_runs": if kind == "no_checks":
return _retry_no_runs(head_branch, config) return _retry_no_checks(pr_number, config)
status = _extract_json_field_loose(runs_str, "status") return _classify_rollup_with_poll(pr_number, config, json_str)
if status is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось распарсить status CI run")
if status == "completed":
conclusion = _extract_json_field_loose(runs_str, "conclusion")
return _classify_ci_status(status, conclusion)
return _poll_until_done(head_branch, config, runs_str, status)
def _retry_no_runs(head_branch: str, config: CiPollConfig) -> PhaseResult: def _retry_no_checks(pr_number: int, config: CiPollConfig) -> PhaseResult:
"""Retry CI query when no run registered yet (CI may lag after push). """Retry CI query when no checks registered yet (CI may lag after push).
Up to ``CI_NO_RUNS_RETRY`` total attempts (initial + retries), sleeping Up to ``CI_NO_RUNS_RETRY`` total attempts, sleeping ``CI_NO_RUNS_INTERVAL``
``CI_NO_RUNS_INTERVAL`` between attempts. On success classify/poll; between attempts. On success classify/poll; exhausted AMBIGUOUS.
on API error AMBIGUOUS; exhausted AMBIGUOUS.
""" """
for attempt in range(CI_NO_RUNS_RETRY): for attempt in range(CI_NO_RUNS_RETRY):
if attempt > 0: if attempt > 0:
time.sleep(CI_NO_RUNS_INTERVAL) time.sleep(CI_NO_RUNS_INTERVAL)
kind, runs_str, err = _query_ci_run(head_branch) kind, json_str, err = _query_ci_rollup(pr_number)
if kind == "error": if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err) return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "run": if kind == "rollup":
status = _extract_json_field_loose(runs_str, "status") return _classify_rollup_with_poll(pr_number, config, json_str)
if status is None:
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось распарсить status CI run")
if status == "completed":
conclusion = _extract_json_field_loose(runs_str, "conclusion")
return _classify_ci_status(status, conclusion)
return _poll_until_done(head_branch, config, runs_str, status)
return PhaseResult( return PhaseResult(
PhaseStatus.AMBIGUOUS, PhaseStatus.AMBIGUOUS,
f"нет CI run на ветке {head_branch} — возможна проблема триггера", f"нет CI checks на PR #{pr_number} — возможна проблема триггера",
) )
def _poll_until_done( def _classify_rollup_with_poll(pr_number: int, config: CiPollConfig, json_str: str) -> PhaseResult:
head_branch: str, config: CiPollConfig, runs_str: str, last_status: str """Classify rollup; if in_progress → poll until completed or timeout."""
) -> PhaseResult: result = _classify_rollup(json_str)
"""Poll Actions API until status == completed or CI_WAIT_TIMEOUT elapsed. if result.status != PhaseStatus.AMBIGUOUS or "in progress" not in result.detail.lower():
return result
``runs_str``/``last_status`` are the most recent query results (avoids
re-querying immediately). Sleeps ``CI_POLL_INTERVAL`` between queries.
On timeout AMBIGUOUS (CI still running check manually). On completed
classify.
"""
elapsed = 0 elapsed = 0
status = last_status while elapsed < config.wait_timeout:
runs_str_cur = runs_str
while status != "completed" and elapsed < config.wait_timeout:
if elapsed + config.poll_interval > config.wait_timeout: if elapsed + config.poll_interval > config.wait_timeout:
break break
time.sleep(config.poll_interval) time.sleep(config.poll_interval)
elapsed += config.poll_interval elapsed += config.poll_interval
kind, runs_str_new, err = _query_ci_run(head_branch) kind, json_str_new, err = _query_ci_rollup(pr_number)
if kind == "error": if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err) return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_runs": if kind == "no_checks":
return PhaseResult( return PhaseResult(
PhaseStatus.AMBIGUOUS, PhaseStatus.AMBIGUOUS,
f"нет CI run на ветке {head_branch} — возможна проблема триггера", f"нет CI checks на PR #{pr_number} — возможна проблема триггера",
) )
runs_str_cur = runs_str_new result = _classify_rollup(json_str_new)
status_new = _extract_json_field_loose(runs_str_cur, "status") if result.status != PhaseStatus.AMBIGUOUS or "in progress" not in result.detail.lower():
if status_new is None: return result
return PhaseResult(PhaseStatus.AMBIGUOUS, "не удалось распарсить status CI run")
status = status_new
if status == "completed":
conclusion = _extract_json_field_loose(runs_str_cur, "conclusion")
return _classify_ci_status(status, conclusion)
return PhaseResult( return PhaseResult(
PhaseStatus.AMBIGUOUS, PhaseStatus.AMBIGUOUS,
f"CI ещё идёт после {config.wait_timeout}s — проверь вручную: " f"CI ещё идёт после {config.wait_timeout}s — проверь вручную: gh pr checks {pr_number}",
f"gh run view --branch {head_branch}",
) )
def _get_pr_head_branch(pr_number: int) -> tuple[str | None, str | None]: def _query_ci_rollup(pr_number: int) -> tuple[str, str, str]:
"""Return (head_branch, None) or (None, error_message).""" """Query PR statusCheckRollup via gh CLI.
GitHub aggregates ALL checks (CI, CI (always), ADR check) for PR head SHA.
Return ``(kind, json_str, error)`` where ``kind`` is:
- ``"error"`` API call failed (rc != 0).
- ``"no_checks"`` rollup array is empty (no checks registered yet).
- ``"rollup"`` JSON with statusCheckRollup array, ``json_str`` set.
"""
rc, out, err = run_cmd( rc, out, err = run_cmd(
[ [
"gh", "gh",
@ -472,77 +440,49 @@ def _get_pr_head_branch(pr_number: int) -> tuple[str | None, str | None]:
"view", "view",
str(pr_number), str(pr_number),
"--json", "--json",
"headRefName", "statusCheckRollup",
"--repo", "--repo",
get_repo_full_name(), get_repo_full_name(),
] ]
) )
if rc != 0: if rc != 0:
return None, f"не удалось получить ветку PR: {err.strip()}" return "error", "", f"PR API error: {err.strip()}"
head_branch = extract_json_field(out, "headRefName") json_str = out.strip()
if not head_branch: if not json_str:
return None, "не удалось распарсить headRefName PR" return "no_checks", "", ""
return head_branch, None if re.search(r'"statusCheckRollup"\s*:\s*\[\s*\]', json_str):
return "no_checks", "", ""
return "rollup", json_str, ""
def _query_ci_run(head_branch: str) -> tuple[str, str, str]: def _classify_rollup(json_str: str) -> PhaseResult:
"""Query Actions API for latest CI run on ``head_branch``. """Classify CI status from statusCheckRollup JSON.
Return ``(kind, json_str, error)`` where ``kind`` is one of: All checks COMPLETED + SUCCESS/SKIPPED/NEUTRAL DONE.
- ``"error"`` API call failed (rc != 0, e.g. 403), ``error`` set. Any check COMPLETED + non-success conclusion NOT_DONE.
- ``"no_runs"`` jq returned null/empty (no CI run registered yet). Any check IN_PROGRESS/QUEUED/PENDING AMBIGUOUS (poll).
- ``"run"`` JSON with status/conclusion, ``json_str`` set.
""" """
jq_filter = ( statuses = re.findall(r'"status"\s*:\s*"([^"]*)"', json_str, re.IGNORECASE)
f'[.workflow_runs[] | select(.head_branch == "{head_branch}") ' if not statuses:
f'| select(.name == "CI")] | .[0]' return PhaseResult(PhaseStatus.AMBIGUOUS, "no checks found in rollup")
)
rc, out, err = run_cmd(
[
"gh",
"api",
f"repos/{get_repo_full_name()}/actions/runs",
"--jq",
jq_filter,
]
)
if rc != 0:
return "error", "", f"Actions API error: {err.strip()}"
runs_str = out.strip()
if not runs_str or runs_str == "null":
return "no_runs", "", ""
return "run", runs_str, ""
in_progress = [s for s in statuses if s.upper() in ("IN_PROGRESS", "QUEUED", "PENDING")]
if in_progress:
return PhaseResult(PhaseStatus.AMBIGUOUS, "CI in progress")
conclusions = re.findall(r'"conclusion"\s*:\s*"([^"]*)"', json_str, re.IGNORECASE)
null_conclusions = re.findall(r'"conclusion"\s*:\s*null', json_str, re.IGNORECASE)
for c in conclusions:
if c.upper() not in ("SUCCESS", "SKIPPED", "NEUTRAL"):
return PhaseResult(PhaseStatus.NOT_DONE, f"CI {c.lower()} — fix needed")
if null_conclusions:
return PhaseResult(PhaseStatus.AMBIGUOUS, "CI completed but conclusion missing")
def _classify_ci_status(status: str, conclusion: str | None) -> PhaseResult:
"""Map CI status+conclusion to PhaseResult."""
if status != "completed":
return PhaseResult(PhaseStatus.AMBIGUOUS, f"CI {status} — wait")
if conclusion is None:
return PhaseResult(
PhaseStatus.AMBIGUOUS,
"CI completed but conclusion missing",
)
if conclusion != "success":
return PhaseResult(PhaseStatus.NOT_DONE, f"CI {conclusion} — fix needed")
return PhaseResult(PhaseStatus.DONE, "CI green") return PhaseResult(PhaseStatus.DONE, "CI green")
def _extract_json_field_loose(json_str: str, field: str) -> str | None:
"""Extract a JSON string field handling null values (unlike extract_json_field).
``extract_json_field`` uses ``"([^"]*)"`` which never matches ``null``.
This helper accepts both ``"value"`` and ``null`` (returns None for null).
"""
match = re.search(rf'"{field}"\s*:\s*"(?P<v>[^"]*)"', json_str)
if match:
return match.group("v")
null_match = re.search(rf'"{field}"\s*:\s*null', json_str)
if null_match:
return None
return None
def _extract_comment_bodies(json_str: str) -> list[str]: def _extract_comment_bodies(json_str: str) -> list[str]:
"""Extract 'body' fields from gh pr view --json comments output. """Extract 'body' fields from gh pr view --json comments output.

View file

@ -0,0 +1,49 @@
# ADR-054: pipeline-status statusCheckRollup
## Статус
Accepted (2026-07-29)
## Контекст
`check_ci` в `pipeline-status.py` искал CI-ран через `gh api
repos/{org}/{repo}/actions/runs` с jq-фильтром `select(.name == "CI")`.
Этот подход ломается для docs-only PR:
- `ci.yml` имеет `paths-ignore` на `docs/**` — для docs-only PR workflow
`CI` НЕ запускается.
- Запускается только `always-ci.yml` (workflow name `CI (always)`), который
не имеет `paths-ignore`.
- jq-фильтр `select(.name == "CI")` не матчит `CI (always)` → возвращал
`null``_retry_no_runs` перепробовал 3 попытки → AMBIGUOUS
("нет CI run на ветке ...").
- Pipeline застревал на CI-фазе для docs-only PR, хотя CI фактически зелёный.
## Решение
Использовать `gh pr view --json statusCheckRollup` вместо `gh api
.../actions/runs`. `statusCheckRollup` — GitHub-native API, который
агрегирует ВСЕ checks (workflow runs + status checks) по head SHA PR:
- Не зависит от имени workflow (CI, CI (always), ADR check — все в одном
массиве).
- Не требует знания branch name (раньше нужен `gh pr view --json headRefName`
+ `select(.head_branch == ...)`).
- Один вызов `gh` вместо двух (`headRefName` + `actions/runs`).
Классификация `_classify_rollup`: все checks `COMPLETED` +
`SUCCESS`/`SKIPPED`/`NEUTRAL` → DONE; любой check `COMPLETED` с non-success
conclusion → NOT_DONE; любой check `IN_PROGRESS`/`QUEUED`/`PENDING`
AMBIGUOUS (poll до timeout).
## Альтернативы
- **(B) Ослабить jq-фильтр** — убрать `select(.name == "CI")`, брать первый
run на ветке. Отвергнуто: `actions/runs` возвращает runs ВСЕХ workflows на
ветке (включая устаревшие), первый может быть не тем. Также не покрывает
status checks (не-workflow) — `statusCheckRollup` единый источник.
- **(C) SHA + all workflows** — искать runs по head SHA без фильтра по name.
Отвергнуто: та же проблема с выбором "правильного" run из нескольких, плюс
`actions/runs` не включает non-workflow status checks. `statusCheckRollup`
уже агрегирует всё корректно на стороне GitHub.
- **Оставить `actions/runs` + `name=="CI"`** — отвергнуто: баг для docs-only
PR не исправляется, pipeline застревает.

View file

@ -0,0 +1,58 @@
---
pr: 122
title: fix(pipeline-status): use statusCheckRollup instead of actions/runs CI filter
---
## Что сделано
- **CI-проверка переведена с `gh api .../actions/runs` + `name=="CI"` фильтра
на `gh pr view --json statusCheckRollup`** в `.opencode/scripts/pipeline-status.py`.
`statusCheckRollup` агрегирует ВСЕ checks по head SHA PR (CI, CI (always),
ADR check и т.д.) — не зависит от имени workflow.
- **Убраны функции**: `_get_pr_head_branch`, `_query_ci_run`,
`_classify_ci_status`, `_poll_until_done`, `_retry_no_runs`,
`_extract_json_field_loose` (использовалась только в CI-фазе).
- **Добавлены функции**: `_query_ci_rollup` (запрос rollup), `_classify_rollup`
(классификация статусов/conclusions), `_classify_rollup_with_poll`
(polling при IN_PROGRESS/QUEUED/PENDING), `_retry_no_checks` (ререй при
пустом rollup).
- **Тесты переписаны** (`tests/test_pipeline_status_ci.py`): 23 теста под
statusCheckRollup, включая ключевой `test_ci_docs_only_pr` (CI (always) +
ADR check без ci.yml — regression guard для PR#120 bug). Mock-фабрика
`mock_run_cmd_seq` теперь принимает `pr_view_responses` вместо `api_responses`.
- **Docstring модуля** обновлён: CI-фаза описана через statusCheckRollup.
- **Handoff + ADR-054** созданы.
Проверки: `ruff check` OK; `ruff format --check` OK (40 files);
`pytest tests/test_pipeline_status_ci.py tests/test_pipeline_status.py` 107
passed; `check-permissions.py` OK.
## Почему
Docs-only PR (PR#120) не запускают `ci.yml` (paths-ignore), только
`always-ci.yml`. Старый код фильтровал workflow runs по `name=="CI"` → не
находил `CI (always)` → возвращал AMBIGUOUS ("нет CI run на ветке ...").
Pipeline застревал на CI-фазе. `statusCheckRollup` агрегирует все checks по
head SHA — не зависит от имени workflow, корректно обрабатывает docs-only PR.
## Pending
## Watch out
- `_extract_json_field_loose` убрана — использовалась ТОЛЬКО в CI-фазе.
`extract_json_field` (без `_loose`) оставлена — используется в `check_merge`
и `get_pr_title`.
- `JSON_FIELD_RE` (модульная константа) не используется нигде — оставлена как
есть (не входит в scope issue #121, была не используется и до PR).
- `_classify_rollup` считает `SKIPPED` и `NEUTRAL` conclusions успешными
(вместе с `SUCCESS`) — GitHub помечает skipped jobs как SKIPPED, это не
failure.
- `_classify_rollup` использует regex для извлечения `status`/`conclusion`
из JSON (без `json` импорта) — стиль скрипта (см. `extract_json_field`).
Если в JSON появятся поля с такими же именами вне `statusCheckRollup`
возможны ложные matchи; но `gh pr view --json statusCheckRollup` возвращает
только rollup, так что на практике безопасно.
- Polling сообщение изменилось: было `gh run view --branch {head_branch}`,
стало `gh pr checks {pr_number}`.

View file

@ -1,18 +1,18 @@
"""Tests for ``check_ci`` in .opencode/scripts/pipeline-status.py — CI phase. """Tests for ``check_ci`` in .opencode/scripts/pipeline-status.py — CI phase.
Covers Actions API gate via ``gh api repos/<owner>/<repo>/actions/runs``. Covers CI gate via ``gh pr view --json statusCheckRollup`` (aggregates ALL
All gh/git calls are mocked via monkeypatch on the module's ``run_cmd`` checks on PR head SHA CI, CI (always), ADR check, etc.). All gh/git calls
helper. ``time.sleep`` is mocked to no-op via autouse fixture (polling loop are mocked via monkeypatch on the module's ``run_cmd`` helper. ``time.sleep``
would otherwise hang tests for up to 5 minutes). is mocked to no-op via autouse fixture (polling loop would otherwise hang
tests for up to 5 minutes).
Mocking strategy: ``check_ci`` issues calls in sequence: Mocking strategy: ``check_ci`` issues calls in sequence:
1. ``gh pr view N --json headRefName`` returns branch name JSON (once) 1. ``git remote get-url origin`` returns remote URL (for ``get_repo_full_name``)
2. ``git remote get-url origin`` returns remote URL (for ``get_repo_full_name``) 2. ``gh pr view N --json statusCheckRollup`` returns rollup JSON; may be
3. ``gh api repos/<org>/<repo>/actions/runs --jq <filter>`` returns CI run called multiple times (polling loop / no-checks retries).
JSON; may be called multiple times (polling loop / no-runs retries).
``mock_run_cmd`` dispatches by command prefix (single response per prefix). ``mock_run_cmd`` dispatches by command prefix (single response per prefix).
``mock_run_cmd_seq`` supports a sequence of responses for the ``gh api`` ``mock_run_cmd_seq`` supports a sequence of responses for the ``gh pr view``
prefix (consumed in order; last repeats if exhausted) for polling tests. prefix (consumed in order; last repeats if exhausted) for polling tests.
``get_repo_full_name`` is cached via ``functools.cache`` cleared before ``get_repo_full_name`` is cached via ``functools.cache`` cleared before
@ -84,20 +84,18 @@ def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
def mock_run_cmd_seq( def mock_run_cmd_seq(
pr_view: tuple[int, str, str] = (0, '{"headRefName": "feat/test-branch"}', ""), pr_view_responses: list[tuple[int, str, str]] | None = None,
api_responses: list[tuple[int, str, str]] | None = None,
extra: dict[tuple, tuple[int, str, str]] | None = None, extra: dict[tuple, tuple[int, str, str]] | None = None,
): ):
"""Factory: mock run_cmd with sequence of responses for ``gh api``. """Factory: mock run_cmd with sequence of responses for ``gh pr view``.
``gh pr view`` returns ``pr_view`` for every call (single response). ``gh pr view`` returns ``pr_view_responses[i]`` on the i-th call; if
``gh api`` returns ``api_responses[i]`` on the i-th call; if exhausted, exhausted, repeats the last response (so polling loops don't run out of
repeats the last response (so polling loops don't run out of responses responses and hit the "unmocked" fallback). ``extra`` adds fixed overrides
and hit the "unmocked" fallback). ``extra`` adds fixed overrides for for other prefixes (e.g. ``git remote``).
other prefixes (e.g. ``git remote``).
""" """
api_responses = api_responses or [] pr_view_responses = pr_view_responses or []
api_idx = [0] view_idx = [0]
merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **(extra or {})} merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **(extra or {})}
def _mock(args: list[str]) -> tuple[int, str, str]: def _mock(args: list[str]) -> tuple[int, str, str]:
@ -105,13 +103,11 @@ def mock_run_cmd_seq(
if tuple(args[: len(prefix)]) == tuple(prefix): if tuple(args[: len(prefix)]) == tuple(prefix):
return result return result
if tuple(args[:3]) == ("gh", "pr", "view"): if tuple(args[:3]) == ("gh", "pr", "view"):
return pr_view if not pr_view_responses:
if tuple(args[:2]) == ("gh", "api"): return (1, "", "no pr_view_responses configured")
if not api_responses: idx = min(view_idx[0], len(pr_view_responses) - 1)
return (1, "", "no api_responses configured") view_idx[0] += 1
idx = min(api_idx[0], len(api_responses) - 1) return pr_view_responses[idx]
api_idx[0] += 1
return api_responses[idx]
return (1, "", f"unmocked call: {args}") return (1, "", f"unmocked call: {args}")
return _mock return _mock
@ -121,7 +117,7 @@ def mock_run_cmd_seq(
def test_ci_success(monkeypatch): def test_ci_success(monkeypatch):
"""CI run completed & success → DONE, 0 sleeps.""" """Rollup: 2 checks COMPLETED+SUCCESS → DONE, 0 sleeps."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
@ -129,12 +125,10 @@ def test_ci_success(monkeypatch):
{ {
("gh", "pr", "view"): ( ("gh", "pr", "view"): (
0, 0,
'{"headRefName": "feat/test-branch"}', '{"statusCheckRollup": ['
"", '{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
), '{"name": "ADR check", "status": "COMPLETED", "conclusion": "SUCCESS"}'
("gh", "api"): ( "]}",
0,
'{"status": "completed", "conclusion": "success"}',
"", "",
), ),
} }
@ -147,7 +141,7 @@ def test_ci_success(monkeypatch):
def test_ci_failure(monkeypatch): def test_ci_failure(monkeypatch):
"""CI run completed but conclusion=failure → NOT_DONE, 0 sleeps (don't wait for fail).""" """Rollup: 1 check COMPLETED+FAILURE → NOT_DONE, 0 sleeps."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
@ -155,12 +149,9 @@ def test_ci_failure(monkeypatch):
{ {
("gh", "pr", "view"): ( ("gh", "pr", "view"): (
0, 0,
'{"headRefName": "feat/test-branch"}', '{"statusCheckRollup": ['
"", '{"name": "CI", "status": "COMPLETED", "conclusion": "FAILURE"}'
), "]}",
("gh", "api"): (
0,
'{"status": "completed", "conclusion": "failure"}',
"", "",
), ),
} }
@ -172,19 +163,66 @@ def test_ci_failure(monkeypatch):
assert ps._test_sleep_calls == [] # type: ignore[attr-defined] assert ps._test_sleep_calls == [] # type: ignore[attr-defined]
def test_ci_docs_only_pr(monkeypatch):
"""Docs-only PR: CI (always) + ADR check, both SUCCESS → DONE.
Key test for PR#120 bug: docs-only PRs don't trigger ``ci.yml``
(paths-ignore), only ``always-ci.yml``. Old code filtered by
``name=="CI"`` missed AMBIGUOUS. statusCheckRollup aggregates all.
"""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"statusCheckRollup": ['
'{"name": "CI (always)", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
'{"name": "ADR Refs", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
),
}
),
)
result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.DONE
assert "CI green" in result.detail
assert ps._test_sleep_calls == [] # type: ignore[attr-defined]
# ── check_ci — polling loop ──────────────────────────────────────────────────── # ── check_ci — polling loop ────────────────────────────────────────────────────
def test_ci_wait_then_success(monkeypatch): def test_ci_wait_then_success(monkeypatch):
"""in_progress → in_progress → completed+success → DONE, sleep called 2 times.""" """IN_PROGRESS → IN_PROGRESS → COMPLETED+SUCCESS → DONE, sleep called 2 times."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""), (
(0, '{"status": "in_progress", "conclusion": null}', ""), 0,
(0, '{"status": "completed", "conclusion": "success"}', ""), '{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
),
], ],
), ),
) )
@ -195,14 +233,20 @@ def test_ci_wait_then_success(monkeypatch):
def test_ci_wait_timeout(monkeypatch): def test_ci_wait_timeout(monkeypatch):
"""All calls in_progress, timeout 1s via env → AMBIGUOUS with "после 1s".""" """All calls IN_PROGRESS, timeout 1s via env → AMBIGUOUS with "после 1s"."""
monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
], ],
), ),
) )
@ -219,9 +263,21 @@ def test_ci_wait_poll_interval(monkeypatch):
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""), (
(0, '{"status": "completed", "conclusion": "success"}', ""), 0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
),
], ],
), ),
) )
@ -231,14 +287,20 @@ def test_ci_wait_poll_interval(monkeypatch):
def test_ci_in_progress(monkeypatch): def test_ci_in_progress(monkeypatch):
"""status=in_progress, all polls in_progress, timeout 1s → AMBIGUOUS.""" """IN_PROGRESS, all polls IN_PROGRESS, timeout 1s → AMBIGUOUS."""
monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
], ],
), ),
) )
@ -248,14 +310,20 @@ def test_ci_in_progress(monkeypatch):
def test_ci_queued(monkeypatch): def test_ci_queued(monkeypatch):
"""status=queued, all polls in_progress, timeout 1s → AMBIGUOUS.""" """QUEUED, all polls IN_PROGRESS, timeout 1s → AMBIGUOUS."""
monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1") monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "queued", "conclusion": null}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "QUEUED", "conclusion": null}'
"]}",
"",
),
], ],
), ),
) )
@ -264,19 +332,25 @@ def test_ci_queued(monkeypatch):
assert "после 1s" in result.detail assert "после 1s" in result.detail
# ── check_ci — no runs (short retry) ─────────────────────────────────────────── # ── check_ci — no checks (short retry) ─────────────────────────────────────────
def test_ci_no_runs_retry_then_appears(monkeypatch): def test_ci_no_checks_retry_then_appears(monkeypatch):
"""1st call null, 2nd null, 3rd success → DONE (after CI_NO_RUNS_RETRY retries).""" """1st empty, 2nd empty, 3rd SUCCESS → DONE (after CI_NO_RUNS_RETRY retries)."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, "null", ""), (0, '{"statusCheckRollup": []}', ""),
(0, "null", ""), (0, '{"statusCheckRollup": []}', ""),
(0, '{"status": "completed", "conclusion": "success"}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
),
], ],
), ),
) )
@ -285,59 +359,49 @@ def test_ci_no_runs_retry_then_appears(monkeypatch):
assert "CI green" in result.detail assert "CI green" in result.detail
def test_ci_no_runs_retry_exhausted(monkeypatch): def test_ci_no_checks_retry_exhausted(monkeypatch):
"""All calls null, CI_NO_RUNS_RETRY=3 → AMBIGUOUS.""" """All calls empty, CI_NO_RUNS_RETRY=3 → AMBIGUOUS."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, "null", ""), (0, '{"statusCheckRollup": []}', ""),
], ],
), ),
) )
result = ps.check_ci(46) result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "нет CI run" in result.detail assert "нет CI checks" in result.detail
def test_ci_no_runs(monkeypatch): def test_ci_no_checks(monkeypatch):
"""No CI run (jq null) with single response → AMBIGUOUS (retry exhausted).""" """Empty rollup with single response → AMBIGUOUS (retry exhausted)."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd( mock_run_cmd(
{ {
("gh", "pr", "view"): ( ("gh", "pr", "view"): (0, '{"statusCheckRollup": []}', ""),
0,
'{"headRefName": "feat/test-branch"}',
"",
),
("gh", "api"): (0, "null", ""),
} }
), ),
) )
result = ps.check_ci(46) result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "нет CI run" in result.detail assert "нет CI checks" in result.detail
# ── check_ci — API error (no retry) ──────────────────────────────────────────── # ── check_ci — API error (no retry) ────────────────────────────────────────────
def test_ci_api_error(monkeypatch): def test_ci_api_error(monkeypatch):
"""Actions API returns rc=1 (403) → AMBIGUOUS, no retries/sleeps.""" """gh pr view returns rc=1 (403) → AMBIGUOUS, no retries/sleeps."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd( mock_run_cmd(
{ {
("gh", "pr", "view"): ( ("gh", "pr", "view"): (
0,
'{"headRefName": "feat/test-branch"}',
"",
),
("gh", "api"): (
1, 1,
"", "",
"HTTP 403: Forbidden", "HTTP 403: Forbidden",
@ -347,7 +411,7 @@ def test_ci_api_error(monkeypatch):
) )
result = ps.check_ci(46) result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "Actions API" in result.detail assert "PR API error" in result.detail
assert ps._test_sleep_calls == [] # type: ignore[attr-defined] assert ps._test_sleep_calls == [] # type: ignore[attr-defined]
@ -357,18 +421,75 @@ def test_ci_api_error_no_retry(monkeypatch):
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(1, "", "HTTP 403: Forbidden"), (1, "", "HTTP 403: Forbidden"),
(0, '{"status": "completed", "conclusion": "success"}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
),
], ],
), ),
) )
result = ps.check_ci(46) result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "Actions API" in result.detail assert "PR API error" in result.detail
assert ps._test_sleep_calls == [] # type: ignore[attr-defined] assert ps._test_sleep_calls == [] # type: ignore[attr-defined]
# ── check_ci — partial status (multiple checks) ───────────────────────────────
def test_ci_partial_failure(monkeypatch):
"""1 SUCCESS + 1 FAILURE → NOT_DONE (any failure blocks)."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
'{"name": "ADR check", "status": "COMPLETED", "conclusion": "FAILURE"}'
"]}",
"",
),
}
),
)
result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "failure" in result.detail
assert ps._test_sleep_calls == [] # type: ignore[attr-defined]
def test_ci_partial_in_progress(monkeypatch):
"""1 COMPLETED+SUCCESS + 1 IN_PROGRESS → AMBIGUOUS (poll)."""
monkeypatch.setenv("OPENCODE_CI_WAIT_TIMEOUT", "1")
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd_seq(
pr_view_responses=[
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
'{"name": "ADR check", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
],
),
)
result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "после 1s" in result.detail
# ── check_ci — config priority (CLI > env > constant) ────────────────────────── # ── check_ci — config priority (CLI > env > constant) ──────────────────────────
@ -379,8 +500,14 @@ def test_ci_wait_env_var_override(monkeypatch):
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
], ],
), ),
) )
@ -401,8 +528,14 @@ def test_ci_wait_cli_flag_overrides_env(monkeypatch):
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
], ],
), ),
) )
@ -412,13 +545,19 @@ def test_ci_wait_cli_flag_overrides_env(monkeypatch):
def test_ci_wait_failure_no_wait(monkeypatch): def test_ci_wait_failure_no_wait(monkeypatch):
"""1st call completed+failure → сразу NOT_DONE, 0 sleeps (don't wait for fail).""" """1st call COMPLETED+FAILURE → сразу NOT_DONE, 0 sleeps (don't wait for fail)."""
monkeypatch.setattr( monkeypatch.setattr(
ps, ps,
"run_cmd", "run_cmd",
mock_run_cmd_seq( mock_run_cmd_seq(
api_responses=[ pr_view_responses=[
(0, '{"status": "completed", "conclusion": "failure"}', ""), (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "FAILURE"}'
"]}",
"",
),
], ],
), ),
) )
@ -435,8 +574,8 @@ def test_ci_uses_dynamic_repo_full_name(monkeypatch):
"""``get_repo_full_name`` is derived from ``git remote``, not hardcoded. """``get_repo_full_name`` is derived from ``git remote``, not hardcoded.
Mocks a non-opencode remote (``slaid098/media-gen``) and asserts that the Mocks a non-opencode remote (``slaid098/media-gen``) and asserts that the
``gh api`` call uses ``repos/slaid098/media-gen/actions/runs`` (dynamic), ``gh pr view`` call uses ``--repo slaid098/media-gen`` (dynamic), not a
not a hardcoded ``slaid098/opencode-config``. hardcoded ``slaid098/opencode-config``.
""" """
captured_args: list[list[str]] = [] captured_args: list[list[str]] = []
@ -445,19 +584,24 @@ def test_ci_uses_dynamic_repo_full_name(monkeypatch):
if tuple(args[:3]) == ("git", "remote", "get-url"): if tuple(args[:3]) == ("git", "remote", "get-url"):
return (0, "https://github.com/slaid098/media-gen.git\n", "") return (0, "https://github.com/slaid098/media-gen.git\n", "")
if tuple(args[:3]) == ("gh", "pr", "view"): if tuple(args[:3]) == ("gh", "pr", "view"):
return (0, '{"headRefName": "feat/test-branch"}', "") return (
if tuple(args[:2]) == ("gh", "api"): 0,
return (0, '{"status": "completed", "conclusion": "success"}', "") '{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
)
return (1, "", f"unmocked call: {args}") return (1, "", f"unmocked call: {args}")
monkeypatch.setattr(ps, "run_cmd", _capture_mock) monkeypatch.setattr(ps, "run_cmd", _capture_mock)
result = ps.check_ci(46) result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.DONE assert result.status == ps.PhaseStatus.DONE
api_calls = [a for a in captured_args if a[:2] == ["gh", "api"]] pr_view_calls = [a for a in captured_args if a[:3] == ["gh", "pr", "view"]]
assert len(api_calls) == 1 assert len(pr_view_calls) == 1
assert "repos/slaid098/media-gen/actions/runs" in " ".join(api_calls[0]) assert "--repo" in pr_view_calls[0]
assert "slaid098/opencode-config" not in " ".join(api_calls[0]) assert "slaid098/media-gen" in " ".join(pr_view_calls[0])
assert "slaid098/opencode-config" not in " ".join(pr_view_calls[0])
# ── _load_ci_config — priority ──────────────────────────────────────────────── # ── _load_ci_config — priority ────────────────────────────────────────────────