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
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
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 ────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue