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
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
(transitive guard first phase blocks all subsequent phases).
for the PR pipeline. CI gate via ``gh pr view --json statusCheckRollup`` (read-only):
CI blocks MERGE (transitive guard first phase blocks all subsequent phases).
Usage:
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
2. IMPLEMENT PR exists + handoff file docs/handoff/pr-N-slug.md in diff
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
6. MERGE PR state is MERGED
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:
"""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
``status == completed`` or ``CI_WAIT_TIMEOUT`` elapsed. One tool call
final status (DONE on green, NOT_DONE on failure, AMBIGUOUS on
timeout / API error).
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.
Uses ``gh pr view --json statusCheckRollup`` which aggregates ALL
workflows for the PR head SHA (CI, CI (always), ADR check, etc.).
This handles docs-only PRs where ``ci.yml`` has ``paths-ignore`` and
only ``always-ci.yml`` runs.
"""
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()
return _run_ci_loop(head_branch, config)
return _run_ci_loop(pr_number, config)
def _run_ci_loop(head_branch: str, config: CiPollConfig) -> PhaseResult:
"""Initial CI query + edge-case dispatch + delegate to poll/no-runs helpers."""
kind, runs_str, err = _query_ci_run(head_branch)
def _run_ci_loop(pr_number: int, config: CiPollConfig) -> PhaseResult:
"""Initial CI query + edge-case dispatch + delegate to poll/no-checks helpers."""
kind, json_str, err = _query_ci_rollup(pr_number)
if kind == "error":
return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_runs":
return _retry_no_runs(head_branch, config)
status = _extract_json_field_loose(runs_str, "status")
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)
if kind == "no_checks":
return _retry_no_checks(pr_number, config)
return _classify_rollup_with_poll(pr_number, config, json_str)
def _retry_no_runs(head_branch: str, config: CiPollConfig) -> PhaseResult:
"""Retry CI query when no run registered yet (CI may lag after push).
def _retry_no_checks(pr_number: int, config: CiPollConfig) -> PhaseResult:
"""Retry CI query when no checks registered yet (CI may lag after push).
Up to ``CI_NO_RUNS_RETRY`` total attempts (initial + retries), sleeping
``CI_NO_RUNS_INTERVAL`` between attempts. On success classify/poll;
on API error AMBIGUOUS; exhausted AMBIGUOUS.
Up to ``CI_NO_RUNS_RETRY`` total attempts, sleeping ``CI_NO_RUNS_INTERVAL``
between attempts. On success classify/poll; exhausted AMBIGUOUS.
"""
for attempt in range(CI_NO_RUNS_RETRY):
if attempt > 0:
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":
return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "run":
status = _extract_json_field_loose(runs_str, "status")
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)
if kind == "rollup":
return _classify_rollup_with_poll(pr_number, config, json_str)
return PhaseResult(
PhaseStatus.AMBIGUOUS,
f"нет CI run на ветке {head_branch} — возможна проблема триггера",
f"нет CI checks на PR #{pr_number} — возможна проблема триггера",
)
def _poll_until_done(
head_branch: str, config: CiPollConfig, runs_str: str, last_status: str
) -> PhaseResult:
"""Poll Actions API until status == completed or CI_WAIT_TIMEOUT elapsed.
``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.
"""
def _classify_rollup_with_poll(pr_number: int, config: CiPollConfig, json_str: str) -> PhaseResult:
"""Classify rollup; if in_progress → poll until completed or timeout."""
result = _classify_rollup(json_str)
if result.status != PhaseStatus.AMBIGUOUS or "in progress" not in result.detail.lower():
return result
elapsed = 0
status = last_status
runs_str_cur = runs_str
while status != "completed" and elapsed < config.wait_timeout:
while elapsed < config.wait_timeout:
if elapsed + config.poll_interval > config.wait_timeout:
break
time.sleep(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":
return PhaseResult(PhaseStatus.AMBIGUOUS, err)
if kind == "no_runs":
if kind == "no_checks":
return PhaseResult(
PhaseStatus.AMBIGUOUS,
f"нет CI run на ветке {head_branch} — возможна проблема триггера",
f"нет CI checks на PR #{pr_number} — возможна проблема триггера",
)
runs_str_cur = runs_str_new
status_new = _extract_json_field_loose(runs_str_cur, "status")
if status_new is None:
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)
result = _classify_rollup(json_str_new)
if result.status != PhaseStatus.AMBIGUOUS or "in progress" not in result.detail.lower():
return result
return PhaseResult(
PhaseStatus.AMBIGUOUS,
f"CI ещё идёт после {config.wait_timeout}s — проверь вручную: "
f"gh run view --branch {head_branch}",
f"CI ещё идёт после {config.wait_timeout}s — проверь вручную: gh pr checks {pr_number}",
)
def _get_pr_head_branch(pr_number: int) -> tuple[str | None, str | None]:
"""Return (head_branch, None) or (None, error_message)."""
def _query_ci_rollup(pr_number: int) -> tuple[str, str, str]:
"""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(
[
"gh",
@ -472,77 +440,49 @@ def _get_pr_head_branch(pr_number: int) -> tuple[str | None, str | None]:
"view",
str(pr_number),
"--json",
"headRefName",
"statusCheckRollup",
"--repo",
get_repo_full_name(),
]
)
if rc != 0:
return None, f"не удалось получить ветку PR: {err.strip()}"
head_branch = extract_json_field(out, "headRefName")
if not head_branch:
return None, "не удалось распарсить headRefName PR"
return head_branch, None
return "error", "", f"PR API error: {err.strip()}"
json_str = out.strip()
if not json_str:
return "no_checks", "", ""
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]:
"""Query Actions API for latest CI run on ``head_branch``.
def _classify_rollup(json_str: str) -> PhaseResult:
"""Classify CI status from statusCheckRollup JSON.
Return ``(kind, json_str, error)`` where ``kind`` is one of:
- ``"error"`` API call failed (rc != 0, e.g. 403), ``error`` set.
- ``"no_runs"`` jq returned null/empty (no CI run registered yet).
- ``"run"`` JSON with status/conclusion, ``json_str`` set.
All checks COMPLETED + SUCCESS/SKIPPED/NEUTRAL DONE.
Any check COMPLETED + non-success conclusion NOT_DONE.
Any check IN_PROGRESS/QUEUED/PENDING AMBIGUOUS (poll).
"""
jq_filter = (
f'[.workflow_runs[] | select(.head_branch == "{head_branch}") '
f'| select(.name == "CI")] | .[0]'
)
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, ""
statuses = re.findall(r'"status"\s*:\s*"([^"]*)"', json_str, re.IGNORECASE)
if not statuses:
return PhaseResult(PhaseStatus.AMBIGUOUS, "no checks found in rollup")
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")
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]:
"""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.
Covers Actions API gate via ``gh api repos/<owner>/<repo>/actions/runs``.
All gh/git calls are mocked via monkeypatch on the module's ``run_cmd``
helper. ``time.sleep`` is mocked to no-op via autouse fixture (polling loop
would otherwise hang tests for up to 5 minutes).
Covers CI gate via ``gh pr view --json statusCheckRollup`` (aggregates ALL
checks on PR head SHA CI, CI (always), ADR check, etc.). All gh/git calls
are mocked via monkeypatch on the module's ``run_cmd`` helper. ``time.sleep``
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:
1. ``gh pr view N --json headRefName`` returns branch name JSON (once)
2. ``git remote get-url origin`` returns remote URL (for ``get_repo_full_name``)
3. ``gh api repos/<org>/<repo>/actions/runs --jq <filter>`` returns CI run
JSON; may be called multiple times (polling loop / no-runs retries).
1. ``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
called multiple times (polling loop / no-checks retries).
``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.
``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(
pr_view: tuple[int, str, str] = (0, '{"headRefName": "feat/test-branch"}', ""),
api_responses: list[tuple[int, str, str]] | None = None,
pr_view_responses: list[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 api`` returns ``api_responses[i]`` on the i-th call; if exhausted,
repeats the last response (so polling loops don't run out of responses
and hit the "unmocked" fallback). ``extra`` adds fixed overrides for
other prefixes (e.g. ``git remote``).
``gh pr view`` returns ``pr_view_responses[i]`` on the i-th call; if
exhausted, repeats the last response (so polling loops don't run out of
responses and hit the "unmocked" fallback). ``extra`` adds fixed overrides
for other prefixes (e.g. ``git remote``).
"""
api_responses = api_responses or []
api_idx = [0]
pr_view_responses = pr_view_responses or []
view_idx = [0]
merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **(extra or {})}
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):
return result
if tuple(args[:3]) == ("gh", "pr", "view"):
return pr_view
if tuple(args[:2]) == ("gh", "api"):
if not api_responses:
return (1, "", "no api_responses configured")
idx = min(api_idx[0], len(api_responses) - 1)
api_idx[0] += 1
return api_responses[idx]
if not pr_view_responses:
return (1, "", "no pr_view_responses configured")
idx = min(view_idx[0], len(pr_view_responses) - 1)
view_idx[0] += 1
return pr_view_responses[idx]
return (1, "", f"unmocked call: {args}")
return _mock
@ -121,7 +117,7 @@ def mock_run_cmd_seq(
def test_ci_success(monkeypatch):
"""CI run completed & success → DONE, 0 sleeps."""
"""Rollup: 2 checks COMPLETED+SUCCESS → DONE, 0 sleeps."""
monkeypatch.setattr(
ps,
"run_cmd",
@ -129,12 +125,10 @@ def test_ci_success(monkeypatch):
{
("gh", "pr", "view"): (
0,
'{"headRefName": "feat/test-branch"}',
"",
),
("gh", "api"): (
0,
'{"status": "completed", "conclusion": "success"}',
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}, '
'{"name": "ADR check", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
),
}
@ -147,7 +141,7 @@ def test_ci_success(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(
ps,
"run_cmd",
@ -155,12 +149,9 @@ def test_ci_failure(monkeypatch):
{
("gh", "pr", "view"): (
0,
'{"headRefName": "feat/test-branch"}',
"",
),
("gh", "api"): (
0,
'{"status": "completed", "conclusion": "failure"}',
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "FAILURE"}'
"]}",
"",
),
}
@ -172,19 +163,66 @@ def test_ci_failure(monkeypatch):
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 ────────────────────────────────────────────────────
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(
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""),
(0, '{"status": "in_progress", "conclusion": null}', ""),
(0, '{"status": "completed", "conclusion": "success"}', ""),
pr_view_responses=[
(
0,
'{"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):
"""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.setattr(
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""),
pr_view_responses=[
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
],
),
)
@ -219,9 +263,21 @@ def test_ci_wait_poll_interval(monkeypatch):
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""),
(0, '{"status": "completed", "conclusion": "success"}', ""),
pr_view_responses=[
(
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):
"""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.setattr(
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""),
pr_view_responses=[
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
],
),
)
@ -248,14 +310,20 @@ def test_ci_in_progress(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.setattr(
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "queued", "conclusion": null}', ""),
pr_view_responses=[
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "QUEUED", "conclusion": null}'
"]}",
"",
),
],
),
)
@ -264,19 +332,25 @@ def test_ci_queued(monkeypatch):
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):
"""1st call null, 2nd null, 3rd success → DONE (after CI_NO_RUNS_RETRY retries)."""
def test_ci_no_checks_retry_then_appears(monkeypatch):
"""1st empty, 2nd empty, 3rd SUCCESS → DONE (after CI_NO_RUNS_RETRY retries)."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, "null", ""),
(0, "null", ""),
(0, '{"status": "completed", "conclusion": "success"}', ""),
pr_view_responses=[
(0, '{"statusCheckRollup": []}', ""),
(0, '{"statusCheckRollup": []}', ""),
(
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
def test_ci_no_runs_retry_exhausted(monkeypatch):
"""All calls null, CI_NO_RUNS_RETRY=3 → AMBIGUOUS."""
def test_ci_no_checks_retry_exhausted(monkeypatch):
"""All calls empty, CI_NO_RUNS_RETRY=3 → AMBIGUOUS."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, "null", ""),
pr_view_responses=[
(0, '{"statusCheckRollup": []}', ""),
],
),
)
result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "нет CI run" in result.detail
assert "нет CI checks" in result.detail
def test_ci_no_runs(monkeypatch):
"""No CI run (jq null) with single response → AMBIGUOUS (retry exhausted)."""
def test_ci_no_checks(monkeypatch):
"""Empty rollup with single response → AMBIGUOUS (retry exhausted)."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"headRefName": "feat/test-branch"}',
"",
),
("gh", "api"): (0, "null", ""),
("gh", "pr", "view"): (0, '{"statusCheckRollup": []}', ""),
}
),
)
result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "нет CI run" in result.detail
assert "нет CI checks" in result.detail
# ── check_ci — API error (no retry) ────────────────────────────────────────────
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(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"headRefName": "feat/test-branch"}',
"",
),
("gh", "api"): (
1,
"",
"HTTP 403: Forbidden",
@ -347,7 +411,7 @@ def test_ci_api_error(monkeypatch):
)
result = ps.check_ci(46)
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]
@ -357,18 +421,75 @@ def test_ci_api_error_no_retry(monkeypatch):
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
pr_view_responses=[
(1, "", "HTTP 403: Forbidden"),
(0, '{"status": "completed", "conclusion": "success"}', ""),
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
),
],
),
)
result = ps.check_ci(46)
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]
# ── 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) ──────────────────────────
@ -379,8 +500,14 @@ def test_ci_wait_env_var_override(monkeypatch):
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""),
pr_view_responses=[
(
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "IN_PROGRESS", "conclusion": null}'
"]}",
"",
),
],
),
)
@ -401,8 +528,14 @@ def test_ci_wait_cli_flag_overrides_env(monkeypatch):
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "in_progress", "conclusion": null}', ""),
pr_view_responses=[
(
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):
"""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(
ps,
"run_cmd",
mock_run_cmd_seq(
api_responses=[
(0, '{"status": "completed", "conclusion": "failure"}', ""),
pr_view_responses=[
(
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.
Mocks a non-opencode remote (``slaid098/media-gen``) and asserts that the
``gh api`` call uses ``repos/slaid098/media-gen/actions/runs`` (dynamic),
not a hardcoded ``slaid098/opencode-config``.
``gh pr view`` call uses ``--repo slaid098/media-gen`` (dynamic), not a
hardcoded ``slaid098/opencode-config``.
"""
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"):
return (0, "https://github.com/slaid098/media-gen.git\n", "")
if tuple(args[:3]) == ("gh", "pr", "view"):
return (0, '{"headRefName": "feat/test-branch"}', "")
if tuple(args[:2]) == ("gh", "api"):
return (0, '{"status": "completed", "conclusion": "success"}', "")
return (
0,
'{"statusCheckRollup": ['
'{"name": "CI", "status": "COMPLETED", "conclusion": "SUCCESS"}'
"]}",
"",
)
return (1, "", f"unmocked call: {args}")
monkeypatch.setattr(ps, "run_cmd", _capture_mock)
result = ps.check_ci(46)
assert result.status == ps.PhaseStatus.DONE
api_calls = [a for a in captured_args if a[:2] == ["gh", "api"]]
assert len(api_calls) == 1
assert "repos/slaid098/media-gen/actions/runs" in " ".join(api_calls[0])
assert "slaid098/opencode-config" not in " ".join(api_calls[0])
pr_view_calls = [a for a in captured_args if a[:3] == ["gh", "pr", "view"]]
assert len(pr_view_calls) == 1
assert "--repo" in pr_view_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 ────────────────────────────────────────────────