diff --git a/.opencode/scripts/pipeline-status.py b/.opencode/scripts/pipeline-status.py index 2d999e4..6b518d4 100644 --- a/.opencode/scripts/pipeline-status.py +++ b/.opencode/scripts/pipeline-status.py @@ -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 # 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[^"]*)"', 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. diff --git a/docs/decisions/054-pr-122-pipeline-status-statuscheckrollup.md b/docs/decisions/054-pr-122-pipeline-status-statuscheckrollup.md new file mode 100644 index 0000000..cf55f32 --- /dev/null +++ b/docs/decisions/054-pr-122-pipeline-status-statuscheckrollup.md @@ -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 застревает. \ No newline at end of file diff --git a/docs/handoff/pr-122-pipeline-status-statuscheckrollup.md b/docs/handoff/pr-122-pipeline-status-statuscheckrollup.md new file mode 100644 index 0000000..e7cca94 --- /dev/null +++ b/docs/handoff/pr-122-pipeline-status-statuscheckrollup.md @@ -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}`. \ No newline at end of file diff --git a/tests/test_pipeline_status_ci.py b/tests/test_pipeline_status_ci.py index 26a516e..59829ec 100644 --- a/tests/test_pipeline_status_ci.py +++ b/tests/test_pipeline_status_ci.py @@ -1,18 +1,18 @@ """Tests for ``check_ci`` in .opencode/scripts/pipeline-status.py — CI phase. -Covers Actions API gate via ``gh api repos///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///actions/runs --jq `` → 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 ────────────────────────────────────────────────