From 596aa9b18afa7303dd3ff01cdd67107dedbd988c Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:10:18 +0300 Subject: [PATCH] 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 --- .opencode/scripts/pipeline-status.py | 30 +++-- tests/test_pipeline_status.py | 176 ++++++++++++++++++++++----- 2 files changed, 166 insertions(+), 40 deletions(-) diff --git a/.opencode/scripts/pipeline-status.py b/.opencode/scripts/pipeline-status.py index ed349a7..43aff15 100644 --- a/.opencode/scripts/pipeline-status.py +++ b/.opencode/scripts/pipeline-status.py @@ -173,13 +173,23 @@ def parse_remote_url(url: str) -> tuple[str, str, str]: raise ValueError(f"Cannot parse remote URL: {url}") -def get_memory_file_path() -> Path: - """Derive memory file path from ``git remote get-url origin``.""" +def _resolve_memory_base() -> tuple[Path, str]: + """Derive (memory_dir, repo_name) from ``git remote get-url origin``.""" rc, out, err = run_cmd(["git", "remote", "get-url", "origin"]) if rc != 0: raise RuntimeError(f"Cannot get git remote URL: {err.strip()}") host, org, repo = parse_remote_url(out.strip()) - return MEMORY_DIR / host / org / f"{repo}.md" + return MEMORY_DIR / host / org, repo + + +def get_memory_files() -> list[Path]: + """All ``repo*.md`` sorted by mtime descending (newest first).""" + base_dir, repo = _resolve_memory_base() + if not base_dir.exists(): + return [] + rot_pattern = re.compile(rf"^{re.escape(repo)}(-\d+)?$") + files = [f for f in base_dir.glob("*.md") if rot_pattern.fullmatch(f.stem)] + return sorted(files, key=lambda p: p.stat().st_mtime, reverse=True) @functools.cache @@ -458,24 +468,24 @@ def check_merge(pr_number: int) -> PhaseResult: def check_memory(pr_number: int) -> PhaseResult: """Phase 7: MEMORY — PR#N distilled into memory file.""" try: - memory_file = get_memory_file_path() + files = get_memory_files() except (RuntimeError, ValueError) as exc: return PhaseResult(PhaseStatus.NOT_DONE, str(exc)) - if not memory_file.exists(): + if not files: return PhaseResult( PhaseStatus.NOT_DONE, - f"memory file не существует: {memory_file.name}", + "memory files не найдены", ) - content = memory_file.read_text() pattern = f"PR#{pr_number}" - if pattern in content: - return PhaseResult(PhaseStatus.DONE, f"{pattern} в {memory_file.name}") + for f in files: + if pattern in f.read_text(): + return PhaseResult(PhaseStatus.DONE, f"{pattern} в {f.name}") return PhaseResult( PhaseStatus.NOT_DONE, - f"{pattern} не найден в {memory_file.name}", + f"{pattern} не найден в {len(files)} файл(ах)", ) diff --git a/tests/test_pipeline_status.py b/tests/test_pipeline_status.py index 06ea8c0..f510a40 100644 --- a/tests/test_pipeline_status.py +++ b/tests/test_pipeline_status.py @@ -13,7 +13,9 @@ without extra boilerplate in every test. import importlib.util import json +import os import sys +import time from pathlib import Path import pytest @@ -488,44 +490,42 @@ def test_check_merge_not_done_pr_missing(monkeypatch): 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_file_path", lambda: memory_file) + 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_file(tmp_path, monkeypatch): - memory_file = tmp_path / "opencode.md" - monkeypatch.setattr(ps, "get_memory_file_path", lambda: memory_file) +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 + 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_file_path", lambda: memory_file) + 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): - monkeypatch.setattr( - ps, - "get_memory_file_path", - lambda: (_ for _ in ()).throw(RuntimeError("remote error")), - ) + 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 -# ── get_memory_file_path ───────────────────────────────────────────────────── +# ── _resolve_memory_base ───────────────────────────────────────────────────── -def test_get_memory_file_path(monkeypatch): +def test_resolve_memory_base(monkeypatch): monkeypatch.setattr( ps, "run_cmd", @@ -535,32 +535,33 @@ def test_get_memory_file_path(monkeypatch): } ), ) - path = ps.get_memory_file_path() - assert path == ps.MEMORY_DIR / "github.com" / "slaid098" / "opencode-config.md" + result_dir, result_repo = ps._resolve_memory_base() + assert result_repo == "opencode-config" + assert result_dir == ps.MEMORY_DIR / "github.com" / "slaid098" -def test_get_memory_file_path_ssh(monkeypatch): +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", "")}), ) - path = ps.get_memory_file_path() - assert path.name == "opencode-config.md" - assert "github.com" in str(path) - assert "slaid098" in str(path) + 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_get_memory_file_path_with_userinfo(monkeypatch): - """get_memory_file_path строит правильный путь когда remote URL содержит userinfo. +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, ``get_memory_file_path`` built - ``repos/x-access-token:TOKEN@github.com/slaid098/opencode-config.md`` - (nonexistent) → ``check_memory`` returned NOT_DONE. With the fix, the - path is the same as for the plain URL. + 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, @@ -575,11 +576,126 @@ def test_get_memory_file_path_with_userinfo(monkeypatch): } ), ) - path = ps.get_memory_file_path() - assert path == ps.MEMORY_DIR / "github.com" / "slaid098" / "opencode-config.md" + 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(path) - assert "github_pat_TOKEN" not in str(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 ───────────────────────────────────────────────────────