opencode-config/tests/test_pipeline_status.py
Sergey 596aa9b18a
fix(pipeline-status): scan all rotated memory files in check_memory (#245)
* refactor(pipeline-status): split get_memory_file_path into _resolve_memory_base + get_memory_files

* test(pipeline-status): update check_memory tests for multi-file scan

* test(pipeline-status): add rotation/mtime/glob coverage

* fix(ci): move os/time imports to top-level in test_pipeline_status

---------

Co-authored-by: opencode-agent <agent@opencode.local>
2026-08-03 19:10:18 +03:00

1170 lines
43 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for .opencode/scripts/pipeline-status.py — pipeline oracle.
All gh/git calls are mocked via monkeypatch on the module's ``run_cmd``
helper. Filesystem checks (PR body headings, memory) use tmp_path.
``get_repo_full_name`` is cached via ``functools.cache`` and now called by
every ``gh pr view``/``gh pr list``/``gh issue view`` site (``--repo`` flag,
PR#101). The ``_clear_repo_cache`` autouse fixture clears the cache before
and after each test; ``mock_run_cmd`` automatically includes a ``git remote
get-url origin`` mock (``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works
without extra boilerplate in every test.
"""
import importlib.util
import json
import os
import sys
import time
from pathlib import Path
import pytest
SCRIPT_PATH = (
Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "pipeline-status.py"
)
spec = importlib.util.spec_from_file_location("pipeline_status", SCRIPT_PATH)
ps = importlib.util.module_from_spec(spec)
sys.modules["pipeline_status"] = ps
spec.loader.exec_module(ps)
GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = (
("git", "remote"),
(0, "https://github.com/slaid098/opencode-config.git\n", ""),
)
@pytest.fixture(autouse=True)
def _clear_repo_cache():
"""Clear ``functools.cache`` on ``get_repo_full_name`` before/after each test.
``get_repo_full_name`` is now invoked by every ``gh pr view``/``gh pr list``/
``gh issue view`` call site (``--repo`` flag, PR#101). Without cache_clear,
a test that mocks ``git remote`` first would leak its cached value into the
next test.
"""
ps.get_repo_full_name.cache_clear()
yield
ps.get_repo_full_name.cache_clear()
def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
"""Factory: mock run_cmd matching by command prefix.
Keys are tuples of leading args (e.g. ("gh", "pr", "view")),
values are (returncode, stdout, stderr).
Automatically includes a ``git remote get-url origin`` mock
(``GIT_REMOTE_MOCK``) so ``get_repo_full_name()`` works without extra
boilerplate in every test.
"""
merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses}
def _mock(args: list[str]) -> tuple[int, str, str]:
for prefix, result in merged.items():
if tuple(args[: len(prefix)]) == tuple(prefix):
return result
return (1, "", f"unmocked call: {args}")
return _mock
# ── parse_remote_url ────────────────────────────────────────────────────────
def test_parse_remote_url_https():
assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_https_no_git_suffix():
assert ps.parse_remote_url("https://github.com/slaid098/opencode-config") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_ssh():
assert ps.parse_remote_url("git@github.com:slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_ssh_no_git_suffix():
assert ps.parse_remote_url("git@github.com:slaid098/opencode-config") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_media_gen_https():
"""parse_remote_url для не-opencode репо (HTTPS)."""
assert ps.parse_remote_url("https://github.com/slaid098/media-gen.git") == (
"github.com",
"slaid098",
"media-gen",
)
def test_parse_remote_url_mediakit_ssh():
"""parse_remote_url для не-opencode репо (SSH, другой org)."""
assert ps.parse_remote_url("git@github.com:anomaly/mediakit.git") == (
"github.com",
"anomaly",
"mediakit",
)
def test_parse_remote_url_invalid():
with pytest.raises(ValueError, match="Cannot parse remote URL"):
ps.parse_remote_url("not-a-valid-url")
def test_parse_remote_url_https_with_userinfo():
"""URL с git insteadOf userinfo (x-access-token:TOKEN@host) — host без userinfo.
Regression for PR#53/ADR-022: ``git config url.insteadOf`` rewrites
``https://github.com/`` to ``https://x-access-token:TOKEN@github.com/``,
so ``git remote get-url origin`` returns the rewritten URL. The old regex
``[^/]+`` greedily matched ``x-access-token:TOKEN@github.com`` as host,
breaking ``get_memory_file_path`` and ``check_memory``.
"""
assert ps.parse_remote_url(
"https://x-access-token:github_pat_TOKEN@github.com/slaid098/opencode-config.git"
) == ("github.com", "slaid098", "opencode-config")
def test_parse_remote_url_https_without_userinfo():
"""Обычный HTTPS URL без userinfo работает (regression guard).
The ``(?:[^/@]*@)?`` optional userinfo group must NOT break the plain-URL
case — ``host`` stays ``github.com`` with no userinfo to skip.
"""
assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
"github.com",
"slaid098",
"opencode-config",
)
def test_parse_remote_url_https_userinfo_no_git_suffix():
"""URL с userinfo и без .git suffix — опциональный .git работает с userinfo."""
assert ps.parse_remote_url(
"https://x-access-token:TOKEN@github.com/slaid098/opencode-config"
) == ("github.com", "slaid098", "opencode-config")
# ── check_issue ──────────────────────────────────────────────────────────────
def test_check_issue_done(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (0, '{"body": "Closes #45"}', ""),
("gh", "issue", "view"): (0, "issue body", ""),
}
),
)
result = ps.check_issue(46)
assert result.status == ps.PhaseStatus.DONE
assert "#45" in result.detail
def test_check_issue_not_done_no_closes(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, '{"body": "no closure"}', "")}),
)
result = ps.check_issue(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "Closes" in result.detail
def test_check_issue_not_done_pr_missing(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (1, "", "not found")}),
)
result = ps.check_issue(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "не существует" in result.detail
def test_check_issue_ambiguous_multiple(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (0, '{"body": "Closes #45 Fixes #47"}', ""),
("gh", "issue", "view"): (0, "issue", ""),
}
),
)
result = ps.check_issue(46)
assert result.status == ps.PhaseStatus.AMBIGUOUS
assert "несколько issue" in result.detail
def test_check_issue_not_done_issue_missing(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (0, '{"body": "Closes #45"}', ""),
("gh", "issue", "view"): (1, "", "not found"),
}
),
)
result = ps.check_issue(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "не существует" in result.detail
# ── check_implement ──────────────────────────────────────────────────────────
def test_check_implement_done(monkeypatch):
"""PR body with all 4 required headings → DONE."""
body = "## Что сделано\n...\n\n## Почему\n...\n\n## Watch out\n...\n\n## Pending\n..."
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
)
result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.DONE
assert "heading" in result.detail
def test_check_implement_not_done_missing_headings(monkeypatch):
"""PR body missing required headings → NOT_DONE."""
body = "## Что сделано\n...\n\n## Почему\n..."
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
)
result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "Watch out" in result.detail
assert "Pending" in result.detail
def test_check_implement_not_done_pr_missing(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (1, "", "not found")}),
)
result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.NOT_DONE
def test_check_implement_done_with_quotes_in_body(monkeypatch):
"""PR body with escaped quotes (regression for PR#208 / json.loads fix).
Old regex ``"([^"]*)"`` stopped at the first escaped quote ``\\"`` inside
body, truncating the parsed body and breaking heading detection even when
the PR body was valid. ``json.loads`` correctly unescapes ``\\"`` → ``"``.
"""
body = (
'## Что сделано\nfeat: "quoted" text\n\n## Почему\nreason\n'
"\n## Watch out\nx\n\n## Pending\ny"
)
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, json.dumps({"body": body}), "")}),
)
result = ps.check_implement(208)
assert result.status == ps.PhaseStatus.DONE
assert "heading" in result.detail
def test_check_implement_not_done_invalid_json(monkeypatch):
"""Invalid JSON output → NOT_DONE with parse error (not silent empty body)."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, "not valid json{", "")}),
)
result = ps.check_implement(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "распарсить" in result.detail
# ── check_review ─────────────────────────────────────────────────────────────
def test_check_review_done(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Code Review Summary\\n\\n### Verdict: APPROVE"}]}',
"",
),
}
),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.DONE
assert "APPROVE" in result.detail
def test_check_review_not_done(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, '{"comments": [{"body": "changes needed"}]}', "")}),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.NOT_DONE
def test_check_review_not_done_error(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (1, "", "error")}),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.NOT_DONE
def test_check_review_false_positive_unrelated_summary_comment(monkeypatch):
"""Unrelated review-summary comment must NOT trigger check_review DONE."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Docs Review Summary\\n\\n### Verdict: APPROVE"}]}',
"",
),
}
),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.NOT_DONE
def test_check_review_done_code_review_summary(monkeypatch):
"""Reviewer comment with '## Code Review Summary' + '### Verdict: APPROVE' -> DONE."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"body": "## Code Review Summary\\n\\n### Verdict: APPROVE"}]}',
"",
),
}
),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.DONE
def test_check_review_not_done_approve_in_metadata(monkeypatch):
"""'approv' in author login (not body) must NOT trigger check_review."""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
'{"comments": [{"author": {"login": "approver-bot"}, "body": "LGTM"}]}',
"",
),
}
),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.NOT_DONE
def test_check_review_stale_approve_then_request_changes(monkeypatch):
"""Old APPROVE + new REQUEST_CHANGES -> NOT_DONE (only latest verdict counts)."""
body1 = "## Code Review Summary\\n\\n### Verdict: APPROVE"
body2 = "## Code Review Summary\\n\\n### Verdict: REQUEST_CHANGES"
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
f'{{"comments": [{{"body": "{body1}"}}, {{"body": "{body2}"}}]}}',
"",
),
}
),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "REQUEST_CHANGES" in result.detail
def test_check_review_request_changes(monkeypatch):
"""Reviewer '### Verdict: REQUEST_CHANGES' -> NOT_DONE."""
body = "## Code Review Summary\\n\\n### Verdict: REQUEST_CHANGES"
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("gh", "pr", "view"): (
0,
f'{{"comments": [{{"body": "{body}"}}]}}',
"",
),
}
),
)
result = ps.check_review(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "REQUEST_CHANGES" in result.detail
# ── check_merge ──────────────────────────────────────────────────────────────
def test_check_merge_done(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, '{"state": "MERGED"}', "")}),
)
result = ps.check_merge(46)
assert result.status == ps.PhaseStatus.DONE
assert "merged" in result.detail
def test_check_merge_not_done_open(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (0, '{"state": "OPEN"}', "")}),
)
result = ps.check_merge(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "OPEN" in result.detail
def test_check_merge_not_done_pr_missing(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("gh", "pr", "view"): (1, "", "not found")}),
)
result = ps.check_merge(46)
assert result.status == ps.PhaseStatus.NOT_DONE
# ── check_memory ─────────────────────────────────────────────────────────────
def test_check_memory_done(tmp_path, monkeypatch):
memory_file = tmp_path / "opencode.md"
memory_file.write_text("- [2026-07-19, PR#46] test entry\n")
monkeypatch.setattr(ps, "get_memory_files", lambda: [memory_file])
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.DONE
assert "PR#46" in result.detail
def test_check_memory_not_done_no_files(tmp_path, monkeypatch):
monkeypatch.setattr(ps, "get_memory_files", lambda: [])
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "не найдены" in result.detail
def test_check_memory_not_done_no_pattern(tmp_path, monkeypatch):
memory_file = tmp_path / "opencode.md"
memory_file.write_text("- some other entry\n")
monkeypatch.setattr(ps, "get_memory_files", lambda: [memory_file])
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "не найден" in result.detail
def test_check_memory_not_done_remote_error(monkeypatch):
def raise_err():
raise RuntimeError("remote error")
monkeypatch.setattr(ps, "get_memory_files", raise_err)
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "remote error" in result.detail
# ── _resolve_memory_base ─────────────────────────────────────────────────────
def test_resolve_memory_base(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("git", "remote"): (0, "https://github.com/slaid098/opencode-config.git\n", ""),
}
),
)
result_dir, result_repo = ps._resolve_memory_base()
assert result_repo == "opencode-config"
assert result_dir == ps.MEMORY_DIR / "github.com" / "slaid098"
def test_resolve_memory_base_ssh(monkeypatch):
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("git", "remote"): (0, "git@github.com:slaid098/opencode-config.git\n", "")}),
)
result_dir, result_repo = ps._resolve_memory_base()
assert result_repo == "opencode-config"
assert "github.com" in str(result_dir)
assert "slaid098" in str(result_dir)
def test_resolve_memory_base_with_userinfo(monkeypatch):
"""_resolve_memory_base строит правильный путь когда remote URL содержит userinfo.
Regression for PR#53/ADR-022: after ``git config url.insteadOf``, ``git
remote get-url origin`` returns
``https://x-access-token:TOKEN@github.com/slaid098/opencode-config.git``.
Without the regex fix, ``_resolve_memory_base`` built
``repos/x-access-token:TOKEN@github.com/slaid098`` (nonexistent) →
``get_memory_files`` found no files → ``check_memory`` returned NOT_DONE.
With the fix, the path is the same as for the plain URL.
"""
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{
("git", "remote"): (
0,
"https://x-access-token:github_pat_TOKEN@github.com/slaid098/opencode-config.git\n",
"",
),
}
),
)
result_dir, result_repo = ps._resolve_memory_base()
assert result_repo == "opencode-config"
assert result_dir == ps.MEMORY_DIR / "github.com" / "slaid098"
# userinfo must NOT leak into the path
assert "x-access-token" not in str(result_dir)
assert "github_pat_TOKEN" not in str(result_dir)
# ── check_memory: rotation coverage ──────────────────────────────────────────
def test_check_memory_done_rotated_file(tmp_path, monkeypatch):
"""{repo}.md frozen (no PR#N), {repo}-002.md with PR#N → DONE, -002.md in detail."""
frozen = tmp_path / "opencode-config.md"
frozen.write_text("- other entries\n")
rotated = tmp_path / "opencode-config-002.md"
rotated.write_text("- [2026-08-03, PR#46] test\n")
monkeypatch.setattr(ps, "get_memory_files", lambda: [frozen, rotated])
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.DONE
assert "opencode-config-002.md" in result.detail
def test_check_memory_done_newest_first(tmp_path, monkeypatch):
"""PR#N in several files → returns mtime-newest (first in the sorted list)."""
file_old = tmp_path / "opencode-config.md"
file_old.write_text("- [2026-08-03, PR#46] old\n")
file_new = tmp_path / "opencode-config-002.md"
file_new.write_text("- [2026-08-03, PR#46] new\n")
# get_memory_files already sorted mtime desc — mock in this order
monkeypatch.setattr(ps, "get_memory_files", lambda: [file_new, file_old])
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.DONE
assert "opencode-config-002.md" in result.detail
def test_check_memory_not_done_multiple_files(tmp_path, monkeypatch):
"""2 files without PR#N → NOT_DONE, detail mentions count."""
f1 = tmp_path / "opencode-config.md"
f1.write_text("nope\n")
f2 = tmp_path / "opencode-config-002.md"
f2.write_text("also nope\n")
monkeypatch.setattr(ps, "get_memory_files", lambda: [f1, f2])
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "2" in result.detail
def test_check_memory_not_done_value_error(monkeypatch):
"""parse_remote_url raises ValueError → NOT_DONE with message."""
def raise_err():
raise ValueError("bad url")
monkeypatch.setattr(ps, "get_memory_files", raise_err)
result = ps.check_memory(46)
assert result.status == ps.PhaseStatus.NOT_DONE
assert "bad url" in result.detail
# ── get_memory_files: glob / mtime / filter ───────────────────────────────────
def test_get_memory_files_sorted_by_mtime_desc(tmp_path, monkeypatch):
"""Files sorted by mtime descending (newest first)."""
base = tmp_path / "github.com" / "slaid098"
base.mkdir(parents=True)
f1 = base / "opencode-config.md"
f1.write_text("a\n")
f2 = base / "opencode-config-002.md"
f2.write_text("b\n")
now = time.time()
os.utime(f1, (now - 100, now - 100))
os.utime(f2, (now, now))
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
result = ps.get_memory_files()
assert result == [f2, f1]
def test_get_memory_files_excludes_unrelated_repo(tmp_path, monkeypatch):
"""Only files matching {repo} stem, not other repos in the same dir."""
base = tmp_path / "github.com" / "slaid098"
base.mkdir(parents=True)
(base / "opencode-config.md").write_text("a\n")
(base / "opencode.md").write_text("b\n") # different repo
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
result = ps.get_memory_files()
assert len(result) == 1
assert result[0].name == "opencode-config.md"
def test_get_memory_files_excludes_non_rotation_suffix(tmp_path, monkeypatch):
"""{repo}-old.md / {repo}-notes.md are excluded (non-numeric suffix)."""
base = tmp_path / "github.com" / "slaid098"
base.mkdir(parents=True)
(base / "opencode-config.md").write_text("a\n")
(base / "opencode-config-002.md").write_text("b\n")
(base / "opencode-config-old.md").write_text("c\n") # non-numeric — exclude
(base / "opencode-config-notes.md").write_text("d\n") # non-numeric — exclude
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
result = ps.get_memory_files()
names = sorted(f.name for f in result)
assert names == ["opencode-config-002.md", "opencode-config.md"]
def test_get_memory_files_empty_dir(tmp_path, monkeypatch):
"""Empty dir → empty list (not error)."""
base = tmp_path / "github.com" / "slaid098"
base.mkdir(parents=True)
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
result = ps.get_memory_files()
assert result == []
def test_get_memory_files_no_dir(tmp_path, monkeypatch):
"""Nonexistent dir → empty list (not error)."""
base = tmp_path / "nonexistent" # does not exist
monkeypatch.setattr(ps, "_resolve_memory_base", lambda: (base, "opencode-config"))
result = ps.get_memory_files()
assert result == []
# ── get_repo_full_name ───────────────────────────────────────────────────────
def test_get_repo_full_name_current_repo(monkeypatch):
"""get_repo_full_name возвращает org/repo из git remote (current repo)."""
ps.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd(
{("git", "remote"): (0, "https://github.com/slaid098/opencode-config.git\n", "")}
),
)
assert ps.get_repo_full_name() == "slaid098/opencode-config"
ps.get_repo_full_name.cache_clear()
def test_get_repo_full_name_media_gen(monkeypatch):
"""get_repo_full_name для не-opencode репо (HTTPS)."""
ps.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("git", "remote"): (0, "https://github.com/slaid098/media-gen.git\n", "")}),
)
assert ps.get_repo_full_name() == "slaid098/media-gen"
ps.get_repo_full_name.cache_clear()
def test_get_repo_full_name_mediakit_ssh(monkeypatch):
"""get_repo_full_name для не-opencode репо (SSH, другой org)."""
ps.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("git", "remote"): (0, "git@github.com:anomaly/mediakit.git\n", "")}),
)
assert ps.get_repo_full_name() == "anomaly/mediakit"
ps.get_repo_full_name.cache_clear()
def test_get_repo_full_name_cached(monkeypatch):
"""functools.cache: second call does not hit run_cmd again."""
ps.get_repo_full_name.cache_clear()
call_count = [0]
def _counting_mock(args: list[str]) -> tuple[int, str, str]:
if tuple(args[:2]) == ("git", "remote"):
call_count[0] += 1
return (0, "https://github.com/slaid098/opencode-config.git\n", "")
return (1, "", f"unmocked: {args}")
monkeypatch.setattr(ps, "run_cmd", _counting_mock)
assert ps.get_repo_full_name() == "slaid098/opencode-config"
assert ps.get_repo_full_name() == "slaid098/opencode-config"
assert call_count[0] == 1
ps.get_repo_full_name.cache_clear()
def test_get_repo_full_name_remote_error(monkeypatch):
"""git remote fails → RuntimeError with explicit message."""
ps.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ps,
"run_cmd",
mock_run_cmd({("git", "remote"): (1, "", "not a git repository")}),
)
with pytest.raises(RuntimeError, match="Cannot get git remote URL"):
ps.get_repo_full_name()
ps.get_repo_full_name.cache_clear()
# ── MEMORY_DIR env var ──────────────────────────────────────────────────────
@pytest.fixture
def reload_ps_after_test():
"""Re-execute the ps module after test to restore module-level constants.
Uses spec_from_file_location + exec_module (same as initial load) instead of
importlib.reload — reload tries _find_spec via sys.path, which doesn't know
about pipeline_status (loaded from a file path, not on sys.path).
Must be listed BEFORE monkeypatch in the test signature so monkeypatch
finalizes first (env var reverted), then this fixture re-execs ps with
the reverted env.
"""
yield
_reload_ps()
def _reload_ps() -> None:
"""Re-execute the pipeline_status module from disk with current env."""
spec = importlib.util.spec_from_file_location("pipeline_status", SCRIPT_PATH)
spec.loader.exec_module(ps)
def test_memory_dir_uses_env_var(reload_ps_after_test, monkeypatch):
"""OPENCODE_MEMORY_DIR env var overrides default path."""
monkeypatch.setenv("OPENCODE_MEMORY_DIR", "/custom/memory")
_reload_ps()
assert Path("/custom/memory") / "repos" == ps.MEMORY_DIR
def test_memory_dir_fallback_no_env_var(reload_ps_after_test, monkeypatch):
"""Without OPENCODE_MEMORY_DIR, fallback to REPO_ROOT/app_data/opencode-memory."""
monkeypatch.delenv("OPENCODE_MEMORY_DIR", raising=False)
_reload_ps()
expected = ps.REPO_ROOT / "app_data" / "opencode-memory" / "repos"
assert expected == ps.MEMORY_DIR
# ── format_single_pr (integration) ───────────────────────────────────────────
def test_format_single_pr_complete(monkeypatch):
monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test PR"}', "")})
)
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"),
ps.PhaseResult(ps.PhaseStatus.DONE, "merged"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR#46"),
]
output = ps.format_single_pr(46, results)
assert "Status: COMPLETE" in output
assert "PR #46" in output
def test_format_single_pr_review_not_done(monkeypatch):
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "APPROVE не найден"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"),
]
monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")})
)
output = ps.format_single_pr(46, results)
assert "NEXT: dispatch subagent (subagent_type=reviewer" in output
assert "" in output
def test_format_single_pr_review_request_changes(monkeypatch):
"""REVIEW not_done с verdict REQUEST_CHANGES -> NEXT про fix subagent, не re-run reviewer."""
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"),
]
monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")})
)
output = ps.format_single_pr(46, results)
assert "NEXT: dispatch subagent (subagent_type=general)" in output
assert "re-loop" in output
assert "dispatch subagent (subagent_type=reviewer" not in output.split("NEXT:")[1]
def test_format_single_pr_review_needs_discussion(monkeypatch):
"""REVIEW not_done с verdict NEEDS_DISCUSSION -> NEXT про уточнение, НЕ про re-run reviewer."""
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: NEEDS_DISCUSSION"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"),
]
monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")})
)
output = ps.format_single_pr(46, results)
assert "NEXT: уточни вопросы" in output
assert "dispatch subagent (subagent_type=reviewer" not in output.split("NEXT:")[1]
def test_format_single_pr_memory_not_done(monkeypatch):
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue #1"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.DONE, "APPROVE"),
ps.PhaseResult(ps.PhaseStatus.DONE, "merged"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден в memory"),
]
monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")})
)
output = ps.format_single_pr(46, results)
assert "NEXT: dispatch subagent (subagent_type=memory-syncer" in output
def test_format_single_pr_ambiguous(monkeypatch):
results = [
ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, "несколько issue"),
] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 5
monkeypatch.setattr(
ps, "run_cmd", mock_run_cmd({("gh", "pr", "view"): (0, '{"title": "test"}', "")})
)
output = ps.format_single_pr(46, results)
assert "AMBIGUOUS" in output
# ── format_table (no-args mode) ──────────────────────────────────────────────
def test_format_table_empty(monkeypatch):
monkeypatch.setattr(ps, "list_open_pr_numbers", lambda: [])
output = ps.format_table([])
assert "Нет открытых PR" in output
def test_format_table_with_prs(monkeypatch):
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "ci"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "review"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "merge"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "memory"),
]
monkeypatch.setattr(ps, "run_all_checks", lambda n: results)
monkeypatch.setattr(ps, "get_pr_title", lambda n: "test PR title")
output = ps.format_table([47])
assert "PR#47" in output
assert "NEXT: проверь статус CI вручную" in output
def test_format_pr_row_review_request_changes(monkeypatch):
"""format_pr_row (table-view): REVIEW REQUEST_CHANGES -> NEXT про fix subagent."""
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, "issue"),
ps.PhaseResult(ps.PhaseStatus.DONE, "PR body valid"),
ps.PhaseResult(ps.PhaseStatus.DONE, "CI green"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "последний verdict reviewer'а: REQUEST_CHANGES"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "state=OPEN"),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "PR#46 не найден"),
]
monkeypatch.setattr(ps, "run_all_checks", lambda n: results)
monkeypatch.setattr(ps, "get_pr_title", lambda n: "test")
output = ps.format_pr_row(47)
assert "NEXT: dispatch subagent (subagent_type=general)" in output
assert "dispatch subagent (subagent_type=reviewer" not in output.split("NEXT:")[1]
# ── get_next_action ──────────────────────────────────────────────────────────
@pytest.mark.parametrize(
("phase", "expected"),
[
("ISSUE", "dispatch subagent (subagent_type=general, template=A) for PR #46"),
("IMPLEMENT", "dispatch subagent (subagent_type=general, template=A) for PR #46"),
("CI", "проверь статус CI вручную (gh run view)"),
("REVIEW", "dispatch subagent (subagent_type=reviewer, template=C) for PR #46"),
("MERGE", "call merge_pr tool with pr_number=46"),
("MEMORY", "dispatch subagent (subagent_type=memory-syncer, template=E) for PR #46"),
],
)
def test_get_next_action(phase, expected):
assert ps.get_next_action(phase, 46) == expected
@pytest.mark.parametrize(
("detail", "expected_substring"),
[
(
"последний verdict reviewer'а: REQUEST_CHANGES",
"dispatch subagent (subagent_type=general)",
),
("последний verdict reviewer'а: NEEDS_DISCUSSION", "уточни вопросы с автором"),
(
"Code Review Summary не найден в комментариях",
"dispatch subagent (subagent_type=reviewer",
),
("APPROVE не найден", "dispatch subagent (subagent_type=reviewer"),
("нет комментариев PR", "dispatch subagent (subagent_type=reviewer"),
],
)
def test_get_next_action_review(detail, expected_substring):
result = ps.PhaseResult(ps.PhaseStatus.NOT_DONE, detail)
assert expected_substring in ps.get_next_action_review(result)
# ── find_current_phase ───────────────────────────────────────────────────────
def test_find_current_phase_all_done():
results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 6
assert ps.find_current_phase(results) is None
def test_find_current_phase_first_not_done():
results = [
ps.PhaseResult(ps.PhaseStatus.DONE, ""),
ps.PhaseResult(ps.PhaseStatus.NOT_DONE, ""),
] + [ps.PhaseResult(ps.PhaseStatus.NOT_DONE, "")] * 4
assert ps.find_current_phase(results) == 1
def test_find_current_phase_skips_ambiguous():
results = [ps.PhaseResult(ps.PhaseStatus.DONE, "")] * 5 + [
ps.PhaseResult(ps.PhaseStatus.AMBIGUOUS, ""),
]
assert ps.find_current_phase(results) == 5
# ── main ─────────────────────────────────────────────────────────────────────
def test_main_pr_not_found(monkeypatch, capsys):
monkeypatch.setattr(ps, "check_gh_auth", lambda: None)
monkeypatch.setattr(ps, "pr_exists", lambda n: False)
monkeypatch.setattr("sys.argv", ["pipeline-status.py", "999"])
with pytest.raises(SystemExit) as exc_info:
ps.main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "не существует" in captured.err
def test_main_invalid_pr_number(monkeypatch, capsys):
monkeypatch.setattr(ps, "check_gh_auth", lambda: None)
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({}))
monkeypatch.setattr("sys.argv", ["pipeline-status.py", "abc"])
with pytest.raises(SystemExit) as exc_info:
ps.main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "Некорректный" in captured.err
def test_main_gh_not_authenticated(monkeypatch, capsys):
monkeypatch.setattr(ps, "check_gh_auth", lambda: "gh CLI не авторизован")
with pytest.raises(SystemExit) as exc_info:
ps.main()
assert exc_info.value.code == 1
captured = capsys.readouterr()
assert "не авторизован" in captured.err
# ── _resolve_repo_root ────────────────────────────────────────────────────────
def test_resolve_repo_root_via_git(monkeypatch, tmp_path):
"""When git rev-parse succeeds, use its output as repo root (cwd-aware)."""
fake_root = tmp_path / "some-repo"
fake_root.mkdir()
class _FakeResult:
returncode = 0
stdout = f"{fake_root}\n"
stderr = ""
def fake_run(args, **kwargs):
if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"):
return _FakeResult()
raise AssertionError(f"unmocked: {args}")
monkeypatch.setattr(ps.subprocess, "run", fake_run)
root = ps._resolve_repo_root()
assert root == fake_root.resolve()
def test_resolve_repo_root_fallback_to_file(monkeypatch):
"""When git rev-parse fails, fallback to Path(__file__).parent.parent.parent."""
class _FakeResult:
returncode = 1
stdout = ""
stderr = "not a git repo"
def fake_run(args, **kwargs):
if tuple(args[:3]) == ("git", "rev-parse", "--show-toplevel"):
return _FakeResult()
raise AssertionError(f"unmocked: {args}")
monkeypatch.setattr(ps.subprocess, "run", fake_run)
root = ps._resolve_repo_root()
expected = Path(ps.__file__).resolve().parent.parent.parent
assert root == expected
# ── --repo flag on gh calls (PR#101) ──────────────────────────────────────────
def test_pr_exists_with_explicit_repo(monkeypatch):
"""pr_exists passes --repo flag, so it works from non-git cwd.
PR#101: every ``gh pr view``/``gh pr list``/``gh issue view`` call site
appends ``--repo {get_repo_full_name()}`` (defense in depth — even if
the TS tool wrapper fails to pass ``cwd``, the script still resolves
the repo explicitly). This test captures the argv passed to ``run_cmd``
and verifies ``--repo <org>/<repo>`` is present with a slash in the value.
"""
calls: list[list[str]] = []
def _capture(args: list[str]) -> tuple[int, str, str]:
calls.append(args)
return (0, '{"number": 100}', "")
monkeypatch.setattr(ps, "run_cmd", _capture)
ps.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ps,
"run_cmd",
lambda a: (
(0, "https://github.com/slaid098/opencode-config.git\n", "")
if tuple(a[:2]) == ("git", "remote")
else _capture(a)
),
)
ps.pr_exists(100)
assert "--repo" in calls[0]
repo_idx = calls[0].index("--repo")
assert "/" in calls[0][repo_idx + 1], (
f"expected org/repo format after --repo, got: {calls[0][repo_idx + 1]!r}"
)
def test_check_issue_uses_repo_flag(monkeypatch):
"""check_issue passes --repo to gh pr view + gh issue view, works from any cwd.
PR#101: ``check_issue`` issues two gh calls (``gh pr view --json body``
for the closure pattern + ``gh issue view`` for issue existence). Both
must carry ``--repo {get_repo_full_name()}`` so they work when the
process cwd is not a git repo.
"""
calls: list[list[str]] = []
def _capture(args: list[str]) -> tuple[int, str, str]:
calls.append(args)
if tuple(args[:3]) == ("gh", "pr", "view"):
return (0, '{"body": "Closes #45"}', "")
if tuple(args[:3]) == ("gh", "issue", "view"):
return (0, "issue body", "")
return (1, "", f"unmocked: {args}")
monkeypatch.setattr(ps, "run_cmd", _capture)
ps.get_repo_full_name.cache_clear()
monkeypatch.setattr(
ps,
"run_cmd",
lambda a: (
(0, "https://github.com/slaid098/opencode-config.git\n", "")
if tuple(a[:2]) == ("git", "remote")
else _capture(a)
),
)
result = ps.check_issue(46)
assert result.status == ps.PhaseStatus.DONE
# Two gh calls captured (pr view + issue view), each with --repo.
gh_calls = [c for c in calls if c[0] == "gh"]
assert len(gh_calls) == 2, f"expected 2 gh calls, got {len(gh_calls)}: {gh_calls}"
for call in gh_calls:
assert "--repo" in call, f"missing --repo in gh call: {call}"
repo_idx = call.index("--repo")
assert "/" in call[repo_idx + 1], (
f"expected org/repo format after --repo, got: {call[repo_idx + 1]!r}"
)