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:
parent
c9533050b8
commit
d724e29f96
4 changed files with 429 additions and 238 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 застревает.
|
||||||
58
docs/handoff/pr-122-pipeline-status-statuscheckrollup.md
Normal file
58
docs/handoff/pr-122-pipeline-status-statuscheckrollup.md
Normal 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}`.
|
||||||
|
|
@ -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 ────────────────────────────────────────────────
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue